// Presentational components for the Reader prototype.
// All state-free. Consumed by reader-app.jsx.
const COLOR_KEYS = {
scout: "var(--agent-scout)",
analyst: "var(--agent-analyst)",
watchdog: "var(--agent-watchdog)",
risk: "var(--agent-risk)",
executioner: "var(--agent-executioner)",
orchestrator: "var(--agent-orchestrator)",
};
window.COLOR_KEYS = COLOR_KEYS;
window.fmtTime = function fmtTime(s) {
const m = Math.floor(s / 60);
const ss = String(Math.floor(s % 60)).padStart(2, "0");
return `${m}:${ss}`;
};
window.speakerColor = function speakerColor(sp) {
if (!sp) return "var(--accent)";
if (sp.colorKey === "_accent") return "var(--accent)";
return COLOR_KEYS[sp.colorKey] || "var(--accent)";
};
// SUPERB-ER emits 3-letter codes (ang/hap/neu/sad). Map to friendly labels
// and a color hint for the per-turn chip. Unknown labels pass through raw.
const EMOTION_META = {
ang: { label: "angry", color: "#f4677e" },
hap: { label: "happy", color: "#f5c84a" },
neu: { label: "neutral", color: "#6f7686" },
sad: { label: "sad", color: "#4fa3ff" },
};
window.emotionLabel = function emotionLabel(code) {
return (EMOTION_META[code] && EMOTION_META[code].label) || code;
};
window.emotionColor = function emotionColor(code) {
return (EMOTION_META[code] && EMOTION_META[code].color) || "var(--text-muted)";
};
// 0.18.0: the exact local date and time of a Facebook post or comment, e.g.
// "Sep 28, 2026, 2:05 PM PDT". Takes an ISO string or epoch milliseconds;
// returns "" when the value is missing or unparseable.
window.fmtExactTime = function fmtExactTime(value) {
if (value == null || value === "") return "";
const d = new Date(value);
if (isNaN(d.getTime())) return "";
return d.toLocaleString("en-US", {
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit", timeZoneName: "short",
});
};
// ---------- icons ----------
const I = {
link: (p) => (
),
play: (p) => (),
check: (p) => (),
search: (p) => (),
copy: (p) => (),
download: (p) => (),
upload: (p) => (),
user: (p) => (),
clock: (p) => (),
users: (p) => (),
film: (p) => (),
edit: (p) => (),
plus: (p) => (),
x: (p) => (),
trash: (p) => (),
refresh: (p) => (),
alert: (p) => (),
spark: (p) => (),
arrow: (p) => (),
arrowBack: (p) => (),
waveform: (p) => (),
notes: (p) => (),
logo: (p) => (
),
};
window.I = I;
// ---------- threadComments ----------
// 0.18.0: threads a flat comment list for the page and for Copy. Replies nest
// under their parent to any depth (Facebook has replies to replies). A
// visited set means every comment renders once, even on a parent_id cycle or
// a comment naming itself as its parent. A reply whose parent was not
// captured goes to the top level with `orphan` set. Returns the top-level
// nodes ({comment, replies, orphan}) and how many comments they hold, which
// is what the comment headers count.
function threadComments(comments) {
const ids = new Set(comments.map((c) => c.id));
const hasParent = (c) => !!c.parent_id && c.parent_id !== c.id && ids.has(c.parent_id);
const childrenOf = new Map();
for (const c of comments) {
if (!hasParent(c)) continue;
if (!childrenOf.has(c.parent_id)) childrenOf.set(c.parent_id, []);
childrenOf.get(c.parent_id).push(c);
}
const visited = new Set();
const build = (c, orphan) => {
visited.add(c.id);
const replies = [];
for (const r of childrenOf.get(c.id) || []) {
if (!visited.has(r.id)) replies.push(build(r, false));
}
return { comment: c, replies, orphan };
};
const roots = [];
for (const c of comments) {
if (!visited.has(c.id) && !hasParent(c)) {
roots.push(build(c, !!c.parent_id && c.parent_id !== c.id));
}
}
// Whatever is left sits on a parent_id cycle with no top-level comment
// above it: each one is shown once, at the top level.
for (const c of comments) {
if (!visited.has(c.id)) roots.push(build(c, false));
}
return { roots, rendered: visited.size };
}
window.threadComments = threadComments;
// 0.19.0: how many comments a list of threadComments nodes holds, replies to
// replies included: under one comment, its captured replies at all depths.
function countReplies(nodes) {
return nodes.reduce((n, node) => n + 1 + countReplies(node.replies), 0);
}
// ---------- CommentsSection ----------
// Renders the source-platform comments scraped by yt-dlp when the user
// ticks "Capture comments" before a run. Top-level comments first, replies
// nested under their parents. Pinned and creator-reply comments get a small
// badge so they stand out in political-analysis review.
//
// 0.18.0: `post` is set for a Facebook result. It switches on the Facebook
// meta line, the "of about M reported" count and two empty states (R15):
// comments null means they were not captured, [] that none were visible.
// Every other result renders nothing for both, as before.
function CommentsSection({ comments, post }) {
const facebook = !!post;
const empty = !comments || comments.length === 0;
if (empty && !facebook) return null;
const { roots, rendered } = empty ? { roots: [], rendered: 0 } : threadComments(comments);
const reported = facebook ? post.comment_count : null;
const notes = facebook ? post.notes || [] : [];
// A walk that stopped before its first comment (a block, the budget, a
// cancel) stores [] with comments_complete false: that is "not captured",
// not "none visible", and its notes say why.
const notCaptured = empty && (comments == null || (facebook && post.comments_complete === false));
const emptyStyle = {
margin: "0 0 8px",
color: "var(--text-secondary)",
fontSize: 14,
lineHeight: 1.55,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
};
return (
Speaker-labeled transcripts from YouTube, Instagram and Facebook.
Paste a URL or upload a file. Get a clean transcript with speaker diarization,
voiceprint matching, and optional scene descriptions —
all running on the Pi in your closet.
);
}
window.HeroForm = HeroForm;
// ---------- Running card (in-progress hero) ----------
// 0.18.0: `facebook` is true for a full-mode Facebook job (its strip starts
// at fb_post). `batch` is /status's {done, total} or null; during
// fb_comments those are the captured and reported comment counts.
function RunningCard({ url, phaseIdx, subIdx, phases, elapsed, gpu, onCancel, interpret, facebook, batch }) {
const fmt = window.fmtTime;
const totalPhases = phases.length;
const cur = phases[phaseIdx];
const commentProgress = cur?.id === "fb_comments" && batch
? (batch.total > 0
? `${batch.done.toLocaleString()} of about ${batch.total.toLocaleString()} comments`
: `${batch.done.toLocaleString()} comments`)
: null;
return (
RUNNING{fmt(elapsed)}·{url}
{cur?.label || "Starting…"}
step {phaseIdx + 1} of {totalPhases}
{facebook ? (
<>
Facebook capture·3 to 6 s between requests
>
) : (
<>
{interpret ? "Interpreting video content" : "Audio-only mode"}·whisper-large-v3 · pyannote-3.1{interpret ? " · qwen-vl" : ""}
>
)}
);
}
window.RunningCard = RunningCard;
// ---------- Done hero ----------
// 0.10.2: thumbnail now shows the real YouTube poster frame when the source
// is a YouTube video, and the whole panel links out to the source. For
// non-YouTube sources (e.g. Facebook Reels) we fall back to the original
// glow-art placeholder but still make it clickable when a URL exists.
//
// 0.18.0: `video.postOnly` is a Facebook post with no video. Its hero says
// "Post captured" (or "Partial capture" when the comment walk was cut short),
// labels the art "Facebook" and shows the author, the posted date and the
// captured comment count; duration, speakers, scenes, GPU peak and the play
// overlay are left out. `post` is result.post; `commentCount` is how many
// comments were captured, or null when they were not.
function DoneHero({ video, meta, speakers, accent, onSaveSpeaker, post, commentCount }) {
const postOnly = !!video.postOnly;
const partial = postOnly && !!post && post.comments_complete === false;
// YouTube video IDs are 11 chars of [A-Za-z0-9_-]. Use that to tell
// YouTube (use ytimg poster) from anything else (fall back to glow art).
const ytId = video.videoId && /^[A-Za-z0-9_-]{11}$/.test(video.videoId)
? video.videoId : null;
const ytIsLikely = ytId && (
!video.sourceUrl
|| /youtube\.com|youtu\.be/.test(video.sourceUrl)
);
const thumbUrl = ytIsLikely
? `https://i.ytimg.com/vi/${ytId}/hqdefault.jpg`
: null;
// Local uploads carry url "file:": nothing to link out to.
const isLocal = /^file:/.test(video.sourceUrl || "");
const linkUrl = isLocal ? null
: (video.sourceUrl || (ytId ? `https://www.youtube.com/watch?v=${ytId}` : null));
const thumbInner = thumbUrl ? (
);
}
// ---------- Error banner ----------
// 0.18.0: `title` is optional; Facebook jobs pass "Facebook capture failed"
// and the server's message is shown exactly as sent.
function ErrorBanner({ message, onRetry, onDismiss, title }) {
return (
);
}
window.SpeechTurn = SpeechTurn;
// ---------- EmotionChip ----------
// Compact per-turn badge for SER output. Categorical comes from
// superb/wav2vec2-base-superb-er (4-class). Dimensional comes from the
// audeering MSP-Podcast model — values are 0-1 floats. We render both
// when present, side by side, to keep the row scannable.
function EmotionChip({ seg }) {
const ec = seg.emotionCategorical;
const ed = seg.emotionDimensional;
return (
{ec && (
{window.emotionLabel(ec.label)} {Math.round((ec.confidence || 0) * 100)}%
)}
{ec && ed && ·}
{ed && (
A{ed.arousal.toFixed(2)} V{ed.valence.toFixed(2)} D{ed.dominance.toFixed(2)}
)}
);
}
window.EmotionChip = EmotionChip;
// ---------- Scene block ----------
function SceneBlock({ seg, showTs, query, accent }) {
return (
scene
{showTs && {window.fmtTime(seg.start)}–{window.fmtTime(seg.end)}}
);
}
window.SceneBlock = SceneBlock;
// ---------- Highlight helper ----------
function Highlight({ text, query, accent }) {
if (!query || query.length < 2) return text;
const lower = text.toLowerCase();
const q = query.toLowerCase();
const parts = [];
let i = 0;
let idx;
while ((idx = lower.indexOf(q, i)) !== -1) {
if (idx > i) parts.push(text.slice(i, idx));
parts.push(
{text.slice(idx, idx + q.length)}
);
i = idx + q.length;
}
if (i < text.length) parts.push(text.slice(i));
return parts;
}
// ---------- Meeting notes panel (0.17.0) ----------
// Renders result.meeting above the transcript: brain badge, the synthesis
// error if the model step failed, the summary as paragraphs, then the
// action-item and follow-up lists. Each item's [mm:ss] button calls onJump
// with item.start; reader-app resolves that to the nearest transcript turn.
// Timestamps are shown zero-padded because that is the form the model was
// shown and cited ("[02:15]"), unlike the transcript's own m:ss.
function fmtMmSs(s) {
const m = String(Math.floor(s / 60)).padStart(2, "0");
const ss = String(Math.floor(s % 60)).padStart(2, "0");
return `${m}:${ss}`;
}
function MeetingNotesPanel({ meeting, onJump }) {
if (!meeting) return null;
const brain = meeting.brain === "claude" ? "claude" : "local";
const paragraphs = (meeting.summary || "")
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
const actions = meeting.action_items || [];
const followups = meeting.followups || [];
return (
);
}
// ---------- Facebook post panel (0.18.0) ----------
// Renders result.post above the transcript: the author and exact time, the
// text, attachments (a link target is shown as text, never fetched and never
// made a link), the reaction, comment and share counts, and every capture
// note as a warning line. "Open on Facebook" is the one link: https only,
// sent with no referrer. `comments` is the result's list, or null when
// comments were not captured, and gives the captured count.
// 0.19.0: an attachment with `media` shows its picture (FbMedia) with
// Facebook's alt text and the picture's page, so its caption and address,
// the same text, are not repeated.
function PostPanel({ post, comments }) {
if (!post) return null;
const num = (v) => Number(v).toLocaleString("en-US");
const plural = (v, word) => `${num(v)} ${word}${v === 1 ? "" : "s"}`;
const posted = window.fmtExactTime(post.created_at);
const attachments = post.attachments || [];
const notes = post.notes || [];
const breakdown = Object.entries(post.reactions || {})
.map(([label, count]) => `${label} ${num(count)}`)
.join(" · ");
const reactionLine = post.reaction_count != null
? plural(post.reaction_count, "reaction") + (breakdown ? ` (${breakdown})` : "")
: breakdown;
const captured = Array.isArray(comments) ? comments.length : null;
const commentBits = [];
if (post.comment_count != null) commentBits.push(`${plural(post.comment_count, "comment")} reported`);
if (captured != null) {
commentBits.push(post.comment_count != null ? `${num(captured)} captured` : `${plural(captured, "comment")} captured`);
}
const completeness = post.comments_complete === true ? "Complete"
: post.comments_complete === false ? "Partial"
: null;
const openUrl = /^https:\/\//i.test(post.url || "") ? post.url : null;
return (
FACEBOOK POST
{post.author && {post.author}}
{posted && {posted}}
))}
);
}
window.PostPanel = PostPanel;
// ---------- Facebook media (0.19.0) ----------
// A picture a Facebook capture saved and read (schema.FacebookMedia). The
// page loads it only from the owner-only /facebook/media route, by the
// post's id and the file's name, and clicking it opens it in a new tab.
// Beside it: Facebook's alt text, the local model's one-line description,
// and the text read from the picture under a collapsed "Text in image",
// left out when there is none. A picture that was not saved or not read
// says why in a muted line. A link title, site and address show whatever
// the kind (a GIF's site, a photo's page), the address as plain text,
// never fetched and never a link; a shared link or a Facebook video (no
// picture in 0.19.0) is a small card. Nothing renders when there is
// nothing to show. `variant` is "post" (up to the panel's width) or
// "comment" (a thumbnail beside the text).
function fbMediaSrc(postId, media) {
if (!postId || !media.local_path) return null;
return `/facebook/media/${encodeURIComponent(postId)}/${encodeURIComponent(media.local_path)}`;
}
function FbMedia({ media, postId, variant }) {
const m = media;
const src = fbMediaSrc(postId, m);
if (!(src || m.link_title || m.link_source || m.url || m.alt_text || m.description || m.ocr_text || m.error)) {
return null;
}
const card = m.kind === "link" || m.kind === "video";
return (
{src && (
)}
{m.link_title &&
{m.link_title}
}
{m.link_source &&
{m.link_source}
}
{m.url &&
{m.url}
}
{m.alt_text && (
Facebook alt text{m.alt_text}
)}
{m.description && (
Description{m.description}
)}
{m.ocr_text && (
Text in image
{m.ocr_text}
)}
{m.error &&
{m.error}
}
);
}
window.FbMedia = FbMedia;
// ---------- Sticky footer ----------
// 0.18.0: a post-only Facebook result has no timed segments, so it offers
// no .srt and its copy button reads "Copy post".
// 0.19.0: `hasZip` is a Facebook capture that saved pictures (post.media_dir
// set); /download/zip packs its .json with them.
function StickyFooter({ onCopy, copied, accent, onDownload, hasNotes, postOnly, hasZip }) {
return (
EXPORT
{(postOnly ? ["txt", "json"] : ["txt", "json", "srt"]).map((ext) => (
))}
{hasZip && (
)}
{/* 0.17.0: /download/notes exists only when the result carries meeting notes. */}
{hasNotes && (
)}
);
}
window.VoiceprintsView = VoiceprintsView;
// ---------- VoiceSampleRow ----------
// Click the row to reveal the source jump-link and the transcript excerpt
// captured at enrollment time. The delete button stops propagation so it
// doesn't toggle expansion when clicked.
//
// IMPORTANT: ``.vp-sample`` in reader.css is a CSS grid with named child
// classes (.vp-sample-time / .vp-sample-src / etc.) on specific columns.
// We render those children as DIRECT children of the
, with the
// optional detail panel as a final sibling that spans the whole row via
// ``gridColumn: '1 / -1'``. Wrapping the row in an extra
would
// break the grid.
function VoiceSampleRow({ sample, onDelete }) {
const [expanded, setExpanded] = React.useState(false);
const hasDetail = sample.excerpt || sample.jumpUrl;
const handleRowClick = () => { if (hasDetail) setExpanded(!expanded); };
return (