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:
parent
71cef7b540
commit
71e1472650
25 changed files with 1223 additions and 210 deletions
|
|
@ -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");
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1777920947745,
|
||||
"when": 1777928320804,
|
||||
"tag": "0001_tts_segments",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1777920947478,
|
||||
"when": 1777928320552,
|
||||
"tag": "0001_tts_segments",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
77
src/app/api/tts/segments/clear/route.ts
Normal file
77
src/app/api/tts/segments/clear/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
199
src/app/api/tts/segments/manifest/route.ts
Normal file
199
src/app/api/tts/segments/manifest/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
98
src/components/reader/ReaderSidebarShell.tsx
Normal file
98
src/components/reader/ReaderSidebarShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
484
src/components/reader/SegmentsSidebar.tsx
Normal file
484
src/components/reader/SegmentsSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
|
|
|
|||
30
src/hooks/useReaderSidebarBounds.ts
Normal file
30
src/hooks/useReaderSidebarBounds.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue