import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; import type { V4SentenceEntry } from '../lib/transcription/TranscriptionWorkerClient'; export interface TranscriptionDisplayProps { confirmedText: string; pendingText: string; sentenceEntries?: V4SentenceEntry[]; isV4Mode?: boolean; isRecording: boolean; lcsLength?: number; anchorValid?: boolean; showConfidence?: boolean; placeholder?: string; class?: string; } const formatClockTime = (timestamp: number): string => { if (!Number.isFinite(timestamp)) return '--:--:--'; return new Date(timestamp).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', }); }; const formatAudioTime = (seconds: number): string => { if (!Number.isFinite(seconds)) return '0:00.00'; const totalSeconds = Math.max(0, seconds); const minutes = Math.floor(totalSeconds / 60); const secondPart = (totalSeconds % 60).toFixed(2).padStart(5, '0'); return `${minutes}:${secondPart}`; }; const formatAudioRange = (startTime: number, endTime: number): string => `${formatAudioTime(startTime)} -> ${formatAudioTime(endTime)}`; const MERGED_SPLIT_STORAGE_KEY = 'keet-merged-split-ratio'; const MIN_MERGED_SPLIT_RATIO = 0.3; const MAX_MERGED_SPLIT_RATIO = 0.7; const clampMergedSplitRatio = (ratio: number): number => Math.min(MAX_MERGED_SPLIT_RATIO, Math.max(MIN_MERGED_SPLIT_RATIO, ratio)); const getInitialMergedSplitRatio = (): number => { if (typeof localStorage === 'undefined') return 0.5; try { const raw = Number(localStorage.getItem(MERGED_SPLIT_STORAGE_KEY)); if (Number.isFinite(raw)) return clampMergedSplitRatio(raw); } catch (_) {} return 0.5; }; export const TranscriptionDisplay: Component = (props) => { let liveContainerRef: HTMLDivElement | undefined; let mergedContainerRef: HTMLDivElement | undefined; let mergedSplitContainerRef: HTMLDivElement | undefined; let sentenceListDesktopRef: HTMLDivElement | undefined; let sentenceListMobileRef: HTMLDivElement | undefined; let scrollScheduled = false; const [activeTab, setActiveTab] = createSignal<'live' | 'merged'>('live'); const [mergedSplitRatio, setMergedSplitRatio] = createSignal(getInitialMergedSplitRatio()); const [isSplitResizing, setIsSplitResizing] = createSignal(false); let splitMouseMoveHandler: ((event: MouseEvent) => void) | null = null; let splitMouseUpHandler: (() => void) | null = null; const scrollToBottom = () => { if (scrollScheduled) return; scrollScheduled = true; requestAnimationFrame(() => { scrollScheduled = false; const activeContainer = activeTab() === 'merged' ? mergedContainerRef : liveContainerRef; if (activeContainer) { activeContainer.scrollTop = activeContainer.scrollHeight; } }); }; const getVisibleSentenceListContainer = (): HTMLDivElement | undefined => { if (sentenceListDesktopRef && sentenceListDesktopRef.offsetParent !== null) { return sentenceListDesktopRef; } if (sentenceListMobileRef && sentenceListMobileRef.offsetParent !== null) { return sentenceListMobileRef; } return sentenceListDesktopRef ?? sentenceListMobileRef; }; const scrollSentenceListToBottom = () => { requestAnimationFrame(() => { const container = getVisibleSentenceListContainer(); if (!container) return; container.scrollTop = container.scrollHeight; }); }; const persistMergedSplitRatio = (ratio: number) => { if (typeof localStorage === 'undefined') return; try { localStorage.setItem(MERGED_SPLIT_STORAGE_KEY, String(ratio)); } catch (_) {} }; const startSplitResize = (event: MouseEvent) => { if (!mergedSplitContainerRef) return; event.preventDefault(); const rect = mergedSplitContainerRef.getBoundingClientRect(); if (rect.width <= 0) return; setIsSplitResizing(true); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; const applyRatioFromClientX = (clientX: number) => { const nextRatio = clampMergedSplitRatio((clientX - rect.left) / rect.width); setMergedSplitRatio(nextRatio); }; const onMouseMove = (moveEvent: MouseEvent) => { applyRatioFromClientX(moveEvent.clientX); }; const onMouseUp = () => { setIsSplitResizing(false); document.body.style.cursor = ''; document.body.style.userSelect = ''; persistMergedSplitRatio(mergedSplitRatio()); window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); splitMouseMoveHandler = null; splitMouseUpHandler = null; }; splitMouseMoveHandler = onMouseMove; splitMouseUpHandler = onMouseUp; window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); }; createEffect(() => { if (!props.isV4Mode && activeTab() !== 'live') { setActiveTab('live'); } }); const hasContent = createMemo(() => (props.confirmedText?.length ?? 0) > 0 || (props.pendingText?.length ?? 0) > 0 ); const finalizedEntries = createMemo(() => props.sentenceEntries ?? []); const mergedCount = createMemo(() => finalizedEntries().length + (props.pendingText?.trim() ? 1 : 0)); const fullTextBody = createMemo(() => { const finalized = finalizedEntries() .map((entry) => entry.text.trim()) .filter((text) => text.length > 0) .join(' ') .trim(); const live = props.pendingText.trim(); if (finalized && live) return `${finalized} ${live}`.trim(); return finalized || live || ''; }); createEffect(() => { activeTab(); props.confirmedText; props.pendingText; finalizedEntries().length; scrollToBottom(); }); createEffect(() => { if (!props.isV4Mode || activeTab() !== 'merged') return; finalizedEntries().length; props.pendingText; scrollSentenceListToBottom(); }); onCleanup(() => { document.body.style.cursor = ''; document.body.style.userSelect = ''; if (splitMouseMoveHandler) { window.removeEventListener('mousemove', splitMouseMoveHandler); } if (splitMouseUpHandler) { window.removeEventListener('mouseup', splitMouseUpHandler); } }); const renderFullTextContent = () => ( 0} fallback={

Waiting for transcript text...

}>

{fullTextBody()}

); const renderSentenceListContent = () => ( 0 || !!props.pendingText.trim()} fallback={
view_list

No merged conversation entries yet...

}>
{(entry) => (
{formatClockTime(entry.emittedAt)} [{formatAudioRange(entry.startTime, entry.endTime)}] {entry.text}
)}
{formatClockTime(Date.now())} LIVE {props.pendingText}
); return (
graphic_eq

{props.placeholder ?? 'Ready to transcribe...'}

} >
{/* Confirmed text */}

{props.confirmedText}

{/* Pending text */} {props.pendingText}
{/* Listening indicator when idle but recording */}
Listening...
}>
{/* Mobile / tablet stacked layout */}
Full Text Body
{renderFullTextContent()}
Sentence List
{renderSentenceListContent()}
{/* Desktop adjustable split layout (defaults to 50/50) */} {/* Merge Stats / Legend (Floating style inside container) */}
LCS: {props.lcsLength}
PTFA Merged
); }; export default TranscriptionDisplay;