WitNote / dashboard /src /components /screencast /ScreencastTile.tsx
AUXteam's picture
Upload folder using huggingface_hub
6a7089a verified
import { useEffect, useRef, useState } from "react";
import { addTokenToUrl } from "../../services/auth";
import * as api from "../../services/api";
interface Props {
instanceId: string;
tabId: string;
label: string;
url: string;
quality?: number;
maxWidth?: number;
fps?: number;
showTitle?: boolean;
}
type Status = "connecting" | "streaming" | "error";
export default function ScreencastTile({
instanceId,
tabId,
label,
url,
quality = 30,
maxWidth = 800,
fps = 1,
showTitle = true,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const socketRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<Status>("connecting");
const [fpsDisplay, setFpsDisplay] = useState("โ€”");
const [sizeDisplay, setSizeDisplay] = useState("โ€”");
const [localFps, setLocalFps] = useState(fps);
const [isCapturing, setIsCapturing] = useState(false);
const [isPdfGenerating, setIsPdfGenerating] = useState(false);
const [fallbackUrl, setFallbackUrl] = useState<string | null>(null);
// Reset local FPS when the tab changes to match the new tab's initial request
useEffect(() => {
setLocalFps(fps);
setStatus("connecting");
setFallbackUrl(null);
}, [tabId, fps]);
// Clean up static preview URL on unmount or tab change
useEffect(() => {
return () => {
if (fallbackUrl) {
URL.revokeObjectURL(fallbackUrl);
}
};
}, [fallbackUrl]);
const takeScreenshot = async () => {
if (isCapturing) return;
setIsCapturing(true);
try {
const blob = await api.fetchTabScreenshot(tabId, "png");
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `screenshot-${tabId}-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (e) {
console.error("Screenshot capture failed", e);
} finally {
setIsCapturing(false);
}
};
const downloadPdf = async () => {
if (isPdfGenerating) return;
setIsPdfGenerating(true);
try {
const blob = await api.fetchTabPdf(tabId);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `page-${tabId}-${Date.now()}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (e) {
console.error("PDF generation failed", e);
} finally {
setIsPdfGenerating(false);
}
};
const captureFallback = async () => {
try {
const blob = await api.fetchTabScreenshot(tabId, "png");
if (fallbackUrl) URL.revokeObjectURL(fallbackUrl);
setFallbackUrl(URL.createObjectURL(blob));
} catch (e) {
console.error("Fallback capture failed", e);
}
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const params = new URLSearchParams({
tabId,
quality: String(quality),
maxWidth: String(maxWidth),
fps: String(localFps),
});
const path = addTokenToUrl(
`/instances/${encodeURIComponent(instanceId)}/proxy/screencast?${params.toString()}`,
);
const wsUrl = new URL(path, window.location.origin);
wsUrl.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(wsUrl.toString());
socket.binaryType = "arraybuffer";
socketRef.current = socket;
let frameCount = 0;
let lastFpsTime = Date.now();
socket.onopen = () => {
setStatus("streaming");
};
socket.onmessage = (evt) => {
const blob = new Blob([evt.data], { type: "image/jpeg" });
const imgUrl = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(imgUrl);
};
img.src = imgUrl;
frameCount++;
const now = Date.now();
if (now - lastFpsTime >= 1000) {
setFpsDisplay(`${frameCount} fps`);
setSizeDisplay(`${(evt.data.byteLength / 1024).toFixed(0)} KB/frame`);
frameCount = 0;
lastFpsTime = now;
}
};
socket.onerror = () => {
setStatus("error");
};
socket.onclose = () => {
setStatus("error");
};
return () => {
socket.close();
socketRef.current = null;
};
}, [instanceId, tabId, quality, maxWidth, localFps]);
const statusColor =
status === "streaming"
? "bg-success"
: status === "connecting"
? "bg-warning"
: "bg-destructive";
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border-subtle bg-bg-elevated">
{/* Header */}
{showTitle && (
<div className="flex shrink-0 items-center justify-between border-b border-border-subtle px-3 py-2">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-text-secondary">
{label}
</span>
<div className={`h-2 w-2 rounded-full ${statusColor}`} />
</div>
<span className="max-w-50 truncate text-xs text-text-muted">
{url}
</span>
</div>
)}
{/* Canvas */}
<div className="relative flex min-h-0 flex-1 items-center justify-center bg-black">
{status === "error" && fallbackUrl ? (
<img
src={fallbackUrl}
alt="Tab preview"
className="max-h-full max-w-full object-contain"
/>
) : (
<canvas
ref={canvasRef}
className="max-h-full max-w-full object-contain"
width={800}
height={600}
/>
)}
{status === "error" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/80 text-sm text-text-primary backdrop-blur-[2px]">
<div className="font-semibold text-white drop-shadow-md">
Connection lost
</div>
<div className="flex gap-2">
{!fallbackUrl && (
<button
onClick={captureFallback}
className="rounded bg-white/10 px-3 py-1.5 font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-white/20"
>
Show static preview
</button>
)}
<button
onClick={() => setStatus("connecting")}
className="rounded bg-primary/30 px-3 py-1.5 font-medium text-white shadow-lg backdrop-blur-md transition-colors hover:bg-primary/40"
>
Retry connection
</button>
</div>
</div>
)}
</div>
<div className="flex shrink-0 items-center justify-between border-t border-border-subtle px-3 py-1 text-xs text-text-muted">
<div className="flex items-center gap-3">
<div className="flex items-center overflow-hidden rounded border border-border-subtle bg-black/20">
<button
onClick={() => setLocalFps((prev) => Math.max(1, prev - 1))}
className="flex h-5 w-5 items-center justify-center hover:bg-white/5 active:bg-white/10"
title="Decrease FPS"
>
-
</button>
<div className="min-w-16 px-1.5 text-center font-mono text-[10px] text-text-secondary">
{localFps} FPS ({fpsDisplay})
</div>
<button
onClick={() => setLocalFps((prev) => Math.min(30, prev + 1))}
className="flex h-5 w-5 items-center justify-center hover:bg-white/5 active:bg-white/10"
title="Increase FPS"
>
+
</button>
</div>
<button
onClick={takeScreenshot}
disabled={isCapturing || status !== "streaming"}
className={`flex h-6 w-6 items-center justify-center rounded-md border border-border-subtle transition-colors hover:bg-white/5 disabled:opacity-50 ${
isCapturing ? "bg-primary/20" : "bg-black/20"
}`}
title="Take full quality screenshot (PNG)"
>
{isCapturing ? (
<span className="animate-pulse">โŒ›</span>
) : (
<span>๐Ÿ“ธ</span>
)}
</button>
<button
onClick={downloadPdf}
disabled={isPdfGenerating || status !== "streaming"}
className={`flex h-6 w-6 items-center justify-center rounded-md border border-border-subtle transition-colors hover:bg-white/5 disabled:opacity-50 ${
isPdfGenerating ? "bg-primary/20" : "bg-black/20"
}`}
title="Download as PDF"
>
{isPdfGenerating ? (
<span className="animate-pulse">โŒ›</span>
) : (
<span>๐Ÿ“„</span>
)}
</button>
</div>
<span>{sizeDisplay}</span>
</div>
</div>
);
}