From 45ae2f50000d0d79aac50dec186fe221f28e9dc9 Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 4 May 2026 20:08:51 -0600 Subject: [PATCH] fix(tts): improve segment grouping and sidebar selection for locator variants Update segment manifest API to group segments by both index and locator attributes, ensuring distinct segment variants are not merged incorrectly. Adjust SegmentsSidebar logic to select the most relevant segment per index based on locator match and recency, improving sidebar accuracy when multiple locator variants exist for a segment. --- src/app/api/tts/segments/manifest/route.ts | 33 +++++++++++--- src/components/reader/SegmentsSidebar.tsx | 51 ++++++++++++++++++++-- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts index 635bb73..c123e6d 100644 --- a/src/app/api/tts/segments/manifest/route.ts +++ b/src/app/api/tts/segments/manifest/route.ts @@ -90,6 +90,16 @@ function dedupeVariants(variants: Array<{ dedupeKey: string; variant: TTSSegment return Array.from(byKey.values()); } +function locatorGroupKey(locator: TTSSegmentLocator | null): string { + if (!locator) return 'none'; + const page = typeof locator.page === 'number' && Number.isFinite(locator.page) + ? String(Math.floor(locator.page)) + : ''; + const location = typeof locator.location === 'string' ? locator.location : ''; + const readerType = locator.readerType || ''; + return `p:${page}|l:${location}|r:${readerType}`; +} + export async function GET(request: NextRequest) { try { const documentIdRaw = request.nextUrl.searchParams.get('documentId'); @@ -131,21 +141,24 @@ export async function GET(request: NextRequest) { updatedAt: number | null; }>; - const grouped = new Map & { + const grouped = new Map & { variants: Array<{ dedupeKey: string; variant: TTSSegmentVariant }>; }>(); for (const row of rows) { - let entry = grouped.get(row.segmentIndex); + const locator = parseLocator(row.locatorJson); + const groupKey = `${row.segmentIndex}|${locatorGroupKey(locator)}`; + + let entry = grouped.get(groupKey); if (!entry) { entry = { segmentIndex: row.segmentIndex, - locator: parseLocator(row.locatorJson), + locator, variants: [], }; - grouped.set(row.segmentIndex, entry); + grouped.set(groupKey, entry); } else if (!entry.locator) { - entry.locator = parseLocator(row.locatorJson); + entry.locator = locator; } let alignmentWordCount = 0; @@ -189,7 +202,15 @@ export async function GET(request: NextRequest) { locator: segment.locator, variants: dedupeVariants(segment.variants), })) - .sort((a, b) => a.segmentIndex - b.segmentIndex); + .sort((a, b) => { + if (a.segmentIndex !== b.segmentIndex) return a.segmentIndex - b.segmentIndex; + const aPage = typeof a.locator?.page === 'number' ? a.locator.page : Number.MAX_SAFE_INTEGER; + const bPage = typeof b.locator?.page === 'number' ? b.locator.page : Number.MAX_SAFE_INTEGER; + if (aPage !== bPage) return aPage - bPage; + const aLoc = a.locator?.location || ''; + const bLoc = b.locator?.location || ''; + return aLoc.localeCompare(bLoc); + }); const response: TTSSegmentsManifestResponse = { documentId, segments }; return NextResponse.json(response); } catch (error) { diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 7a69ed1..8049087 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -7,6 +7,7 @@ import { useConfig } from '@/contexts/ConfigContext'; import { RefreshIcon, InfoIcon } from '@/components/icons/Icons'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import type { + TTSSegmentLocator, TTSSegmentRow, TTSSegmentSettings, TTSSegmentVariant, @@ -75,10 +76,34 @@ function statusColor(status: TTSSegmentVariant['status']): string { return 'bg-muted'; } +function locatorMatchesCurrent( + locator: TTSSegmentLocator | null, + currentLocation: string | number, + currentPageNumber: number, +): boolean { + if (!locator) return false; + if (typeof locator.location === 'string' && locator.location.length > 0) { + return String(locator.location) === String(currentLocation); + } + if (typeof locator.page === 'number' && Number.isFinite(locator.page)) { + return Math.floor(locator.page) === Math.floor(Number(currentPageNumber || 1)); + } + return false; +} + +function latestUpdatedAt(row: TTSSegmentRow): number { + return row.variants.reduce((max, variant) => { + const updated = typeof variant.updatedAt === 'number' ? variant.updatedAt : 0; + return Math.max(max, updated); + }, 0); +} + export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSidebarProps) { const { sentences, currentSentenceIndex, + currDocPage, + currDocPageNumber, isPlaying, stopAndPlayFromIndex, } = useTTS(); @@ -170,10 +195,28 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb const segmentsByIndex = useMemo(() => { if (state.kind !== 'ready') return new Map(); - const map = new Map(); - for (const row of state.data) map.set(row.segmentIndex, row); - return map; - }, [state]); + const map = new Map(); + for (const row of state.data) { + const isCurrent = locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber); + const score = isCurrent ? 2 : row.locator ? 0 : 1; + if (score === 0) continue; + + const candidateUpdatedAt = latestUpdatedAt(row); + const existing = map.get(row.segmentIndex); + if (!existing) { + map.set(row.segmentIndex, { row, score, updatedAt: candidateUpdatedAt }); + continue; + } + + if (score > existing.score || (score === existing.score && candidateUpdatedAt >= existing.updatedAt)) { + map.set(row.segmentIndex, { row, score, updatedAt: candidateUpdatedAt }); + } + } + + const selected = new Map(); + for (const [idx, entry] of map) selected.set(idx, entry.row); + return selected; + }, [state, currDocPage, currDocPageNumber]); const indicesToRender = useMemo(() => { const indices = new Set();