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();