import { useEffect, useRef, useState, useCallback } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; interface CastHeader { version: number; width: number; height: number; timestamp?: number; title?: string; env?: Record; } interface CastEvent { time: number; type: string; data: string; } interface TerminalPlayerProps { /** Path to the .cast file (asciinema v2 format) */ castUrl: string; /** Optional callback when playback completes */ onComplete?: () => void; /** Optional callback for current playback time */ onTimeUpdate?: (time: number) => void; /** External control: seek to this time (in seconds) */ seekTo?: number; /** External control: play/pause state */ playing?: boolean; /** Playback speed multiplier (default: 1) */ speed?: number; /** Show built-in controls */ showControls?: boolean; } export default function TerminalPlayer({ castUrl, onComplete, onTimeUpdate, seekTo, playing: externalPlaying, speed = 1, showControls = true, }: TerminalPlayerProps) { const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); const [header, setHeader] = useState(null); const [events, setEvents] = useState([]); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [isLoaded, setIsLoaded] = useState(false); const [error, setError] = useState(null); const playbackRef = useRef<{ startTime: number; startEventIndex: number; rafId: number | null; }>({ startTime: 0, startEventIndex: 0, rafId: null }); // Parse cast file useEffect(() => { async function loadCast() { try { const response = await fetch(castUrl); if (!response.ok) { throw new Error(`Failed to load cast file: ${response.statusText}`); } const text = await response.text(); const lines = text.trim().split("\n"); if (lines.length === 0) { throw new Error("Empty cast file"); } // First line is header const parsedHeader = JSON.parse(lines[0]) as CastHeader; setHeader(parsedHeader); // Remaining lines are events const parsedEvents: CastEvent[] = []; for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; const parsed = JSON.parse(line) as [number, string, string]; parsedEvents.push({ time: parsed[0], type: parsed[1], data: parsed[2], }); } setEvents(parsedEvents); if (parsedEvents.length > 0) { setDuration(parsedEvents[parsedEvents.length - 1].time); } setIsLoaded(true); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load cast file"); } } loadCast(); }, [castUrl]); // Initialize terminal useEffect(() => { if (!containerRef.current || !header) return; const terminal = new Terminal({ cols: header.width, rows: header.height, cursorBlink: false, disableStdin: true, fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace", fontSize: 14, lineHeight: 1.2, theme: { background: "#1a1a1a", foreground: "#d4d4d4", cursor: "#d4d4d4", black: "#1a1a1a", red: "#f87171", green: "#4ade80", yellow: "#facc15", blue: "#60a5fa", magenta: "#c084fc", cyan: "#22d3ee", white: "#d4d4d4", brightBlack: "#525252", brightRed: "#fca5a5", brightGreen: "#86efac", brightYellow: "#fde047", brightBlue: "#93c5fd", brightMagenta: "#d8b4fe", brightCyan: "#67e8f9", brightWhite: "#f5f5f5", }, }); const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); terminal.open(containerRef.current); fitAddon.fit(); terminalRef.current = terminal; fitAddonRef.current = fitAddon; // Handle resize const resizeObserver = new ResizeObserver(() => { fitAddon.fit(); }); resizeObserver.observe(containerRef.current); return () => { resizeObserver.disconnect(); terminal.dispose(); terminalRef.current = null; fitAddonRef.current = null; }; }, [header]); // Apply events up to a specific time const applyEventsUpTo = useCallback( (targetTime: number, fromIndex: number = 0) => { if (!terminalRef.current) return fromIndex; let lastIndex = fromIndex; for (let i = fromIndex; i < events.length; i++) { if (events[i].time > targetTime) break; if (events[i].type === "o") { terminalRef.current.write(events[i].data); } lastIndex = i + 1; } return lastIndex; }, [events] ); // Reset terminal and apply events from start const seekToTime = useCallback( (targetTime: number) => { if (!terminalRef.current) return; // Reset terminal terminalRef.current.reset(); // Apply all events up to target time const nextIndex = applyEventsUpTo(targetTime, 0); playbackRef.current.startEventIndex = nextIndex; setCurrentTime(targetTime); onTimeUpdate?.(targetTime); }, [applyEventsUpTo, onTimeUpdate] ); // Handle external seek control useEffect(() => { if (seekTo !== undefined && isLoaded) { seekToTime(seekTo); } }, [seekTo, isLoaded, seekToTime]); // Handle external play/pause control useEffect(() => { if (externalPlaying !== undefined) { setIsPlaying(externalPlaying); } }, [externalPlaying]); // Playback loop useEffect(() => { if (!isPlaying || !isLoaded) { if (playbackRef.current.rafId) { cancelAnimationFrame(playbackRef.current.rafId); playbackRef.current.rafId = null; } return; } playbackRef.current.startTime = performance.now() - (currentTime * 1000) / speed; const tick = () => { const elapsed = ((performance.now() - playbackRef.current.startTime) * speed) / 1000; const newTime = Math.min(elapsed, duration); // Apply any events between last time and now const nextIndex = applyEventsUpTo(newTime, playbackRef.current.startEventIndex); playbackRef.current.startEventIndex = nextIndex; setCurrentTime(newTime); onTimeUpdate?.(newTime); if (newTime >= duration) { setIsPlaying(false); onComplete?.(); return; } playbackRef.current.rafId = requestAnimationFrame(tick); }; playbackRef.current.rafId = requestAnimationFrame(tick); return () => { if (playbackRef.current.rafId) { cancelAnimationFrame(playbackRef.current.rafId); playbackRef.current.rafId = null; } }; }, [ isPlaying, isLoaded, speed, duration, currentTime, applyEventsUpTo, onTimeUpdate, onComplete, ]); // Play/pause toggle const togglePlayback = () => { if (!isPlaying && currentTime >= duration) { // Restart from beginning seekToTime(0); } setIsPlaying(!isPlaying); }; // Format time for display const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, "0")}`; }; if (error) { return (
Error: {error}
); } return (
{showControls && isLoaded && (
{formatTime(currentTime)} / {formatTime(duration)} seekToTime(parseFloat(e.target.value))} style={{ flex: 1 }} />
)}
); }