diff --git a/drizzle/postgres/0001_tts_segments.sql b/drizzle/postgres/0001_tts_segments.sql index 98580c4..84ce8d1 100644 --- a/drizzle/postgres/0001_tts_segments.sql +++ b/drizzle/postgres/0001_tts_segments.sql @@ -1,4 +1,3 @@ -DROP TABLE IF EXISTS "tts_segments" CASCADE;--> statement-breakpoint CREATE TABLE "tts_segments" ( "segment_id" text NOT NULL, "user_id" text NOT NULL, @@ -7,6 +6,7 @@ CREATE TABLE "tts_segments" ( "document_version" bigint NOT NULL, "segment_index" integer NOT NULL, "locator_json" text, + "settings_hash" text NOT NULL, "settings_json" jsonb NOT NULL, "text_hash" text NOT NULL, "text_length" integer DEFAULT 0 NOT NULL, @@ -22,5 +22,5 @@ CREATE TABLE "tts_segments" ( ); --> statement-breakpoint ALTER TABLE "tts_segments" ADD CONSTRAINT "tts_segments_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "idx_tts_segments_lookup" ON "tts_segments" USING btree ("user_id","document_id","document_version","settings_json");--> statement-breakpoint +CREATE INDEX "idx_tts_segments_lookup" ON "tts_segments" USING btree ("user_id","document_id","document_version","settings_hash");--> statement-breakpoint CREATE INDEX "idx_tts_segments_doc_index" ON "tts_segments" USING btree ("user_id","document_id","segment_index"); \ No newline at end of file diff --git a/drizzle/postgres/meta/0001_snapshot.json b/drizzle/postgres/meta/0001_snapshot.json index 5a990f0..6922786 100644 --- a/drizzle/postgres/meta/0001_snapshot.json +++ b/drizzle/postgres/meta/0001_snapshot.json @@ -1,5 +1,5 @@ { - "id": "40f491fc-703e-42c5-a56b-a3f60068f269", + "id": "f4752b9c-f77d-45c6-91a1-ce5eb03b246b", "prevId": "a6243c0b-4032-4549-aa08-1f7602362f15", "version": "7", "dialect": "postgresql", @@ -510,6 +510,12 @@ "primaryKey": false, "notNull": false }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, "settings_json": { "name": "settings_json", "type": "jsonb", @@ -605,7 +611,7 @@ "nulls": "last" }, { - "expression": "settings_json", + "expression": "settings_hash", "isExpression": false, "asc": true, "nulls": "last" diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index ee0dfee..1cc5219 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -12,7 +12,7 @@ { "idx": 1, "version": "7", - "when": 1777920947745, + "when": 1777928320804, "tag": "0001_tts_segments", "breakpoints": true } diff --git a/drizzle/sqlite/0001_tts_segments.sql b/drizzle/sqlite/0001_tts_segments.sql index b153d09..23b36e8 100644 --- a/drizzle/sqlite/0001_tts_segments.sql +++ b/drizzle/sqlite/0001_tts_segments.sql @@ -1,4 +1,3 @@ -DROP TABLE IF EXISTS `tts_segments`;--> statement-breakpoint CREATE TABLE `tts_segments` ( `segment_id` text NOT NULL, `user_id` text NOT NULL, @@ -7,6 +6,7 @@ CREATE TABLE `tts_segments` ( `document_version` integer NOT NULL, `segment_index` integer NOT NULL, `locator_json` text, + `settings_hash` text NOT NULL, `settings_json` text NOT NULL, `text_hash` text NOT NULL, `text_length` integer DEFAULT 0 NOT NULL, @@ -22,5 +22,5 @@ CREATE TABLE `tts_segments` ( FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint -CREATE INDEX `idx_tts_segments_lookup` ON `tts_segments` (`user_id`,`document_id`,`document_version`,`settings_json`);--> statement-breakpoint +CREATE INDEX `idx_tts_segments_lookup` ON `tts_segments` (`user_id`,`document_id`,`document_version`,`settings_hash`);--> statement-breakpoint CREATE INDEX `idx_tts_segments_doc_index` ON `tts_segments` (`user_id`,`document_id`,`segment_index`); \ No newline at end of file diff --git a/drizzle/sqlite/meta/0001_snapshot.json b/drizzle/sqlite/meta/0001_snapshot.json index 486474a..c0f7816 100644 --- a/drizzle/sqlite/meta/0001_snapshot.json +++ b/drizzle/sqlite/meta/0001_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "8c86c476-e208-4bc1-8fff-7111b3d7ab38", + "id": "c7822c2d-e42e-4c2e-868d-77175178be88", "prevId": "23271fb3-935b-4a9b-a890-e73fc9943bef", "tables": { "audiobook_chapters": { @@ -511,6 +511,13 @@ "notNull": false, "autoincrement": false }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "settings_json": { "name": "settings_json", "type": "text", @@ -601,7 +608,7 @@ "user_id", "document_id", "document_version", - "settings_json" + "settings_hash" ], "isUnique": false }, diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 674e0c8..0e82b2b 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -12,7 +12,7 @@ { "idx": 1, "version": "6", - "when": 1777920947478, + "when": 1777928320552, "tag": "0001_tts_segments", "breakpoints": true } diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index bae604c..69659cb 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -12,6 +12,7 @@ import { useTTS } from "@/contexts/TTSContext"; import TTSPlayer from '@/components/player/TTSPlayer'; import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu'; +import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; @@ -29,8 +30,7 @@ export default function EPUBPage() { const { isAtLimit } = useAuthRateLimit(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false); + const [activeSidebar, setActiveSidebar] = useState(null); const [containerHeight, setContainerHeight] = useState('auto'); const [padPct, setPadPct] = useState(100); // 0..100 (100 = full width, 0 = max padding) const [maxPadPx, setMaxPadPx] = useState(0); @@ -40,6 +40,7 @@ export default function EPUBPage() { useEffect(() => { setIsLoading(true); setError(null); + setActiveSidebar(null); inFlightDocIdRef.current = null; loadedDocIdRef.current = null; }, [id]); @@ -180,8 +181,12 @@ export default function EPUBPage() { zoomLevel={padPct} onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))} onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))} - onOpenSettings={() => setIsSettingsOpen(true)} - onOpenAudiobook={() => setIsAudiobookModalOpen(true)} + onOpenSettings={() => setActiveSidebar((prev) => prev === 'settings' ? null : 'settings')} + onOpenAudiobook={() => setActiveSidebar((prev) => prev === 'audiobook' ? null : 'audiobook')} + onOpenSegments={() => setActiveSidebar((prev) => prev === 'segments' ? null : 'segments')} + isSettingsOpen={activeSidebar === 'settings'} + isAudiobookOpen={activeSidebar === 'audiobook'} + isSegmentsOpen={activeSidebar === 'segments'} showAudiobookExport={canExportAudiobook} minZoom={0} maxZoom={100} @@ -203,8 +208,8 @@ export default function EPUBPage() { {canExportAudiobook && ( setActiveSidebar((prev) => isOpen ? 'audiobook' : (prev === 'audiobook' ? null : prev))} documentType="epub" documentId={id as string} onGenerateAudiobook={handleGenerateAudiobook} @@ -221,7 +226,16 @@ export default function EPUBPage() { ) : ( )} - + setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))} + /> + setActiveSidebar((prev) => isOpen ? 'segments' : (prev === 'segments' ? null : prev))} + documentId={id as string} + /> ); } diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index c1ab787..7d84452 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -13,6 +13,7 @@ import { useTTS } from "@/contexts/TTSContext"; import TTSPlayer from '@/components/player/TTSPlayer'; import { resolveDocumentId } from '@/lib/client/dexie'; import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu'; +import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; @@ -24,7 +25,7 @@ export default function HTMLPage() { const { isAtLimit } = useAuthRateLimit(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [activeSidebar, setActiveSidebar] = useState(null); const [containerHeight, setContainerHeight] = useState('auto'); const [padPct, setPadPct] = useState(100); // 0..100 (100 = full width) const [maxPadPx, setMaxPadPx] = useState(0); @@ -34,6 +35,7 @@ export default function HTMLPage() { useEffect(() => { setIsLoading(true); setError(null); + setActiveSidebar(null); inFlightDocIdRef.current = null; loadedDocIdRef.current = null; }, [id]); @@ -149,7 +151,10 @@ export default function HTMLPage() { zoomLevel={padPct} onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))} onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))} - onOpenSettings={() => setIsSettingsOpen(true)} + onOpenSettings={() => setActiveSidebar((prev) => prev === 'settings' ? null : 'settings')} + onOpenSegments={() => setActiveSidebar((prev) => prev === 'segments' ? null : 'segments')} + isSettingsOpen={activeSidebar === 'settings'} + isSegmentsOpen={activeSidebar === 'segments'} minZoom={0} maxZoom={100} /> @@ -177,7 +182,16 @@ export default function HTMLPage() { ) : ( )} - + setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))} + /> + setActiveSidebar((prev) => isOpen ? 'segments' : (prev === 'segments' ? null : prev))} + documentId={id as string} + /> ); } diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 7b43bb0..6fc280b 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -9,6 +9,7 @@ import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { useTTS } from '@/contexts/TTSContext'; import { DocumentSettings } from '@/components/documents/DocumentSettings'; import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu'; +import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar'; import { Header } from '@/components/Header'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import type { TTSAudiobookChapter } from '@/types/tts'; @@ -39,8 +40,7 @@ export default function PDFViewerPage() { const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [zoomLevel, setZoomLevel] = useState(100); - const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false); + const [activeSidebar, setActiveSidebar] = useState(null); const [containerHeight, setContainerHeight] = useState('auto'); const inFlightDocIdRef = useRef(null); const loadedDocIdRef = useRef(null); @@ -48,6 +48,7 @@ export default function PDFViewerPage() { useEffect(() => { setIsLoading(true); setError(null); + setActiveSidebar(null); inFlightDocIdRef.current = null; loadedDocIdRef.current = null; }, [id]); @@ -176,8 +177,12 @@ export default function PDFViewerPage() { zoomLevel={zoomLevel} onZoomIncrease={handleZoomIn} onZoomDecrease={handleZoomOut} - onOpenSettings={() => setIsSettingsOpen(true)} - onOpenAudiobook={() => setIsAudiobookModalOpen(true)} + onOpenSettings={() => setActiveSidebar((prev) => prev === 'settings' ? null : 'settings')} + onOpenAudiobook={() => setActiveSidebar((prev) => prev === 'audiobook' ? null : 'audiobook')} + onOpenSegments={() => setActiveSidebar((prev) => prev === 'segments' ? null : 'segments')} + isSettingsOpen={activeSidebar === 'settings'} + isAudiobookOpen={activeSidebar === 'audiobook'} + isSegmentsOpen={activeSidebar === 'segments'} showAudiobookExport={canExportAudiobook} minZoom={50} maxZoom={300} @@ -197,8 +202,8 @@ export default function PDFViewerPage() { {canExportAudiobook && ( setActiveSidebar((prev) => isOpen ? 'audiobook' : (prev === 'audiobook' ? null : prev))} documentType="pdf" documentId={id as string} onGenerateAudiobook={handleGenerateAudiobook} @@ -215,7 +220,15 @@ export default function PDFViewerPage() { ) : ( )} - + setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))} + /> + setActiveSidebar((prev) => isOpen ? 'segments' : (prev === 'segments' ? null : prev))} + documentId={id as string} + /> ); } diff --git a/src/app/api/tts/segments/clear/route.ts b/src/app/api/tts/segments/clear/route.ts new file mode 100644 index 0000000..41d7e34 --- /dev/null +++ b/src/app/api/tts/segments/clear/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { ttsSegments } from '@/db/schema'; +import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore'; +import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function parseBody(value: unknown): { documentId: string } | null { + if (!value || typeof value !== 'object') return null; + const rec = value as Record; + if (typeof rec.documentId !== 'string' || !rec.documentId.trim()) return null; + return { documentId: rec.documentId.trim().toLowerCase() }; +} + +export async function POST(request: NextRequest) { + try { + const parsed = parseBody(await request.json().catch(() => null)); + if (!parsed) { + return NextResponse.json({ error: 'Invalid request payload' }, { status: 400 }); + } + + const scope = await resolveSegmentDocumentScope(request, parsed.documentId); + if (scope instanceof Response) return scope; + + const rows = (await db + .select({ + segmentId: ttsSegments.segmentId, + audioKey: ttsSegments.audioKey, + }) + .from(ttsSegments) + .where(and( + eq(ttsSegments.userId, scope.storageUserId), + eq(ttsSegments.documentId, parsed.documentId), + eq(ttsSegments.documentVersion, scope.documentVersion), + ))) as Array<{ segmentId: string; audioKey: string | null }>; + + await db + .delete(ttsSegments) + .where(and( + eq(ttsSegments.userId, scope.storageUserId), + eq(ttsSegments.documentId, parsed.documentId), + eq(ttsSegments.documentVersion, scope.documentVersion), + )); + + const audioKeys = rows + .map((row) => row.audioKey) + .filter((key): key is string => Boolean(key)); + + let deletedAudioObjects = 0; + let warning: string | undefined; + if (audioKeys.length > 0) { + try { + deletedAudioObjects = await deleteTtsSegmentAudioObjects(audioKeys); + } catch (error) { + warning = error instanceof Error ? error.message : 'Failed deleting some audio objects'; + console.warn('Failed clearing some TTS segment audio objects:', { + documentId: parsed.documentId, + userId: scope.storageUserId, + error: warning, + }); + } + } + + return NextResponse.json({ + documentId: parsed.documentId, + deletedSegments: rows.length, + deletedAudioObjects, + ...(warning ? { warning } : {}), + }); + } catch (error) { + console.error('Error clearing TTS segment cache:', error); + return NextResponse.json({ error: 'Failed to clear TTS segment cache' }, { status: 500 }); + } +} diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index e7cf577..bab9a18 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -1,14 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq, inArray, ne } from 'drizzle-orm'; import { db } from '@/db'; import { ttsSegments } from '@/db/schema'; import { isS3Configured, getS3Config } from '@/lib/server/storage/s3'; import { generateTTSBuffer } from '@/lib/server/tts/generate'; -import { getTtsSegmentAudioObject, putTtsSegmentAudioObject } from '@/lib/server/tts/segments-blobstore'; +import { + deleteTtsSegmentAudioObjects, + getTtsSegmentAudioObject, + putTtsSegmentAudioObject, +} from '@/lib/server/tts/segments-blobstore'; import { buildTtsSegmentAudioKey, buildTtsSegmentId, buildTtsSegmentSettingsHash, + buildTtsSegmentSettingsJson, buildTtsSegmentTextHash, locatorFingerprint, normalizeLocator, @@ -120,6 +125,62 @@ function buildSegmentAudioUrls(documentId: string, segmentId: string): { }; } +async function cleanupStaleErroredVariants(input: { + userId: string; + documentId: string; + documentVersion: number; + segmentIndex: number; + activeSegmentId: string; + activeSettingsHash: string; +}): Promise { + const staleRows = (await db + .select({ + segmentId: ttsSegments.segmentId, + settingsHash: ttsSegments.settingsHash, + audioKey: ttsSegments.audioKey, + }) + .from(ttsSegments) + .where(and( + eq(ttsSegments.userId, input.userId), + eq(ttsSegments.documentId, input.documentId), + eq(ttsSegments.documentVersion, input.documentVersion), + eq(ttsSegments.segmentIndex, input.segmentIndex), + ne(ttsSegments.segmentId, input.activeSegmentId), + ))) as Array<{ + segmentId: string; + settingsHash: string; + audioKey: string | null; + }>; + + const staleRowsForSettings = staleRows.filter((row) => row.settingsHash === input.activeSettingsHash); + const staleIds = staleRowsForSettings.map((row) => row.segmentId); + + if (staleIds.length === 0) return; + + await db + .delete(ttsSegments) + .where(and( + eq(ttsSegments.userId, input.userId), + inArray(ttsSegments.segmentId, staleIds), + )); + + const staleAudioKeys = staleRowsForSettings + .map((row) => row.audioKey) + .filter((key): key is string => Boolean(key)); + if (staleAudioKeys.length > 0) { + try { + await deleteTtsSegmentAudioObjects(staleAudioKeys); + } catch (error) { + console.warn('Failed deleting stale TTS segment audio objects:', { + documentId: input.documentId, + userId: input.userId, + segmentIndex: input.segmentIndex, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + export async function POST(request: NextRequest) { let didCreateDeviceIdCookie = false; let deviceIdToSet: string | null = null; @@ -135,6 +196,7 @@ export async function POST(request: NextRequest) { if (scope instanceof Response) return scope; const settingsHash = buildTtsSegmentSettingsHash(parsed.settings); + const settingsJson = buildTtsSegmentSettingsJson(parsed.settings); const nowMs = Date.now(); const storagePrefix = getS3Config().prefix; const secret = textHmacSecret(); @@ -181,6 +243,7 @@ export async function POST(request: NextRequest) { segmentIndex: number; locatorJson: string | null; settingsHash: string; + settingsJson: unknown; textHash: string; textLength: number; audioKey: string | null; @@ -200,6 +263,15 @@ export async function POST(request: NextRequest) { const existing = existingById.get(segment.segmentId); if (existing?.status === 'completed' && existing.audioKey) { + await cleanupStaleErroredVariants({ + userId: scope.storageUserId, + documentId: parsed.documentId, + documentVersion: scope.documentVersion, + segmentIndex: segment.original.segmentIndex, + activeSegmentId: segment.segmentId, + activeSettingsHash: settingsHash, + }); + let alignment = existing.alignmentJson ? (JSON.parse(existing.alignmentJson) as TTSSegmentManifestItem['alignment']) : null; @@ -272,6 +344,7 @@ export async function POST(request: NextRequest) { segmentIndex: segment.original.segmentIndex, locatorJson: segment.locator ? JSON.stringify(segment.locator) : null, settingsHash, + settingsJson, textHash: segment.textHash, textLength: segment.text.length, audioKey, @@ -289,6 +362,7 @@ export async function POST(request: NextRequest) { segmentIndex: segment.original.segmentIndex, locatorJson: segment.locator ? JSON.stringify(segment.locator) : null, settingsHash, + settingsJson, textHash: segment.textHash, textLength: segment.text.length, audioKey, @@ -398,6 +472,15 @@ export async function POST(request: NextRequest) { }) .where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId))); + await cleanupStaleErroredVariants({ + userId: scope.storageUserId, + documentId: parsed.documentId, + documentVersion: scope.documentVersion, + segmentIndex: segment.original.segmentIndex, + activeSegmentId: segment.segmentId, + activeSettingsHash: settingsHash, + }); + manifest.push({ segmentId: segment.segmentId, segmentIndex: segment.original.segmentIndex, diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts new file mode 100644 index 0000000..635bb73 --- /dev/null +++ b/src/app/api/tts/segments/manifest/route.ts @@ -0,0 +1,199 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, asc, eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { ttsSegments } from '@/db/schema'; +import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; +import type { + TTSSegmentLocator, + TTSSegmentRow, + TTSSegmentSettings, + TTSSegmentVariant, + TTSSegmentsManifestResponse, +} from '@/types/client'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function parseSettingsValue(value: unknown): TTSSegmentSettings | null { + let raw: unknown = value; + if (typeof raw === 'string') { + try { raw = JSON.parse(raw); } catch { return null; } + } + if (!raw || typeof raw !== 'object') return null; + const rec = raw as Record; + + // Settings stored via buildTtsSegmentSettingsJson — accept either the runtime + // shape (ttsProvider/ttsModel/voice/nativeSpeed/ttsInstructions) or the + // canonical hash form (provider/model/voice/speed/instructions/format). + const ttsProvider = typeof rec.ttsProvider === 'string' + ? rec.ttsProvider + : typeof rec.provider === 'string' ? rec.provider : null; + const ttsModel = typeof rec.ttsModel === 'string' + ? rec.ttsModel + : typeof rec.model === 'string' ? rec.model : null; + const voice = typeof rec.voice === 'string' ? rec.voice : null; + const speedSource = rec.nativeSpeed ?? rec.speed; + const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1; + const instructionsSource = rec.ttsInstructions ?? rec.instructions; + const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : ''; + + if (!ttsProvider || !ttsModel || !voice) return null; + return { ttsProvider, ttsModel, voice, nativeSpeed, ttsInstructions }; +} + +function parseLocator(value: unknown): TTSSegmentLocator | null { + if (!value) return null; + if (typeof value !== 'string') return value as TTSSegmentLocator; + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== 'object') return null; + return parsed as TTSSegmentLocator; + } catch { + return null; + } +} + +function buildSegmentAudioUrls(documentId: string, segmentId: string): { + audioPresignUrl: string; + audioFallbackUrl: string; +} { + const encodedDocumentId = encodeURIComponent(documentId); + const encodedSegmentId = encodeURIComponent(segmentId); + return { + audioPresignUrl: `/api/tts/segments/audio/presign?documentId=${encodedDocumentId}&segmentId=${encodedSegmentId}`, + audioFallbackUrl: `/api/tts/segments/audio/fallback?documentId=${encodedDocumentId}&segmentId=${encodedSegmentId}`, + }; +} + +function statusRank(status: TTSSegmentVariant['status']): number { + if (status === 'completed') return 3; + if (status === 'pending') return 2; + return 1; +} + +function dedupeVariants(variants: Array<{ dedupeKey: string; variant: TTSSegmentVariant }>): TTSSegmentVariant[] { + const byKey = new Map(); + for (const { dedupeKey, variant } of variants) { + const existing = byKey.get(dedupeKey); + if (!existing) { + byKey.set(dedupeKey, variant); + continue; + } + const existingRank = statusRank(existing.status); + const nextRank = statusRank(variant.status); + const existingUpdatedAt = existing.updatedAt ?? 0; + const nextUpdatedAt = variant.updatedAt ?? 0; + if (nextRank > existingRank || (nextRank === existingRank && nextUpdatedAt >= existingUpdatedAt)) { + byKey.set(dedupeKey, variant); + } + } + return Array.from(byKey.values()); +} + +export async function GET(request: NextRequest) { + try { + const documentIdRaw = request.nextUrl.searchParams.get('documentId'); + const documentId = documentIdRaw?.trim().toLowerCase(); + if (!documentId) { + return NextResponse.json({ error: 'Missing documentId' }, { status: 400 }); + } + + const scope = await resolveSegmentDocumentScope(request, documentId); + if (scope instanceof Response) return scope; + + const rows = (await db + .select() + .from(ttsSegments) + .where(and( + eq(ttsSegments.userId, scope.storageUserId), + eq(ttsSegments.documentId, documentId), + eq(ttsSegments.documentVersion, scope.documentVersion), + )) + .orderBy(asc(ttsSegments.segmentIndex), asc(ttsSegments.updatedAt))) as Array<{ + segmentId: string; + userId: string; + documentId: string; + readerType: string; + documentVersion: number; + segmentIndex: number; + locatorJson: string | null; + settingsHash: string; + settingsJson: unknown; + textHash: string; + textLength: number; + audioKey: string | null; + audioFormat: string; + durationMs: number | null; + alignmentJson: string | null; + status: string; + error: string | null; + createdAt: number | null; + updatedAt: number | null; + }>; + + const grouped = new Map & { + variants: Array<{ dedupeKey: string; variant: TTSSegmentVariant }>; + }>(); + + for (const row of rows) { + let entry = grouped.get(row.segmentIndex); + if (!entry) { + entry = { + segmentIndex: row.segmentIndex, + locator: parseLocator(row.locatorJson), + variants: [], + }; + grouped.set(row.segmentIndex, entry); + } else if (!entry.locator) { + entry.locator = parseLocator(row.locatorJson); + } + + let alignmentWordCount = 0; + if (row.alignmentJson) { + try { + const parsed = JSON.parse(row.alignmentJson) as { words?: unknown[] }; + alignmentWordCount = Array.isArray(parsed?.words) ? parsed.words.length : 0; + } catch { + alignmentWordCount = 0; + } + } + + const status: TTSSegmentVariant['status'] = row.status === 'completed' || row.status === 'error' + ? row.status + : 'pending'; + + const audioUrls = row.status === 'completed' && row.audioKey + ? buildSegmentAudioUrls(documentId, row.segmentId) + : { audioPresignUrl: null, audioFallbackUrl: null }; + + entry.variants.push({ + dedupeKey: `settings:${row.settingsHash}`, + variant: { + segmentId: row.segmentId, + settings: parseSettingsValue(row.settingsJson), + audioPresignUrl: audioUrls.audioPresignUrl, + audioFallbackUrl: audioUrls.audioFallbackUrl, + durationMs: row.durationMs, + status, + textLength: row.textLength, + alignmentWordCount, + audioKey: row.audioKey, + updatedAt: row.updatedAt, + }, + }); + } + + const segments = Array.from(grouped.values()) + .map((segment) => ({ + segmentIndex: segment.segmentIndex, + locator: segment.locator, + variants: dedupeVariants(segment.variants), + })) + .sort((a, b) => a.segmentIndex - b.segmentIndex); + const response: TTSSegmentsManifestResponse = { documentId, segments }; + return NextResponse.json(response); + } catch (error) { + console.error('Error listing TTS segments manifest:', error); + return NextResponse.json({ error: 'Failed to list TTS segments' }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index bfce723..2b661aa 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -265,6 +265,20 @@ h1, h2, h3, h4, h5, h6 { background-color: color-mix(in srgb, var(--foreground), transparent 75%); } +/* Shared floating reader-sidebar sizing (Segments/Settings/Audiobook) */ +.reader-sidebar-panel { + width: 100%; +} + +@media (min-width: 640px) { + .reader-sidebar-panel { + width: min( + calc(100% - 1.5rem), + clamp(360px, 32vw, 480px) + ); + } +} + /* Scrollbar styling for better fullscreen experience */ *::-webkit-scrollbar { width: 10px; diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index b029a82..7060a47 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -1,7 +1,7 @@ 'use client'; import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react'; -import { Dialog, DialogPanel, Transition, TransitionChild, Button, Listbox, ListboxButton, ListboxOptions, ListboxOption, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'; +import { Transition, Button, Listbox, ListboxButton, ListboxOptions, ListboxOption, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { ProgressPopup } from '@/components/ProgressPopup'; import { ProgressCard } from '@/components/ProgressCard'; @@ -11,6 +11,7 @@ import { LoadingSpinner } from '@/components/Spinner'; import { useConfig } from '@/contexts/ConfigContext'; import { useTTS } from '@/contexts/TTSContext'; import { VoicesControlBase } from '@/components/player/VoicesControlBase'; +import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; import { @@ -458,42 +459,19 @@ export function AudiobookExportModal({ completedChapters={chapters.filter(c => c.status === 'completed').length} /> - - setIsOpen(false)}> - -
- - -
-
- - - {isLoadingExisting ? ( -
- -
- ) : ( - <> + setIsOpen(false)} + ariaLabel="Export audiobook" + title="Export Audiobook" + > + {isLoadingExisting ? ( +
+ +
+ ) : ( + <>
-
-

Export Audiobook

-
{!isGenerating && (
{/* Header */} @@ -912,26 +890,9 @@ export function AudiobookExportModal({ )}
-
- -
)} - - -
-
-
-
+ {/* Confirm delete chapter */} void; onOpenSettings: () => void; onOpenAudiobook?: () => void; + onOpenSegments?: () => void; + isSettingsOpen?: boolean; + isAudiobookOpen?: boolean; + isSegmentsOpen?: boolean; showAudiobookExport?: boolean; minZoom?: number; maxZoom?: number; @@ -23,6 +27,10 @@ export function DocumentHeaderMenu({ onZoomDecrease, onOpenSettings, onOpenAudiobook, + onOpenSegments, + isSettingsOpen = false, + isAudiobookOpen = false, + isSegmentsOpen = false, showAudiobookExport, minZoom = 0, maxZoom = 100 @@ -38,22 +46,45 @@ export function DocumentHeaderMenu({ min={minZoom} max={maxZoom} /> + {onOpenSegments && ( + + )} {showAudiobookExport && onOpenAudiobook && ( )} @@ -102,16 +133,30 @@ export function DocumentHeaderMenu({ {/* Actions Section */}
+ {onOpenSegments && ( + + {({ active }) => ( + + )} + + )} {showAudiobookExport && onOpenAudiobook && ( {({ active }) => ( )} @@ -120,11 +165,11 @@ export function DocumentHeaderMenu({ {({ active }) => ( )} diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx index e31419e..ed721b2 100644 --- a/src/components/documents/DocumentSettings.tsx +++ b/src/components/documents/DocumentSettings.tsx @@ -1,17 +1,11 @@ 'use client'; -import { Fragment, useState, useCallback, useEffect } from 'react'; -import { Dialog, DialogPanel, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react'; +import { Fragment, useState, useEffect } from 'react'; +import { Transition, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'; import { useConfig, ViewType } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; -import { useEPUB } from '@/contexts/EPUBContext'; -import { usePDF } from '@/contexts/PDFContext'; -import { AudiobookExportModal } from '@/components/AudiobookExportModal'; -import { useParams } from 'next/navigation'; -import type { TTSAudiobookChapter } from '@/types/tts'; -import type { AudiobookGenerationSettings } from '@/types/client'; +import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; -const canExportAudiobook = process.env.NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT !== 'false'; const canWordHighlight = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false'; const viewTypeTextMapping = [ @@ -41,19 +35,14 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { pdfWordHighlightEnabled, epubWordHighlightEnabled, } = useConfig(); - const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB(); - const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF(); - const { id } = useParams(); const [localMargins, setLocalMargins] = useState({ header: headerMargin, footer: footerMargin, left: leftMargin, right: rightMargin }); - const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false); const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0]; - // Sync local margins with global state useEffect(() => { setLocalMargins({ header: headerMargin, @@ -80,85 +69,14 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { } }; - const handleGenerateAudiobook = useCallback(async ( - onProgress: (progress: number) => void, - signal: AbortSignal, - onChapterComplete: (chapter: TTSAudiobookChapter) => void, - settings: AudiobookGenerationSettings - ) => { - if (epub) { - return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); - } else { - return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); - } - }, [epub, createEPUBAudioBook, createPDFAudioBook, id]); - - const handleRegenerateChapter = useCallback(async ( - chapterIndex: number, - bookId: string, - settings: AudiobookGenerationSettings, - signal: AbortSignal - ) => { - if (epub) { - return regenerateEPUBChapter(chapterIndex, bookId, settings.format, signal, settings); - } else { - return regeneratePDFChapter(chapterIndex, bookId, settings.format, signal, settings); - } - }, [epub, regenerateEPUBChapter, regeneratePDFChapter]); - return ( - <> - - - - setIsOpen(false)}> - -
- - -
-
- - - {!html &&
- -
} - -
+ setIsOpen(false)} + ariaLabel="Document settings" + title="Settings" + > +
{!epub && !html &&
)} -
- -
- -
- - -
-
-
-
- +
+ ); } diff --git a/src/components/reader/ReaderSidebarShell.tsx b/src/components/reader/ReaderSidebarShell.tsx new file mode 100644 index 0000000..ecb0c42 --- /dev/null +++ b/src/components/reader/ReaderSidebarShell.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { Fragment, type ReactNode } from 'react'; +import { Transition } from '@headlessui/react'; +import { XCircleIcon } from '@/components/icons/Icons'; +import { useReaderSidebarBounds } from '@/hooks/useReaderSidebarBounds'; + +interface ReaderSidebarShellProps { + isOpen: boolean; + onClose: () => void; + ariaLabel: string; + title: string; + children: ReactNode; + headerActions?: ReactNode; + footer?: ReactNode; + bodyClassName?: string; + panelClassName?: string; +} + +export function ReaderSidebarShell({ + isOpen, + onClose, + ariaLabel, + title, + children, + headerActions, + footer, + bodyClassName = 'flex-1 overflow-y-auto px-4 py-4', + panelClassName = '', +}: ReaderSidebarShellProps) { + const bounds = useReaderSidebarBounds(isOpen); + + return ( + +
+ + +
+ + +
+ {children} +
+ + {footer} + + + +
+ ); +} diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx new file mode 100644 index 0000000..7a69ed1 --- /dev/null +++ b/src/components/reader/SegmentsSidebar.tsx @@ -0,0 +1,484 @@ +'use client'; + +import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react'; +import { useTTS } from '@/contexts/TTSContext'; +import { useConfig } from '@/contexts/ConfigContext'; +import { RefreshIcon, InfoIcon } from '@/components/icons/Icons'; +import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; +import type { + TTSSegmentRow, + TTSSegmentSettings, + TTSSegmentVariant, + TTSSegmentsManifestResponse, +} from '@/types/client'; + +interface SegmentsSidebarProps { + isOpen: boolean; + setIsOpen: (open: boolean) => void; + documentId: string; +} + +type FetchState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'ready'; data: TTSSegmentRow[]; fetchedAt: number } + | { kind: 'error'; message: string }; + +function formatDuration(ms: number | null | undefined): string { + if (!ms || !Number.isFinite(ms) || ms <= 0) return '—'; + const sec = ms / 1000; + if (sec < 10) return `${sec.toFixed(1)}s`; + if (sec < 60) return `${Math.round(sec)}s`; + const m = Math.floor(sec / 60); + const s = Math.round(sec % 60); + return `${m}m ${s}s`; +} + +function formatIndex(i: number): string { + return String(i + 1).padStart(3, '0'); +} + +function formatVoiceLabel(settings: TTSSegmentSettings | null): string { + if (!settings) return 'Unknown voice'; + const voice = settings.voice?.trim() || ''; + if (!voice) return 'Unknown voice'; + + const voices = voice + .split('+') + .map((name) => name.trim()) + .filter(Boolean) + .map((name) => name.replace(/\([^)]*\)/g, '').trim()) + .filter(Boolean); + + const baseLabel = voices.length > 0 ? voices.join(' + ') : voice; + const speed = Number(settings.nativeSpeed); + const speedSuffix = Number.isFinite(speed) && speed !== 1 ? ` (${speed}x)` : ''; + + return `${baseLabel}${speedSuffix}`; +} + +function settingsAreEqual(a: TTSSegmentSettings | null, b: TTSSegmentSettings | null): boolean { + if (!a || !b) return false; + return ( + a.ttsProvider === b.ttsProvider + && a.ttsModel === b.ttsModel + && a.voice === b.voice + && Number(a.nativeSpeed) === Number(b.nativeSpeed) + && (a.ttsInstructions || '') === (b.ttsInstructions || '') + ); +} + +function statusColor(status: TTSSegmentVariant['status']): string { + if (status === 'completed') return 'bg-accent'; + if (status === 'error') return 'bg-red-500'; + return 'bg-muted'; +} + +export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSidebarProps) { + const { + sentences, + currentSentenceIndex, + isPlaying, + stopAndPlayFromIndex, + } = useTTS(); + const { ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions, updateConfigKey } = useConfig(); + + const [state, setState] = useState({ kind: 'idle' }); + const [isClearing, setIsClearing] = useState(false); + const [clearError, setClearError] = useState(null); + const abortRef = useRef(null); + + const activeSettings = useMemo(() => ({ + ttsProvider, + ttsModel, + voice, + nativeSpeed: Number.isFinite(Number(voiceSpeed)) ? Number(voiceSpeed) : 1, + ttsInstructions: ttsInstructions || '', + }), [ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions]); + + const loadManifest = useCallback(async () => { + if (!documentId) return; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setState({ kind: 'loading' }); + try { + const res = await fetch( + `/api/tts/segments/manifest?documentId=${encodeURIComponent(documentId)}`, + { signal: controller.signal, cache: 'no-store' }, + ); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(body || `Request failed (${res.status})`); + } + const data = (await res.json()) as TTSSegmentsManifestResponse; + setState({ kind: 'ready', data: data.segments, fetchedAt: Date.now() }); + } catch (err) { + if (controller.signal.aborted) return; + setState({ kind: 'error', message: err instanceof Error ? err.message : 'Failed to load' }); + } + }, [documentId]); + + const handleClearCache = useCallback(async () => { + if (!documentId || isClearing) return; + const confirmed = window.confirm('Clear cached segments for this document? This removes generated audio and metadata for listed segments.'); + if (!confirmed) return; + + setIsClearing(true); + setClearError(null); + try { + const res = await fetch('/api/tts/segments/clear', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ documentId }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(body || `Request failed (${res.status})`); + } + await loadManifest(); + } catch (error) { + setClearError(error instanceof Error ? error.message : 'Failed to clear segments cache'); + } finally { + setIsClearing(false); + } + }, [documentId, isClearing, loadManifest]); + + useEffect(() => { + if (!isOpen) return; + void loadManifest(); + return () => { + abortRef.current?.abort(); + }; + }, [isOpen, loadManifest]); + + const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => { + if (!settings) return; + await Promise.all([ + updateConfigKey('ttsProvider', settings.ttsProvider), + updateConfigKey('ttsModel', settings.ttsModel), + updateConfigKey('voice', settings.voice), + updateConfigKey('voiceSpeed', Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1), + updateConfigKey('ttsInstructions', settings.ttsInstructions || ''), + ]); + }, [updateConfigKey]); + + const handleJump = useCallback((index: number) => { + stopAndPlayFromIndex(index); + }, [stopAndPlayFromIndex]); + + const segmentsByIndex = useMemo(() => { + if (state.kind !== 'ready') return new Map(); + const map = new Map(); + for (const row of state.data) map.set(row.segmentIndex, row); + return map; + }, [state]); + + const indicesToRender = useMemo(() => { + const indices = new Set(); + for (let i = 0; i < sentences.length; i += 1) indices.add(i); + if (state.kind === 'ready') { + for (const row of state.data) { + if (Number.isInteger(row.segmentIndex) && row.segmentIndex >= 0) { + indices.add(row.segmentIndex); + } + } + } + return Array.from(indices).sort((a, b) => a - b); + }, [sentences, state]); + + const rowsToRender: Array<{ segmentIndex: number; sentenceText: string; row: TTSSegmentRow | null }> = []; + for (const i of indicesToRender) { + rowsToRender.push({ + segmentIndex: i, + sentenceText: sentences[i] ?? '', + row: segmentsByIndex.get(i) ?? null, + }); + } + + const totalVariants = state.kind === 'ready' + ? state.data.reduce((sum, r) => sum + r.variants.length, 0) + : 0; + + const headerActions = ( + <> + + + + ); + + const footer = ( +
+

+ Click an index or sentence to jump. Click a voice label to switch the active voice. +

+ {clearError && ( +

+ {clearError} +

+ )} +
+ ); + + return ( + setIsOpen(false)} + ariaLabel="TTS segments" + title="Segments" + headerActions={headerActions} + footer={footer} + bodyClassName="flex-1 overflow-y-auto px-0 py-0" + > +
+
+ {state.kind === 'ready' ? ( + <> + {state.data.length} indexed + · + {totalVariants} variants + + ) : state.kind === 'loading' ? ( + Loading… + ) : state.kind === 'error' ? ( + error + ) : ( + + )} +
+
+ +
+ {state.kind === 'error' && ( +
{state.message}
+ )} + {state.kind === 'loading' && ( +
Loading segments…
+ )} + {state.kind === 'ready' && rowsToRender.length === 0 && ( +
+
+ No segments +
+

+ Press play in the reader to generate audio segments. +

+
+ )} + {state.kind === 'ready' && rowsToRender.length > 0 && ( +
    + {rowsToRender.map(({ segmentIndex, sentenceText, row }) => { + const isCurrent = segmentIndex === currentSentenceIndex; + const variants = row?.variants ?? []; + const activeVariant = variants.find((v) => settingsAreEqual(v.settings, activeSettings)) + ?? variants[0] + ?? null; + const status = activeVariant?.status ?? 'pending'; + const canJump = sentenceText.length > 0; + const playable = !!(activeVariant && activeVariant.audioPresignUrl); + return ( +
  • + {isCurrent && ( + + )} +
    + + +
    + + +
    + + + {formatDuration(activeVariant?.durationMs)} + + {isCurrent && isPlaying && ( + + playing + + )} + {!canJump && ( + + not loaded + + )} + + {variants.length > 0 && ( + + {variants.map((variant) => { + const isActive = settingsAreEqual(variant.settings, activeSettings); + const known = !!variant.settings; + return ( + + ); + })} + + )} +
    +
    + + {row && ( + + )} +
    +
  • + ); + })} +
+ )} +
+
+ ); +} + +function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) { + return ( + + + + + + +
+ + {row.locator ? ( + + {row.locator.page !== undefined ? `p.${row.locator.page} ` : ''} + {row.locator.location ?? ''} + {row.locator.readerType ? ` (${row.locator.readerType})` : ''} + + ) : ( + none + )} + + + + {row.variants.length} + + + {row.variants.map((v) => ( +
+ + + {v.segmentId.slice(0, 16)}… + + + + + {v.settings + ? `${v.settings.ttsProvider} · ${v.settings.ttsModel} · ${formatVoiceLabel(v.settings)}` + : 'unknown'} + + + + + {formatDuration(v.durationMs)} + + + + {v.status} + + + + {v.alignmentWordCount} words + + +
+ ))} +
+
+
+
+ ); +} + +function Row({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 1ac6925..61e4eac 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -69,6 +69,10 @@ interface TTSContextType extends TTSPlaybackState { voice: string; availableVoices: string[]; + // Sentence/segment list and cursor (for the segments sidebar) + sentences: string[]; + currentSentenceIndex: number; + // Alignment metadata for the current sentence currentSentenceAlignment?: TTSSentenceAlignment; currentWordIndex?: number | null; @@ -1992,6 +1996,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement isPlaying, isProcessing, currentSentence: sentences[currentIndex] || '', + sentences, + currentSentenceIndex: currentIndex, currentSentenceAlignment, currentWordIndex, currDocPage, diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index 548d0a9..685e49a 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -117,6 +117,7 @@ export const ttsSegments = pgTable('tts_segments', { documentVersion: bigint('document_version', { mode: 'number' }).notNull(), segmentIndex: integer('segment_index').notNull(), locatorJson: text('locator_json'), + settingsHash: text('settings_hash').notNull(), settingsJson: jsonb('settings_json').notNull(), textHash: text('text_hash').notNull(), textLength: integer('text_length').notNull().default(0), @@ -130,6 +131,6 @@ export const ttsSegments = pgTable('tts_segments', { updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), }, (table) => [ primaryKey({ columns: [table.segmentId, table.userId] }), - index('idx_tts_segments_lookup').on(table.userId, table.documentId, table.documentVersion, table.settingsJson), + index('idx_tts_segments_lookup').on(table.userId, table.documentId, table.documentVersion, table.settingsHash), index('idx_tts_segments_doc_index').on(table.userId, table.documentId, table.segmentIndex), ]); diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 88321ed..57b65fd 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -117,6 +117,7 @@ export const ttsSegments = sqliteTable('tts_segments', { documentVersion: integer('document_version').notNull(), segmentIndex: integer('segment_index').notNull(), locatorJson: text('locator_json'), + settingsHash: text('settings_hash').notNull(), settingsJson: text('settings_json').notNull(), textHash: text('text_hash').notNull(), textLength: integer('text_length').notNull().default(0), @@ -130,6 +131,6 @@ export const ttsSegments = sqliteTable('tts_segments', { updatedAt: integer('updated_at').default(SQLITE_NOW_MS), }, (table) => [ primaryKey({ columns: [table.segmentId, table.userId] }), - index('idx_tts_segments_lookup').on(table.userId, table.documentId, table.documentVersion, table.settingsJson), + index('idx_tts_segments_lookup').on(table.userId, table.documentId, table.documentVersion, table.settingsHash), index('idx_tts_segments_doc_index').on(table.userId, table.documentId, table.segmentIndex), ]); diff --git a/src/hooks/useReaderSidebarBounds.ts b/src/hooks/useReaderSidebarBounds.ts new file mode 100644 index 0000000..660052c --- /dev/null +++ b/src/hooks/useReaderSidebarBounds.ts @@ -0,0 +1,30 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +interface ReaderSidebarBounds { + top: number; + bottom: number; +} + +export function useReaderSidebarBounds(isOpen: boolean): ReaderSidebarBounds { + const [bounds, setBounds] = useState({ top: 0, bottom: 0 }); + + useEffect(() => { + if (!isOpen) return; + + const computeBounds = () => { + const header = document.querySelector('[data-app-header]') as HTMLElement | null; + const ttsbar = document.querySelector('[data-app-ttsbar]') as HTMLElement | null; + const headerH = header ? Math.ceil(header.getBoundingClientRect().height) : 0; + const ttsH = ttsbar ? Math.ceil(ttsbar.getBoundingClientRect().height) : 0; + setBounds({ top: headerH, bottom: ttsH }); + }; + + computeBounds(); + window.addEventListener('resize', computeBounds); + return () => window.removeEventListener('resize', computeBounds); + }, [isOpen]); + + return bounds; +} diff --git a/src/lib/server/tts/segments-blobstore.ts b/src/lib/server/tts/segments-blobstore.ts index 305bd57..6ca8bf9 100644 --- a/src/lib/server/tts/segments-blobstore.ts +++ b/src/lib/server/tts/segments-blobstore.ts @@ -81,6 +81,31 @@ export async function presignTtsSegmentAudioGet( ); } +export async function deleteTtsSegmentAudioObjects(keys: string[]): Promise { + const uniqueKeys = Array.from(new Set(keys.filter((key) => typeof key === 'string' && key.length > 0))); + if (uniqueKeys.length === 0) return 0; + + const cfg = getS3Config(); + const client = getS3ProxyClient(); + let deleted = 0; + + for (let i = 0; i < uniqueKeys.length; i += 1000) { + const chunk = uniqueKeys.slice(i, i + 1000); + const deleteRes = await client.send( + new DeleteObjectsCommand({ + Bucket: cfg.bucket, + Delete: { + Objects: chunk.map((Key) => ({ Key })), + Quiet: true, + }, + }), + ); + deleted += deleteRes.Deleted?.length ?? 0; + } + + return deleted; +} + export async function deleteTtsSegmentPrefix(prefix: string): Promise { const cfg = getS3Config(); const client = getS3ProxyClient(); diff --git a/src/lib/server/tts/segments.ts b/src/lib/server/tts/segments.ts index 03e2bec..95d5e42 100644 --- a/src/lib/server/tts/segments.ts +++ b/src/lib/server/tts/segments.ts @@ -38,6 +38,18 @@ export function buildTtsSegmentSettingsHash(settings: TTSSegmentSettings): strin return createHash('sha256').update(settingsCanonical(settings)).digest('hex'); } +export function buildTtsSegmentSettingsJson(settings: TTSSegmentSettings): TTSSegmentSettings | string { + const canonical: TTSSegmentSettings = { + ttsProvider: settings.ttsProvider, + ttsModel: settings.ttsModel, + voice: settings.voice, + nativeSpeed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1, + ttsInstructions: settings.ttsInstructions || '', + }; + // Postgres jsonb accepts the object directly; SQLite text needs a canonical JSON string. + return process.env.POSTGRES_URL ? canonical : settingsCanonical(settings); +} + export function normalizeSegmentText(text: string): string { return preprocessSentenceForAudio(text || '').trim(); } diff --git a/src/types/client.ts b/src/types/client.ts index 778a59e..9348a43 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -127,3 +127,27 @@ export interface TTSSegmentsEnsureResponse { documentId: string; segments: TTSSegmentManifestItem[]; } + +export interface TTSSegmentVariant { + segmentId: string; + settings: TTSSegmentSettings | null; + audioPresignUrl: string | null; + audioFallbackUrl: string | null; + durationMs: number | null; + status: 'pending' | 'completed' | 'error'; + textLength: number; + alignmentWordCount: number; + audioKey: string | null; + updatedAt: number | null; +} + +export interface TTSSegmentRow { + segmentIndex: number; + locator: TTSSegmentLocator | null; + variants: TTSSegmentVariant[]; +} + +export interface TTSSegmentsManifestResponse { + documentId: string; + segments: TTSSegmentRow[]; +}