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" (
|
CREATE TABLE "tts_segments" (
|
||||||
"segment_id" text NOT NULL,
|
"segment_id" text NOT NULL,
|
||||||
"user_id" text NOT NULL,
|
"user_id" text NOT NULL,
|
||||||
|
|
@ -7,6 +6,7 @@ CREATE TABLE "tts_segments" (
|
||||||
"document_version" bigint NOT NULL,
|
"document_version" bigint NOT NULL,
|
||||||
"segment_index" integer NOT NULL,
|
"segment_index" integer NOT NULL,
|
||||||
"locator_json" text,
|
"locator_json" text,
|
||||||
|
"settings_hash" text NOT NULL,
|
||||||
"settings_json" jsonb NOT NULL,
|
"settings_json" jsonb NOT NULL,
|
||||||
"text_hash" text NOT NULL,
|
"text_hash" text NOT NULL,
|
||||||
"text_length" integer DEFAULT 0 NOT NULL,
|
"text_length" integer DEFAULT 0 NOT NULL,
|
||||||
|
|
@ -22,5 +22,5 @@ CREATE TABLE "tts_segments" (
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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
|
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");
|
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",
|
"prevId": "a6243c0b-4032-4549-aa08-1f7602362f15",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
|
|
@ -510,6 +510,12 @@
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
|
"settings_hash": {
|
||||||
|
"name": "settings_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
"settings_json": {
|
"settings_json": {
|
||||||
"name": "settings_json",
|
"name": "settings_json",
|
||||||
"type": "jsonb",
|
"type": "jsonb",
|
||||||
|
|
@ -605,7 +611,7 @@
|
||||||
"nulls": "last"
|
"nulls": "last"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expression": "settings_json",
|
"expression": "settings_hash",
|
||||||
"isExpression": false,
|
"isExpression": false,
|
||||||
"asc": true,
|
"asc": true,
|
||||||
"nulls": "last"
|
"nulls": "last"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1777920947745,
|
"when": 1777928320804,
|
||||||
"tag": "0001_tts_segments",
|
"tag": "0001_tts_segments",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
DROP TABLE IF EXISTS `tts_segments`;--> statement-breakpoint
|
|
||||||
CREATE TABLE `tts_segments` (
|
CREATE TABLE `tts_segments` (
|
||||||
`segment_id` text NOT NULL,
|
`segment_id` text NOT NULL,
|
||||||
`user_id` text NOT NULL,
|
`user_id` text NOT NULL,
|
||||||
|
|
@ -7,6 +6,7 @@ CREATE TABLE `tts_segments` (
|
||||||
`document_version` integer NOT NULL,
|
`document_version` integer NOT NULL,
|
||||||
`segment_index` integer NOT NULL,
|
`segment_index` integer NOT NULL,
|
||||||
`locator_json` text,
|
`locator_json` text,
|
||||||
|
`settings_hash` text NOT NULL,
|
||||||
`settings_json` text NOT NULL,
|
`settings_json` text NOT NULL,
|
||||||
`text_hash` text NOT NULL,
|
`text_hash` text NOT NULL,
|
||||||
`text_length` integer DEFAULT 0 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
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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`);
|
CREATE INDEX `idx_tts_segments_doc_index` ON `tts_segments` (`user_id`,`document_id`,`segment_index`);
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "8c86c476-e208-4bc1-8fff-7111b3d7ab38",
|
"id": "c7822c2d-e42e-4c2e-868d-77175178be88",
|
||||||
"prevId": "23271fb3-935b-4a9b-a890-e73fc9943bef",
|
"prevId": "23271fb3-935b-4a9b-a890-e73fc9943bef",
|
||||||
"tables": {
|
"tables": {
|
||||||
"audiobook_chapters": {
|
"audiobook_chapters": {
|
||||||
|
|
@ -511,6 +511,13 @@
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"settings_hash": {
|
||||||
|
"name": "settings_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"settings_json": {
|
"settings_json": {
|
||||||
"name": "settings_json",
|
"name": "settings_json",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
|
@ -601,7 +608,7 @@
|
||||||
"user_id",
|
"user_id",
|
||||||
"document_id",
|
"document_id",
|
||||||
"document_version",
|
"document_version",
|
||||||
"settings_json"
|
"settings_hash"
|
||||||
],
|
],
|
||||||
"isUnique": false
|
"isUnique": false
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1777920947478,
|
"when": 1777928320552,
|
||||||
"tag": "0001_tts_segments",
|
"tag": "0001_tts_segments",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { useTTS } from "@/contexts/TTSContext";
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
|
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
|
||||||
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
|
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
|
||||||
|
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import type { TTSAudiobookChapter } from '@/types/tts';
|
import type { TTSAudiobookChapter } from '@/types/tts';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
|
|
@ -29,8 +30,7 @@ export default function EPUBPage() {
|
||||||
const { isAtLimit } = useAuthRateLimit();
|
const { isAtLimit } = useAuthRateLimit();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [activeSidebar, setActiveSidebar] = useState<null | 'settings' | 'audiobook' | 'segments'>(null);
|
||||||
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
|
||||||
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||||
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width, 0 = max padding)
|
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width, 0 = max padding)
|
||||||
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
||||||
|
|
@ -40,6 +40,7 @@ export default function EPUBPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setActiveSidebar(null);
|
||||||
inFlightDocIdRef.current = null;
|
inFlightDocIdRef.current = null;
|
||||||
loadedDocIdRef.current = null;
|
loadedDocIdRef.current = null;
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
@ -180,8 +181,12 @@ export default function EPUBPage() {
|
||||||
zoomLevel={padPct}
|
zoomLevel={padPct}
|
||||||
onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))}
|
onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))}
|
||||||
onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))}
|
onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))}
|
||||||
onOpenSettings={() => setIsSettingsOpen(true)}
|
onOpenSettings={() => setActiveSidebar((prev) => prev === 'settings' ? null : 'settings')}
|
||||||
onOpenAudiobook={() => setIsAudiobookModalOpen(true)}
|
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}
|
showAudiobookExport={canExportAudiobook}
|
||||||
minZoom={0}
|
minZoom={0}
|
||||||
maxZoom={100}
|
maxZoom={100}
|
||||||
|
|
@ -203,8 +208,8 @@ export default function EPUBPage() {
|
||||||
</div>
|
</div>
|
||||||
{canExportAudiobook && (
|
{canExportAudiobook && (
|
||||||
<AudiobookExportModal
|
<AudiobookExportModal
|
||||||
isOpen={isAudiobookModalOpen}
|
isOpen={activeSidebar === 'audiobook'}
|
||||||
setIsOpen={setIsAudiobookModalOpen}
|
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'audiobook' : (prev === 'audiobook' ? null : prev))}
|
||||||
documentType="epub"
|
documentType="epub"
|
||||||
documentId={id as string}
|
documentId={id as string}
|
||||||
onGenerateAudiobook={handleGenerateAudiobook}
|
onGenerateAudiobook={handleGenerateAudiobook}
|
||||||
|
|
@ -221,7 +226,16 @@ export default function EPUBPage() {
|
||||||
) : (
|
) : (
|
||||||
<TTSPlayer />
|
<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 TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
import { resolveDocumentId } from '@/lib/client/dexie';
|
import { resolveDocumentId } from '@/lib/client/dexie';
|
||||||
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
|
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
|
||||||
|
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
|
||||||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
|
|
||||||
|
|
@ -24,7 +25,7 @@ export default function HTMLPage() {
|
||||||
const { isAtLimit } = useAuthRateLimit();
|
const { isAtLimit } = useAuthRateLimit();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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 [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||||
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
|
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
|
||||||
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
||||||
|
|
@ -34,6 +35,7 @@ export default function HTMLPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setActiveSidebar(null);
|
||||||
inFlightDocIdRef.current = null;
|
inFlightDocIdRef.current = null;
|
||||||
loadedDocIdRef.current = null;
|
loadedDocIdRef.current = null;
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
@ -149,7 +151,10 @@ export default function HTMLPage() {
|
||||||
zoomLevel={padPct}
|
zoomLevel={padPct}
|
||||||
onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))}
|
onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))}
|
||||||
onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))}
|
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}
|
minZoom={0}
|
||||||
maxZoom={100}
|
maxZoom={100}
|
||||||
/>
|
/>
|
||||||
|
|
@ -177,7 +182,16 @@ export default function HTMLPage() {
|
||||||
) : (
|
) : (
|
||||||
<TTSPlayer />
|
<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 { useTTS } from '@/contexts/TTSContext';
|
||||||
import { DocumentSettings } from '@/components/documents/DocumentSettings';
|
import { DocumentSettings } from '@/components/documents/DocumentSettings';
|
||||||
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
|
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
|
||||||
|
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import type { TTSAudiobookChapter } from '@/types/tts';
|
import type { TTSAudiobookChapter } from '@/types/tts';
|
||||||
|
|
@ -39,8 +40,7 @@ export default function PDFViewerPage() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [activeSidebar, setActiveSidebar] = useState<null | 'settings' | 'audiobook' | 'segments'>(null);
|
||||||
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
|
||||||
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||||
const inFlightDocIdRef = useRef<string | null>(null);
|
const inFlightDocIdRef = useRef<string | null>(null);
|
||||||
const loadedDocIdRef = useRef<string | null>(null);
|
const loadedDocIdRef = useRef<string | null>(null);
|
||||||
|
|
@ -48,6 +48,7 @@ export default function PDFViewerPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setActiveSidebar(null);
|
||||||
inFlightDocIdRef.current = null;
|
inFlightDocIdRef.current = null;
|
||||||
loadedDocIdRef.current = null;
|
loadedDocIdRef.current = null;
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
@ -176,8 +177,12 @@ export default function PDFViewerPage() {
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
onZoomIncrease={handleZoomIn}
|
onZoomIncrease={handleZoomIn}
|
||||||
onZoomDecrease={handleZoomOut}
|
onZoomDecrease={handleZoomOut}
|
||||||
onOpenSettings={() => setIsSettingsOpen(true)}
|
onOpenSettings={() => setActiveSidebar((prev) => prev === 'settings' ? null : 'settings')}
|
||||||
onOpenAudiobook={() => setIsAudiobookModalOpen(true)}
|
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}
|
showAudiobookExport={canExportAudiobook}
|
||||||
minZoom={50}
|
minZoom={50}
|
||||||
maxZoom={300}
|
maxZoom={300}
|
||||||
|
|
@ -197,8 +202,8 @@ export default function PDFViewerPage() {
|
||||||
</div>
|
</div>
|
||||||
{canExportAudiobook && (
|
{canExportAudiobook && (
|
||||||
<AudiobookExportModal
|
<AudiobookExportModal
|
||||||
isOpen={isAudiobookModalOpen}
|
isOpen={activeSidebar === 'audiobook'}
|
||||||
setIsOpen={setIsAudiobookModalOpen}
|
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'audiobook' : (prev === 'audiobook' ? null : prev))}
|
||||||
documentType="pdf"
|
documentType="pdf"
|
||||||
documentId={id as string}
|
documentId={id as string}
|
||||||
onGenerateAudiobook={handleGenerateAudiobook}
|
onGenerateAudiobook={handleGenerateAudiobook}
|
||||||
|
|
@ -215,7 +220,15 @@ export default function PDFViewerPage() {
|
||||||
) : (
|
) : (
|
||||||
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
|
<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 { 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 { db } from '@/db';
|
||||||
import { ttsSegments } from '@/db/schema';
|
import { ttsSegments } from '@/db/schema';
|
||||||
import { isS3Configured, getS3Config } from '@/lib/server/storage/s3';
|
import { isS3Configured, getS3Config } from '@/lib/server/storage/s3';
|
||||||
import { generateTTSBuffer } from '@/lib/server/tts/generate';
|
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 {
|
import {
|
||||||
buildTtsSegmentAudioKey,
|
buildTtsSegmentAudioKey,
|
||||||
buildTtsSegmentId,
|
buildTtsSegmentId,
|
||||||
buildTtsSegmentSettingsHash,
|
buildTtsSegmentSettingsHash,
|
||||||
|
buildTtsSegmentSettingsJson,
|
||||||
buildTtsSegmentTextHash,
|
buildTtsSegmentTextHash,
|
||||||
locatorFingerprint,
|
locatorFingerprint,
|
||||||
normalizeLocator,
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
let didCreateDeviceIdCookie = false;
|
let didCreateDeviceIdCookie = false;
|
||||||
let deviceIdToSet: string | null = null;
|
let deviceIdToSet: string | null = null;
|
||||||
|
|
@ -135,6 +196,7 @@ export async function POST(request: NextRequest) {
|
||||||
if (scope instanceof Response) return scope;
|
if (scope instanceof Response) return scope;
|
||||||
|
|
||||||
const settingsHash = buildTtsSegmentSettingsHash(parsed.settings);
|
const settingsHash = buildTtsSegmentSettingsHash(parsed.settings);
|
||||||
|
const settingsJson = buildTtsSegmentSettingsJson(parsed.settings);
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const storagePrefix = getS3Config().prefix;
|
const storagePrefix = getS3Config().prefix;
|
||||||
const secret = textHmacSecret();
|
const secret = textHmacSecret();
|
||||||
|
|
@ -181,6 +243,7 @@ export async function POST(request: NextRequest) {
|
||||||
segmentIndex: number;
|
segmentIndex: number;
|
||||||
locatorJson: string | null;
|
locatorJson: string | null;
|
||||||
settingsHash: string;
|
settingsHash: string;
|
||||||
|
settingsJson: unknown;
|
||||||
textHash: string;
|
textHash: string;
|
||||||
textLength: number;
|
textLength: number;
|
||||||
audioKey: string | null;
|
audioKey: string | null;
|
||||||
|
|
@ -200,6 +263,15 @@ export async function POST(request: NextRequest) {
|
||||||
const existing = existingById.get(segment.segmentId);
|
const existing = existingById.get(segment.segmentId);
|
||||||
|
|
||||||
if (existing?.status === 'completed' && existing.audioKey) {
|
if (existing?.status === 'completed' && existing.audioKey) {
|
||||||
|
await cleanupStaleErroredVariants({
|
||||||
|
userId: scope.storageUserId,
|
||||||
|
documentId: parsed.documentId,
|
||||||
|
documentVersion: scope.documentVersion,
|
||||||
|
segmentIndex: segment.original.segmentIndex,
|
||||||
|
activeSegmentId: segment.segmentId,
|
||||||
|
activeSettingsHash: settingsHash,
|
||||||
|
});
|
||||||
|
|
||||||
let alignment = existing.alignmentJson
|
let alignment = existing.alignmentJson
|
||||||
? (JSON.parse(existing.alignmentJson) as TTSSegmentManifestItem['alignment'])
|
? (JSON.parse(existing.alignmentJson) as TTSSegmentManifestItem['alignment'])
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -272,6 +344,7 @@ export async function POST(request: NextRequest) {
|
||||||
segmentIndex: segment.original.segmentIndex,
|
segmentIndex: segment.original.segmentIndex,
|
||||||
locatorJson: segment.locator ? JSON.stringify(segment.locator) : null,
|
locatorJson: segment.locator ? JSON.stringify(segment.locator) : null,
|
||||||
settingsHash,
|
settingsHash,
|
||||||
|
settingsJson,
|
||||||
textHash: segment.textHash,
|
textHash: segment.textHash,
|
||||||
textLength: segment.text.length,
|
textLength: segment.text.length,
|
||||||
audioKey,
|
audioKey,
|
||||||
|
|
@ -289,6 +362,7 @@ export async function POST(request: NextRequest) {
|
||||||
segmentIndex: segment.original.segmentIndex,
|
segmentIndex: segment.original.segmentIndex,
|
||||||
locatorJson: segment.locator ? JSON.stringify(segment.locator) : null,
|
locatorJson: segment.locator ? JSON.stringify(segment.locator) : null,
|
||||||
settingsHash,
|
settingsHash,
|
||||||
|
settingsJson,
|
||||||
textHash: segment.textHash,
|
textHash: segment.textHash,
|
||||||
textLength: segment.text.length,
|
textLength: segment.text.length,
|
||||||
audioKey,
|
audioKey,
|
||||||
|
|
@ -398,6 +472,15 @@ export async function POST(request: NextRequest) {
|
||||||
})
|
})
|
||||||
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId)));
|
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId)));
|
||||||
|
|
||||||
|
await cleanupStaleErroredVariants({
|
||||||
|
userId: scope.storageUserId,
|
||||||
|
documentId: parsed.documentId,
|
||||||
|
documentVersion: scope.documentVersion,
|
||||||
|
segmentIndex: segment.original.segmentIndex,
|
||||||
|
activeSegmentId: segment.segmentId,
|
||||||
|
activeSettingsHash: settingsHash,
|
||||||
|
});
|
||||||
|
|
||||||
manifest.push({
|
manifest.push({
|
||||||
segmentId: segment.segmentId,
|
segmentId: segment.segmentId,
|
||||||
segmentIndex: segment.original.segmentIndex,
|
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%);
|
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 */
|
/* Scrollbar styling for better fullscreen experience */
|
||||||
*::-webkit-scrollbar {
|
*::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
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 { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
import { ProgressCard } from '@/components/ProgressCard';
|
import { ProgressCard } from '@/components/ProgressCard';
|
||||||
|
|
@ -11,6 +11,7 @@ import { LoadingSpinner } from '@/components/Spinner';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
|
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
|
||||||
|
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||||
import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
||||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||||
import {
|
import {
|
||||||
|
|
@ -458,42 +459,19 @@ export function AudiobookExportModal({
|
||||||
completedChapters={chapters.filter(c => c.status === 'completed').length}
|
completedChapters={chapters.filter(c => c.status === 'completed').length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<ReaderSidebarShell
|
||||||
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
|
isOpen={isOpen}
|
||||||
<TransitionChild
|
onClose={() => setIsOpen(false)}
|
||||||
as={Fragment}
|
ariaLabel="Export audiobook"
|
||||||
enter="ease-out duration-300"
|
title="Export Audiobook"
|
||||||
enterFrom="opacity-0"
|
>
|
||||||
enterTo="opacity-100"
|
{isLoadingExisting ? (
|
||||||
leave="ease-in duration-200"
|
<div className="flex justify-center py-8">
|
||||||
leaveFrom="opacity-100"
|
<LoadingSpinner />
|
||||||
leaveTo="opacity-0"
|
</div>
|
||||||
>
|
) : (
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-4">
|
<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 && (
|
{!isGenerating && (
|
||||||
<div className="w-full rounded-xl border border-offbase bg-background">
|
<div className="w-full rounded-xl border border-offbase bg-background">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -912,26 +890,9 @@ export function AudiobookExportModal({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</ReaderSidebarShell>
|
||||||
</TransitionChild>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
{/* Confirm delete chapter */}
|
{/* Confirm delete chapter */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={pendingDeleteChapter !== null}
|
isOpen={pendingDeleteChapter !== null}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
|
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
|
||||||
import { Fragment } from '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 { ZoomControl } from '@/components/documents/ZoomControl';
|
||||||
import { UserMenu } from '@/components/auth/UserMenu';
|
import { UserMenu } from '@/components/auth/UserMenu';
|
||||||
|
|
||||||
|
|
@ -12,6 +12,10 @@ interface DocumentHeaderMenuProps {
|
||||||
onZoomDecrease: () => void;
|
onZoomDecrease: () => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onOpenAudiobook?: () => void;
|
onOpenAudiobook?: () => void;
|
||||||
|
onOpenSegments?: () => void;
|
||||||
|
isSettingsOpen?: boolean;
|
||||||
|
isAudiobookOpen?: boolean;
|
||||||
|
isSegmentsOpen?: boolean;
|
||||||
showAudiobookExport?: boolean;
|
showAudiobookExport?: boolean;
|
||||||
minZoom?: number;
|
minZoom?: number;
|
||||||
maxZoom?: number;
|
maxZoom?: number;
|
||||||
|
|
@ -23,6 +27,10 @@ export function DocumentHeaderMenu({
|
||||||
onZoomDecrease,
|
onZoomDecrease,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onOpenAudiobook,
|
onOpenAudiobook,
|
||||||
|
onOpenSegments,
|
||||||
|
isSettingsOpen = false,
|
||||||
|
isAudiobookOpen = false,
|
||||||
|
isSegmentsOpen = false,
|
||||||
showAudiobookExport,
|
showAudiobookExport,
|
||||||
minZoom = 0,
|
minZoom = 0,
|
||||||
maxZoom = 100
|
maxZoom = 100
|
||||||
|
|
@ -38,22 +46,45 @@ export function DocumentHeaderMenu({
|
||||||
min={minZoom}
|
min={minZoom}
|
||||||
max={maxZoom}
|
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 && (
|
{showAudiobookExport && onOpenAudiobook && (
|
||||||
<button
|
<button
|
||||||
onClick={onOpenAudiobook}
|
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"
|
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] ${
|
||||||
aria-label="Open audiobook export"
|
isAudiobookOpen
|
||||||
title="Export Audiobook"
|
? '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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onOpenSettings}
|
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"
|
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] ${
|
||||||
aria-label="Open settings"
|
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>
|
</button>
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -102,16 +133,30 @@ export function DocumentHeaderMenu({
|
||||||
|
|
||||||
{/* Actions Section */}
|
{/* Actions Section */}
|
||||||
<div className="p-1">
|
<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 && (
|
{showAudiobookExport && onOpenAudiobook && (
|
||||||
<MenuItem>
|
<MenuItem>
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<button
|
<button
|
||||||
onClick={onOpenAudiobook}
|
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`}
|
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||||
>
|
>
|
||||||
<DownloadIcon className="h-4 w-4" />
|
<DownloadIcon className="h-4 w-4" />
|
||||||
Export Audiobook
|
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
@ -120,11 +165,11 @@ export function DocumentHeaderMenu({
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<button
|
<button
|
||||||
onClick={onOpenSettings}
|
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`}
|
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||||
>
|
>
|
||||||
<FileSettingsIcon className="h-4 w-4" />
|
<FileSettingsIcon className="h-4 w-4" />
|
||||||
Settings
|
{isSettingsOpen ? 'Hide Settings' : 'Settings'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,11 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState, useCallback, useEffect } from 'react';
|
import { Fragment, useState, useEffect } from 'react';
|
||||||
import { Dialog, DialogPanel, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react';
|
import { Transition, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react';
|
||||||
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
||||||
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
||||||
import { useEPUB } from '@/contexts/EPUBContext';
|
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||||
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';
|
|
||||||
|
|
||||||
const canExportAudiobook = process.env.NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT !== 'false';
|
|
||||||
const canWordHighlight = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false';
|
const canWordHighlight = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false';
|
||||||
|
|
||||||
const viewTypeTextMapping = [
|
const viewTypeTextMapping = [
|
||||||
|
|
@ -41,19 +35,14 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
pdfWordHighlightEnabled,
|
pdfWordHighlightEnabled,
|
||||||
epubWordHighlightEnabled,
|
epubWordHighlightEnabled,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
|
|
||||||
const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
|
|
||||||
const { id } = useParams();
|
|
||||||
const [localMargins, setLocalMargins] = useState({
|
const [localMargins, setLocalMargins] = useState({
|
||||||
header: headerMargin,
|
header: headerMargin,
|
||||||
footer: footerMargin,
|
footer: footerMargin,
|
||||||
left: leftMargin,
|
left: leftMargin,
|
||||||
right: rightMargin
|
right: rightMargin
|
||||||
});
|
});
|
||||||
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
|
||||||
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
|
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
|
||||||
|
|
||||||
// Sync local margins with global state
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalMargins({
|
setLocalMargins({
|
||||||
header: headerMargin,
|
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 (
|
return (
|
||||||
<>
|
<ReaderSidebarShell
|
||||||
<AudiobookExportModal
|
isOpen={isOpen}
|
||||||
isOpen={isAudiobookModalOpen}
|
onClose={() => setIsOpen(false)}
|
||||||
setIsOpen={setIsAudiobookModalOpen}
|
ariaLabel="Document settings"
|
||||||
documentType={epub ? 'epub' : 'pdf'}
|
title="Settings"
|
||||||
documentId={id as string}
|
>
|
||||||
onGenerateAudiobook={handleGenerateAudiobook}
|
<div className="space-y-4">
|
||||||
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">
|
|
||||||
{!epub && !html && <div className="space-y-6">
|
{!epub && !html && <div className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
|
@ -425,26 +343,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</ReaderSidebarShell>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
voice: string;
|
||||||
availableVoices: string[];
|
availableVoices: string[];
|
||||||
|
|
||||||
|
// Sentence/segment list and cursor (for the segments sidebar)
|
||||||
|
sentences: string[];
|
||||||
|
currentSentenceIndex: number;
|
||||||
|
|
||||||
// Alignment metadata for the current sentence
|
// Alignment metadata for the current sentence
|
||||||
currentSentenceAlignment?: TTSSentenceAlignment;
|
currentSentenceAlignment?: TTSSentenceAlignment;
|
||||||
currentWordIndex?: number | null;
|
currentWordIndex?: number | null;
|
||||||
|
|
@ -1992,6 +1996,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
currentSentence: sentences[currentIndex] || '',
|
currentSentence: sentences[currentIndex] || '',
|
||||||
|
sentences,
|
||||||
|
currentSentenceIndex: currentIndex,
|
||||||
currentSentenceAlignment,
|
currentSentenceAlignment,
|
||||||
currentWordIndex,
|
currentWordIndex,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ export const ttsSegments = pgTable('tts_segments', {
|
||||||
documentVersion: bigint('document_version', { mode: 'number' }).notNull(),
|
documentVersion: bigint('document_version', { mode: 'number' }).notNull(),
|
||||||
segmentIndex: integer('segment_index').notNull(),
|
segmentIndex: integer('segment_index').notNull(),
|
||||||
locatorJson: text('locator_json'),
|
locatorJson: text('locator_json'),
|
||||||
|
settingsHash: text('settings_hash').notNull(),
|
||||||
settingsJson: jsonb('settings_json').notNull(),
|
settingsJson: jsonb('settings_json').notNull(),
|
||||||
textHash: text('text_hash').notNull(),
|
textHash: text('text_hash').notNull(),
|
||||||
textLength: integer('text_length').notNull().default(0),
|
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),
|
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
primaryKey({ columns: [table.segmentId, table.userId] }),
|
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),
|
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(),
|
documentVersion: integer('document_version').notNull(),
|
||||||
segmentIndex: integer('segment_index').notNull(),
|
segmentIndex: integer('segment_index').notNull(),
|
||||||
locatorJson: text('locator_json'),
|
locatorJson: text('locator_json'),
|
||||||
|
settingsHash: text('settings_hash').notNull(),
|
||||||
settingsJson: text('settings_json').notNull(),
|
settingsJson: text('settings_json').notNull(),
|
||||||
textHash: text('text_hash').notNull(),
|
textHash: text('text_hash').notNull(),
|
||||||
textLength: integer('text_length').notNull().default(0),
|
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),
|
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
primaryKey({ columns: [table.segmentId, table.userId] }),
|
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),
|
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> {
|
export async function deleteTtsSegmentPrefix(prefix: string): Promise<number> {
|
||||||
const cfg = getS3Config();
|
const cfg = getS3Config();
|
||||||
const client = getS3ProxyClient();
|
const client = getS3ProxyClient();
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,18 @@ export function buildTtsSegmentSettingsHash(settings: TTSSegmentSettings): strin
|
||||||
return createHash('sha256').update(settingsCanonical(settings)).digest('hex');
|
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 {
|
export function normalizeSegmentText(text: string): string {
|
||||||
return preprocessSentenceForAudio(text || '').trim();
|
return preprocessSentenceForAudio(text || '').trim();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,3 +127,27 @@ export interface TTSSegmentsEnsureResponse {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
segments: TTSSegmentManifestItem[];
|
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