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
|
const audioKeys = rows
|
||||||
.map((row) => row.audioKey)
|
.map((row) => row.audioKey)
|
||||||
.filter((key): key is string => Boolean(key));
|
.filter((key): key is string => Boolean(key));
|
||||||
|
const uniqueAudioKeys = Array.from(new Set(audioKeys));
|
||||||
|
|
||||||
let deletedAudioObjects = 0;
|
let deletedAudioObjects = 0;
|
||||||
let warning: string | undefined;
|
let warning: string | undefined;
|
||||||
if (audioKeys.length > 0) {
|
if (uniqueAudioKeys.length > 0) {
|
||||||
try {
|
try {
|
||||||
deletedAudioObjects = await deleteTtsSegmentAudioObjects(audioKeys);
|
deletedAudioObjects = await deleteTtsSegmentAudioObjects(uniqueAudioKeys);
|
||||||
|
if (deletedAudioObjects < uniqueAudioKeys.length) {
|
||||||
|
warning = `Deleted ${deletedAudioObjects} of ${uniqueAudioKeys.length} audio objects.`;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
warning = error instanceof Error ? error.message : 'Failed deleting some audio objects';
|
warning = error instanceof Error ? error.message : 'Failed deleting some audio objects';
|
||||||
console.warn('Failed clearing some TTS segment audio objects:', {
|
console.warn('Failed clearing some TTS segment audio objects:', {
|
||||||
|
|
@ -67,6 +71,7 @@ export async function POST(request: NextRequest) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
documentId: parsed.documentId,
|
documentId: parsed.documentId,
|
||||||
deletedSegments: rows.length,
|
deletedSegments: rows.length,
|
||||||
|
requestedAudioObjects: uniqueAudioKeys.length,
|
||||||
deletedAudioObjects,
|
deletedAudioObjects,
|
||||||
...(warning ? { warning } : {}),
|
...(warning ? { warning } : {}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import {
|
||||||
buildTtsSegmentSettingsHash,
|
buildTtsSegmentSettingsHash,
|
||||||
buildTtsSegmentSettingsJson,
|
buildTtsSegmentSettingsJson,
|
||||||
buildTtsSegmentTextHash,
|
buildTtsSegmentTextHash,
|
||||||
|
canonicalLocatorJson,
|
||||||
|
canonicalizeLocatorJsonString,
|
||||||
locatorFingerprint,
|
locatorFingerprint,
|
||||||
normalizeLocator,
|
normalizeLocator,
|
||||||
normalizeSegmentText,
|
normalizeSegmentText,
|
||||||
|
|
@ -125,11 +127,22 @@ function buildSegmentAudioUrls(documentId: string, segmentId: string): {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupStaleErroredVariants(input: {
|
function isAbortLikeError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === 'string'
|
||||||
|
? error
|
||||||
|
: '';
|
||||||
|
if (!message) return false;
|
||||||
|
return /abort/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupStaleCanonicalVariants(input: {
|
||||||
userId: string;
|
userId: string;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
documentVersion: number;
|
documentVersion: number;
|
||||||
segmentIndex: number;
|
segmentIndex: number;
|
||||||
|
activeLocatorJson: string | null;
|
||||||
activeSegmentId: string;
|
activeSegmentId: string;
|
||||||
activeSettingsHash: string;
|
activeSettingsHash: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
|
@ -138,6 +151,7 @@ async function cleanupStaleErroredVariants(input: {
|
||||||
segmentId: ttsSegments.segmentId,
|
segmentId: ttsSegments.segmentId,
|
||||||
settingsHash: ttsSegments.settingsHash,
|
settingsHash: ttsSegments.settingsHash,
|
||||||
audioKey: ttsSegments.audioKey,
|
audioKey: ttsSegments.audioKey,
|
||||||
|
locatorJson: ttsSegments.locatorJson,
|
||||||
})
|
})
|
||||||
.from(ttsSegments)
|
.from(ttsSegments)
|
||||||
.where(and(
|
.where(and(
|
||||||
|
|
@ -150,9 +164,14 @@ async function cleanupStaleErroredVariants(input: {
|
||||||
segmentId: string;
|
segmentId: string;
|
||||||
settingsHash: string;
|
settingsHash: string;
|
||||||
audioKey: string | null;
|
audioKey: string | null;
|
||||||
|
locatorJson: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const staleRowsForSettings = staleRows.filter((row) => row.settingsHash === input.activeSettingsHash);
|
const activeCanonicalLocator = canonicalizeLocatorJsonString(input.activeLocatorJson);
|
||||||
|
const staleRowsForSettings = staleRows.filter((row) =>
|
||||||
|
row.settingsHash === input.activeSettingsHash
|
||||||
|
&& canonicalizeLocatorJsonString(row.locatorJson) === activeCanonicalLocator,
|
||||||
|
);
|
||||||
const staleIds = staleRowsForSettings.map((row) => row.segmentId);
|
const staleIds = staleRowsForSettings.map((row) => row.segmentId);
|
||||||
|
|
||||||
if (staleIds.length === 0) return;
|
if (staleIds.length === 0) return;
|
||||||
|
|
@ -164,9 +183,9 @@ async function cleanupStaleErroredVariants(input: {
|
||||||
inArray(ttsSegments.segmentId, staleIds),
|
inArray(ttsSegments.segmentId, staleIds),
|
||||||
));
|
));
|
||||||
|
|
||||||
const staleAudioKeys = staleRowsForSettings
|
const staleAudioKeys = Array.from(new Set(staleRowsForSettings
|
||||||
.map((row) => row.audioKey)
|
.map((row) => row.audioKey)
|
||||||
.filter((key): key is string => Boolean(key));
|
.filter((key): key is string => Boolean(key))));
|
||||||
if (staleAudioKeys.length > 0) {
|
if (staleAudioKeys.length > 0) {
|
||||||
try {
|
try {
|
||||||
await deleteTtsSegmentAudioObjects(staleAudioKeys);
|
await deleteTtsSegmentAudioObjects(staleAudioKeys);
|
||||||
|
|
@ -263,11 +282,12 @@ export async function POST(request: NextRequest) {
|
||||||
const existing = existingById.get(segment.segmentId);
|
const existing = existingById.get(segment.segmentId);
|
||||||
|
|
||||||
if (existing?.status === 'completed' && existing.audioKey) {
|
if (existing?.status === 'completed' && existing.audioKey) {
|
||||||
await cleanupStaleErroredVariants({
|
await cleanupStaleCanonicalVariants({
|
||||||
userId: scope.storageUserId,
|
userId: scope.storageUserId,
|
||||||
documentId: parsed.documentId,
|
documentId: parsed.documentId,
|
||||||
documentVersion: scope.documentVersion,
|
documentVersion: scope.documentVersion,
|
||||||
segmentIndex: segment.original.segmentIndex,
|
segmentIndex: segment.original.segmentIndex,
|
||||||
|
activeLocatorJson: existing.locatorJson,
|
||||||
activeSegmentId: segment.segmentId,
|
activeSegmentId: segment.segmentId,
|
||||||
activeSettingsHash: settingsHash,
|
activeSettingsHash: settingsHash,
|
||||||
});
|
});
|
||||||
|
|
@ -333,6 +353,7 @@ export async function POST(request: NextRequest) {
|
||||||
segmentId: segment.segmentId,
|
segmentId: segment.segmentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const segmentLocatorJson = canonicalLocatorJson(segment.locator);
|
||||||
await db
|
await db
|
||||||
.insert(ttsSegments)
|
.insert(ttsSegments)
|
||||||
.values({
|
.values({
|
||||||
|
|
@ -342,7 +363,7 @@ export async function POST(request: NextRequest) {
|
||||||
readerType: scope.readerType,
|
readerType: scope.readerType,
|
||||||
documentVersion: scope.documentVersion,
|
documentVersion: scope.documentVersion,
|
||||||
segmentIndex: segment.original.segmentIndex,
|
segmentIndex: segment.original.segmentIndex,
|
||||||
locatorJson: segment.locator ? JSON.stringify(segment.locator) : null,
|
locatorJson: segmentLocatorJson,
|
||||||
settingsHash,
|
settingsHash,
|
||||||
settingsJson,
|
settingsJson,
|
||||||
textHash: segment.textHash,
|
textHash: segment.textHash,
|
||||||
|
|
@ -360,7 +381,7 @@ export async function POST(request: NextRequest) {
|
||||||
readerType: scope.readerType,
|
readerType: scope.readerType,
|
||||||
documentVersion: scope.documentVersion,
|
documentVersion: scope.documentVersion,
|
||||||
segmentIndex: segment.original.segmentIndex,
|
segmentIndex: segment.original.segmentIndex,
|
||||||
locatorJson: segment.locator ? JSON.stringify(segment.locator) : null,
|
locatorJson: segmentLocatorJson,
|
||||||
settingsHash,
|
settingsHash,
|
||||||
settingsJson,
|
settingsJson,
|
||||||
textHash: segment.textHash,
|
textHash: segment.textHash,
|
||||||
|
|
@ -472,11 +493,12 @@ export async function POST(request: NextRequest) {
|
||||||
})
|
})
|
||||||
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId)));
|
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId)));
|
||||||
|
|
||||||
await cleanupStaleErroredVariants({
|
await cleanupStaleCanonicalVariants({
|
||||||
userId: scope.storageUserId,
|
userId: scope.storageUserId,
|
||||||
documentId: parsed.documentId,
|
documentId: parsed.documentId,
|
||||||
documentVersion: scope.documentVersion,
|
documentVersion: scope.documentVersion,
|
||||||
segmentIndex: segment.original.segmentIndex,
|
segmentIndex: segment.original.segmentIndex,
|
||||||
|
activeLocatorJson: canonicalLocatorJson(segment.locator),
|
||||||
activeSegmentId: segment.segmentId,
|
activeSegmentId: segment.segmentId,
|
||||||
activeSettingsHash: settingsHash,
|
activeSettingsHash: settingsHash,
|
||||||
});
|
});
|
||||||
|
|
@ -492,11 +514,12 @@ export async function POST(request: NextRequest) {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to generate segment';
|
const message = error instanceof Error ? error.message : 'Failed to generate segment';
|
||||||
|
const aborted = isAbortLikeError(error);
|
||||||
await db
|
await db
|
||||||
.update(ttsSegments)
|
.update(ttsSegments)
|
||||||
.set({
|
.set({
|
||||||
status: 'error',
|
status: aborted ? 'pending' : 'error',
|
||||||
error: message,
|
error: aborted ? null : message,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId)));
|
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId)));
|
||||||
|
|
@ -509,7 +532,7 @@ export async function POST(request: NextRequest) {
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
alignment: null,
|
alignment: null,
|
||||||
locator: segment.locator,
|
locator: segment.locator,
|
||||||
status: 'error',
|
status: aborted ? 'pending' : 'error',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@ import { and, asc, eq } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { ttsSegments } from '@/db/schema';
|
import { ttsSegments } from '@/db/schema';
|
||||||
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
||||||
|
import {
|
||||||
|
compareManifestSegments,
|
||||||
|
decodeManifestCursor,
|
||||||
|
dedupeManifestVariants,
|
||||||
|
encodeManifestCursor,
|
||||||
|
locatorGroupKey,
|
||||||
|
parseManifestPageSize,
|
||||||
|
} from '@/lib/server/tts/segments-manifest';
|
||||||
import type {
|
import type {
|
||||||
TTSSegmentLocator,
|
TTSSegmentLocator,
|
||||||
TTSSegmentRow,
|
TTSSegmentRow,
|
||||||
|
|
@ -65,45 +73,17 @@ function buildSegmentAudioUrls(documentId: string, segmentId: string): {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusRank(status: TTSSegmentVariant['status']): number {
|
function isAbortLikeMessage(message: string | null | undefined): boolean {
|
||||||
if (status === 'completed') return 3;
|
if (!message) return false;
|
||||||
if (status === 'pending') return 2;
|
return /abort/i.test(message);
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dedupeVariants(variants: Array<{ dedupeKey: string; variant: TTSSegmentVariant }>): TTSSegmentVariant[] {
|
|
||||||
const byKey = new Map<string, TTSSegmentVariant>();
|
|
||||||
for (const { dedupeKey, variant } of variants) {
|
|
||||||
const existing = byKey.get(dedupeKey);
|
|
||||||
if (!existing) {
|
|
||||||
byKey.set(dedupeKey, variant);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const existingRank = statusRank(existing.status);
|
|
||||||
const nextRank = statusRank(variant.status);
|
|
||||||
const existingUpdatedAt = existing.updatedAt ?? 0;
|
|
||||||
const nextUpdatedAt = variant.updatedAt ?? 0;
|
|
||||||
if (nextRank > existingRank || (nextRank === existingRank && nextUpdatedAt >= existingUpdatedAt)) {
|
|
||||||
byKey.set(dedupeKey, variant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(byKey.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
function locatorGroupKey(locator: TTSSegmentLocator | null): string {
|
|
||||||
if (!locator) return 'none';
|
|
||||||
const page = typeof locator.page === 'number' && Number.isFinite(locator.page)
|
|
||||||
? String(Math.floor(locator.page))
|
|
||||||
: '';
|
|
||||||
const location = typeof locator.location === 'string' ? locator.location : '';
|
|
||||||
const readerType = locator.readerType || '';
|
|
||||||
return `p:${page}|l:${location}|r:${readerType}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const documentIdRaw = request.nextUrl.searchParams.get('documentId');
|
const documentIdRaw = request.nextUrl.searchParams.get('documentId');
|
||||||
const documentId = documentIdRaw?.trim().toLowerCase();
|
const documentId = documentIdRaw?.trim().toLowerCase();
|
||||||
|
const limit = parseManifestPageSize(request.nextUrl.searchParams.get('limit'));
|
||||||
|
const cursor = decodeManifestCursor(request.nextUrl.searchParams.get('cursor'));
|
||||||
if (!documentId) {
|
if (!documentId) {
|
||||||
return NextResponse.json({ error: 'Missing documentId' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing documentId' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
@ -171,9 +151,11 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const status: TTSSegmentVariant['status'] = row.status === 'completed' || row.status === 'error'
|
const status: TTSSegmentVariant['status'] = row.status === 'completed'
|
||||||
? row.status
|
? 'completed'
|
||||||
: 'pending';
|
: row.status === 'error' && !isAbortLikeMessage(row.error)
|
||||||
|
? 'error'
|
||||||
|
: 'pending';
|
||||||
|
|
||||||
const audioUrls = row.status === 'completed' && row.audioKey
|
const audioUrls = row.status === 'completed' && row.audioKey
|
||||||
? buildSegmentAudioUrls(documentId, row.segmentId)
|
? buildSegmentAudioUrls(documentId, row.segmentId)
|
||||||
|
|
@ -196,22 +178,40 @@ export async function GET(request: NextRequest) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = Array.from(grouped.values())
|
const segments = Array.from(grouped.entries())
|
||||||
.map((segment) => ({
|
.map(([groupKey, segment]) => ({
|
||||||
|
groupKey,
|
||||||
segmentIndex: segment.segmentIndex,
|
segmentIndex: segment.segmentIndex,
|
||||||
locator: segment.locator,
|
locator: segment.locator,
|
||||||
variants: dedupeVariants(segment.variants),
|
variants: dedupeManifestVariants(segment.variants),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => {
|
.sort(compareManifestSegments);
|
||||||
if (a.segmentIndex !== b.segmentIndex) return a.segmentIndex - b.segmentIndex;
|
|
||||||
const aPage = typeof a.locator?.page === 'number' ? a.locator.page : Number.MAX_SAFE_INTEGER;
|
let startIndex = 0;
|
||||||
const bPage = typeof b.locator?.page === 'number' ? b.locator.page : Number.MAX_SAFE_INTEGER;
|
if (cursor) {
|
||||||
if (aPage !== bPage) return aPage - bPage;
|
const cursorIndex = segments.findIndex((segment) => segment.groupKey === cursor);
|
||||||
const aLoc = a.locator?.location || '';
|
if (cursorIndex < 0) {
|
||||||
const bLoc = b.locator?.location || '';
|
return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 });
|
||||||
return aLoc.localeCompare(bLoc);
|
}
|
||||||
});
|
if (cursorIndex >= 0) startIndex = cursorIndex + 1;
|
||||||
const response: TTSSegmentsManifestResponse = { documentId, segments };
|
}
|
||||||
|
|
||||||
|
const page = segments.slice(startIndex, startIndex + limit);
|
||||||
|
const hasMore = startIndex + page.length < segments.length;
|
||||||
|
const nextCursor = hasMore && page.length > 0
|
||||||
|
? encodeManifestCursor(page[page.length - 1].groupKey)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const response: TTSSegmentsManifestResponse = {
|
||||||
|
documentId,
|
||||||
|
segments: page.map((segment) => ({
|
||||||
|
segmentIndex: segment.segmentIndex,
|
||||||
|
locator: segment.locator,
|
||||||
|
variants: segment.variants,
|
||||||
|
})),
|
||||||
|
nextCursor,
|
||||||
|
hasMore,
|
||||||
|
};
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error listing TTS segments manifest:', error);
|
console.error('Error listing TTS segments manifest:', error);
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@ function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch {
|
||||||
break;
|
break;
|
||||||
case 'voiceSpeed':
|
case 'voiceSpeed':
|
||||||
case 'audioPlayerSpeed':
|
case 'audioPlayerSpeed':
|
||||||
|
case 'segmentPreloadDepthPages':
|
||||||
|
case 'segmentPreloadSentenceLookahead':
|
||||||
|
case 'ttsSegmentMaxBlockLength':
|
||||||
case 'headerMargin':
|
case 'headerMargin':
|
||||||
case 'footerMargin':
|
case 'footerMargin':
|
||||||
case 'leftMargin':
|
case 'leftMargin':
|
||||||
|
|
|
||||||
|
|
@ -464,6 +464,7 @@ export function AudiobookExportModal({
|
||||||
onClose={() => setIsOpen(false)}
|
onClose={() => setIsOpen(false)}
|
||||||
ariaLabel="Export audiobook"
|
ariaLabel="Export audiobook"
|
||||||
title="Export Audiobook"
|
title="Export Audiobook"
|
||||||
|
subtitle="Only leaving the document cancels generation."
|
||||||
>
|
>
|
||||||
{isLoadingExisting ? (
|
{isLoadingExisting ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
|
|
@ -882,9 +883,7 @@ export function AudiobookExportModal({
|
||||||
{chapters.length === 0 && !isGenerating && !isLoadingExisting && (
|
{chapters.length === 0 && !isGenerating && !isLoadingExisting && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Audiobook settings are fixed after generation. Chapters will appear here as they are ready.
|
Audiobook settings are fixed after generation. Chapters will appear here as they are ready.
|
||||||
<br></br>
|
|
||||||
You can close this dialog while the audiobook is being generated. But returning to the home screen will cancel the generation.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState, useEffect } from 'react';
|
import { useState, useEffect, type ChangeEvent } from 'react';
|
||||||
import { Transition, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react';
|
|
||||||
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
||||||
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
|
||||||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||||
|
import {
|
||||||
|
SEGMENT_PRELOAD_DEPTH_MIN,
|
||||||
|
SEGMENT_PRELOAD_DEPTH_MAX,
|
||||||
|
SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN,
|
||||||
|
SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX,
|
||||||
|
TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN,
|
||||||
|
TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX,
|
||||||
|
TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP,
|
||||||
|
clampSegmentPreloadDepth,
|
||||||
|
clampSegmentPreloadSentenceLookahead,
|
||||||
|
clampTtsSegmentMaxBlockLength,
|
||||||
|
} from '@/types/config';
|
||||||
|
|
||||||
const canWordHighlight = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false';
|
const canWordHighlight = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false';
|
||||||
|
|
||||||
|
|
@ -14,6 +24,81 @@ const viewTypeTextMapping = [
|
||||||
{ id: 'scroll', name: 'Continuous Scroll' },
|
{ id: 'scroll', name: 'Continuous Scroll' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const rangeInputClassName = 'w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent';
|
||||||
|
|
||||||
|
type MarginKey = 'header' | 'footer' | 'left' | 'right';
|
||||||
|
|
||||||
|
type RangeSettingProps = {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
description: string;
|
||||||
|
valueWidth?: string;
|
||||||
|
formatter?: (value: number) => string;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RangeSetting({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
description,
|
||||||
|
valueWidth = 'w-10',
|
||||||
|
formatter = (next) => String(next),
|
||||||
|
onChange,
|
||||||
|
}: RangeSettingProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-foreground">{label}</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
|
className={`flex-1 ${rangeInputClassName}`}
|
||||||
|
/>
|
||||||
|
<span className={`${valueWidth} text-xs font-semibold text-right text-foreground`}>{formatter(value)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToggleRowProps = {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ToggleRow({ label, description, checked, onChange, disabled = false }: ToggleRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-offbase bg-background px-3 py-2.5 shadow-sm">
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange(event.target.checked)}
|
||||||
|
className="mt-0.5 form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<span className="space-y-0.5">
|
||||||
|
<span className="block text-sm font-medium text-foreground">{label}</span>
|
||||||
|
<span className="block text-xs text-muted">{description}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
setIsOpen: (isOpen: boolean) => void,
|
setIsOpen: (isOpen: boolean) => void,
|
||||||
|
|
@ -25,6 +110,9 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
skipBlank,
|
skipBlank,
|
||||||
epubTheme,
|
epubTheme,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
|
segmentPreloadDepthPages,
|
||||||
|
segmentPreloadSentenceLookahead,
|
||||||
|
ttsSegmentMaxBlockLength,
|
||||||
headerMargin,
|
headerMargin,
|
||||||
footerMargin,
|
footerMargin,
|
||||||
leftMargin,
|
leftMargin,
|
||||||
|
|
@ -42,6 +130,15 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
right: rightMargin
|
right: rightMargin
|
||||||
});
|
});
|
||||||
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
|
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
|
||||||
|
const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages);
|
||||||
|
const [localSentenceLookahead, setLocalSentenceLookahead] = useState(segmentPreloadSentenceLookahead);
|
||||||
|
const [localMaxBlockLength, setLocalMaxBlockLength] = useState(ttsSegmentMaxBlockLength);
|
||||||
|
const marginValues: Record<MarginKey, number> = {
|
||||||
|
header: headerMargin,
|
||||||
|
footer: footerMargin,
|
||||||
|
left: leftMargin,
|
||||||
|
right: rightMargin,
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalMargins({
|
setLocalMargins({
|
||||||
|
|
@ -52,297 +149,235 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
});
|
});
|
||||||
}, [headerMargin, footerMargin, leftMargin, rightMargin]);
|
}, [headerMargin, footerMargin, leftMargin, rightMargin]);
|
||||||
|
|
||||||
// Handler for slider change (updates local state only)
|
useEffect(() => {
|
||||||
const handleMarginChange = (margin: keyof typeof localMargins) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
setLocalPreloadDepth(segmentPreloadDepthPages);
|
||||||
setLocalMargins(prev => ({
|
}, [segmentPreloadDepthPages]);
|
||||||
...prev,
|
|
||||||
[margin]: Number(event.target.value)
|
useEffect(() => {
|
||||||
}));
|
setLocalSentenceLookahead(segmentPreloadSentenceLookahead);
|
||||||
};
|
}, [segmentPreloadSentenceLookahead]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalMaxBlockLength(ttsSegmentMaxBlockLength);
|
||||||
|
}, [ttsSegmentMaxBlockLength]);
|
||||||
|
|
||||||
// Handler for slider release
|
// Handler for slider release
|
||||||
const handleMarginChangeComplete = (margin: keyof typeof localMargins) => () => {
|
const handleMarginChangeComplete = (margin: MarginKey) => () => {
|
||||||
const value = localMargins[margin];
|
const value = localMargins[margin];
|
||||||
const configKey = `${margin}Margin`;
|
const configKey = `${margin}Margin`;
|
||||||
if (value !== (useConfig)[configKey as keyof typeof useConfig]) {
|
if (value !== marginValues[margin]) {
|
||||||
updateConfigKey(configKey as 'headerMargin' | 'footerMargin' | 'leftMargin' | 'rightMargin', value);
|
updateConfigKey(configKey as 'headerMargin' | 'footerMargin' | 'leftMargin' | 'rightMargin', value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMarginSliderChange = (margin: MarginKey) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setLocalMargins((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[margin]: Number(event.target.value),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReaderSidebarShell
|
<ReaderSidebarShell
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={() => setIsOpen(false)}
|
onClose={() => setIsOpen(false)}
|
||||||
ariaLabel="Document settings"
|
ariaLabel="Document settings"
|
||||||
title="Settings"
|
title="Reader Settings"
|
||||||
|
subtitle="Configure layout, preloading, and playback behavior for this document."
|
||||||
|
bodyClassName="flex-1 overflow-y-auto px-4 py-4 bg-[radial-gradient(circle_at_top_right,rgba(239,68,68,0.08),transparent_35%)]"
|
||||||
|
panelClassName="w-full sm:w-[30rem]"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!epub && !html && <div className="space-y-6">
|
{!html && (
|
||||||
<div className="space-y-2">
|
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-3">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<div>
|
||||||
Text extraction margins
|
<h3 className="text-sm font-semibold text-foreground">Playback Flow</h3>
|
||||||
</label>
|
<p className="text-xs text-muted mt-0.5">Control segment generation and lookahead while audio is active.</p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
</div>
|
||||||
{/* Header Margin */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-xs">Header</span>
|
|
||||||
<span className="text-xs font-bold">{Math.round(localMargins.header * 100)}%</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="0.2"
|
|
||||||
step="0.01"
|
|
||||||
value={localMargins.header}
|
|
||||||
onChange={handleMarginChange('header')}
|
|
||||||
onMouseUp={handleMarginChangeComplete('header')}
|
|
||||||
onKeyUp={handleMarginChangeComplete('header')}
|
|
||||||
onTouchEnd={handleMarginChangeComplete('header')}
|
|
||||||
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer Margin */}
|
<ToggleRow
|
||||||
<div className="space-y-1">
|
label="Skip blank pages"
|
||||||
<div className="flex justify-between">
|
description="Automatically skip pages with no readable text."
|
||||||
<span className="text-xs">Footer</span>
|
checked={skipBlank}
|
||||||
<span className="text-xs font-bold">{Math.round(localMargins.footer * 100)}%</span>
|
onChange={(checked) => updateConfigKey('skipBlank', checked)}
|
||||||
</div>
|
/>
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="0.2"
|
|
||||||
step="0.01"
|
|
||||||
value={localMargins.footer}
|
|
||||||
onChange={handleMarginChange('footer')}
|
|
||||||
onMouseUp={handleMarginChangeComplete('footer')}
|
|
||||||
onKeyUp={handleMarginChangeComplete('footer')}
|
|
||||||
onTouchEnd={handleMarginChangeComplete('footer')}
|
|
||||||
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Left Margin */}
|
<ToggleRow
|
||||||
<div className="space-y-1">
|
label="Smart sentence splitting"
|
||||||
<div className="flex justify-between">
|
description="Merge sentence fragments across page or section boundaries."
|
||||||
<span className="text-xs">Left</span>
|
checked={smartSentenceSplitting}
|
||||||
<span className="text-xs font-bold">{Math.round(localMargins.left * 100)}%</span>
|
onChange={(checked) => updateConfigKey('smartSentenceSplitting', checked)}
|
||||||
</div>
|
/>
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="0.2"
|
|
||||||
step="0.01"
|
|
||||||
value={localMargins.left}
|
|
||||||
onChange={handleMarginChange('left')}
|
|
||||||
onMouseUp={handleMarginChangeComplete('left')}
|
|
||||||
onKeyUp={handleMarginChangeComplete('left')}
|
|
||||||
onTouchEnd={handleMarginChangeComplete('left')}
|
|
||||||
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Margin */}
|
<div className="rounded-xl border border-offbase bg-background px-3 py-3 space-y-3 shadow-sm">
|
||||||
<div className="space-y-1">
|
<RangeSetting
|
||||||
<div className="flex justify-between">
|
label="Segment preload depth"
|
||||||
<span className="text-xs">Right</span>
|
value={localPreloadDepth}
|
||||||
<span className="text-xs font-bold">{Math.round(localMargins.right * 100)}%</span>
|
min={SEGMENT_PRELOAD_DEPTH_MIN}
|
||||||
</div>
|
max={SEGMENT_PRELOAD_DEPTH_MAX}
|
||||||
<input
|
step={1}
|
||||||
type="range"
|
description="How many upcoming pages or locations to queue in the background."
|
||||||
min="0"
|
formatter={(value) => String(value)}
|
||||||
max="0.2"
|
onChange={(value) => {
|
||||||
step="0.01"
|
const next = clampSegmentPreloadDepth(value);
|
||||||
value={localMargins.right}
|
setLocalPreloadDepth(next);
|
||||||
onChange={handleMarginChange('right')}
|
void updateConfigKey('segmentPreloadDepthPages', next);
|
||||||
onMouseUp={handleMarginChangeComplete('right')}
|
}}
|
||||||
onKeyUp={handleMarginChangeComplete('right')}
|
/>
|
||||||
onTouchEnd={handleMarginChangeComplete('right')}
|
|
||||||
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted mt-2">
|
|
||||||
Adjust margins to exclude content from edges of the page during text extraction (experimental)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Listbox
|
|
||||||
value={selectedView}
|
|
||||||
onChange={(newView) => updateConfigKey('viewType', newView.id as ViewType)}
|
|
||||||
>
|
|
||||||
<div className="relative z-10 space-y-2">
|
|
||||||
<label className="block text-sm font-medium text-foreground">Mode</label>
|
|
||||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
|
|
||||||
<span className="block truncate">{selectedView.name}</span>
|
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
||||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
|
||||||
</span>
|
|
||||||
</ListboxButton>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
|
|
||||||
{viewTypeTextMapping.map((view) => (
|
|
||||||
<ListboxOption
|
|
||||||
key={view.id}
|
|
||||||
className={({ active }) =>
|
|
||||||
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
value={view}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
|
||||||
{view.name}
|
|
||||||
</span>
|
|
||||||
{selected ? (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
|
||||||
<CheckIcon className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ListboxOption>
|
|
||||||
))}
|
|
||||||
</ListboxOptions>
|
|
||||||
</Transition>
|
|
||||||
{selectedView.id === 'scroll' && (
|
|
||||||
<p className="text-sm text-warning pt-2">
|
|
||||||
Note: Continuous scroll may perform poorly for larger documents.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Listbox>
|
|
||||||
|
|
||||||
</div>}
|
<RangeSetting
|
||||||
|
label="Segment lookahead per page/location"
|
||||||
|
value={localSentenceLookahead}
|
||||||
|
min={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN}
|
||||||
|
max={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX}
|
||||||
|
step={1}
|
||||||
|
description="How many segments to ensure from each queued page or section."
|
||||||
|
formatter={(value) => String(value)}
|
||||||
|
onChange={(value) => {
|
||||||
|
const next = clampSegmentPreloadSentenceLookahead(value);
|
||||||
|
setLocalSentenceLookahead(next);
|
||||||
|
void updateConfigKey('segmentPreloadSentenceLookahead', next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{!html && <div className="space-y-1">
|
<RangeSetting
|
||||||
<label className="flex items-center space-x-2">
|
label="TTS segment max block length"
|
||||||
<input
|
value={localMaxBlockLength}
|
||||||
type="checkbox"
|
min={TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN}
|
||||||
checked={skipBlank}
|
max={TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX}
|
||||||
onChange={(e) => updateConfigKey('skipBlank', e.target.checked)}
|
step={TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP}
|
||||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
description="Maximum character count used when chunking text into segment blocks."
|
||||||
/>
|
valueWidth="w-14"
|
||||||
<span className="text-sm font-medium text-foreground">Skip blank pages</span>
|
formatter={(value) => String(value)}
|
||||||
</label>
|
onChange={(value) => {
|
||||||
<p className="text-sm text-muted pl-6">
|
const next = clampTtsSegmentMaxBlockLength(value);
|
||||||
Automatically skip pages with no text content
|
setLocalMaxBlockLength(next);
|
||||||
</p>
|
void updateConfigKey('ttsSegmentMaxBlockLength', next);
|
||||||
</div>}
|
}}
|
||||||
{!html && (
|
/>
|
||||||
<div className="space-y-1">
|
</div>
|
||||||
<label className="flex items-center space-x-2">
|
</section>
|
||||||
<input
|
)}
|
||||||
type="checkbox"
|
|
||||||
checked={smartSentenceSplitting}
|
{!epub && !html && (
|
||||||
onChange={(e) => updateConfigKey('smartSentenceSplitting', e.target.checked)}
|
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-3">
|
||||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
<div>
|
||||||
/>
|
<h3 className="text-sm font-semibold text-foreground">PDF Layout & Extraction</h3>
|
||||||
<span className="text-sm font-medium text-foreground">
|
<p className="text-xs text-muted mt-0.5">Set viewer mode and trim page edges before extraction.</p>
|
||||||
Smart sentence splitting
|
</div>
|
||||||
</span>
|
|
||||||
</label>
|
<div className="space-y-1.5">
|
||||||
<p className="text-sm text-muted pl-6">
|
<label className="block text-sm font-medium text-foreground">Page mode</label>
|
||||||
Merge sentences across page or section breaks
|
<div
|
||||||
</p>
|
role="radiogroup"
|
||||||
</div>
|
aria-label="Page mode"
|
||||||
)}
|
className="grid grid-cols-3 gap-1 rounded-full border border-offbase bg-background p-1"
|
||||||
{!epub && !html && (
|
>
|
||||||
<div className="space-y-2">
|
{viewTypeTextMapping.map((view) => {
|
||||||
<div className="space-y-1">
|
const active = selectedView.id === view.id;
|
||||||
<label className="flex items-center space-x-2">
|
return (
|
||||||
<input
|
<button
|
||||||
type="checkbox"
|
key={view.id}
|
||||||
checked={pdfHighlightEnabled}
|
type="button"
|
||||||
onChange={(e) => updateConfigKey('pdfHighlightEnabled', e.target.checked)}
|
role="radio"
|
||||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
aria-checked={active}
|
||||||
/>
|
onClick={() => updateConfigKey('viewType', view.id as ViewType)}
|
||||||
<span className="text-sm font-medium text-foreground">Highlight text during playback</span>
|
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||||
</label>
|
active
|
||||||
<p className="text-sm text-muted pl-6">
|
? 'bg-accent text-background shadow-sm'
|
||||||
Visual text playback highlighting in the PDF viewer
|
: 'text-muted hover:bg-base hover:text-foreground'
|
||||||
</p>
|
}`}
|
||||||
</div>
|
>
|
||||||
<div className="space-y-1 pl-6">
|
{view.name}
|
||||||
<label className="flex items-center space-x-2">
|
</button>
|
||||||
<input
|
);
|
||||||
type="checkbox"
|
})}
|
||||||
checked={pdfWordHighlightEnabled && pdfHighlightEnabled}
|
</div>
|
||||||
disabled={!pdfHighlightEnabled || !canWordHighlight}
|
{selectedView.id === 'scroll' ? (
|
||||||
onChange={(e) =>
|
<p className="text-xs text-warning">Continuous scroll may perform poorly for very large PDFs.</p>
|
||||||
updateConfigKey('pdfWordHighlightEnabled', e.target.checked)
|
) : null}
|
||||||
}
|
</div>
|
||||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
/>
|
<div className="rounded-xl border border-offbase bg-background px-3 py-3 shadow-sm">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<p className="text-xs font-medium text-foreground">Text extraction margins</p>
|
||||||
Word-by-word
|
<p className="text-xs text-muted mt-0.5">
|
||||||
</span>
|
Exclude content near edges before sentence extraction.
|
||||||
</label>
|
</p>
|
||||||
<p className="text-sm text-muted pl-6">
|
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
Highlight individual words using audio timestamps generated by whisper.cpp {!canWordHighlight && '(disabled by configuration)'}
|
{(['header', 'footer', 'left', 'right'] as MarginKey[]).map((margin) => (
|
||||||
</p>
|
<div key={margin} className="space-y-1">
|
||||||
</div>
|
<div className="flex items-center justify-between text-xs">
|
||||||
</div>
|
<span className="capitalize text-foreground">{margin}</span>
|
||||||
)}
|
<span className="font-semibold text-foreground">{Math.round(localMargins[margin] * 100)}%</span>
|
||||||
{epub && (
|
</div>
|
||||||
<div className="space-y-2">
|
<input
|
||||||
<div className="space-y-1">
|
type="range"
|
||||||
<label className="flex items-center space-x-2">
|
min="0"
|
||||||
<input
|
max="0.2"
|
||||||
type="checkbox"
|
step="0.01"
|
||||||
checked={epubHighlightEnabled}
|
value={localMargins[margin]}
|
||||||
onChange={(e) => updateConfigKey('epubHighlightEnabled', e.target.checked)}
|
onChange={handleMarginSliderChange(margin)}
|
||||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
onMouseUp={handleMarginChangeComplete(margin)}
|
||||||
/>
|
onKeyUp={handleMarginChangeComplete(margin)}
|
||||||
<span className="text-sm font-medium text-foreground">Highlight text during playback</span>
|
onTouchEnd={handleMarginChangeComplete(margin)}
|
||||||
</label>
|
className={rangeInputClassName}
|
||||||
<p className="text-sm text-muted pl-6">
|
/>
|
||||||
Visual text playback highlighting in the EPUB viewer
|
</div>
|
||||||
</p>
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 pl-6">
|
</div>
|
||||||
<label className="flex items-center space-x-2">
|
</section>
|
||||||
<input
|
)}
|
||||||
type="checkbox"
|
|
||||||
checked={epubWordHighlightEnabled && epubHighlightEnabled}
|
{!epub && !html && (
|
||||||
disabled={!epubHighlightEnabled || !canWordHighlight}
|
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-2">
|
||||||
onChange={(e) =>
|
<div>
|
||||||
updateConfigKey('epubWordHighlightEnabled', e.target.checked)
|
<h3 className="text-sm font-semibold text-foreground">PDF Highlighting</h3>
|
||||||
}
|
<p className="text-xs text-muted mt-0.5">Control playback highlighting behavior in PDF mode.</p>
|
||||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
</div>
|
||||||
/>
|
<ToggleRow
|
||||||
<span className="text-sm font-medium text-foreground">
|
label="Highlight text during playback"
|
||||||
Word-by-word
|
description="Visual sentence-level playback highlighting in the PDF viewer."
|
||||||
</span>
|
checked={pdfHighlightEnabled}
|
||||||
</label>
|
onChange={(checked) => updateConfigKey('pdfHighlightEnabled', checked)}
|
||||||
<p className="text-sm text-muted pl-6">
|
/>
|
||||||
Highlight individual words using audio timestamps generated by whisper.cpp {!canWordHighlight && '(disabled by configuration)'}
|
<ToggleRow
|
||||||
</p>
|
label="Word-by-word highlighting"
|
||||||
</div>
|
description={`Use whisper.cpp timing data to highlight words as speech progresses${!canWordHighlight ? ' (disabled by configuration)' : ''}.`}
|
||||||
</div>
|
checked={pdfWordHighlightEnabled && pdfHighlightEnabled}
|
||||||
)}
|
disabled={!pdfHighlightEnabled || !canWordHighlight}
|
||||||
{epub && (
|
onChange={(checked) => updateConfigKey('pdfWordHighlightEnabled', checked)}
|
||||||
<div className="space-y-1">
|
/>
|
||||||
<label className="flex items-center space-x-2">
|
</section>
|
||||||
<input
|
)}
|
||||||
type="checkbox"
|
|
||||||
checked={epubTheme}
|
{epub && (
|
||||||
onChange={(e) => updateConfigKey('epubTheme', e.target.checked)}
|
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-2">
|
||||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
<div>
|
||||||
/>
|
<h3 className="text-sm font-semibold text-foreground">EPUB Appearance</h3>
|
||||||
<span className="text-sm font-medium text-foreground">Use theme</span>
|
<p className="text-xs text-muted mt-0.5">Apply app styling and playback highlighting in EPUB mode.</p>
|
||||||
</label>
|
</div>
|
||||||
<p className="text-sm text-muted pl-6">
|
<ToggleRow
|
||||||
Apply the current app theme to the EPUB viewer background and text colors
|
label="Apply app theme"
|
||||||
</p>
|
description="Use selected theme on EPUB documents. May require refresh."
|
||||||
</div>
|
checked={epubTheme}
|
||||||
)}
|
onChange={(checked) => updateConfigKey('epubTheme', checked)}
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="Highlight text during playback"
|
||||||
|
description="Visual sentence-level playback highlighting in the EPUB viewer."
|
||||||
|
checked={epubHighlightEnabled}
|
||||||
|
onChange={(checked) => updateConfigKey('epubHighlightEnabled', checked)}
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="Word-by-word highlighting"
|
||||||
|
description={`Use whisper.cpp timing data to highlight words as speech progresses${!canWordHighlight ? ' (disabled by configuration)' : ''}.`}
|
||||||
|
checked={epubWordHighlightEnabled && epubHighlightEnabled}
|
||||||
|
disabled={!epubHighlightEnabled || !canWordHighlight}
|
||||||
|
onChange={(checked) => updateConfigKey('epubWordHighlightEnabled', checked)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ReaderSidebarShell>
|
</ReaderSidebarShell>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface ReaderSidebarShellProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
headerActions?: ReactNode;
|
headerActions?: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
|
@ -22,6 +23,7 @@ export function ReaderSidebarShell({
|
||||||
onClose,
|
onClose,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
title,
|
title,
|
||||||
|
subtitle,
|
||||||
children,
|
children,
|
||||||
headerActions,
|
headerActions,
|
||||||
footer,
|
footer,
|
||||||
|
|
@ -70,6 +72,7 @@ export function ReaderSidebarShell({
|
||||||
<div className="border-b border-offbase px-4 py-3 flex items-start justify-between gap-3">
|
<div className="border-b border-offbase px-4 py-3 flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||||
|
{subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{headerActions}
|
{headerActions}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
|
|
||||||
import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
|
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
|
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
|
||||||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||||
|
import { locatorGroupKey, normalizeEpubLocationToken } from '@/lib/shared/tts-locator';
|
||||||
import type {
|
import type {
|
||||||
TTSSegmentLocator,
|
TTSSegmentLocator,
|
||||||
TTSSegmentRow,
|
TTSSegmentRow,
|
||||||
|
|
@ -23,9 +25,18 @@ interface SegmentsSidebarProps {
|
||||||
type FetchState =
|
type FetchState =
|
||||||
| { kind: 'idle' }
|
| { kind: 'idle' }
|
||||||
| { kind: 'loading' }
|
| { kind: 'loading' }
|
||||||
| { kind: 'ready'; data: TTSSegmentRow[]; fetchedAt: number }
|
| {
|
||||||
|
kind: 'ready';
|
||||||
|
data: TTSSegmentRow[];
|
||||||
|
fetchedAt: number;
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
loadingMore: boolean;
|
||||||
|
}
|
||||||
| { kind: 'error'; message: string };
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
|
const MANIFEST_PAGE_SIZE = 150;
|
||||||
|
|
||||||
function formatDuration(ms: number | null | undefined): string {
|
function formatDuration(ms: number | null | undefined): string {
|
||||||
if (!ms || !Number.isFinite(ms) || ms <= 0) return '—';
|
if (!ms || !Number.isFinite(ms) || ms <= 0) return '—';
|
||||||
const sec = ms / 1000;
|
const sec = ms / 1000;
|
||||||
|
|
@ -83,6 +94,10 @@ function locatorMatchesCurrent(
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!locator) return false;
|
if (!locator) return false;
|
||||||
if (typeof locator.location === 'string' && locator.location.length > 0) {
|
if (typeof locator.location === 'string' && locator.location.length > 0) {
|
||||||
|
if (locator.readerType === 'epub') {
|
||||||
|
if (typeof currentLocation !== 'string') return false;
|
||||||
|
return normalizeEpubLocationToken(locator.location) === normalizeEpubLocationToken(currentLocation);
|
||||||
|
}
|
||||||
return String(locator.location) === String(currentLocation);
|
return String(locator.location) === String(currentLocation);
|
||||||
}
|
}
|
||||||
if (typeof locator.page === 'number' && Number.isFinite(locator.page)) {
|
if (typeof locator.page === 'number' && Number.isFinite(locator.page)) {
|
||||||
|
|
@ -91,11 +106,71 @@ function locatorMatchesCurrent(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function latestUpdatedAt(row: TTSSegmentRow): number {
|
function formatLocatorGroupLabel(locator: TTSSegmentLocator | null): string {
|
||||||
return row.variants.reduce((max, variant) => {
|
if (!locator) return 'Unknown location';
|
||||||
const updated = typeof variant.updatedAt === 'number' ? variant.updatedAt : 0;
|
const parts: string[] = [];
|
||||||
return Math.max(max, updated);
|
if (typeof locator.page === 'number' && Number.isFinite(locator.page)) {
|
||||||
}, 0);
|
parts.push(`Page ${Math.floor(locator.page)}`);
|
||||||
|
}
|
||||||
|
if (typeof locator.location === 'string' && locator.location) {
|
||||||
|
parts.push(locator.location);
|
||||||
|
}
|
||||||
|
if (locator.readerType) {
|
||||||
|
parts.push(locator.readerType.toUpperCase());
|
||||||
|
}
|
||||||
|
return parts.join(' · ') || 'Unknown location';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRows(a: TTSSegmentRow, b: TTSSegmentRow): number {
|
||||||
|
const aPage = typeof a.locator?.page === 'number' ? a.locator.page : Number.MAX_SAFE_INTEGER;
|
||||||
|
const bPage = typeof b.locator?.page === 'number' ? b.locator.page : Number.MAX_SAFE_INTEGER;
|
||||||
|
if (aPage !== bPage) return aPage - bPage;
|
||||||
|
const aLoc = a.locator?.location || '';
|
||||||
|
const bLoc = b.locator?.location || '';
|
||||||
|
const byLocation = aLoc.localeCompare(bLoc);
|
||||||
|
if (byLocation !== 0) return byLocation;
|
||||||
|
if (a.segmentIndex !== b.segmentIndex) return a.segmentIndex - b.segmentIndex;
|
||||||
|
return locatorGroupKey(a.locator).localeCompare(locatorGroupKey(b.locator));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRows(existing: TTSSegmentRow[], incoming: TTSSegmentRow[]): TTSSegmentRow[] {
|
||||||
|
const map = new Map<string, TTSSegmentRow>();
|
||||||
|
const upsert = (row: TTSSegmentRow) => {
|
||||||
|
const key = `${row.segmentIndex}|${locatorGroupKey(row.locator)}`;
|
||||||
|
const prev = map.get(key);
|
||||||
|
if (!prev) {
|
||||||
|
map.set(key, row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bySegmentId = new Map<string, TTSSegmentVariant>();
|
||||||
|
for (const variant of prev.variants) bySegmentId.set(variant.segmentId, variant);
|
||||||
|
for (const variant of row.variants) bySegmentId.set(variant.segmentId, variant);
|
||||||
|
map.set(key, {
|
||||||
|
...row,
|
||||||
|
variants: Array.from(bySegmentId.values()),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
existing.forEach(upsert);
|
||||||
|
incoming.forEach(upsert);
|
||||||
|
return Array.from(map.values()).sort(compareRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findScrollableAncestor(node: HTMLElement, fallback: HTMLElement | null): HTMLElement | null {
|
||||||
|
let current: HTMLElement | null = node.parentElement;
|
||||||
|
while (current) {
|
||||||
|
const style = window.getComputedStyle(current);
|
||||||
|
const overflowY = style.overflowY.toLowerCase();
|
||||||
|
const canScroll = (overflowY === 'auto' || overflowY === 'scroll') && current.scrollHeight > current.clientHeight + 1;
|
||||||
|
if (canScroll) return current;
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isElementFullyVisibleWithinContainer(element: HTMLElement, container: HTMLElement): boolean {
|
||||||
|
const elRect = element.getBoundingClientRect();
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
return elRect.top >= containerRect.top && elRect.bottom <= containerRect.bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSidebarProps) {
|
export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSidebarProps) {
|
||||||
|
|
@ -105,14 +180,15 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
currDocPage,
|
currDocPage,
|
||||||
currDocPageNumber,
|
currDocPageNumber,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
stopAndPlayFromIndex,
|
playFromSegment,
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
const { ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions, updateConfigKey } = useConfig();
|
const { ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions, updateConfigKey } = useConfig();
|
||||||
|
|
||||||
const [state, setState] = useState<FetchState>({ kind: 'idle' });
|
const [state, setState] = useState<FetchState>({ kind: 'idle' });
|
||||||
const [isClearing, setIsClearing] = useState(false);
|
const [isClearing, setIsClearing] = useState(false);
|
||||||
const [clearError, setClearError] = useState<string | null>(null);
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const didAutoScrollOnOpenRef = useRef(false);
|
||||||
|
|
||||||
const activeSettings = useMemo<TTSSegmentSettings>(() => ({
|
const activeSettings = useMemo<TTSSegmentSettings>(() => ({
|
||||||
ttsProvider,
|
ttsProvider,
|
||||||
|
|
@ -122,15 +198,33 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
ttsInstructions: ttsInstructions || '',
|
ttsInstructions: ttsInstructions || '',
|
||||||
}), [ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions]);
|
}), [ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions]);
|
||||||
|
|
||||||
const loadManifest = useCallback(async () => {
|
const loadManifest = useCallback(async (
|
||||||
|
mode: 'reset' | 'append' = 'reset',
|
||||||
|
cursorOverride: string | null = null,
|
||||||
|
) => {
|
||||||
if (!documentId) return;
|
if (!documentId) return;
|
||||||
|
const cursor = mode === 'append' ? cursorOverride : null;
|
||||||
|
if (mode === 'append' && !cursor) return;
|
||||||
|
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
setState({ kind: 'loading' });
|
if (mode === 'reset') {
|
||||||
|
setState({ kind: 'loading' });
|
||||||
|
} else {
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.kind !== 'ready') return prev;
|
||||||
|
return { ...prev, loadingMore: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
documentId,
|
||||||
|
limit: String(MANIFEST_PAGE_SIZE),
|
||||||
|
});
|
||||||
|
if (cursor) params.set('cursor', cursor);
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/tts/segments/manifest?documentId=${encodeURIComponent(documentId)}`,
|
`/api/tts/segments/manifest?${params.toString()}`,
|
||||||
{ signal: controller.signal, cache: 'no-store' },
|
{ signal: controller.signal, cache: 'no-store' },
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -138,33 +232,71 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
throw new Error(body || `Request failed (${res.status})`);
|
throw new Error(body || `Request failed (${res.status})`);
|
||||||
}
|
}
|
||||||
const data = (await res.json()) as TTSSegmentsManifestResponse;
|
const data = (await res.json()) as TTSSegmentsManifestResponse;
|
||||||
setState({ kind: 'ready', data: data.segments, fetchedAt: Date.now() });
|
setState((prev) => {
|
||||||
|
const merged = mode === 'append' && prev.kind === 'ready'
|
||||||
|
? mergeRows(prev.data, data.segments)
|
||||||
|
: mergeRows([], data.segments);
|
||||||
|
return {
|
||||||
|
kind: 'ready',
|
||||||
|
data: merged,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
nextCursor: data.nextCursor,
|
||||||
|
hasMore: data.hasMore,
|
||||||
|
loadingMore: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
|
if (mode === 'append' && err instanceof Error && err.message.includes('Invalid cursor')) {
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.kind !== 'ready') return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loadingMore: false,
|
||||||
|
hasMore: false,
|
||||||
|
nextCursor: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState({ kind: 'error', message: err instanceof Error ? err.message : 'Failed to load' });
|
setState({ kind: 'error', message: err instanceof Error ? err.message : 'Failed to load' });
|
||||||
}
|
}
|
||||||
}, [documentId]);
|
}, [documentId]);
|
||||||
|
|
||||||
const handleClearCache = useCallback(async () => {
|
const handleClearCache = useCallback(async () => {
|
||||||
if (!documentId || isClearing) return;
|
if (!documentId || isClearing) return;
|
||||||
const confirmed = window.confirm('Clear cached segments for this document? This removes generated audio and metadata for listed segments.');
|
const confirmed = window.confirm('Clear cached segments for this document version? This removes stored segment metadata and audio objects.');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
setIsClearing(true);
|
setIsClearing(true);
|
||||||
setClearError(null);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/tts/segments/clear', {
|
const res = await fetch('/api/tts/segments/clear', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ documentId }),
|
body: JSON.stringify({ documentId }),
|
||||||
});
|
});
|
||||||
|
const payload = (await res.json().catch(() => null)) as {
|
||||||
|
error?: string;
|
||||||
|
deletedSegments?: number;
|
||||||
|
requestedAudioObjects?: number;
|
||||||
|
deletedAudioObjects?: number;
|
||||||
|
warning?: string;
|
||||||
|
} | null;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text().catch(() => '');
|
throw new Error(payload?.error || `Request failed (${res.status})`);
|
||||||
throw new Error(body || `Request failed (${res.status})`);
|
|
||||||
}
|
}
|
||||||
await loadManifest();
|
|
||||||
|
if (payload?.warning) {
|
||||||
|
toast.error(`Segments cleared, but audio cleanup was partial: ${payload.warning}`);
|
||||||
|
} else if (payload) {
|
||||||
|
const deletedSegments = Number(payload.deletedSegments ?? 0);
|
||||||
|
const deletedAudioObjects = Number(payload.deletedAudioObjects ?? 0);
|
||||||
|
const requestedAudioObjects = Number(payload.requestedAudioObjects ?? deletedAudioObjects);
|
||||||
|
toast.success(`Cleared ${deletedSegments} segments and ${deletedAudioObjects}/${requestedAudioObjects} audio objects.`);
|
||||||
|
}
|
||||||
|
await loadManifest('reset');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setClearError(error instanceof Error ? error.message : 'Failed to clear segments cache');
|
toast.error(error instanceof Error ? error.message : 'Failed to clear segments cache');
|
||||||
} finally {
|
} finally {
|
||||||
setIsClearing(false);
|
setIsClearing(false);
|
||||||
}
|
}
|
||||||
|
|
@ -172,12 +304,31 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
void loadManifest();
|
void loadManifest('reset');
|
||||||
return () => {
|
return () => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
};
|
};
|
||||||
}, [isOpen, loadManifest]);
|
}, [isOpen, loadManifest]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const node = listRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.kind !== 'ready' || !prev.hasMore || prev.loadingMore) return prev;
|
||||||
|
const distance = node.scrollHeight - node.scrollTop - node.clientHeight;
|
||||||
|
if (distance > 280) return prev;
|
||||||
|
void loadManifest('append', prev.nextCursor);
|
||||||
|
return { ...prev, loadingMore: true };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
node.addEventListener('scroll', onScroll);
|
||||||
|
return () => node.removeEventListener('scroll', onScroll);
|
||||||
|
}, [isOpen, loadManifest]);
|
||||||
|
|
||||||
const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => {
|
const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => {
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|
@ -189,61 +340,132 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
]);
|
]);
|
||||||
}, [updateConfigKey]);
|
}, [updateConfigKey]);
|
||||||
|
|
||||||
const handleJump = useCallback((index: number) => {
|
const handleRefresh = useCallback(() => {
|
||||||
stopAndPlayFromIndex(index);
|
didAutoScrollOnOpenRef.current = false;
|
||||||
}, [stopAndPlayFromIndex]);
|
void loadManifest('reset');
|
||||||
|
}, [loadManifest]);
|
||||||
|
|
||||||
const segmentsByIndex = useMemo(() => {
|
const handleJump = useCallback((index: number, locator: TTSSegmentLocator | null) => {
|
||||||
if (state.kind !== 'ready') return new Map<number, TTSSegmentRow>();
|
playFromSegment(index, locator);
|
||||||
const map = new Map<number, { row: TTSSegmentRow; score: number; updatedAt: number }>();
|
}, [playFromSegment]);
|
||||||
for (const row of state.data) {
|
|
||||||
const isCurrent = locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber);
|
|
||||||
const score = isCurrent ? 2 : row.locator ? 0 : 1;
|
|
||||||
if (score === 0) continue;
|
|
||||||
|
|
||||||
const candidateUpdatedAt = latestUpdatedAt(row);
|
const rowsToRender = useMemo(() => {
|
||||||
const existing = map.get(row.segmentIndex);
|
if (state.kind !== 'ready') return [] as Array<{
|
||||||
if (!existing) {
|
segmentIndex: number;
|
||||||
map.set(row.segmentIndex, { row, score, updatedAt: candidateUpdatedAt });
|
sentenceText: string;
|
||||||
continue;
|
row: TTSSegmentRow;
|
||||||
|
isCurrentLocation: boolean;
|
||||||
|
groupKey: string;
|
||||||
|
groupLabel: string;
|
||||||
|
}>;
|
||||||
|
const currentRowsFromManifest = state.data.filter((row) =>
|
||||||
|
locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber),
|
||||||
|
);
|
||||||
|
const nonCurrentRows = state.data.filter((row) =>
|
||||||
|
!locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber),
|
||||||
|
);
|
||||||
|
|
||||||
|
const inferredCurrentLocator = (() => {
|
||||||
|
const first = currentRowsFromManifest[0]?.locator;
|
||||||
|
if (first) return first;
|
||||||
|
if (typeof currDocPage === 'string' && currDocPage.length > 0) {
|
||||||
|
return {
|
||||||
|
location: currDocPage,
|
||||||
|
readerType: 'epub' as const,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
if (typeof currDocPageNumber === 'number' && Number.isFinite(currDocPageNumber)) {
|
||||||
|
return {
|
||||||
|
page: Math.floor(currDocPageNumber),
|
||||||
|
readerType: 'pdf' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
if (score > existing.score || (score === existing.score && candidateUpdatedAt >= existing.updatedAt)) {
|
const variantsByIndex = new Map<number, TTSSegmentVariant[]>();
|
||||||
map.set(row.segmentIndex, { row, score, updatedAt: candidateUpdatedAt });
|
for (const row of currentRowsFromManifest) {
|
||||||
|
if (!variantsByIndex.has(row.segmentIndex)) {
|
||||||
|
variantsByIndex.set(row.segmentIndex, []);
|
||||||
|
}
|
||||||
|
const merged = variantsByIndex.get(row.segmentIndex)!;
|
||||||
|
const seenIds = new Set(merged.map((variant) => variant.segmentId));
|
||||||
|
for (const variant of row.variants ?? []) {
|
||||||
|
if (seenIds.has(variant.segmentId)) continue;
|
||||||
|
seenIds.add(variant.segmentId);
|
||||||
|
merged.push(variant);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = new Map<number, TTSSegmentRow>();
|
const currentRows: TTSSegmentRow[] = sentences.map((_, segmentIndex) => ({
|
||||||
for (const [idx, entry] of map) selected.set(idx, entry.row);
|
segmentIndex,
|
||||||
return selected;
|
locator: inferredCurrentLocator,
|
||||||
}, [state, currDocPage, currDocPageNumber]);
|
variants: variantsByIndex.get(segmentIndex) ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
const indicesToRender = useMemo(() => {
|
const mergedRows = [...currentRows, ...nonCurrentRows].sort(compareRows);
|
||||||
const indices = new Set<number>();
|
return mergedRows.map((row) => {
|
||||||
for (let i = 0; i < sentences.length; i += 1) indices.add(i);
|
const isCurrentLocation = locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber);
|
||||||
if (state.kind === 'ready') {
|
return {
|
||||||
for (const row of state.data) {
|
segmentIndex: row.segmentIndex,
|
||||||
if (Number.isInteger(row.segmentIndex) && row.segmentIndex >= 0) {
|
sentenceText: isCurrentLocation ? (sentences[row.segmentIndex] ?? '') : '',
|
||||||
indices.add(row.segmentIndex);
|
row,
|
||||||
}
|
isCurrentLocation,
|
||||||
}
|
groupKey: locatorGroupKey(row.locator),
|
||||||
}
|
groupLabel: formatLocatorGroupLabel(row.locator),
|
||||||
return Array.from(indices).sort((a, b) => a - b);
|
};
|
||||||
}, [sentences, state]);
|
|
||||||
|
|
||||||
const rowsToRender: Array<{ segmentIndex: number; sentenceText: string; row: TTSSegmentRow | null }> = [];
|
|
||||||
for (const i of indicesToRender) {
|
|
||||||
rowsToRender.push({
|
|
||||||
segmentIndex: i,
|
|
||||||
sentenceText: sentences[i] ?? '',
|
|
||||||
row: segmentsByIndex.get(i) ?? null,
|
|
||||||
});
|
});
|
||||||
}
|
}, [state, currDocPage, currDocPageNumber, sentences]);
|
||||||
|
|
||||||
const totalVariants = state.kind === 'ready'
|
const totalVariants = state.kind === 'ready'
|
||||||
? state.data.reduce((sum, r) => sum + r.variants.length, 0)
|
? rowsToRender.reduce((sum, r) => sum + r.row.variants.length, 0)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
didAutoScrollOnOpenRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (didAutoScrollOnOpenRef.current) return;
|
||||||
|
if (state.kind !== 'ready' || rowsToRender.length === 0) return;
|
||||||
|
|
||||||
|
const container = listRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const activeRow = container.querySelector<HTMLElement>('[data-active-segment="true"]');
|
||||||
|
if (!activeRow) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
activeRow.scrollIntoView({ block: 'center', behavior: 'auto' });
|
||||||
|
didAutoScrollOnOpenRef.current = true;
|
||||||
|
});
|
||||||
|
}, [isOpen, state.kind, rowsToRender.length, currentSentenceIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !isPlaying) return;
|
||||||
|
if (state.kind !== 'ready' || rowsToRender.length === 0) return;
|
||||||
|
|
||||||
|
const root = listRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const activeRow = root.querySelector<HTMLElement>('[data-active-segment="true"]');
|
||||||
|
if (!activeRow) return;
|
||||||
|
|
||||||
|
const scrollContainer = findScrollableAncestor(activeRow, root);
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
if (isElementFullyVisibleWithinContainer(activeRow, scrollContainer)) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
activeRow.scrollIntoView({ block: 'center', behavior: 'auto' });
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
isOpen,
|
||||||
|
isPlaying,
|
||||||
|
state.kind,
|
||||||
|
rowsToRender.length,
|
||||||
|
currentSentenceIndex,
|
||||||
|
currDocPage,
|
||||||
|
currDocPageNumber,
|
||||||
|
]);
|
||||||
|
|
||||||
const headerActions = (
|
const headerActions = (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|
@ -258,7 +480,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void loadManifest()}
|
onClick={handleRefresh}
|
||||||
aria-label="Refresh segments"
|
aria-label="Refresh segments"
|
||||||
title="Refresh"
|
title="Refresh"
|
||||||
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-offbase bg-base text-muted hover:bg-offbase hover:text-accent transition-colors"
|
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-offbase bg-base text-muted hover:bg-offbase hover:text-accent transition-colors"
|
||||||
|
|
@ -268,36 +490,29 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const footer = (
|
|
||||||
<div className="border-t border-offbase px-4 py-2">
|
|
||||||
<p className="text-xs text-muted leading-relaxed">
|
|
||||||
Click an index or sentence to jump. Click a voice label to switch the active voice.
|
|
||||||
</p>
|
|
||||||
{clearError && (
|
|
||||||
<p className="mt-1 text-xs text-red-500">
|
|
||||||
{clearError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReaderSidebarShell
|
<ReaderSidebarShell
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={() => setIsOpen(false)}
|
onClose={() => setIsOpen(false)}
|
||||||
ariaLabel="TTS segments"
|
ariaLabel="TTS segments"
|
||||||
title="Segments"
|
title="Segments"
|
||||||
|
subtitle="Click an index or sentence to jump. Click a voice label to switch the active voice."
|
||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
footer={footer}
|
|
||||||
bodyClassName="flex-1 overflow-y-auto px-0 py-0"
|
bodyClassName="flex-1 overflow-y-auto px-0 py-0"
|
||||||
>
|
>
|
||||||
<div className="px-4 py-2 border-b border-offbase">
|
<div className="px-4 py-2 border-b border-offbase">
|
||||||
<div className="text-xs text-muted">
|
<div className="text-xs text-muted">
|
||||||
{state.kind === 'ready' ? (
|
{state.kind === 'ready' ? (
|
||||||
<>
|
<>
|
||||||
{state.data.length} indexed
|
{rowsToRender.length} indexed
|
||||||
<span> · </span>
|
<span> · </span>
|
||||||
{totalVariants} variants
|
{totalVariants} variants
|
||||||
|
{state.hasMore ? (
|
||||||
|
<>
|
||||||
|
<span> · </span>
|
||||||
|
more…
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : state.kind === 'loading' ? (
|
) : state.kind === 'loading' ? (
|
||||||
<span>Loading…</span>
|
<span>Loading…</span>
|
||||||
|
|
@ -309,7 +524,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div ref={listRef} className="flex-1 overflow-y-auto">
|
||||||
{state.kind === 'error' && (
|
{state.kind === 'error' && (
|
||||||
<div className="px-4 py-6 text-sm text-red-500">{state.message}</div>
|
<div className="px-4 py-6 text-sm text-red-500">{state.message}</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -328,27 +543,48 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
)}
|
)}
|
||||||
{state.kind === 'ready' && rowsToRender.length > 0 && (
|
{state.kind === 'ready' && rowsToRender.length > 0 && (
|
||||||
<ul className="divide-y divide-offbase">
|
<ul className="divide-y divide-offbase">
|
||||||
{rowsToRender.map(({ segmentIndex, sentenceText, row }) => {
|
{rowsToRender.map(({ segmentIndex, sentenceText, row, isCurrentLocation, groupKey, groupLabel }, rowIndex) => {
|
||||||
const isCurrent = segmentIndex === currentSentenceIndex;
|
const previousGroupKey = rowIndex > 0 ? rowsToRender[rowIndex - 1]?.groupKey : null;
|
||||||
const variants = row?.variants ?? [];
|
const showGroupHeader = previousGroupKey !== groupKey;
|
||||||
|
const isCurrent = isCurrentLocation && segmentIndex === currentSentenceIndex;
|
||||||
|
const variants = row.variants ?? [];
|
||||||
|
const bestVariant = variants
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => {
|
||||||
|
const rank = (status: TTSSegmentVariant['status']) => {
|
||||||
|
if (status === 'completed') return 3;
|
||||||
|
if (status === 'pending') return 2;
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
const byRank = rank(b.status) - rank(a.status);
|
||||||
|
if (byRank !== 0) return byRank;
|
||||||
|
return (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
|
||||||
|
})[0] ?? null;
|
||||||
const activeVariant = variants.find((v) => settingsAreEqual(v.settings, activeSettings))
|
const activeVariant = variants.find((v) => settingsAreEqual(v.settings, activeSettings))
|
||||||
?? variants[0]
|
?? bestVariant;
|
||||||
?? null;
|
|
||||||
const status = activeVariant?.status ?? 'pending';
|
const status = activeVariant?.status ?? 'pending';
|
||||||
const canJump = sentenceText.length > 0;
|
const canJump = !!row.locator || sentenceText.length > 0;
|
||||||
const playable = !!(activeVariant && activeVariant.audioPresignUrl);
|
const playable = !!(activeVariant && activeVariant.audioPresignUrl);
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={segmentIndex}
|
key={`${groupKey}::${segmentIndex}`}
|
||||||
|
data-active-segment={isCurrent ? 'true' : undefined}
|
||||||
className={`relative px-4 py-3 ${isCurrent ? 'bg-offbase/40' : ''}`}
|
className={`relative px-4 py-3 ${isCurrent ? 'bg-offbase/40' : ''}`}
|
||||||
>
|
>
|
||||||
|
{showGroupHeader && (
|
||||||
|
<div className="mb-2 -mx-4 px-4 py-1.5 bg-offbase/30 border-y border-offbase">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.14em] text-muted">
|
||||||
|
{groupLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<span className="absolute inset-y-2 left-0 w-0.5 bg-accent rounded-r" aria-hidden />
|
<span className="absolute inset-y-2 left-0 w-0.5 bg-accent rounded-r" aria-hidden />
|
||||||
)}
|
)}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { if (canJump) handleJump(segmentIndex); }}
|
onClick={() => { if (canJump) handleJump(segmentIndex, row.locator); }}
|
||||||
disabled={!canJump}
|
disabled={!canJump}
|
||||||
className={`text-xs font-medium shrink-0 pt-0.5 ${canJump ? 'text-muted hover:text-accent' : 'text-muted/50 cursor-not-allowed'}`}
|
className={`text-xs font-medium shrink-0 pt-0.5 ${canJump ? 'text-muted hover:text-accent' : 'text-muted/50 cursor-not-allowed'}`}
|
||||||
title={canJump ? (playable ? 'Play this segment' : 'Jump to this segment') : 'Text not loaded yet'}
|
title={canJump ? (playable ? 'Play this segment' : 'Jump to this segment') : 'Text not loaded yet'}
|
||||||
|
|
@ -360,7 +596,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { if (canJump) handleJump(segmentIndex); }}
|
onClick={() => { if (canJump) handleJump(segmentIndex, row.locator); }}
|
||||||
disabled={!canJump}
|
disabled={!canJump}
|
||||||
className={`block w-full text-left ${canJump ? '' : 'cursor-not-allowed'}`}
|
className={`block w-full text-left ${canJump ? '' : 'cursor-not-allowed'}`}
|
||||||
>
|
>
|
||||||
|
|
@ -427,15 +663,16 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{row && (
|
<SegmentMetadataPopover row={row} />
|
||||||
<SegmentMetadataPopover row={row} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
{state.kind === 'ready' && state.loadingMore && (
|
||||||
|
<div className="px-4 py-3 text-xs text-muted">Loading more segments…</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ReaderSidebarShell>
|
</ReaderSidebarShell>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,12 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights,
|
clearHighlights,
|
||||||
highlightWordIndex,
|
highlightWordIndex,
|
||||||
clearWordHighlights
|
clearWordHighlights,
|
||||||
|
walkUpcomingRenderedLocations,
|
||||||
} = useEPUB();
|
} = useEPUB();
|
||||||
const {
|
const {
|
||||||
registerLocationChangeHandler,
|
registerLocationChangeHandler,
|
||||||
|
registerEpubLocationWalker,
|
||||||
pause,
|
pause,
|
||||||
currentSentence,
|
currentSentence,
|
||||||
currentSentenceAlignment,
|
currentSentenceAlignment,
|
||||||
|
|
@ -71,7 +73,12 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
// Register the location change handler
|
// Register the location change handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerLocationChangeHandler(handleLocationChanged);
|
registerLocationChangeHandler(handleLocationChanged);
|
||||||
}, [registerLocationChangeHandler, handleLocationChanged]);
|
registerEpubLocationWalker(walkUpcomingRenderedLocations);
|
||||||
|
return () => {
|
||||||
|
registerLocationChangeHandler(null);
|
||||||
|
registerEpubLocationWalker(null);
|
||||||
|
};
|
||||||
|
}, [registerLocationChangeHandler, registerEpubLocationWalker, handleLocationChanged, walkUpcomingRenderedLocations]);
|
||||||
|
|
||||||
// Handle highlighting
|
// Handle highlighting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -132,7 +139,7 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => renditionRef.current?.prev()}
|
onClick={() => handleLocationChanged('prev')}
|
||||||
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
|
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="Previous section"
|
aria-label="Previous section"
|
||||||
>
|
>
|
||||||
|
|
@ -146,7 +153,7 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => renditionRef.current?.next()}
|
onClick={() => handleLocationChanged('next')}
|
||||||
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
|
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="Next section"
|
aria-label="Next section"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ interface ConfigContextType {
|
||||||
skipBlank: boolean;
|
skipBlank: boolean;
|
||||||
epubTheme: boolean;
|
epubTheme: boolean;
|
||||||
smartSentenceSplitting: boolean;
|
smartSentenceSplitting: boolean;
|
||||||
|
segmentPreloadDepthPages: number;
|
||||||
|
segmentPreloadSentenceLookahead: number;
|
||||||
|
ttsSegmentMaxBlockLength: number;
|
||||||
headerMargin: number;
|
headerMargin: number;
|
||||||
footerMargin: number;
|
footerMargin: number;
|
||||||
leftMargin: number;
|
leftMargin: number;
|
||||||
|
|
@ -278,6 +281,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
ttsInstructions,
|
ttsInstructions,
|
||||||
savedVoices,
|
savedVoices,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
|
segmentPreloadDepthPages,
|
||||||
|
segmentPreloadSentenceLookahead,
|
||||||
|
ttsSegmentMaxBlockLength,
|
||||||
pdfHighlightEnabled,
|
pdfHighlightEnabled,
|
||||||
pdfWordHighlightEnabled,
|
pdfWordHighlightEnabled,
|
||||||
epubHighlightEnabled,
|
epubHighlightEnabled,
|
||||||
|
|
@ -354,6 +360,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
skipBlank,
|
skipBlank,
|
||||||
epubTheme,
|
epubTheme,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
|
segmentPreloadDepthPages,
|
||||||
|
segmentPreloadSentenceLookahead,
|
||||||
|
ttsSegmentMaxBlockLength,
|
||||||
headerMargin,
|
headerMargin,
|
||||||
footerMargin,
|
footerMargin,
|
||||||
leftMargin,
|
leftMargin,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { setLastDocumentLocation } from '@/lib/client/dexie';
|
||||||
import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
|
import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
|
||||||
import { getDocumentMetadata } from '@/lib/client/api/documents';
|
import { getDocumentMetadata } from '@/lib/client/api/documents';
|
||||||
import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
||||||
|
import { EpubRenderedLocationCloneManager } from '@/lib/client/epub/rendered-location-walker';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||||
import { createRangeCfi } from '@/lib/client/epub';
|
import { createRangeCfi } from '@/lib/client/epub';
|
||||||
|
|
@ -29,6 +30,7 @@ import { useParams } from 'next/navigation';
|
||||||
import { useConfig } from './ConfigContext';
|
import { useConfig } from './ConfigContext';
|
||||||
import { CmpStr } from 'cmpstr';
|
import { CmpStr } from 'cmpstr';
|
||||||
import type {
|
import type {
|
||||||
|
EpubRenderedLocationWalker,
|
||||||
TTSSentenceAlignment,
|
TTSSentenceAlignment,
|
||||||
TTSAudiobookFormat,
|
TTSAudiobookFormat,
|
||||||
TTSAudiobookChapter,
|
TTSAudiobookChapter,
|
||||||
|
|
@ -44,6 +46,7 @@ interface EPUBContextType {
|
||||||
setCurrentDocument: (id: string) => Promise<void>;
|
setCurrentDocument: (id: string) => Promise<void>;
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
||||||
|
walkUpcomingRenderedLocations: EpubRenderedLocationWalker;
|
||||||
createFullAudioBook: (
|
createFullAudioBook: (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
|
@ -291,6 +294,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
ttsProvider,
|
ttsProvider,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
|
epubTheme,
|
||||||
epubHighlightEnabled,
|
epubHighlightEnabled,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
// Current document state
|
// Current document state
|
||||||
|
|
@ -310,6 +314,17 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
// Track current highlight CFI for removal
|
// Track current highlight CFI for removal
|
||||||
const currentHighlightCfi = useRef<string | null>(null);
|
const currentHighlightCfi = useRef<string | null>(null);
|
||||||
const currentWordHighlightCfi = useRef<string | null>(null);
|
const currentWordHighlightCfi = useRef<string | null>(null);
|
||||||
|
const renderedLocationCloneManagerRef = useRef<EpubRenderedLocationCloneManager>(
|
||||||
|
new EpubRenderedLocationCloneManager(),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
void renderedLocationCloneManagerRef.current.destroy();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
renderedLocationCloneManagerRef.current.invalidate();
|
||||||
|
}, [currDocData]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears all current document state and stops any active TTS
|
* Clears all current document state and stops any active TTS
|
||||||
|
|
@ -324,6 +339,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
renditionRef.current = undefined;
|
renditionRef.current = undefined;
|
||||||
locationRef.current = 1;
|
locationRef.current = 1;
|
||||||
tocRef.current = [];
|
tocRef.current = [];
|
||||||
|
renderedLocationCloneManagerRef.current.invalidate();
|
||||||
stop();
|
stop();
|
||||||
}, [setCurrDocPages, stop]);
|
}, [setCurrDocPages, stop]);
|
||||||
|
|
||||||
|
|
@ -413,6 +429,28 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
* Extracts text content from the entire EPUB book
|
* Extracts text content from the entire EPUB book
|
||||||
* @returns {Promise<string[]>} Array of text content from each section
|
* @returns {Promise<string[]>} Array of text content from each section
|
||||||
*/
|
*/
|
||||||
|
const loadSpineSection = useCallback(async (href: string) => {
|
||||||
|
const book = bookRef.current;
|
||||||
|
if (!book?.isOpen) return null;
|
||||||
|
const section = book.spine.get(href);
|
||||||
|
if (!section) return null;
|
||||||
|
const loaded = await Promise.resolve(section.load(book.load.bind(book)));
|
||||||
|
const doc = (() => {
|
||||||
|
if (!loaded) return null;
|
||||||
|
if (typeof Document !== 'undefined' && loaded instanceof Document) {
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
const element = loaded as unknown as Element;
|
||||||
|
if (element?.ownerDocument) {
|
||||||
|
return element.ownerDocument;
|
||||||
|
}
|
||||||
|
const sectionWithDocument = section as unknown as { document?: Document };
|
||||||
|
return sectionWithDocument.document ?? null;
|
||||||
|
})();
|
||||||
|
if (!doc) return null;
|
||||||
|
return { section, doc };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const extractBookText = useCallback(async (): Promise<Array<{ text: string; href: string }>> => {
|
const extractBookText = useCallback(async (): Promise<Array<{ text: string; href: string }>> => {
|
||||||
try {
|
try {
|
||||||
if (!bookRef.current || !bookRef.current.isOpen) return [{ text: '', href: '' }];
|
if (!bookRef.current || !bookRef.current.isOpen) return [{ text: '', href: '' }];
|
||||||
|
|
@ -424,17 +462,19 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
spine.each((item: SpineItem) => {
|
spine.each((item: SpineItem) => {
|
||||||
const url = item.href || '';
|
const url = item.href || '';
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
//console.log('Extracting text from section:', item as SpineItem);
|
const promise = loadSpineSection(url)
|
||||||
|
.then((loaded) => {
|
||||||
const promise = book.load(url)
|
if (!loaded?.doc) return { text: '', href: url };
|
||||||
.then((section) => (section as Document))
|
const text = loaded.doc.body?.textContent || '';
|
||||||
.then((section) => ({
|
return { text, href: url };
|
||||||
text: section.body.textContent || '',
|
})
|
||||||
href: url
|
|
||||||
}))
|
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(`Error loading section ${url}:`, err);
|
console.error(`Error loading section ${url}:`, err);
|
||||||
return { text: '', href: url };
|
return { text: '', href: url };
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
const section = book.spine.get(url);
|
||||||
|
section?.unload?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
promises.push(promise);
|
promises.push(promise);
|
||||||
|
|
@ -442,13 +482,50 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const textArray = await Promise.all(promises);
|
const textArray = await Promise.all(promises);
|
||||||
const filteredArray = textArray.filter(item => item.text.trim() !== '');
|
const filteredArray = textArray.filter(item => item.text.trim() !== '');
|
||||||
console.log('Extracted entire EPUB text array:', filteredArray);
|
|
||||||
return filteredArray;
|
return filteredArray;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error extracting EPUB text:', error);
|
console.error('Error extracting EPUB text:', error);
|
||||||
return [{ text: '', href: '' }];
|
return [{ text: '', href: '' }];
|
||||||
}
|
}
|
||||||
}, []);
|
}, [loadSpineSection]);
|
||||||
|
|
||||||
|
const walkUpcomingRenderedLocations = useCallback<EpubRenderedLocationWalker>(async (startCfi, depth, signal) => {
|
||||||
|
if (!startCfi || depth <= 0 || signal.aborted) return [];
|
||||||
|
const visibleRendition = renditionRef.current;
|
||||||
|
if (!currDocData || !visibleRendition || typeof document === 'undefined') return [];
|
||||||
|
|
||||||
|
const visibleSettings = (visibleRendition as unknown as {
|
||||||
|
settings?: {
|
||||||
|
spread?: string;
|
||||||
|
};
|
||||||
|
manager?: { stage?: { container?: Element | null }; container?: Element | null };
|
||||||
|
}).settings;
|
||||||
|
const containerElement =
|
||||||
|
(visibleRendition as unknown as { manager?: { stage?: { container?: Element | null }; container?: Element | null } }).manager?.stage?.container
|
||||||
|
?? (visibleRendition as unknown as { manager?: { container?: Element | null } }).manager?.container
|
||||||
|
?? null;
|
||||||
|
const bounds = containerElement?.getBoundingClientRect?.();
|
||||||
|
const width = Math.max(320, Math.floor(bounds?.width || 900));
|
||||||
|
const height = Math.max(320, Math.floor(bounds?.height || 700));
|
||||||
|
|
||||||
|
const theme = epubTheme
|
||||||
|
? {
|
||||||
|
foreground: getComputedStyle(document.documentElement).getPropertyValue('--foreground'),
|
||||||
|
base: getComputedStyle(document.documentElement).getPropertyValue('--base'),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return renderedLocationCloneManagerRef.current.walk({
|
||||||
|
data: currDocData,
|
||||||
|
startCfi,
|
||||||
|
depth,
|
||||||
|
signal,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
spread: visibleSettings?.spread,
|
||||||
|
theme,
|
||||||
|
});
|
||||||
|
}, [currDocData, epubTheme]);
|
||||||
|
|
||||||
const audiobookAdapter = useMemo(() => createEpubAudiobookSourceAdapter({
|
const audiobookAdapter = useMemo(() => createEpubAudiobookSourceAdapter({
|
||||||
extractBookText,
|
extractBookText,
|
||||||
|
|
@ -521,13 +598,55 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
renditionRef.current = rendition;
|
renditionRef.current = rendition;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => {
|
||||||
|
const book = bookRef.current;
|
||||||
|
const rendition = renditionRef.current;
|
||||||
|
if (!book?.isOpen || !rendition) return false;
|
||||||
|
|
||||||
|
const guardNavigationPromise = (promiseLike: unknown): void => {
|
||||||
|
const promise = Promise.resolve(promiseLike);
|
||||||
|
void promise.catch((error) => {
|
||||||
|
console.warn(`EPUB rendition ${navigation} failed:`, error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigation === 'display') {
|
||||||
|
if (!location) return false;
|
||||||
|
guardNavigationPromise(rendition.display(location));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (navigation === 'next') {
|
||||||
|
guardNavigationPromise(rendition.next());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
guardNavigationPromise(rendition.prev());
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`EPUB rendition ${navigation} failed:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLocationChanged = useCallback((location: string | number) => {
|
const handleLocationChanged = useCallback((location: string | number) => {
|
||||||
|
// Handle directional navigation before first-location initialization so
|
||||||
|
// "prev"/"next" are not treated as raw CFI strings.
|
||||||
|
if ((location === 'next' || location === 'prev') && renditionRef.current) {
|
||||||
|
if (!isEPUBSetOnce.current) {
|
||||||
|
setIsEPUB(true);
|
||||||
|
isEPUBSetOnce.current = true;
|
||||||
|
}
|
||||||
|
shouldPauseRef.current = false;
|
||||||
|
safeRenditionNavigate(location === 'next' ? 'next' : 'prev');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Set the EPUB flag once the location changes
|
// Set the EPUB flag once the location changes
|
||||||
if (!isEPUBSetOnce.current) {
|
if (!isEPUBSetOnce.current) {
|
||||||
setIsEPUB(true);
|
setIsEPUB(true);
|
||||||
isEPUBSetOnce.current = true;
|
isEPUBSetOnce.current = true;
|
||||||
|
|
||||||
renditionRef.current?.display(location.toString());
|
safeRenditionNavigate('display', location.toString());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -538,7 +657,10 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
if (typeof location === 'string' && location !== 'next' && location !== 'prev' && renditionRef.current?.location) {
|
if (typeof location === 'string' && location !== 'next' && location !== 'prev' && renditionRef.current?.location) {
|
||||||
const currentStartCfi = renditionRef.current.location?.start?.cfi;
|
const currentStartCfi = renditionRef.current.location?.start?.cfi;
|
||||||
if (currentStartCfi && location !== currentStartCfi) {
|
if (currentStartCfi && location !== currentStartCfi) {
|
||||||
renditionRef.current.display(location);
|
// Programmatic cross-location jumps (segments sidebar / TTS navigation)
|
||||||
|
// should keep autoplay intent after the rendition finishes navigating.
|
||||||
|
shouldPauseRef.current = false;
|
||||||
|
safeRenditionNavigate('display', location);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -546,18 +668,17 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
// Handle special 'next' and 'prev' cases
|
// Handle special 'next' and 'prev' cases
|
||||||
if (location === 'next' && renditionRef.current) {
|
if (location === 'next' && renditionRef.current) {
|
||||||
shouldPauseRef.current = false;
|
shouldPauseRef.current = false;
|
||||||
renditionRef.current.next();
|
safeRenditionNavigate('next');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (location === 'prev' && renditionRef.current) {
|
if (location === 'prev' && renditionRef.current) {
|
||||||
shouldPauseRef.current = false;
|
shouldPauseRef.current = false;
|
||||||
renditionRef.current.prev();
|
safeRenditionNavigate('prev');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the location to IndexedDB if not initial
|
// Save the location to IndexedDB if not initial
|
||||||
if (id && locationRef.current !== 1) {
|
if (id && locationRef.current !== 1) {
|
||||||
console.log('Saving location:', location);
|
|
||||||
setLastDocumentLocation(id as string, location.toString());
|
setLastDocumentLocation(id as string, location.toString());
|
||||||
if (authEnabled) {
|
if (authEnabled) {
|
||||||
scheduleDocumentProgressSync({
|
scheduleDocumentProgressSync({
|
||||||
|
|
@ -575,7 +696,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
extractPageText(bookRef.current, renditionRef.current, shouldPauseRef.current);
|
extractPageText(bookRef.current, renditionRef.current, shouldPauseRef.current);
|
||||||
shouldPauseRef.current = true;
|
shouldPauseRef.current = true;
|
||||||
}
|
}
|
||||||
}, [id, skipToLocation, extractPageText, setIsEPUB, authEnabled]);
|
}, [id, skipToLocation, extractPageText, setIsEPUB, authEnabled, safeRenditionNavigate]);
|
||||||
|
|
||||||
const clearWordHighlights = useCallback(() => {
|
const clearWordHighlights = useCallback(() => {
|
||||||
if (!renditionRef.current) return;
|
if (!renditionRef.current) return;
|
||||||
|
|
@ -622,9 +743,14 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
const cfi = content.cfiFromRange(range);
|
const cfi = content.cfiFromRange(range);
|
||||||
// Store CFI for removal
|
// Store CFI for removal
|
||||||
currentHighlightCfi.current = cfi;
|
currentHighlightCfi.current = cfi;
|
||||||
renditionRef.current.annotations.add('highlight', cfi, {}, (e: MouseEvent) => {
|
renditionRef.current.annotations.add(
|
||||||
console.log('Highlight clicked', e);
|
'highlight',
|
||||||
}, '', { fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' });
|
cfi,
|
||||||
|
{},
|
||||||
|
() => {},
|
||||||
|
'',
|
||||||
|
{ fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' },
|
||||||
|
);
|
||||||
|
|
||||||
// Clear the browser selection so it doesn't look like user selected text
|
// Clear the browser selection so it doesn't look like user selected text
|
||||||
sel?.removeAllRanges();
|
sel?.removeAllRanges();
|
||||||
|
|
@ -914,6 +1040,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
currDocText,
|
currDocText,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
|
walkUpcomingRenderedLocations,
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
regenerateChapter,
|
regenerateChapter,
|
||||||
bookRef,
|
bookRef,
|
||||||
|
|
@ -937,6 +1064,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
currDocText,
|
currDocText,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
|
walkUpcomingRenderedLocations,
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
regenerateChapter,
|
regenerateChapter,
|
||||||
handleLocationChanged,
|
handleLocationChanged,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import type {
|
||||||
TTSAudiobookChapter,
|
TTSAudiobookChapter,
|
||||||
} from '@/types/tts';
|
} from '@/types/tts';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
|
import { clampSegmentPreloadDepth } from '@/types/config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface defining all available methods and properties in the PDF context
|
* Interface defining all available methods and properties in the PDF context
|
||||||
|
|
@ -126,6 +127,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
ttsProvider,
|
ttsProvider,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
|
segmentPreloadDepthPages,
|
||||||
|
ttsSegmentMaxBlockLength,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
|
|
||||||
// Current document state
|
// Current document state
|
||||||
|
|
@ -144,7 +147,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
right: rightMargin,
|
right: rightMargin,
|
||||||
},
|
},
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
}), [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting]);
|
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||||
|
}), [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting, ttsSegmentMaxBlockLength]);
|
||||||
const pageTextCacheRef = useRef<Map<number, string>>(new Map());
|
const pageTextCacheRef = useRef<Map<number, string>>(new Map());
|
||||||
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
|
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
|
||||||
|
|
||||||
|
|
@ -173,7 +177,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
* @param {PDFDocumentProxy} pdf - The loaded PDF document proxy object
|
* @param {PDFDocumentProxy} pdf - The loaded PDF document proxy object
|
||||||
*/
|
*/
|
||||||
const onDocumentLoadSuccess = useCallback((pdf: PDFDocumentProxy) => {
|
const onDocumentLoadSuccess = useCallback((pdf: PDFDocumentProxy) => {
|
||||||
console.log('Document loaded:', pdf.numPages);
|
|
||||||
pdfDocGenerationRef.current += 1;
|
pdfDocGenerationRef.current += 1;
|
||||||
pdfDocumentRef.current = pdf;
|
pdfDocumentRef.current = pdf;
|
||||||
setCurrDocPages(pdf.numPages);
|
setCurrDocPages(pdf.numPages);
|
||||||
|
|
@ -239,12 +242,27 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const totalPages = currDocPages ?? currentPdf.numPages;
|
const totalPages = currDocPages ?? currentPdf.numPages;
|
||||||
const prevPageNumber = currDocPageNumber > 1 ? currDocPageNumber - 1 : undefined;
|
const prevPageNumber = currDocPageNumber > 1 ? currDocPageNumber - 1 : undefined;
|
||||||
const nextPageNumber = currDocPageNumber < totalPages ? currDocPageNumber + 1 : undefined;
|
const nextPageNumber = currDocPageNumber < totalPages ? currDocPageNumber + 1 : undefined;
|
||||||
|
const preloadDepth = clampSegmentPreloadDepth(segmentPreloadDepthPages);
|
||||||
|
const upcomingPageNumbers: number[] = [];
|
||||||
|
for (let offset = 1; offset <= preloadDepth; offset += 1) {
|
||||||
|
const pageNum = currDocPageNumber + offset;
|
||||||
|
if (pageNum > totalPages) break;
|
||||||
|
upcomingPageNumbers.push(pageNum);
|
||||||
|
}
|
||||||
|
|
||||||
const [text, prevText, nextText] = await Promise.all([
|
const [text, prevText, ...upcomingTexts] = await Promise.all([
|
||||||
getPageText(currDocPageNumber),
|
getPageText(currDocPageNumber),
|
||||||
prevPageNumber ? getPageText(prevPageNumber) : Promise.resolve<string | undefined>(undefined),
|
prevPageNumber ? getPageText(prevPageNumber) : Promise.resolve<string | undefined>(undefined),
|
||||||
nextPageNumber ? getPageText(nextPageNumber, true) : Promise.resolve<string | undefined>(undefined),
|
...upcomingPageNumbers.map((pageNum) => getPageText(pageNum, true)),
|
||||||
]);
|
]);
|
||||||
|
const nextText = upcomingTexts[0];
|
||||||
|
const additionalUpcoming = upcomingPageNumbers
|
||||||
|
.slice(1)
|
||||||
|
.map((pageNum, idx) => ({
|
||||||
|
location: pageNum,
|
||||||
|
text: upcomingTexts[idx + 1] || '',
|
||||||
|
}))
|
||||||
|
.filter((item) => item.text.trim().length > 0);
|
||||||
|
|
||||||
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -286,6 +304,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
previousText: prevText,
|
previousText: prevText,
|
||||||
nextLocation: nextPageNumber,
|
nextLocation: nextPageNumber,
|
||||||
nextText: nextText,
|
nextText: nextText,
|
||||||
|
upcomingLocations: additionalUpcoming,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -303,6 +322,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
footerMargin,
|
footerMargin,
|
||||||
leftMargin,
|
leftMargin,
|
||||||
rightMargin,
|
rightMargin,
|
||||||
|
segmentPreloadDepthPages,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -482,6 +502,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const clamped = Math.min(Math.max(location, 1), totalPages);
|
const clamped = Math.min(Math.max(location, 1), totalPages);
|
||||||
setCurrDocPage(clamped);
|
setCurrDocPage(clamped);
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
registerVisualPageChangeHandler(null);
|
||||||
|
};
|
||||||
}, [registerVisualPageChangeHandler, currDocPages, pdfDocument]);
|
}, [registerVisualPageChangeHandler, currDocPages, pdfDocument]);
|
||||||
|
|
||||||
// Context value memoization
|
// Context value memoization
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -121,22 +121,28 @@ export const getThemeStyles = (epubTheme: boolean): IReactReaderStyle => {
|
||||||
export const useEPUBTheme = (epubTheme: boolean, rendition: Rendition | undefined) => {
|
export const useEPUBTheme = (epubTheme: boolean, rendition: Rendition | undefined) => {
|
||||||
const updateTheme = useCallback(() => {
|
const updateTheme = useCallback(() => {
|
||||||
if (!epubTheme || !rendition) return;
|
if (!epubTheme || !rendition) return;
|
||||||
|
const maybeBook = (rendition as unknown as { book?: { isOpen?: boolean } }).book;
|
||||||
|
if (!maybeBook?.isOpen) return;
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
foreground: getComputedStyle(document.documentElement).getPropertyValue('--foreground'),
|
foreground: getComputedStyle(document.documentElement).getPropertyValue('--foreground'),
|
||||||
base: getComputedStyle(document.documentElement).getPropertyValue('--base'),
|
base: getComputedStyle(document.documentElement).getPropertyValue('--base'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register theme rules instead of using override
|
try {
|
||||||
rendition.themes.registerRules('theme-light', {
|
// Register theme rules instead of using override
|
||||||
'body': {
|
rendition.themes.registerRules('theme-light', {
|
||||||
'color': colors.foreground,
|
'body': {
|
||||||
'background-color': colors.base
|
'color': colors.foreground,
|
||||||
}
|
'background-color': colors.base
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Select the theme to apply it
|
// Select the theme to apply it
|
||||||
rendition.themes.select('theme-light');
|
rendition.themes.select('theme-light');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to apply EPUB theme rules:', error);
|
||||||
|
}
|
||||||
}, [epubTheme, rendition]);
|
}, [epubTheme, rendition]);
|
||||||
|
|
||||||
// Watch for theme changes
|
// Watch for theme changes
|
||||||
|
|
@ -165,5 +171,23 @@ export const useEPUBTheme = (epubTheme: boolean, rendition: Rendition | undefine
|
||||||
updateTheme();
|
updateTheme();
|
||||||
}, [epubTheme, rendition, updateTheme]);
|
}, [epubTheme, rendition, updateTheme]);
|
||||||
|
|
||||||
|
// Ensure theme is applied once the rendition has fully rendered/opened.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!epubTheme || !rendition) return;
|
||||||
|
const emitter = rendition as unknown as {
|
||||||
|
on?: (event: string, callback: () => void) => void;
|
||||||
|
off?: (event: string, callback: () => void) => void;
|
||||||
|
};
|
||||||
|
if (!emitter.on) return;
|
||||||
|
|
||||||
|
const handleRendered = () => {
|
||||||
|
updateTheme();
|
||||||
|
};
|
||||||
|
emitter.on('rendered', handleRendered);
|
||||||
|
return () => {
|
||||||
|
emitter.off?.('rendered', handleRendered);
|
||||||
|
};
|
||||||
|
}, [epubTheme, rendition, updateTheme]);
|
||||||
|
|
||||||
return { updateTheme };
|
return { updateTheme };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,11 @@ export const withRetry = async <T>(
|
||||||
|
|
||||||
// Do not retry on explicit cancellation/abort errors - surface them
|
// Do not retry on explicit cancellation/abort errors - surface them
|
||||||
// immediately so callers can stop work quickly when the user cancels.
|
// immediately so callers can stop work quickly when the user cancels.
|
||||||
if (lastError.name === 'AbortError' || lastError.message.includes('cancelled')) {
|
if (
|
||||||
|
lastError.name === 'AbortError'
|
||||||
|
|| lastError.message.toLowerCase().includes('cancelled')
|
||||||
|
|| lastError.message.toLowerCase().includes('aborted')
|
||||||
|
) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,14 @@ interface PdfAudiobookAdapterOptions {
|
||||||
right: number;
|
right: number;
|
||||||
};
|
};
|
||||||
smartSentenceSplitting: boolean;
|
smartSentenceSplitting: boolean;
|
||||||
|
maxBlockLength?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractPreparedPdfChapters({
|
async function extractPreparedPdfChapters({
|
||||||
pdfDocument,
|
pdfDocument,
|
||||||
margins,
|
margins,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
|
maxBlockLength,
|
||||||
}: PdfAudiobookAdapterOptions): Promise<PreparedAudiobookChapter[]> {
|
}: PdfAudiobookAdapterOptions): Promise<PreparedAudiobookChapter[]> {
|
||||||
if (!pdfDocument) {
|
if (!pdfDocument) {
|
||||||
throw new Error('No PDF document loaded');
|
throw new Error('No PDF document loaded');
|
||||||
|
|
@ -35,7 +37,7 @@ async function extractPreparedPdfChapters({
|
||||||
chapters.push({
|
chapters.push({
|
||||||
index: chapters.length,
|
index: chapters.length,
|
||||||
title: `Page ${chapters.length + 1}`,
|
title: `Page ${chapters.length + 1}`,
|
||||||
text: smartSentenceSplitting ? normalizeTextForTts(trimmedText) : trimmedText,
|
text: smartSentenceSplitting ? normalizeTextForTts(trimmedText, { maxBlockLength }) : trimmedText,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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;
|
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');
|
return createHash('sha256').update(stableStringify(locator)).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canonicalLocatorJson(locator: TTSSegmentLocator | null | undefined): string | null {
|
||||||
|
if (!locator) return null;
|
||||||
|
return stableStringify(locator);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizeLocatorJsonString(json: string | null | undefined): string | null {
|
||||||
|
if (!json) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
if (parsed === null || typeof parsed !== 'object') return null;
|
||||||
|
return stableStringify(parsed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTtsSegmentId(input: {
|
export function buildTtsSegmentId(input: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
documentVersion: number;
|
documentVersion: number;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,17 @@
|
||||||
import nlp from 'compromise';
|
import nlp from 'compromise';
|
||||||
|
|
||||||
export const MAX_BLOCK_LENGTH = 450;
|
export const MAX_BLOCK_LENGTH = 450;
|
||||||
|
const MIN_BLOCK_LENGTH = 50;
|
||||||
|
|
||||||
|
export interface TtsSplitOptions {
|
||||||
|
maxBlockLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMaxBlockLength(options?: TtsSplitOptions): number {
|
||||||
|
const candidate = Number(options?.maxBlockLength ?? MAX_BLOCK_LENGTH);
|
||||||
|
if (!Number.isFinite(candidate)) return MAX_BLOCK_LENGTH;
|
||||||
|
return Math.max(MIN_BLOCK_LENGTH, Math.floor(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
const splitOversizedText = (text: string, maxLen: number): string[] => {
|
const splitOversizedText = (text: string, maxLen: number): string[] => {
|
||||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||||
|
|
@ -138,7 +149,8 @@ export const preprocessSentenceForAudio = (text: string): string => {
|
||||||
* @param {string} text - The text to split into sentences
|
* @param {string} text - The text to split into sentences
|
||||||
* @returns {string[]} Array of sentence blocks
|
* @returns {string[]} Array of sentence blocks
|
||||||
*/
|
*/
|
||||||
export const splitTextToTtsBlocks = (text: string): string[] => {
|
export const splitTextToTtsBlocks = (text: string, options?: TtsSplitOptions): string[] => {
|
||||||
|
const maxBlockLength = resolveMaxBlockLength(options);
|
||||||
// Treat double-newlines as paragraph boundaries; single newlines are usually
|
// Treat double-newlines as paragraph boundaries; single newlines are usually
|
||||||
// just PDF line wrapping and should not force sentence/block boundaries.
|
// just PDF line wrapping and should not force sentence/block boundaries.
|
||||||
const paragraphs = text.split(/\n{2,}/);
|
const paragraphs = text.split(/\n{2,}/);
|
||||||
|
|
@ -160,10 +172,10 @@ export const splitTextToTtsBlocks = (text: string): string[] => {
|
||||||
|
|
||||||
for (const sentence of mergedSentences) {
|
for (const sentence of mergedSentences) {
|
||||||
const trimmedSentence = sentence.trim();
|
const trimmedSentence = sentence.trim();
|
||||||
const sentenceParts = splitOversizedText(trimmedSentence, MAX_BLOCK_LENGTH);
|
const sentenceParts = splitOversizedText(trimmedSentence, maxBlockLength);
|
||||||
|
|
||||||
for (const sentencePart of sentenceParts) {
|
for (const sentencePart of sentenceParts) {
|
||||||
if (currentBlock && (currentBlock.length + sentencePart.length + 1) > MAX_BLOCK_LENGTH) {
|
if (currentBlock && (currentBlock.length + sentencePart.length + 1) > maxBlockLength) {
|
||||||
blocks.push(currentBlock.trim());
|
blocks.push(currentBlock.trim());
|
||||||
currentBlock = sentencePart;
|
currentBlock = sentencePart;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -186,7 +198,8 @@ export const splitTextToTtsBlocks = (text: string): string[] => {
|
||||||
* EPUB block splitting used where we want the produced sentences
|
* EPUB block splitting used where we want the produced sentences
|
||||||
* to closely match the original DOM text (for exact-match highlighting).
|
* to closely match the original DOM text (for exact-match highlighting).
|
||||||
*/
|
*/
|
||||||
export const splitTextToTtsBlocksEPUB = (text: string): string[] => {
|
export const splitTextToTtsBlocksEPUB = (text: string, options?: TtsSplitOptions): string[] => {
|
||||||
|
const maxBlockLength = resolveMaxBlockLength(options);
|
||||||
const paragraphs = text.split(/\n+/);
|
const paragraphs = text.split(/\n+/);
|
||||||
const blocks: string[] = [];
|
const blocks: string[] = [];
|
||||||
|
|
||||||
|
|
@ -204,12 +217,12 @@ export const splitTextToTtsBlocksEPUB = (text: string): string[] => {
|
||||||
for (const sentence of mergedSentences) {
|
for (const sentence of mergedSentences) {
|
||||||
const trimmedSentence = sentence.trim();
|
const trimmedSentence = sentence.trim();
|
||||||
const sentenceParts =
|
const sentenceParts =
|
||||||
trimmedSentence.length > MAX_BLOCK_LENGTH
|
trimmedSentence.length > maxBlockLength
|
||||||
? splitOversizedText(trimmedSentence, MAX_BLOCK_LENGTH)
|
? splitOversizedText(trimmedSentence, maxBlockLength)
|
||||||
: [trimmedSentence];
|
: [trimmedSentence];
|
||||||
|
|
||||||
for (const sentencePart of sentenceParts) {
|
for (const sentencePart of sentenceParts) {
|
||||||
if (currentBlock && (currentBlock.length + sentencePart.length + 1) > MAX_BLOCK_LENGTH) {
|
if (currentBlock && (currentBlock.length + sentencePart.length + 1) > maxBlockLength) {
|
||||||
blocks.push(currentBlock.trim());
|
blocks.push(currentBlock.trim());
|
||||||
currentBlock = sentencePart;
|
currentBlock = sentencePart;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -235,8 +248,8 @@ export const splitTextToTtsBlocksEPUB = (text: string): string[] => {
|
||||||
* @param {string} text - The text to process
|
* @param {string} text - The text to process
|
||||||
* @returns {string} Normalized text
|
* @returns {string} Normalized text
|
||||||
*/
|
*/
|
||||||
export const normalizeTextForTts = (text: string): string =>
|
export const normalizeTextForTts = (text: string, options?: TtsSplitOptions): string =>
|
||||||
splitTextToTtsBlocks(text).join(' ');
|
splitTextToTtsBlocks(text, options).join(' ');
|
||||||
|
|
||||||
// Helper functions to merge quoted dialogue across sentences
|
// Helper functions to merge quoted dialogue across sentences
|
||||||
const countDoubleQuotes = (s: string): number => {
|
const countDoubleQuotes = (s: string): number => {
|
||||||
|
|
|
||||||
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 {
|
export interface TTSSegmentsManifestResponse {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
segments: TTSSegmentRow[];
|
segments: TTSSegmentRow[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,29 @@ export type ViewType = 'single' | 'dual' | 'scroll';
|
||||||
|
|
||||||
export type SavedVoices = Record<string, string>;
|
export type SavedVoices = Record<string, string>;
|
||||||
|
|
||||||
|
export const SEGMENT_PRELOAD_DEPTH_MIN = 1;
|
||||||
|
export const SEGMENT_PRELOAD_DEPTH_MAX = 5;
|
||||||
|
export const SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN = 1;
|
||||||
|
export const SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX = 10;
|
||||||
|
export const TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN = 150;
|
||||||
|
export const TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX = 1200;
|
||||||
|
export const TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP = 25;
|
||||||
|
|
||||||
|
export function clampSegmentPreloadDepth(value: number | undefined | null): number {
|
||||||
|
const candidate = Math.floor(Number(value) || SEGMENT_PRELOAD_DEPTH_MIN);
|
||||||
|
return Math.max(SEGMENT_PRELOAD_DEPTH_MIN, Math.min(SEGMENT_PRELOAD_DEPTH_MAX, candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampSegmentPreloadSentenceLookahead(value: number | undefined | null): number {
|
||||||
|
const candidate = Math.floor(Number(value) || SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN);
|
||||||
|
return Math.max(SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN, Math.min(SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX, candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampTtsSegmentMaxBlockLength(value: number | undefined | null): number {
|
||||||
|
const candidate = Math.floor(Number(value) || TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN);
|
||||||
|
return Math.max(TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN, Math.min(TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX, candidate));
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppConfigValues {
|
export interface AppConfigValues {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
@ -26,6 +49,9 @@ export interface AppConfigValues {
|
||||||
ttsInstructions: string;
|
ttsInstructions: string;
|
||||||
savedVoices: SavedVoices;
|
savedVoices: SavedVoices;
|
||||||
smartSentenceSplitting: boolean;
|
smartSentenceSplitting: boolean;
|
||||||
|
segmentPreloadDepthPages: number;
|
||||||
|
segmentPreloadSentenceLookahead: number;
|
||||||
|
ttsSegmentMaxBlockLength: number;
|
||||||
pdfHighlightEnabled: boolean;
|
pdfHighlightEnabled: boolean;
|
||||||
pdfWordHighlightEnabled: boolean;
|
pdfWordHighlightEnabled: boolean;
|
||||||
epubHighlightEnabled: boolean;
|
epubHighlightEnabled: boolean;
|
||||||
|
|
@ -54,6 +80,9 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
||||||
ttsInstructions: '',
|
ttsInstructions: '',
|
||||||
savedVoices: {},
|
savedVoices: {},
|
||||||
smartSentenceSplitting: true,
|
smartSentenceSplitting: true,
|
||||||
|
segmentPreloadDepthPages: 1,
|
||||||
|
segmentPreloadSentenceLookahead: 3,
|
||||||
|
ttsSegmentMaxBlockLength: 450,
|
||||||
pdfHighlightEnabled: true,
|
pdfHighlightEnabled: true,
|
||||||
pdfWordHighlightEnabled: wordHighlightEnabledByDefault,
|
pdfWordHighlightEnabled: wordHighlightEnabledByDefault,
|
||||||
epubHighlightEnabled: true,
|
epubHighlightEnabled: true,
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,12 @@ export interface TTSSentenceAlignment {
|
||||||
words: TTSSentenceWord[];
|
words: TTSSentenceWord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EpubRenderedLocationWalker = (
|
||||||
|
startCfi: string,
|
||||||
|
depth: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) => Promise<Array<{ location: string; text: string }>>;
|
||||||
|
|
||||||
// Supported output formats for generated audiobooks
|
// Supported output formats for generated audiobooks
|
||||||
export type TTSAudiobookFormat = 'mp3' | 'm4b';
|
export type TTSAudiobookFormat = 'mp3' | 'm4b';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ export const SYNCED_PREFERENCE_KEYS = [
|
||||||
'skipBlank',
|
'skipBlank',
|
||||||
'epubTheme',
|
'epubTheme',
|
||||||
'smartSentenceSplitting',
|
'smartSentenceSplitting',
|
||||||
|
'segmentPreloadDepthPages',
|
||||||
|
'segmentPreloadSentenceLookahead',
|
||||||
|
'ttsSegmentMaxBlockLength',
|
||||||
'headerMargin',
|
'headerMargin',
|
||||||
'footerMargin',
|
'footerMargin',
|
||||||
'leftMargin',
|
'leftMargin',
|
||||||
|
|
@ -36,4 +39,3 @@ export interface DocumentProgressRecord {
|
||||||
clientUpdatedAtMs: number;
|
clientUpdatedAtMs: number;
|
||||||
updatedAtMs: number;
|
updatedAtMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,8 @@ test.describe('PDF view modes and Navigator', () => {
|
||||||
// Open document settings (page-level settings)
|
// Open document settings (page-level settings)
|
||||||
await page.getByRole('button', { name: 'Open settings' }).click();
|
await page.getByRole('button', { name: 'Open settings' }).click();
|
||||||
|
|
||||||
// The mode Listbox initially shows "Single Page" by default; switch to "Two Pages"
|
// Page mode now uses pill-style radios; switch from Single Page to Two Pages
|
||||||
await page.getByRole('button', { name: /Single Page|Two Pages|Continuous Scroll/i }).click();
|
await page.getByRole('radiogroup', { name: 'Page mode' }).getByRole('radio', { name: 'Two Pages' }).click();
|
||||||
await page.getByRole('option', { name: 'Two Pages' }).click();
|
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
// Expect dual-page rendering (sample.pdf has >= 2 pages)
|
// Expect dual-page rendering (sample.pdf has >= 2 pages)
|
||||||
|
|
@ -84,8 +83,7 @@ test.describe('PDF view modes and Navigator', () => {
|
||||||
|
|
||||||
// Switch to Continuous Scroll
|
// Switch to Continuous Scroll
|
||||||
await page.getByRole('button', { name: 'Open settings' }).click();
|
await page.getByRole('button', { name: 'Open settings' }).click();
|
||||||
await page.getByRole('button', { name: /Single Page|Two Pages|Continuous Scroll/i }).click();
|
await page.getByRole('radiogroup', { name: 'Page mode' }).getByRole('radio', { name: 'Continuous Scroll' }).click();
|
||||||
await page.getByRole('option', { name: 'Continuous Scroll' }).click();
|
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
// Expect continuous scroll renders at least as many pages as dual mode
|
// Expect continuous scroll renders at least as many pages as dual mode
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,14 @@ test.describe('splitTextToTtsBlocks (PDF-oriented)', () => {
|
||||||
expectNormalizedBlocks(result, MAX_BLOCK_LENGTH);
|
expectNormalizedBlocks(result, MAX_BLOCK_LENGTH);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('supports configurable max block length', () => {
|
||||||
|
const input = Array(600).fill('word').join(' ');
|
||||||
|
const result = splitTextToTtsBlocks(input, { maxBlockLength: 220 });
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThan(1);
|
||||||
|
expectNormalizedBlocks(result, 220 * 2);
|
||||||
|
});
|
||||||
|
|
||||||
test('prefers sentence punctuation when chunking long PDF-like text', () => {
|
test('prefers sentence punctuation when chunking long PDF-like text', () => {
|
||||||
const sentences = Array.from(
|
const sentences = Array.from(
|
||||||
{ length: 80 },
|
{ length: 80 },
|
||||||
|
|
@ -175,6 +183,14 @@ test.describe('splitTextToTtsBlocksEPUB (highlight-friendly)', () => {
|
||||||
expect(result.length).toBeGreaterThan(1);
|
expect(result.length).toBeGreaterThan(1);
|
||||||
expectNormalizedBlocks(result, MAX_BLOCK_LENGTH);
|
expectNormalizedBlocks(result, MAX_BLOCK_LENGTH);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('supports configurable max block length for EPUB mode', () => {
|
||||||
|
const input = Array(600).fill('word').join(' ');
|
||||||
|
const result = splitTextToTtsBlocksEPUB(input, { maxBlockLength: 180 });
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThan(1);
|
||||||
|
expectNormalizedBlocks(result, 180);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('normalizeTextForTts', () => {
|
test.describe('normalizeTextForTts', () => {
|
||||||
|
|
|
||||||
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