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.
This commit is contained in:
Richard R 2026-05-04 20:08:51 -06:00
parent 4cb482c3f2
commit 45ae2f5000
2 changed files with 74 additions and 10 deletions

View file

@ -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<number, Omit<TTSSegmentRow, 'variants'> & {
const grouped = new Map<string, Omit<TTSSegmentRow, 'variants'> & {
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) {

View file

@ -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<number, TTSSegmentRow>();
const map = new Map<number, TTSSegmentRow>();
for (const row of state.data) map.set(row.segmentIndex, row);
return map;
}, [state]);
const map = new Map<number, { row: TTSSegmentRow; score: number; updatedAt: number }>();
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<number, TTSSegmentRow>();
for (const [idx, entry] of map) selected.set(idx, entry.row);
return selected;
}, [state, currDocPage, currDocPageNumber]);
const indicesToRender = useMemo(() => {
const indices = new Set<number>();