'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; } 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(); 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(); 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>([]); 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(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((acc, page) => mergeRows(acc, page.segments), []); }, [manifestData]); const clearSegmentsMutation = useMutation({ mutationFn: async (): Promise => { 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(() => ({ 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(); for (const row of manifestRows) { if (row.segmentKey) manifestBySegmentKey.set(row.segmentKey, row); } const entries: Entry[] = []; const claimedManifestKeys = new Set(); 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('[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('[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 = ( <> ); return ( 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" >
{hasLoadedManifest ? ( <> {rowsToRender.length} indexed · {totalVariants} variants {hasMoreManifestPages ? ( <> · more… ) : null} ) : isManifestLoading ? (
) : hasManifestError ? ( error ) : ( )}
{hasManifestError && (
{manifestErrorMessage}
)} {isManifestLoading && ( )} {hasLoadedManifest && rowsToRender.length === 0 && (
No segments

Press play in the reader to generate audio segments.

)} {hasLoadedManifest && rowsToRender.length > 0 && (
    {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 (
  • {showGroupHeader && (
    {groupLabel}
    )} {isCurrent && ( )}
    {formatDuration(activeVariant?.durationMs)} {isCurrent && isPlaying && ( playing )} {!canJump && ( not loaded )} {variants.length > 0 && ( {variants.map((variant) => { const isActive = settingsAreEqual(variant.settings, activeSettings); const known = !!variant.settings; return ( ); })} )}
  • ); })}
)} {hasLoadedManifest && isLoadingMoreManifest && ( )}
); } function SegmentsListSkeleton() { return (
{Array.from({ length: 8 }).map((_, index) => (
))}
); } function SegmentsListSkeletonRows() { return (
{Array.from({ length: 3 }).map((_, index) => (
))}
); } function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) { return (
{row.locator ? ( {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)`} ) : ( none )} {row.variants.length} {row.variants.map((v) => (
{v.segmentId.slice(0, 16)}… {v.settings ? `${v.settings.providerRef} · ${v.settings.ttsModel} · ${formatVoiceLabel(v.settings)}` : 'unknown'} {formatDuration(v.durationMs)} {v.status} {v.alignmentWordCount} words
))}
); } function Row({ label, children }: { label: string; children: ReactNode }) { return (
{label}
{children}
); }