// 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 && (
)}
{segError &&
{segError}
}
{/* Right: Full transcript */}
{!segments && !segError && (
)}
{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 });