import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useCallback, useEffect, useRef, useState } from "react"; import { Pause, Play } from "lucide-react"; import { Terminal } from "@xterm/xterm"; import Guacamole from "guacamole-common-js"; import "@xterm/xterm/css/xterm.css"; import { parseAsciicast, type Asciicast } from "./asciicast"; import type { SessionLogRecord } from "./session-recording-api"; const SPEEDS = [0.5, 1, 2, 4]; function formatPosition(seconds: number) { const minutes = Math.floor(seconds / 60); return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`; } function getErrorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; } function SeekBar({ position, duration, onSeek, }: { position: number; duration: number; onSeek: (position: number) => void; }) { const { t } = useTranslation(); const barRef = useRef(null); const [dragging, setDragging] = useState(false); const progress = duration > 0 ? Math.min(position / duration, 1) : 0; const seekFromPointer = (clientX: number) => { const rect = barRef.current?.getBoundingClientRect(); if (!rect || rect.width === 0 || duration <= 0) return; const ratio = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1); onSeek(ratio * duration); }; return (
{ event.currentTarget.setPointerCapture(event.pointerId); setDragging(true); seekFromPointer(event.clientX); }} onPointerMove={(event) => { if (dragging) seekFromPointer(event.clientX); }} onPointerUp={(event) => { event.currentTarget.releasePointerCapture(event.pointerId); setDragging(false); }} onKeyDown={(event) => { if (event.key === "ArrowLeft") onSeek(Math.max(position - 5, 0)); if (event.key === "ArrowRight") onSeek(Math.min(position + 5, duration)); }} >
); } function PlaybackControls({ playing, position, duration, speed, onToggle, onSeek, onSpeed, }: { playing: boolean; position: number; duration: number; speed: number; onToggle: () => void; onSeek: (position: number) => void; onSpeed: (speed: number) => void; }) { const { t } = useTranslation(); return (
{formatPosition(Math.min(position, duration))} /{" "} {formatPosition(duration)}
{SPEEDS.map((value) => ( ))}
); } function AsciicastPlayer({ blob }: { blob: Blob }) { const containerRef = useRef(null); const screenRef = useRef(null); const terminalRef = useRef(null); const recordingRef = useRef(null); const positionRef = useRef(0); const eventIndexRef = useRef(0); const [position, setPosition] = useState(0); const [duration, setDuration] = useState(0); const [speed, setSpeed] = useState(1); const [playing, setPlaying] = useState(false); const [error, setError] = useState(""); const renderAt = useCallback((nextPosition: number) => { const terminal = terminalRef.current; const recording = recordingRef.current; if (!terminal || !recording) return; if (nextPosition < positionRef.current) { terminal.reset(); terminal.resize(recording.width, recording.height); eventIndexRef.current = 0; } while (eventIndexRef.current < recording.events.length) { const [time, type, data] = recording.events[eventIndexRef.current]; if (time > nextPosition) break; if (type === "o") terminal.write(data); if (type === "r") { const [cols, rows] = data.split("x").map(Number); if (cols > 0 && rows > 0) terminal.resize(cols, rows); } eventIndexRef.current++; } positionRef.current = nextPosition; setPosition(nextPosition); }, []); useEffect(() => { const container = containerRef.current; const screen = screenRef.current; if (!container || !screen) return; const terminal = new Terminal({ cursorBlink: false, disableStdin: true, convertEol: false, fontSize: 12, theme: { background: "#09090b" }, }); terminal.open(screen); terminalRef.current = terminal; // The recording keeps its own size, scaled down to fit the panel. const fit = () => { const width = screen.offsetWidth; const height = screen.offsetHeight; if (!width || !height) return; const scale = Math.min( 1, container.clientWidth / width, container.clientHeight / height, ); screen.style.transform = `scale(${scale})`; }; const observer = new ResizeObserver(fit); observer.observe(container); observer.observe(screen); const resizeListener = terminal.onResize(() => requestAnimationFrame(fit)); blob .text() .then((source) => { const recording = parseAsciicast(source); recordingRef.current = recording; setDuration(recording.duration); terminal.resize(recording.width, recording.height); renderAt(0); }) .catch((reason) => setError(getErrorMessage(reason, String(reason)))); return () => { observer.disconnect(); resizeListener.dispose(); terminal.dispose(); terminalRef.current = null; }; }, [blob, renderAt]); useEffect(() => { if (!playing || !recordingRef.current) return; let frame = 0; let previous = performance.now(); const tick = (now: number) => { const next = Math.min( duration, positionRef.current + ((now - previous) / 1000) * speed, ); previous = now; renderAt(next); if (next >= duration) setPlaying(false); else frame = requestAnimationFrame(tick); }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); }, [duration, playing, renderAt, speed]); if (error) return
{error}
; return (
{ if (!playing && position >= duration) renderAt(0); setPlaying((value) => !value); }} onSeek={renderAt} onSpeed={setSpeed} />
); } function GuacamolePlayer({ blob }: { blob: Blob }) { const containerRef = useRef(null); const recordingRef = useRef(null); const positionRef = useRef(0); const [position, setPosition] = useState(0); const [duration, setDuration] = useState(0); const [speed, setSpeed] = useState(1); const [playing, setPlaying] = useState(false); const [error, setError] = useState(""); useEffect(() => { if (!containerRef.current) return; const recording = new Guacamole.SessionRecording(blob, 100); recordingRef.current = recording; const display = recording.getDisplay(); const element = display.getElement(); containerRef.current.replaceChildren(element); const fit = () => { const width = display.getWidth(); if (width && containerRef.current) { display.scale(containerRef.current.clientWidth / width); } }; const observer = new ResizeObserver(fit); observer.observe(containerRef.current); recording.onload = () => { setDuration(recording.getDuration() / 1000); fit(); }; recording.onprogress = (nextDuration) => setDuration(nextDuration / 1000); recording.onseek = (nextPosition) => { positionRef.current = nextPosition / 1000; setPosition(positionRef.current); }; recording.onerror = setError; return () => { observer.disconnect(); recording.abort(); recordingRef.current = null; }; }, [blob]); useEffect(() => { const recording = recordingRef.current; if (!recording || !playing) return; recording.pause(); let previous = performance.now(); let nextPosition = positionRef.current; const interval = window.setInterval(() => { const now = performance.now(); nextPosition = Math.min( duration, nextPosition + ((now - previous) / 1000) * speed, ); previous = now; recording.seek(nextPosition * 1000); if (nextPosition >= duration) setPlaying(false); }, 100); return () => clearInterval(interval); }, [duration, playing, speed]); if (error) return
{error}
; return (
{ const recording = recordingRef.current; if (!recording) return; if (!playing && position >= duration) recording.seek(0); setPlaying((value) => !value); }} onSeek={(nextPosition) => { recordingRef.current?.seek(nextPosition * 1000); positionRef.current = nextPosition; setPosition(nextPosition); }} onSpeed={setSpeed} />
); } export function SessionRecordingPlayer({ log, blob, }: { log: SessionLogRecord; blob: Blob; }) { return log.format === "guacamole" ? ( ) : ( ); }