// Transcript Page — Layer 3: Timeline + Keyword highlight const TranscriptPage = ({ lang, show, episode, onBack, initSearch, highlightTime }) => { const t = lang === 'zh'; const [search, setSearch] = React.useState(initSearch || ''); const [activeIdx, setActiveIdx] = React.useState(null); const [highlightedIdx, setHighlightedIdx] = React.useState(null); const [segments, setSegments] = React.useState(null); const [segError, setSegError] = React.useState(null); React.useEffect(() => { if (!episode?.id) return; setSegments(null); setSegError(null); setActiveIdx(null); fetch(`${API_BASE}/episodes/${episode.id}/transcript`) .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) .then(data => setSegments(data.segments || [])) .catch(err => setSegError(err.message)); }, [episode?.id]); const fmtTime = (sec) => { const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; }; React.useEffect(() => { if (highlightTime == null || !segments?.length) return; let closest = 0, minDiff = Infinity; segments.forEach((seg, i) => { const diff = Math.abs(seg.start_time - highlightTime); if (diff < minDiff) { minDiff = diff; closest = i; } }); setHighlightedIdx(closest); setActiveIdx(closest); setTimeout(() => { const el = document.getElementById(`seg-${closest}`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); const timer = setTimeout(() => setHighlightedIdx(null), 3000); return () => clearTimeout(timer); }, [highlightTime, segments]); const highlight = (text) => { if (!search.trim()) return text; const parts = text.split(new RegExp(`(${search.trim()})`, 'gi')); return parts.map((p, i) => p.toLowerCase() === search.trim().toLowerCase() ? {p} : p ); }; const matchCount = search.trim() && segments ? segments.filter(s => s.text.toLowerCase().includes(search.trim().toLowerCase())).length : 0; const totalSec = segments?.length ? (segments[segments.length - 1].end_time || segments[segments.length - 1].start_time + 30) : 3600; const activeSeg = activeIdx != null && segments ? segments[activeIdx] : null; const pct = activeSeg ? (activeSeg.start_time / totalSec) * 100 : 0; return (
{/* Top bar */}
{t ? '返回查詢' : 'Back'}
{episode.title}
{episode.published_at && {episode.published_at.slice(0, 10)}} {episode.duration_seconds && {fmtTime(episode.duration_seconds)}}
setSearch(e.target.value)} placeholder={t ? '關鍵字高亮搜尋...' : 'Highlight keywords...'} icon="search" />
{search && {matchCount} {t ? '個匹配' : 'matches'}}
{/* Left: Timeline */}
{t ? '時間軸' : 'Timeline'}
{segments && segments.length > 0 && ( <>
{segments.filter((_, i) => i % Math.max(1, Math.floor(segments.length / 20)) === 0).map((seg, dotIdx) => { const i = segments.indexOf(seg); return (
setActiveIdx(i)} style={{ position: 'absolute', top: -4, width: 12, height: 12, borderRadius: '50%', background: activeIdx === i ? TOKEN.accent : TOKEN.surfaceBorder, border: `2px solid ${activeIdx === i ? TOKEN.accent : TOKEN.textMuted}`, left: `${(seg.start_time / totalSec) * 100}%`, transform: 'translateX(-50%)', cursor: 'pointer', transition: 'all 0.12s', zIndex: 1 }} /> ); })}
{segments.filter((_, i) => i % Math.max(1, Math.floor(segments.length / 40)) === 0).map((seg) => { const i = segments.indexOf(seg); const matched = search && seg.text.toLowerCase().includes(search.toLowerCase()); return (
{ setActiveIdx(i); document.getElementById(`seg-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }} style={{ display: 'flex', gap: 8, padding: '6px 8px', borderRadius: 7, cursor: 'pointer', background: activeIdx === i ? TOKEN.accentDim : matched ? '#fbbf2422' : 'transparent', border: `1px solid ${activeIdx === i ? TOKEN.accent + '44' : matched ? '#fbbf2444' : 'transparent'}`, transition: 'all 0.1s' }}> {fmtTime(seg.start_time)} {seg.text.slice(0, 20)} {matched && }
); })}
)} {!segments && !segError && (
{[0,1,2].map(i =>
)}
)} {segError &&
{segError}
}
{/* Right: Full transcript */}
{!segments && !segError && (
{[0,1,2].map(i =>
)}
)} {segError && (
{t ? '載入失敗' : 'Failed to load'}
{segError}
)} {segments && segments.length === 0 && (
{t ? '此集尚無逐字稿內容' : 'No transcript available for this episode'}
)} {segments && segments.length > 0 && (
{segments.map((seg, i) => { const matched = search.trim() && seg.text.toLowerCase().includes(search.trim().toLowerCase()); const active = activeIdx === i; return (
setActiveIdx(i)}>
{fmtTime(seg.start_time)}

{highlight(seg.text)}

); })}
)}
); }; Object.assign(window, { TranscriptPage });