feat(reader,db): add segments sidebar and manifest API; update TTS segment schema

Introduce SegmentsSidebar component and supporting reader UI for segment-level
navigation and inspection. Add TTS segments manifest and clear API endpoints to
enable efficient retrieval and management of segment variants. Update TTS
segment schema to include settings_hash for improved lookup and indexing,
and adjust indexes to use settings_hash instead of settings_json. Update
contexts and types to support segment variant display and interaction.

This enhances document navigation and TTS segment management for end users.
This commit is contained in:
Richard R 2026-05-04 15:02:05 -06:00
parent 71cef7b540
commit 71e1472650
25 changed files with 1223 additions and 210 deletions

View file

@ -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");

View file

@ -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"

View file

@ -12,7 +12,7 @@
{
"idx": 1,
"version": "7",
"when": 1777920947745,
"when": 1777928320804,
"tag": "0001_tts_segments",
"breakpoints": true
}

View file

@ -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`);

View file

@ -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
},

View file

@ -12,7 +12,7 @@
{
"idx": 1,
"version": "6",
"when": 1777920947478,
"when": 1777928320552,
"tag": "0001_tts_segments",
"breakpoints": true
}

View file

@ -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<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
const [activeSidebar, setActiveSidebar] = useState<null | 'settings' | 'audiobook' | 'segments'>(null);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width, 0 = max padding)
const [maxPadPx, setMaxPadPx] = useState<number>(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() {
</div>
{canExportAudiobook && (
<AudiobookExportModal
isOpen={isAudiobookModalOpen}
setIsOpen={setIsAudiobookModalOpen}
isOpen={activeSidebar === 'audiobook'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'audiobook' : (prev === 'audiobook' ? null : prev))}
documentType="epub"
documentId={id as string}
onGenerateAudiobook={handleGenerateAudiobook}
@ -221,7 +226,16 @@ export default function EPUBPage() {
) : (
<TTSPlayer />
)}
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
<DocumentSettings
epub
isOpen={activeSidebar === 'settings'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
/>
<SegmentsSidebar
isOpen={activeSidebar === 'segments'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'segments' : (prev === 'segments' ? null : prev))}
documentId={id as string}
/>
</>
);
}

View file

@ -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<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [activeSidebar, setActiveSidebar] = useState<null | 'settings' | 'segments'>(null);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
const [maxPadPx, setMaxPadPx] = useState<number>(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() {
) : (
<TTSPlayer />
)}
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
<DocumentSettings
html
isOpen={activeSidebar === 'settings'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
/>
<SegmentsSidebar
isOpen={activeSidebar === 'segments'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'segments' : (prev === 'segments' ? null : prev))}
documentId={id as string}
/>
</>
);
}

View file

@ -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<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [zoomLevel, setZoomLevel] = useState<number>(100);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
const [activeSidebar, setActiveSidebar] = useState<null | 'settings' | 'audiobook' | 'segments'>(null);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const inFlightDocIdRef = useRef<string | null>(null);
const loadedDocIdRef = useRef<string | null>(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() {
</div>
{canExportAudiobook && (
<AudiobookExportModal
isOpen={isAudiobookModalOpen}
setIsOpen={setIsAudiobookModalOpen}
isOpen={activeSidebar === 'audiobook'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'audiobook' : (prev === 'audiobook' ? null : prev))}
documentType="pdf"
documentId={id as string}
onGenerateAudiobook={handleGenerateAudiobook}
@ -215,7 +220,15 @@ export default function PDFViewerPage() {
) : (
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
)}
<DocumentSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
<DocumentSettings
isOpen={activeSidebar === 'settings'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
/>
<SegmentsSidebar
isOpen={activeSidebar === 'segments'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'segments' : (prev === 'segments' ? null : prev))}
documentId={id as string}
/>
</>
);
}

View file

@ -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<string, unknown>;
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 });
}
}

View file

@ -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<void> {
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,

View file

@ -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<string, unknown>;
// 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<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 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<number, Omit<TTSSegmentRow, 'variants'> & {
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 });
}
}

View file

@ -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;

View file

@ -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}
/>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-2xl transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
{isLoadingExisting ? (
<div className="flex justify-center py-8">
<LoadingSpinner />
</div>
) : (
<>
<ReaderSidebarShell
isOpen={isOpen}
onClose={() => setIsOpen(false)}
ariaLabel="Export audiobook"
title="Export Audiobook"
>
{isLoadingExisting ? (
<div className="flex justify-center py-8">
<LoadingSpinner />
</div>
) : (
<>
<div className="space-y-4">
<div className="flex justify-between items-start gap-3">
<h3 className="text-lg font-medium text-foreground">Export Audiobook</h3>
</div>
{!isGenerating && (
<div className="w-full rounded-xl border border-offbase bg-background">
{/* Header */}
@ -912,26 +890,9 @@ export function AudiobookExportModal({
)}
</div>
<div className="mt-6 flex justify-end">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={() => setIsOpen(false)}
>
Close
</Button>
</div>
</>
)}
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
</ReaderSidebarShell>
{/* Confirm delete chapter */}
<ConfirmDialog
isOpen={pendingDeleteChapter !== null}

View file

@ -2,7 +2,7 @@
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon } from '@/components/icons/Icons';
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons';
import { ZoomControl } from '@/components/documents/ZoomControl';
import { UserMenu } from '@/components/auth/UserMenu';
@ -12,6 +12,10 @@ interface DocumentHeaderMenuProps {
onZoomDecrease: () => 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 && (
<button
onClick={onOpenSegments}
className={`inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-in-out hover:scale-[1.09] ${
isSegmentsOpen
? 'border-accent text-accent bg-offbase'
: 'border-offbase text-foreground hover:bg-offbase hover:text-accent'
}`}
aria-label={isSegmentsOpen ? 'Close segments sidebar' : 'Open segments sidebar'}
title={isSegmentsOpen ? 'Hide Segments' : 'Segments'}
>
<ListIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09]" />
</button>
)}
{showAudiobookExport && onOpenAudiobook && (
<button
onClick={onOpenAudiobook}
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 hover:scale-[1.09] hover:text-accent"
aria-label="Open audiobook export"
title="Export Audiobook"
className={`inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-in-out hover:scale-[1.09] ${
isAudiobookOpen
? 'border-accent text-accent bg-offbase'
: 'border-offbase text-foreground hover:bg-offbase hover:text-accent'
}`}
aria-label={isAudiobookOpen ? 'Close audiobook export' : 'Open audiobook export'}
title={isAudiobookOpen ? 'Hide Export Audiobook' : 'Export Audiobook'}
>
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09]" />
</button>
)}
<button
onClick={onOpenSettings}
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 hover:scale-[1.09] hover:text-accent"
aria-label="Open settings"
className={`inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-in-out hover:scale-[1.09] ${
isSettingsOpen
? 'border-accent text-accent bg-offbase'
: 'border-offbase text-foreground hover:bg-offbase hover:text-accent'
}`}
aria-label={isSettingsOpen ? 'Close settings' : 'Open settings'}
title={isSettingsOpen ? 'Hide Settings' : 'Settings'}
>
<FileSettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
<FileSettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09]" />
</button>
<UserMenu />
</div>
@ -102,16 +133,30 @@ export function DocumentHeaderMenu({
{/* Actions Section */}
<div className="p-1">
{onOpenSegments && (
<MenuItem>
{({ active }) => (
<button
onClick={onOpenSegments}
className={`${active || isSegmentsOpen ? 'bg-offbase text-accent' : 'text-foreground'
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<ListIcon className="h-4 w-4" />
{isSegmentsOpen ? 'Hide Segments' : 'Segments'}
</button>
)}
</MenuItem>
)}
{showAudiobookExport && onOpenAudiobook && (
<MenuItem>
{({ active }) => (
<button
onClick={onOpenAudiobook}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'
className={`${active || isAudiobookOpen ? 'bg-offbase text-accent' : 'text-foreground'
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<DownloadIcon className="h-4 w-4" />
Export Audiobook
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
</button>
)}
</MenuItem>
@ -120,11 +165,11 @@ export function DocumentHeaderMenu({
{({ active }) => (
<button
onClick={onOpenSettings}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'
className={`${active || isSettingsOpen ? 'bg-offbase text-accent' : 'text-foreground'
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<FileSettingsIcon className="h-4 w-4" />
Settings
{isSettingsOpen ? 'Hide Settings' : 'Settings'}
</button>
)}
</MenuItem>

View file

@ -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 (
<>
<AudiobookExportModal
isOpen={isAudiobookModalOpen}
setIsOpen={setIsAudiobookModalOpen}
documentType={epub ? 'epub' : 'pdf'}
documentId={id as string}
onGenerateAudiobook={handleGenerateAudiobook}
onRegenerateChapter={handleRegenerateChapter}
/>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
{!html && <div className="space-y-2 mb-4">
<Button
type="button"
className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-[1] disabled:hover:bg-accent"
onClick={() => setIsAudiobookModalOpen(true)}
disabled={!canExportAudiobook}
>
Export Audiobook {!canExportAudiobook && '(disabled by configuration)'}
</Button>
</div>}
<div className="space-y-4">
<ReaderSidebarShell
isOpen={isOpen}
onClose={() => setIsOpen(false)}
ariaLabel="Document settings"
title="Settings"
>
<div className="space-y-4">
{!epub && !html && <div className="space-y-6">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
@ -425,26 +343,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
</p>
</div>
)}
</div>
<div className="mt-3 flex justify-end">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent z-1"
onClick={() => setIsOpen(false)}
>
Close
</Button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
</>
</div>
</ReaderSidebarShell>
);
}

View file

@ -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 (
<Transition show={isOpen} as={Fragment}>
<div
className="fixed inset-x-0 z-50 pointer-events-none"
style={{ top: bounds.top, bottom: bounds.bottom }}
>
<Transition.Child
as={Fragment}
enter="transition-opacity ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<button
type="button"
aria-label={`Close ${title.toLowerCase()}`}
onClick={onClose}
className="sm:hidden absolute inset-0 overlay-dim backdrop-blur-sm pointer-events-auto"
/>
</Transition.Child>
<Transition.Child
as={Fragment}
enter="transition ease-out duration-220"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in duration-180"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<aside
role="dialog"
aria-label={ariaLabel}
className={`reader-sidebar-panel absolute inset-y-0 right-0 sm:right-3 sm:top-3 sm:bottom-3 pointer-events-auto bg-base border-l sm:border border-offbase shadow-xl sm:rounded-xl flex flex-col ${panelClassName}`}
>
<div className="border-b border-offbase px-4 py-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
</div>
<div className="flex items-center gap-1 shrink-0">
{headerActions}
<button
type="button"
onClick={onClose}
aria-label={`Close ${title.toLowerCase()}`}
title="Close"
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"
>
<XCircleIcon className="w-4 h-4" />
</button>
</div>
</div>
<div className={bodyClassName}>
{children}
</div>
{footer}
</aside>
</Transition.Child>
</div>
</Transition>
);
}

View file

@ -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<FetchState>({ kind: 'idle' });
const [isClearing, setIsClearing] = useState(false);
const [clearError, setClearError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const activeSettings = useMemo<TTSSegmentSettings>(() => ({
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<number, TTSSegmentRow>();
const map = new Map<number, TTSSegmentRow>();
for (const row of state.data) map.set(row.segmentIndex, row);
return map;
}, [state]);
const indicesToRender = useMemo(() => {
const indices = new Set<number>();
for (let i = 0; i < sentences.length; i += 1) indices.add(i);
if (state.kind === 'ready') {
for (const row of state.data) {
if (Number.isInteger(row.segmentIndex) && row.segmentIndex >= 0) {
indices.add(row.segmentIndex);
}
}
}
return Array.from(indices).sort((a, b) => a - b);
}, [sentences, state]);
const rowsToRender: Array<{ segmentIndex: number; sentenceText: string; row: TTSSegmentRow | null }> = [];
for (const i of indicesToRender) {
rowsToRender.push({
segmentIndex: i,
sentenceText: sentences[i] ?? '',
row: segmentsByIndex.get(i) ?? null,
});
}
const totalVariants = state.kind === 'ready'
? state.data.reduce((sum, r) => sum + r.variants.length, 0)
: 0;
const headerActions = (
<>
<button
type="button"
onClick={() => void handleClearCache()}
aria-label="Clear segments cache"
title="Clear cache for listed segments"
disabled={isClearing}
className="inline-flex items-center justify-center h-8 px-2 rounded-lg border border-offbase bg-base text-xs text-muted hover:bg-offbase hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isClearing ? 'Clearing…' : 'Clear'}
</button>
<button
type="button"
onClick={() => void loadManifest()}
aria-label="Refresh segments"
title="Refresh"
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-offbase bg-base text-muted hover:bg-offbase hover:text-accent transition-colors"
>
<RefreshIcon className="w-3.5 h-3.5" />
</button>
</>
);
const footer = (
<div className="border-t border-offbase px-4 py-2">
<p className="text-xs text-muted leading-relaxed">
Click an index or sentence to jump. Click a voice label to switch the active voice.
</p>
{clearError && (
<p className="mt-1 text-xs text-red-500">
{clearError}
</p>
)}
</div>
);
return (
<ReaderSidebarShell
isOpen={isOpen}
onClose={() => setIsOpen(false)}
ariaLabel="TTS segments"
title="Segments"
headerActions={headerActions}
footer={footer}
bodyClassName="flex-1 overflow-y-auto px-0 py-0"
>
<div className="px-4 py-2 border-b border-offbase">
<div className="text-xs text-muted">
{state.kind === 'ready' ? (
<>
{state.data.length} indexed
<span> · </span>
{totalVariants} variants
</>
) : state.kind === 'loading' ? (
<span>Loading</span>
) : state.kind === 'error' ? (
<span className="text-red-500">error</span>
) : (
<span></span>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{state.kind === 'error' && (
<div className="px-4 py-6 text-sm text-red-500">{state.message}</div>
)}
{state.kind === 'loading' && (
<div className="px-4 py-6 text-sm text-muted">Loading segments</div>
)}
{state.kind === 'ready' && rowsToRender.length === 0 && (
<div className="px-4 py-10 flex flex-col items-center text-center gap-2">
<div className="text-sm font-medium text-muted">
No segments
</div>
<p className="text-sm text-muted leading-relaxed max-w-[24ch]">
Press play in the reader to generate audio segments.
</p>
</div>
)}
{state.kind === 'ready' && rowsToRender.length > 0 && (
<ul className="divide-y divide-offbase">
{rowsToRender.map(({ segmentIndex, sentenceText, row }) => {
const isCurrent = segmentIndex === currentSentenceIndex;
const variants = row?.variants ?? [];
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 (
<li
key={segmentIndex}
className={`relative px-4 py-3 ${isCurrent ? 'bg-offbase/40' : ''}`}
>
{isCurrent && (
<span className="absolute inset-y-2 left-0 w-0.5 bg-accent rounded-r" aria-hidden />
)}
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => { if (canJump) handleJump(segmentIndex); }}
disabled={!canJump}
className={`text-xs font-medium shrink-0 pt-0.5 ${canJump ? 'text-muted hover:text-accent' : 'text-muted/50 cursor-not-allowed'}`}
title={canJump ? (playable ? 'Play this segment' : 'Jump to this segment') : 'Text not loaded yet'}
aria-label={`Segment ${segmentIndex + 1}`}
>
{formatIndex(segmentIndex)}
</button>
<div className="min-w-0 flex-1">
<button
type="button"
onClick={() => { if (canJump) handleJump(segmentIndex); }}
disabled={!canJump}
className={`block w-full text-left ${canJump ? '' : 'cursor-not-allowed'}`}
>
<p className={`text-sm leading-snug ${isCurrent ? 'text-foreground' : 'text-foreground/90'} line-clamp-2`}>
{sentenceText || (
<span className="text-muted italic text-xs">
[text not loaded press play to fetch]
</span>
)}
</p>
</button>
<div className="mt-1 flex items-center gap-2 flex-wrap">
<span
className={`inline-block w-1.5 h-1.5 rounded-full ${statusColor(status)}`}
aria-label={`Status ${status}`}
title={status}
/>
<span className="text-xs text-muted">
{formatDuration(activeVariant?.durationMs)}
</span>
{isCurrent && isPlaying && (
<span className="text-xs text-accent font-medium">
playing
</span>
)}
{!canJump && (
<span className="text-[10px] text-muted/80 border border-offbase rounded px-1 py-0.5">
not loaded
</span>
)}
{variants.length > 0 && (
<span className="flex flex-wrap items-start gap-0.5 max-w-full">
{variants.map((variant) => {
const isActive = settingsAreEqual(variant.settings, activeSettings);
const known = !!variant.settings;
return (
<button
key={variant.segmentId}
type="button"
disabled={!known}
onClick={() => void handleSelectVariant(variant.settings)}
title={
variant.settings
? `${variant.settings.ttsProvider} · ${variant.settings.ttsModel} · ${variant.settings.voice}${variant.settings.nativeSpeed && variant.settings.nativeSpeed !== 1 ? ` · ${variant.settings.nativeSpeed}×` : ''}`
: 'Unknown variant'
}
className={[
'max-w-full whitespace-normal break-words text-left leading-none text-[10px] px-1 py-0.5 rounded border transition-colors',
isActive
? 'border-accent text-accent bg-offbase/60'
: known
? 'border-offbase text-muted hover:border-accent hover:text-accent'
: 'border-offbase text-muted opacity-60 cursor-not-allowed',
].join(' ')}
>
{formatVoiceLabel(variant.settings)}
</button>
);
})}
</span>
)}
</div>
</div>
{row && (
<SegmentMetadataPopover row={row} />
)}
</div>
</li>
);
})}
</ul>
)}
</div>
</ReaderSidebarShell>
);
}
function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
return (
<Popover className="relative shrink-0">
<PopoverButton
aria-label="Segment metadata"
title="Metadata"
className="inline-flex items-center justify-center w-7 h-7 rounded-md border border-transparent text-muted hover:bg-offbase hover:border-offbase hover:text-accent transition-colors"
>
<InfoIcon className="w-3.5 h-3.5" />
</PopoverButton>
<Transition
as={Fragment}
enter="transition ease-out duration-150"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel
anchor="bottom end"
className="z-[60] w-[300px] mt-1 rounded-lg border border-offbase bg-base shadow-xl p-3"
>
<dl className="space-y-2">
<Row label="locator">
{row.locator ? (
<span className="font-mono tabular-nums text-[11px] text-foreground break-all">
{row.locator.page !== undefined ? `p.${row.locator.page} ` : ''}
{row.locator.location ?? ''}
{row.locator.readerType ? ` (${row.locator.readerType})` : ''}
</span>
) : (
<span className="text-muted text-[11px]">none</span>
)}
</Row>
<Row label="variants">
<span className="font-mono tabular-nums text-[11px] text-foreground">
{row.variants.length}
</span>
</Row>
{row.variants.map((v) => (
<div key={v.segmentId} className="border-t border-offbase pt-2">
<Row label="segment_id">
<span className="font-mono text-[10px] text-muted break-all">
{v.segmentId.slice(0, 16)}
</span>
</Row>
<Row label="settings">
<span className="font-mono text-[10px] text-foreground">
{v.settings
? `${v.settings.ttsProvider} · ${v.settings.ttsModel} · ${formatVoiceLabel(v.settings)}`
: 'unknown'}
</span>
</Row>
<Row label="duration">
<span className="font-mono tabular-nums text-[10px] text-foreground">
{formatDuration(v.durationMs)}
</span>
</Row>
<Row label="status">
<span className="font-mono text-[10px] text-foreground">{v.status}</span>
</Row>
<Row label="alignment">
<span className="font-mono tabular-nums text-[10px] text-foreground">
{v.alignmentWordCount} words
</span>
</Row>
</div>
))}
</dl>
</PopoverPanel>
</Transition>
</Popover>
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="grid grid-cols-[80px_1fr] gap-2 items-baseline">
<dt className="font-mono uppercase tracking-[0.16em] text-[9px] text-muted">{label}</dt>
<dd className="min-w-0">{children}</dd>
</div>
);
}

View file

@ -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,

View file

@ -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),
]);

View file

@ -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),
]);

View file

@ -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<ReaderSidebarBounds>({ 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;
}

View file

@ -81,6 +81,31 @@ export async function presignTtsSegmentAudioGet(
);
}
export async function deleteTtsSegmentAudioObjects(keys: string[]): Promise<number> {
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<number> {
const cfg = getS3Config();
const client = getS3ProxyClient();

View file

@ -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();
}

View file

@ -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[];
}