976 lines
39 KiB
TypeScript
976 lines
39 KiB
TypeScript
'use client';
|
||
|
||
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Transition } from '@headlessui/react';
|
||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import type { Book } from 'epubjs';
|
||
import toast from 'react-hot-toast';
|
||
import { useTTS } from '@/contexts/TTSContext';
|
||
import { useConfig } from '@/contexts/ConfigContext';
|
||
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
|
||
import { Button, IconButton, PopoverIconTrigger, PopoverRoot, PopoverSurface } from '@/components/ui';
|
||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
|
||
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
|
||
import {
|
||
canonicalizeEpubSegmentsAgainstSpineText,
|
||
type CanonicalizedEpubSegment,
|
||
} from '@/lib/client/epub/canonicalize-epub-segment';
|
||
import {
|
||
getSpineItemPlainText,
|
||
resolveMonotonicSentenceOffsets,
|
||
resolveSpineFromCfi,
|
||
} from '@/lib/client/epub/spine-coordinates';
|
||
import {
|
||
isHtmlLocator,
|
||
isPdfLocator,
|
||
isStableEpubLocator,
|
||
} from '@/types/client';
|
||
import type {
|
||
TTSSegmentLocator,
|
||
TTSSegmentRow,
|
||
TTSSegmentSettings,
|
||
TTSSegmentVariant,
|
||
TTSSegmentsManifestResponse,
|
||
} from '@/types/client';
|
||
|
||
interface SegmentsSidebarProps {
|
||
isOpen: boolean;
|
||
setIsOpen: (open: boolean) => void;
|
||
documentId: string;
|
||
epubBookRef?: RefObject<Book | null>;
|
||
}
|
||
|
||
const MANIFEST_PAGE_SIZE = 150;
|
||
const SEGMENTS_MANIFEST_QUERY_KEY = 'tts-segments-manifest';
|
||
|
||
type ClearSegmentsPayload = {
|
||
error?: string;
|
||
deletedSegments?: number;
|
||
requestedAudioObjects?: number;
|
||
deletedAudioObjects?: number;
|
||
warning?: 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.providerRef === b.providerRef
|
||
&& a.providerType === b.providerType
|
||
&& a.ttsModel === b.ttsModel
|
||
&& a.voice === b.voice
|
||
&& Number(a.nativeSpeed) === Number(b.nativeSpeed)
|
||
&& (a.ttsInstructions || '') === (b.ttsInstructions || '')
|
||
&& (a.language || 'en') === (b.language || 'en')
|
||
);
|
||
}
|
||
|
||
function statusColor(status: TTSSegmentVariant['status']): string {
|
||
if (status === 'completed') return 'bg-accent';
|
||
if (status === 'error') return 'bg-danger';
|
||
return 'bg-muted';
|
||
}
|
||
|
||
function formatLocatorGroupLabel(locator: TTSSegmentLocator | null): string {
|
||
if (!locator) return 'Unknown location';
|
||
if (isStableEpubLocator(locator)) {
|
||
// Show the spine item filename as a recognisable chapter label. epubjs
|
||
// hrefs look like "OEBPS/Chapter_03.xhtml" — strip the directory for the
|
||
// primary label and keep the index for tie-breaks.
|
||
const base = locator.spineHref.split('/').pop() || locator.spineHref;
|
||
const stem = base.replace(/\.x?html?$/i, '');
|
||
return `${stem} · EPUB`;
|
||
}
|
||
if (isPdfLocator(locator)) {
|
||
return `Page ${Math.floor(locator.page)} · PDF`;
|
||
}
|
||
if (isHtmlLocator(locator)) {
|
||
if (/^\d+$/.test(locator.location)) {
|
||
return `Block ${locator.location} · Text`;
|
||
}
|
||
return `${locator.location} · Text`;
|
||
}
|
||
return 'Unknown location';
|
||
}
|
||
|
||
function compareRows(a: TTSSegmentRow, b: TTSSegmentRow): number {
|
||
const byLocator = compareSegmentLocators(a.locator, b.locator);
|
||
if (byLocator !== 0) return byLocator;
|
||
if (a.segmentIndex !== b.segmentIndex) return a.segmentIndex - b.segmentIndex;
|
||
return locatorIdentityKey(a.locator).localeCompare(locatorIdentityKey(b.locator));
|
||
}
|
||
|
||
function mergeRows(existing: TTSSegmentRow[], incoming: TTSSegmentRow[]): TTSSegmentRow[] {
|
||
const map = new Map<string, TTSSegmentRow>();
|
||
const upsert = (row: TTSSegmentRow) => {
|
||
// Identity key (NOT the coarse sidebar group key) so that two rows in the
|
||
// same chapter at different charOffsets stay as separate entries instead
|
||
// of collapsing into one.
|
||
const key = `${row.segmentIndex}|${locatorIdentityKey(row.locator)}`;
|
||
const prev = map.get(key);
|
||
if (!prev) {
|
||
map.set(key, row);
|
||
return;
|
||
}
|
||
const bySegmentId = new Map<string, TTSSegmentVariant>();
|
||
for (const variant of prev.variants) bySegmentId.set(variant.segmentId, variant);
|
||
for (const variant of row.variants) bySegmentId.set(variant.segmentId, variant);
|
||
map.set(key, {
|
||
...row,
|
||
variants: Array.from(bySegmentId.values()),
|
||
});
|
||
};
|
||
existing.forEach(upsert);
|
||
incoming.forEach(upsert);
|
||
return Array.from(map.values()).sort(compareRows);
|
||
}
|
||
|
||
function findScrollableAncestor(node: HTMLElement, fallback: HTMLElement | null): HTMLElement | null {
|
||
let current: HTMLElement | null = node.parentElement;
|
||
while (current) {
|
||
const style = window.getComputedStyle(current);
|
||
const overflowY = style.overflowY.toLowerCase();
|
||
const canScroll = (overflowY === 'auto' || overflowY === 'scroll') && current.scrollHeight > current.clientHeight + 1;
|
||
if (canScroll) return current;
|
||
current = current.parentElement;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function isElementFullyVisibleWithinContainer(element: HTMLElement, container: HTMLElement): boolean {
|
||
const elRect = element.getBoundingClientRect();
|
||
const containerRect = container.getBoundingClientRect();
|
||
return elRect.top >= containerRect.top && elRect.bottom <= containerRect.bottom;
|
||
}
|
||
|
||
export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: SegmentsSidebarProps) {
|
||
const queryClient = useQueryClient();
|
||
const {
|
||
sentences,
|
||
currentSentenceIndex,
|
||
currDocPage,
|
||
currDocPageNumber,
|
||
isPlaying,
|
||
playFromSegment,
|
||
activeReaderType,
|
||
clearSegmentCaches,
|
||
resolvedLanguage,
|
||
} = useTTS();
|
||
const {
|
||
providerRef,
|
||
providerType,
|
||
ttsModel,
|
||
voice,
|
||
voiceSpeed,
|
||
ttsInstructions,
|
||
ttsSegmentMaxBlockLength,
|
||
updateConfigKey,
|
||
} = useConfig();
|
||
|
||
/**
|
||
* Canonicalized per-sentence identities for the currently rendered page.
|
||
* Each local sentence is mapped onto the spine-level canonical segment plan
|
||
* (forward-only ordinal walk), so overlap-boundary local splits still resolve
|
||
* to the same canonical `segmentKey`/`charOffset` as persisted rows.
|
||
*/
|
||
const [synthRowCanonical, setSynthRowCanonical] = useState<Array<CanonicalizedEpubSegment | null>>([]);
|
||
useEffect(() => {
|
||
if (typeof currDocPage !== 'string' || !currDocPage || sentences.length === 0) {
|
||
setSynthRowCanonical([]);
|
||
return;
|
||
}
|
||
const book = epubBookRef?.current;
|
||
if (!book?.isOpen) {
|
||
setSynthRowCanonical([]);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
void (async () => {
|
||
const spine = resolveSpineFromCfi(book, currDocPage);
|
||
if (!spine) {
|
||
if (!cancelled) setSynthRowCanonical([]);
|
||
return;
|
||
}
|
||
const spineText = await getSpineItemPlainText(book, spine.href);
|
||
if (cancelled) return;
|
||
const offsets = resolveMonotonicSentenceOffsets(spineText, sentences);
|
||
const next = canonicalizeEpubSegmentsAgainstSpineText({
|
||
segmentTexts: sentences,
|
||
hintCharOffsets: offsets,
|
||
spineText,
|
||
spineHref: spine.href,
|
||
spineIndex: spine.index,
|
||
cfi: currDocPage,
|
||
keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType),
|
||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||
});
|
||
if (!cancelled) setSynthRowCanonical(next);
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [epubBookRef, currDocPage, sentences, documentId, activeReaderType, ttsSegmentMaxBlockLength]);
|
||
|
||
const listRef = useRef<HTMLDivElement | null>(null);
|
||
const didAutoScrollOnOpenRef = useRef(false);
|
||
const userScrollUntilMsRef = useRef(0);
|
||
const programmaticScrollUntilMsRef = useRef(0);
|
||
const lastSegmentRefreshKeyRef = useRef('');
|
||
const segmentsQueryKey = useMemo(
|
||
() => [SEGMENTS_MANIFEST_QUERY_KEY, documentId] as const,
|
||
[documentId],
|
||
);
|
||
|
||
const segmentsQuery = useInfiniteQuery({
|
||
queryKey: segmentsQueryKey,
|
||
enabled: isOpen && !!documentId,
|
||
initialPageParam: null as string | null,
|
||
queryFn: async ({ pageParam, signal }) => {
|
||
if (!documentId) {
|
||
return {
|
||
documentId: '',
|
||
segments: [],
|
||
hasMore: false,
|
||
nextCursor: null,
|
||
} satisfies TTSSegmentsManifestResponse;
|
||
}
|
||
const cursor = typeof pageParam === 'string' && pageParam.length > 0 ? pageParam : null;
|
||
const params = new URLSearchParams({
|
||
documentId,
|
||
limit: String(MANIFEST_PAGE_SIZE),
|
||
});
|
||
if (cursor) params.set('cursor', cursor);
|
||
const res = await fetch(`/api/tts/segments/manifest?${params.toString()}`, {
|
||
signal,
|
||
cache: 'no-store',
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
// If a stale cursor becomes invalid between page fetches, stop
|
||
// pagination for this query instead of surfacing a hard error.
|
||
if (cursor && body.toLowerCase().includes('invalid cursor')) {
|
||
return {
|
||
documentId,
|
||
segments: [],
|
||
hasMore: false,
|
||
nextCursor: null,
|
||
} satisfies TTSSegmentsManifestResponse;
|
||
}
|
||
throw new Error(body || `Request failed (${res.status})`);
|
||
}
|
||
return (await res.json()) as TTSSegmentsManifestResponse;
|
||
},
|
||
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextCursor : undefined),
|
||
});
|
||
const {
|
||
data: manifestData,
|
||
isPending: isManifestPending,
|
||
isError: hasManifestError,
|
||
error: manifestError,
|
||
hasNextPage: hasMoreManifestPages,
|
||
isFetchingNextPage: isLoadingMoreManifest,
|
||
fetchNextPage,
|
||
refetch: refetchManifest,
|
||
} = segmentsQuery;
|
||
|
||
const manifestRows = useMemo(() => {
|
||
const pages = manifestData?.pages ?? [];
|
||
if (pages.length === 0) return [] as TTSSegmentRow[];
|
||
return pages.reduce<TTSSegmentRow[]>((acc, page) => mergeRows(acc, page.segments), []);
|
||
}, [manifestData]);
|
||
|
||
const clearSegmentsMutation = useMutation({
|
||
mutationFn: async (): Promise<ClearSegmentsPayload | null> => {
|
||
if (!documentId) throw new Error('Missing document id');
|
||
const res = await fetch('/api/tts/segments/clear', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ documentId }),
|
||
});
|
||
const payload = (await res.json().catch(() => null)) as ClearSegmentsPayload | null;
|
||
if (!res.ok) {
|
||
throw new Error(payload?.error || `Request failed (${res.status})`);
|
||
}
|
||
return payload;
|
||
},
|
||
onSuccess: async (payload) => {
|
||
clearSegmentCaches();
|
||
if (payload?.warning) {
|
||
toast.error(`Segments cleared, but audio cleanup was partial: ${payload.warning}`);
|
||
} else if (payload) {
|
||
const deletedSegments = Number(payload.deletedSegments ?? 0);
|
||
const deletedAudioObjects = Number(payload.deletedAudioObjects ?? 0);
|
||
const requestedAudioObjects = Number(payload.requestedAudioObjects ?? deletedAudioObjects);
|
||
toast.success(`Cleared ${deletedSegments} segments and ${deletedAudioObjects}/${requestedAudioObjects} audio objects.`);
|
||
}
|
||
await queryClient.invalidateQueries({ queryKey: segmentsQueryKey });
|
||
await queryClient.refetchQueries({ queryKey: segmentsQueryKey, type: 'active' });
|
||
},
|
||
onError: (error) => {
|
||
toast.error(error instanceof Error ? error.message : 'Failed to clear segments cache');
|
||
},
|
||
});
|
||
const { mutateAsync: clearSegments, isPending: isClearingSegments } = clearSegmentsMutation;
|
||
|
||
const activeSettings = useMemo<TTSSegmentSettings>(() => ({
|
||
providerRef,
|
||
providerType,
|
||
ttsModel,
|
||
voice,
|
||
nativeSpeed: Number.isFinite(Number(voiceSpeed)) ? Number(voiceSpeed) : 1,
|
||
ttsInstructions: ttsInstructions || '',
|
||
language: resolvedLanguage,
|
||
}), [providerRef, providerType, ttsModel, voice, voiceSpeed, ttsInstructions, resolvedLanguage]);
|
||
|
||
const handleClearCache = useCallback(async () => {
|
||
if (!documentId || isClearingSegments) return;
|
||
const confirmed = window.confirm('Clear cached segments for this document version? This removes stored segment metadata and audio objects.');
|
||
if (!confirmed) return;
|
||
await clearSegments();
|
||
}, [documentId, isClearingSegments, clearSegments]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen || !isPlaying) return;
|
||
const locationKey = activeReaderType === 'epub'
|
||
? String(currDocPage)
|
||
: String(currDocPageNumber);
|
||
const refreshKey = `${activeReaderType}|${locationKey}|${currentSentenceIndex}`;
|
||
if (lastSegmentRefreshKeyRef.current === refreshKey) return;
|
||
lastSegmentRefreshKeyRef.current = refreshKey;
|
||
void refetchManifest();
|
||
}, [
|
||
isOpen,
|
||
isPlaying,
|
||
activeReaderType,
|
||
currDocPage,
|
||
currDocPageNumber,
|
||
currentSentenceIndex,
|
||
refetchManifest,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
const node = listRef.current;
|
||
if (!node) return;
|
||
|
||
const markUserScrollActive = () => {
|
||
userScrollUntilMsRef.current = Date.now() + 1200;
|
||
};
|
||
|
||
const onScroll = () => {
|
||
if (Date.now() > programmaticScrollUntilMsRef.current) {
|
||
markUserScrollActive();
|
||
}
|
||
if (!hasMoreManifestPages || isLoadingMoreManifest) return;
|
||
const distance = node.scrollHeight - node.scrollTop - node.clientHeight;
|
||
if (distance > 280) return;
|
||
void fetchNextPage();
|
||
};
|
||
|
||
const onWheel = () => markUserScrollActive();
|
||
const onTouchMove = () => markUserScrollActive();
|
||
|
||
node.addEventListener('scroll', onScroll);
|
||
node.addEventListener('wheel', onWheel, { passive: true });
|
||
node.addEventListener('touchmove', onTouchMove, { passive: true });
|
||
return () => {
|
||
node.removeEventListener('scroll', onScroll);
|
||
node.removeEventListener('wheel', onWheel);
|
||
node.removeEventListener('touchmove', onTouchMove);
|
||
};
|
||
}, [isOpen, hasMoreManifestPages, isLoadingMoreManifest, fetchNextPage]);
|
||
|
||
const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => {
|
||
if (!settings) return;
|
||
await Promise.all([
|
||
updateConfigKey('providerRef', settings.providerRef),
|
||
updateConfigKey('providerType', settings.providerType),
|
||
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 handleRefresh = useCallback(() => {
|
||
didAutoScrollOnOpenRef.current = false;
|
||
void queryClient.invalidateQueries({ queryKey: segmentsQueryKey });
|
||
void refetchManifest();
|
||
}, [queryClient, segmentsQueryKey, refetchManifest]);
|
||
|
||
const handleJump = useCallback((index: number, locator: TTSSegmentLocator | null) => {
|
||
playFromSegment(index, locator);
|
||
}, [playFromSegment]);
|
||
|
||
const rowsToRender = useMemo(() => {
|
||
type Entry = {
|
||
segmentIndex: number;
|
||
sentenceText: string;
|
||
row: TTSSegmentRow;
|
||
isCurrentLocation: boolean;
|
||
groupKey: string;
|
||
groupLabel: string;
|
||
/**
|
||
* Synthesized rows are produced locally from the currently rendered page's
|
||
* sentences. They carry sentence text and the live-play highlight. Manifest
|
||
* rows come from the server-side manifest and may overlap synthesized rows
|
||
* for the current page; we render them as their own listings so the
|
||
* sidebar can show the rest of the chapter (and other chapters) without
|
||
* needing to re-derive page boundaries on the client.
|
||
*/
|
||
isSynthesized: boolean;
|
||
};
|
||
if (!manifestData) return [] as Entry[];
|
||
|
||
// Fallback locator for the live viewport. Used when per-sentence
|
||
// canonical resolution hasn't completed yet and for PDF/HTML, which don't
|
||
// have a spine concept.
|
||
const inferredCurrentLocator: TTSSegmentLocator | null = (() => {
|
||
if (activeReaderType === 'epub' && typeof currDocPage === 'string' && currDocPage.length > 0) {
|
||
const book = epubBookRef?.current;
|
||
const spine = book && book.isOpen ? resolveSpineFromCfi(book, currDocPage) : null;
|
||
if (spine) {
|
||
return {
|
||
readerType: 'epub',
|
||
spineHref: spine.href,
|
||
spineIndex: spine.index,
|
||
charOffset: 0,
|
||
cfi: currDocPage,
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
if (activeReaderType === 'html') {
|
||
if (typeof currDocPage === 'string' && currDocPage.length > 0) {
|
||
return { readerType: 'html', location: currDocPage };
|
||
}
|
||
if (typeof currDocPageNumber === 'number' && Number.isFinite(currDocPageNumber)) {
|
||
return { readerType: 'html', location: String(Math.floor(currDocPageNumber)) };
|
||
}
|
||
return null;
|
||
}
|
||
if (typeof currDocPageNumber === 'number' && Number.isFinite(currDocPageNumber)) {
|
||
return { readerType: 'pdf', page: Math.floor(currDocPageNumber) };
|
||
}
|
||
return null;
|
||
})();
|
||
|
||
// Index manifest rows by their content-stable segmentKey so we can attach
|
||
// their variants to the matching synthesized current-page row. This
|
||
// collapses the visible duplicates (same content showing up twice — once
|
||
// as a synth row with sentence text, once as a manifest row with audio).
|
||
const manifestBySegmentKey = new Map<string, TTSSegmentRow>();
|
||
for (const row of manifestRows) {
|
||
if (row.segmentKey) manifestBySegmentKey.set(row.segmentKey, row);
|
||
}
|
||
|
||
const entries: Entry[] = [];
|
||
const claimedManifestKeys = new Set<string>();
|
||
const keyPrefix = buildSegmentKeyPrefix(documentId, activeReaderType);
|
||
|
||
// Synthesized rows: one per local sentence. Always tagged as
|
||
// `isCurrentLocation: true` so the live-playback highlight can fire on
|
||
// `currentSentenceIndex`. We compute each sentence's `segmentKey` the same
|
||
// way TTSContext does for persistence; when a manifest row carries the
|
||
// same key, we pull in its variants/audio but keep the locally-resolved
|
||
// per-sentence locator for sort positioning.
|
||
//
|
||
// **Locator preference (drives sort order):**
|
||
// 1. Canonicalized per-sentence locator (`synthRowCanonical[i]`) —
|
||
// identity-stable with persisted manifest rows across resize and
|
||
// boundary split variations.
|
||
// 2. Matching manifest row's persisted locator — fallback while the
|
||
// async resolution is still running.
|
||
// 3. `inferredCurrentLocator` (chapter + 0) — final fallback.
|
||
//
|
||
// Variants/audio are always taken from the manifest match when present,
|
||
// regardless of which locator wins.
|
||
if (inferredCurrentLocator) {
|
||
for (let segmentIndex = 0; segmentIndex < sentences.length; segmentIndex += 1) {
|
||
const sentence = sentences[segmentIndex] ?? '';
|
||
const canonical = synthRowCanonical[segmentIndex] ?? null;
|
||
const segmentKey = canonical?.segmentKey
|
||
?? (sentence ? buildSegmentKey(keyPrefix, sentence) : null);
|
||
const manifestMatch = segmentKey ? manifestBySegmentKey.get(segmentKey) : undefined;
|
||
if (segmentKey && manifestMatch) claimedManifestKeys.add(segmentKey);
|
||
|
||
const localPerSentence = canonical?.locator ?? null;
|
||
const mergedLocator: TTSSegmentLocator =
|
||
localPerSentence
|
||
?? manifestMatch?.locator
|
||
?? inferredCurrentLocator;
|
||
const synthRow: TTSSegmentRow = {
|
||
segmentIndex,
|
||
segmentKey,
|
||
locator: mergedLocator,
|
||
variants: manifestMatch?.variants ?? [],
|
||
};
|
||
entries.push({
|
||
segmentIndex,
|
||
sentenceText: sentence,
|
||
row: synthRow,
|
||
isCurrentLocation: true,
|
||
groupKey: locatorGroupKey(mergedLocator),
|
||
groupLabel: formatLocatorGroupLabel(mergedLocator),
|
||
isSynthesized: true,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Manifest rows not claimed by a synth row (i.e. content not currently on
|
||
// screen) render as their own listings. They keep their original locator
|
||
// and group, so they sort into their chapter bucket at their real
|
||
// `charOffset` position.
|
||
for (const row of manifestRows) {
|
||
if (row.segmentKey && claimedManifestKeys.has(row.segmentKey)) continue;
|
||
entries.push({
|
||
segmentIndex: row.segmentIndex,
|
||
sentenceText: '',
|
||
row,
|
||
isCurrentLocation: false,
|
||
groupKey: locatorGroupKey(row.locator),
|
||
groupLabel: formatLocatorGroupLabel(row.locator),
|
||
isSynthesized: false,
|
||
});
|
||
}
|
||
|
||
entries.sort((a, b) => {
|
||
const byLocator = compareSegmentLocators(a.row.locator, b.row.locator);
|
||
if (byLocator !== 0) return byLocator;
|
||
// Within the same locator group, place synthesized (current-page) rows
|
||
// first so the user's active reading position floats to the top of the
|
||
// chapter group.
|
||
if (a.isSynthesized !== b.isSynthesized) return a.isSynthesized ? -1 : 1;
|
||
return a.segmentIndex - b.segmentIndex;
|
||
});
|
||
|
||
return entries;
|
||
}, [manifestData, manifestRows, currDocPage, currDocPageNumber, sentences, epubBookRef, documentId, activeReaderType, synthRowCanonical]);
|
||
|
||
const totalVariants = rowsToRender.reduce((sum, r) => sum + r.row.variants.length, 0);
|
||
|
||
const hasLoadedManifest = !!manifestData;
|
||
const isManifestLoading = isManifestPending && !manifestData;
|
||
const manifestErrorMessage = manifestError instanceof Error ? manifestError.message : 'Failed to load';
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
didAutoScrollOnOpenRef.current = false;
|
||
return;
|
||
}
|
||
if (didAutoScrollOnOpenRef.current) return;
|
||
if (!hasLoadedManifest || rowsToRender.length === 0) return;
|
||
|
||
const container = listRef.current;
|
||
if (!container) return;
|
||
if (Date.now() < userScrollUntilMsRef.current) return;
|
||
const activeRow = container.querySelector<HTMLElement>('[data-active-segment="true"]');
|
||
if (!activeRow) return;
|
||
|
||
requestAnimationFrame(() => {
|
||
programmaticScrollUntilMsRef.current = Date.now() + 300;
|
||
activeRow.scrollIntoView({ block: 'center', behavior: 'auto' });
|
||
didAutoScrollOnOpenRef.current = true;
|
||
});
|
||
}, [isOpen, hasLoadedManifest, rowsToRender.length, currentSentenceIndex]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen || !isPlaying) return;
|
||
if (!hasLoadedManifest || rowsToRender.length === 0) return;
|
||
|
||
const root = listRef.current;
|
||
if (!root) return;
|
||
if (Date.now() < userScrollUntilMsRef.current) return;
|
||
const activeRow = root.querySelector<HTMLElement>('[data-active-segment="true"]');
|
||
if (!activeRow) return;
|
||
|
||
const scrollContainer = findScrollableAncestor(activeRow, root);
|
||
if (!scrollContainer) return;
|
||
if (isElementFullyVisibleWithinContainer(activeRow, scrollContainer)) return;
|
||
|
||
requestAnimationFrame(() => {
|
||
programmaticScrollUntilMsRef.current = Date.now() + 300;
|
||
activeRow.scrollIntoView({ block: 'center', behavior: 'auto' });
|
||
});
|
||
}, [
|
||
isOpen,
|
||
isPlaying,
|
||
hasLoadedManifest,
|
||
rowsToRender.length,
|
||
currentSentenceIndex,
|
||
currDocPage,
|
||
currDocPageNumber,
|
||
]);
|
||
|
||
const headerActions = (
|
||
<>
|
||
<Button
|
||
onClick={() => void handleClearCache()}
|
||
aria-label="Clear segments cache"
|
||
title="Clear cache for listed segments"
|
||
disabled={isClearingSegments}
|
||
variant="secondary"
|
||
size="xs"
|
||
className="h-8 px-2 text-soft"
|
||
>
|
||
{isClearingSegments ? 'Clearing…' : 'Clear'}
|
||
</Button>
|
||
<IconButton
|
||
onClick={handleRefresh}
|
||
aria-label="Refresh segments"
|
||
title="Refresh"
|
||
tone="surface"
|
||
size="md"
|
||
className="h-8 w-8 text-soft"
|
||
>
|
||
<RefreshIcon className="w-3.5 h-3.5" />
|
||
</IconButton>
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<ReaderSidebarShell
|
||
isOpen={isOpen}
|
||
onClose={() => setIsOpen(false)}
|
||
ariaLabel="TTS segments"
|
||
title="Segments"
|
||
subtitle="Click an index or sentence to jump. Click a voice label to switch the active voice."
|
||
headerActions={headerActions}
|
||
bodyClassName="flex-1 overflow-y-auto px-0 py-0"
|
||
>
|
||
<div className="px-4 py-2 border-b border-line-soft">
|
||
<div className="text-xs text-soft">
|
||
{hasLoadedManifest ? (
|
||
<>
|
||
{rowsToRender.length} indexed
|
||
<span> · </span>
|
||
{totalVariants} variants
|
||
{hasMoreManifestPages ? (
|
||
<>
|
||
<span> · </span>
|
||
more…
|
||
</>
|
||
) : null}
|
||
</>
|
||
) : isManifestLoading ? (
|
||
<div className="animate-pulse h-3 w-36 rounded bg-surface-sunken" aria-label="Loading segment summary" aria-busy="true" />
|
||
) : hasManifestError ? (
|
||
<span className="text-danger">error</span>
|
||
) : (
|
||
<span>—</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div ref={listRef} className="flex-1 overflow-y-auto">
|
||
{hasManifestError && (
|
||
<div className="px-4 py-6 text-sm text-danger">{manifestErrorMessage}</div>
|
||
)}
|
||
{isManifestLoading && (
|
||
<SegmentsListSkeleton />
|
||
)}
|
||
{hasLoadedManifest && 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-soft">
|
||
No segments
|
||
</div>
|
||
<p className="text-sm text-soft leading-relaxed max-w-[24ch]">
|
||
Press play in the reader to generate audio segments.
|
||
</p>
|
||
</div>
|
||
)}
|
||
{hasLoadedManifest && rowsToRender.length > 0 && (
|
||
<ul className="divide-y divide-line-soft">
|
||
{rowsToRender.map(({ segmentIndex, sentenceText, row, isCurrentLocation, groupKey, groupLabel, isSynthesized }, rowIndex) => {
|
||
const previousGroupKey = rowIndex > 0 ? rowsToRender[rowIndex - 1]?.groupKey : null;
|
||
const showGroupHeader = previousGroupKey !== groupKey;
|
||
const isCurrent = isCurrentLocation && segmentIndex === currentSentenceIndex;
|
||
const variants = row.variants ?? [];
|
||
const bestVariant = variants
|
||
.slice()
|
||
.sort((a, b) => {
|
||
const rank = (status: TTSSegmentVariant['status']) => {
|
||
if (status === 'completed') return 3;
|
||
if (status === 'pending') return 2;
|
||
return 1;
|
||
};
|
||
const byRank = rank(b.status) - rank(a.status);
|
||
if (byRank !== 0) return byRank;
|
||
return (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
|
||
})[0] ?? null;
|
||
const activeVariant = variants.find((v) => settingsAreEqual(v.settings, activeSettings))
|
||
?? bestVariant;
|
||
const status = activeVariant?.status ?? 'pending';
|
||
const canJump = !!row.locator || sentenceText.length > 0;
|
||
const playable = !!(activeVariant && activeVariant.audioPresignUrl);
|
||
return (
|
||
<li
|
||
key={`${isSynthesized ? 'syn' : 'mfr'}::${groupKey}::${segmentIndex}::${rowIndex}`}
|
||
data-active-segment={isCurrent ? 'true' : undefined}
|
||
className={`relative px-4 py-3 ${isCurrent ? 'bg-surface-sunken' : ''}`}
|
||
>
|
||
{showGroupHeader && (
|
||
<div className="mb-2 -mx-4 px-4 py-1.5 bg-surface-sunken border-y border-line">
|
||
<span className="text-[10px] uppercase tracking-[0.14em] text-soft">
|
||
{groupLabel}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{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, row.locator); }}
|
||
disabled={!canJump}
|
||
className={`text-xs font-medium shrink-0 pt-0.5 ${canJump ? 'text-soft hover:text-accent' : 'text-faint 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, row.locator); }}
|
||
disabled={!canJump}
|
||
className={`block w-full text-left ${canJump ? '' : 'cursor-not-allowed'}`}
|
||
>
|
||
<p className={`text-sm leading-snug ${isCurrent ? 'text-foreground' : 'text-soft'} line-clamp-2`}>
|
||
{sentenceText || (
|
||
<span className="text-soft 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-soft">
|
||
{formatDuration(activeVariant?.durationMs)}
|
||
</span>
|
||
{isCurrent && isPlaying && (
|
||
<span className="text-xs text-accent font-medium">
|
||
playing
|
||
</span>
|
||
)}
|
||
{!canJump && (
|
||
<span className="text-[10px] text-faint border border-line 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.providerRef} · ${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-surface-sunken'
|
||
: known
|
||
? 'border-line text-soft hover:border-accent hover:text-accent'
|
||
: 'border-line text-soft opacity-60 cursor-not-allowed',
|
||
].join(' ')}
|
||
>
|
||
{formatVoiceLabel(variant.settings)}
|
||
</button>
|
||
);
|
||
})}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<SegmentMetadataPopover row={row} />
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
{hasLoadedManifest && isLoadingMoreManifest && (
|
||
<SegmentsListSkeletonRows />
|
||
)}
|
||
</div>
|
||
</ReaderSidebarShell>
|
||
);
|
||
}
|
||
|
||
function SegmentsListSkeleton() {
|
||
return (
|
||
<div className="px-4 py-3">
|
||
<div className="animate-pulse space-y-3" aria-label="Loading segments" aria-busy="true">
|
||
<div className="h-3 w-40 rounded bg-surface-sunken" />
|
||
{Array.from({ length: 8 }).map((_, index) => (
|
||
<div key={index} className="rounded-md border border-line bg-surface px-3 py-2.5">
|
||
<div className="flex items-start gap-3">
|
||
<div className="h-3.5 w-8 rounded bg-surface-sunken mt-0.5 shrink-0" />
|
||
<div className="min-w-0 flex-1 space-y-2">
|
||
<div className="h-3.5 w-11/12 rounded bg-surface-sunken" />
|
||
<div className="h-3.5 w-3/4 rounded bg-surface-sunken" />
|
||
<div className="flex items-center gap-2">
|
||
<div className="h-2 w-2 rounded-full bg-surface-sunken" />
|
||
<div className="h-3 w-12 rounded bg-surface-sunken" />
|
||
<div className="h-4 w-20 rounded bg-surface-sunken" />
|
||
</div>
|
||
</div>
|
||
<div className="h-6 w-6 rounded bg-surface-sunken shrink-0" />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SegmentsListSkeletonRows() {
|
||
return (
|
||
<div className="px-4 py-3 animate-pulse" aria-label="Loading more segments" aria-busy="true">
|
||
<div className="space-y-2">
|
||
{Array.from({ length: 3 }).map((_, index) => (
|
||
<div key={index} className="h-12 rounded-md border border-line bg-surface" />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
||
return (
|
||
<PopoverRoot className="relative shrink-0">
|
||
<PopoverIconTrigger
|
||
size="sm"
|
||
aria-label="Segment metadata"
|
||
title="Metadata"
|
||
>
|
||
<InfoIcon className="w-3.5 h-3.5" />
|
||
</PopoverIconTrigger>
|
||
<Transition
|
||
as={Fragment}
|
||
enter="transition ease-standard duration-fast"
|
||
enterFrom="opacity-0 translate-y-1"
|
||
enterTo="opacity-100 translate-y-0"
|
||
leave="transition ease-standard duration-fast"
|
||
leaveFrom="opacity-100 translate-y-0"
|
||
leaveTo="opacity-0 translate-y-1"
|
||
>
|
||
<PopoverSurface
|
||
anchor="bottom end"
|
||
className="z-[60] w-[300px] mt-1"
|
||
>
|
||
<dl className="space-y-2">
|
||
<Row label="locator">
|
||
{row.locator ? (
|
||
<span className="font-mono tabular-nums text-[11px] text-foreground break-all">
|
||
{isStableEpubLocator(row.locator)
|
||
? `epub spine[${row.locator.spineIndex}] ${row.locator.spineHref} @${row.locator.charOffset}`
|
||
: isPdfLocator(row.locator)
|
||
? `pdf p.${row.locator.page}`
|
||
: isHtmlLocator(row.locator)
|
||
? `html ${row.locator.location}`
|
||
: `${row.locator.readerType || '?'} (legacy)`}
|
||
</span>
|
||
) : (
|
||
<span className="text-soft 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-line-soft pt-2">
|
||
<Row label="segment_id">
|
||
<span className="font-mono text-[10px] text-soft break-all">
|
||
{v.segmentId.slice(0, 16)}…
|
||
</span>
|
||
</Row>
|
||
<Row label="settings">
|
||
<span className="font-mono text-[10px] text-foreground">
|
||
{v.settings
|
||
? `${v.settings.providerRef} · ${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>
|
||
</PopoverSurface>
|
||
</Transition>
|
||
</PopoverRoot>
|
||
);
|
||
}
|
||
|
||
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-soft">{label}</dt>
|
||
<dd className="min-w-0">{children}</dd>
|
||
</div>
|
||
);
|
||
}
|