magic / src /components /VncViewer.tsx
diamond-in's picture
Rename VncViewer.tsx to src/components/VncViewer.tsx
d0329b6 verified
import React, { useEffect, useRef, useState, useCallback } from 'react';
interface FrameHeader {
frameId: number;
width: number;
height: number;
timestamp: number;
isFull: boolean;
}
interface VncViewerProps {
serverUrl?: string;
width?: number;
height?: number;
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (error: Error) => void;
}
interface VncViewerState {
isConnected: boolean;
isConnecting: boolean;
fps: number;
latency: number;
error: string | null;
}
const DEFAULT_PROPS = {
serverUrl: '/api/vnc',
width: 1280,
height: 720,
};
export default function VncViewer(props: VncViewerProps) {
const config = { ...DEFAULT_PROPS, ...props };
const canvasRef = useRef<HTMLCanvasElement>(null);
const offscreenCanvasRef = useRef<OffscreenCanvas | null>(null);
const workerRef = useRef<Worker | null>(null);
const lastFrameIdRef = useRef(0);
const lastFrameTimeRef = useRef(0);
const frameCountRef = useRef(0);
const lastPingTimeRef = useRef(Date.now());
const pollControllerRef = useRef<AbortController | null>(null);
const inputQueueRef = useRef<Array<{ type: string; data: Record<string, unknown> }>>([]);
const [state, setState] = useState<VncViewerState>({
isConnected: false,
isConnecting: false,
fps: 0,
latency: 0,
error: null,
});
// Performance tracking
const fpsCounterRef = useRef({
frames: 0,
lastTime: Date.now(),
fps: 0,
});
/**
* Send input event to server
*/
const sendInput = useCallback(async (type: string, data: Record<string, unknown>) => {
try {
await fetch(`${config.serverUrl}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, data }),
});
} catch (error) {
console.error('Input error:', error);
}
}, [config.serverUrl]);
/**
* Process mouse event
*/
const handleMouseMove = useCallback((event: React.MouseEvent<HTMLCanvasElement>) => {
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const scaleX = config.width! / rect.width;
const scaleY = config.height! / rect.height;
const x = Math.floor((event.clientX - rect.left) * scaleX);
const y = Math.floor((event.clientY - rect.top) * scaleY);
sendInput('mousemove', { x, y });
}, [config, sendInput]);
const handleMouseDown = useCallback((event: React.MouseEvent<HTMLCanvasElement>) => {
const buttonMap: Record<number, number> = {
0: 1, // Left button
1: 2, // Middle button
2: 3, // Right button
};
const button = buttonMap[event.button] || 1;
sendInput('mousedown', { button });
}, [sendInput]);
const handleMouseUp = useCallback((event: React.MouseEvent<HTMLCanvasElement>) => {
const buttonMap: Record<number, number> = {
0: 1,
1: 2,
2: 3,
};
const button = buttonMap[event.button] || 1;
sendInput('mouseup', { button });
}, [sendInput]);
const handleWheel = useCallback((event: React.WheelEvent<HTMLCanvasElement>) => {
sendInput('wheel', {
deltaX: event.deltaX,
deltaY: event.deltaY,
});
}, [sendInput]);
/**
* Process keyboard event
*/
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLCanvasElement>) => {
// Prevent default browser shortcuts
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
event.preventDefault();
sendInput('keydown', {
keyCode: event.which || event.keyCode,
key: event.key,
code: event.code,
});
}, [sendInput]);
const handleKeyUp = useCallback((event: React.KeyboardEvent<HTMLCanvasElement>) => {
event.preventDefault();
sendInput('keyup', {
keyCode: event.which || event.keyCode,
key: event.key,
code: event.code,
});
}, [sendInput]);
/**
* Poll for new frames
*/
const pollFrames = useCallback(async () => {
if (!canvasRef.current) return;
const poll = async () => {
try {
const startTime = Date.now();
const response = await fetch(
`${config.serverUrl}/poll?lastId=${lastFrameIdRef.current}&width=${config.width}&height=${config.height}`,
{
signal: pollControllerRef.current?.signal,
}
);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const latency = Date.now() - startTime;
lastPingTimeRef.current = Date.now();
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/octet-stream')) {
const buffer = await response.arrayBuffer();
const data = new Uint8Array(buffer);
if (data.length > 16) {
// Parse frame header
const view = new DataView(data.buffer);
const header: FrameHeader = {
frameId: view.getUint32(0, true),
width: view.getUint16(4, true),
height: view.getUint16(6, true),
timestamp: view.getUint32(8, true),
isFull: view.getUint8(12) === 1,
};
const frameData = data.slice(16);
// Update canvas
renderFrame(header, frameData);
lastFrameIdRef.current = header.frameId;
// Update FPS counter
fpsCounterRef.current.frames++;
const now = Date.now();
if (now - fpsCounterRef.current.lastTime >= 1000) {
fpsCounterRef.current.fps = fpsCounterRef.current.frames;
fpsCounterRef.current.frames = 0;
fpsCounterRef.current.lastTime = now;
setState(prev => ({
...prev,
fps: fpsCounterRef.current.fps,
latency,
}));
}
}
} else {
// JSON response (timeout or status)
const json = await response.json();
if (json.timeout) {
// Continue polling
setTimeout(poll, 50);
return;
}
}
// Continue polling immediately for smooth updates
requestAnimationFrame(poll);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return; // Stop polling
}
console.error('Poll error:', error);
setState(prev => ({
...prev,
error: error instanceof Error ? error.message : 'Connection error',
}));
// Retry after delay
setTimeout(poll, 1000);
}
};
poll();
}, [config]);
/**
* Render frame to canvas
*/
const renderFrame = (header: FrameHeader, data: Uint8Array) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Create or resize offscreen canvas if needed
if (!offscreenCanvasRef.current ||
offscreenCanvasRef.current.width !== header.width ||
offscreenCanvasRef.current.height !== header.height) {
offscreenCanvasRef.current = new OffscreenCanvas(header.width, header.height);
}
const offscreenCtx = offscreenCanvasRef.current.getContext('2d');
if (!offscreenCtx) return;
// Create image data
const imageData = offscreenCtx.createImageData(header.width, header.height);
if (header.isFull) {
// Full frame - just copy data
imageData.data.set(data);
} else {
// Diff frame - apply to existing canvas data
const currentData = offscreenCtx.getImageData(0, 0, header.width, header.height);
const diffView = new DataView(data.buffer);
let offset = 0;
while (offset < data.length) {
const flags = data[offset];
const startX = diffView.getUint16(offset + 1, true);
const startY = diffView.getUint16(offset + 3, true);
const width = diffView.getUint16(offset + 5, true);
const height = diffView.getUint16(offset + 7, true);
offset += 9;
if (flags === 1) {
// Changed region
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcIdx = offset + (y * width + x) * 4;
const dstIdx = ((startY + y) * header.width + (startX + x)) * 4;
imageData.data[dstIdx] = data[srcIdx];
imageData.data[dstIdx + 1] = data[srcIdx + 1];
imageData.data[dstIdx + 2] = data[srcIdx + 2];
imageData.data[dstIdx + 3] = 255;
}
}
offset += width * height * 4;
}
}
}
// Put image data to offscreen canvas
offscreenCtx.putImageData(imageData, 0, 0);
// Copy to main canvas with scaling
canvas.width = header.width;
canvas.height = header.height;
ctx.imageSmoothingEnabled = true;
ctx.drawImage(offscreenCanvasRef.current, 0, 0);
lastFrameTimeRef.current = Date.now();
};
/**
* Initialize VNC connection
*/
useEffect(() => {
setState(prev => ({ ...prev, isConnecting: true }));
// Start polling for frames
pollFrames();
// Mark as connected after first frame
const connectTimeout = setTimeout(() => {
setState(prev => ({ ...prev, isConnected: true, isConnecting: false }));
config.onConnect?.();
}, 2000);
return () => {
clearTimeout(connectTimeout);
pollControllerRef.current?.abort();
config.onDisconnect?.();
};
}, [pollFrames, config]);
return (
<div className="vnc-viewer">
<style jsx>{`
.vnc-viewer {
position: relative;
width: 100%;
height: 100vh;
background: #0a0a0f;
overflow: hidden;
}
.canvas-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.vnc-canvas {
max-width: 100%;
max-height: 100%;
object-fit: contain;
cursor: crosshair;
}
.status-overlay {
position: absolute;
top: 16px;
right: 16px;
background: rgba(0, 0, 0, 0.75);
padding: 12px 16px;
border-radius: 8px;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12px;
color: #10b981;
backdrop-filter: blur(8px);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.status-row {
display: flex;
justify-content: space-between;
gap: 24px;
margin-bottom: 4px;
}
.status-row:last-child {
margin-bottom: 0;
}
.status-label {
color: #6b7280;
}
.status-value {
color: #10b981;
font-weight: 600;
}
.status-value.error {
color: #ef4444;
}
.status-value.connecting {
color: #f59e0b;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.connection-indicator {
position: absolute;
top: 16px;
left: 16px;
display: flex;
align-items: center;
gap: 8px;
background: rgba(0, 0, 0, 0.75);
padding: 8px 12px;
border-radius: 6px;
backdrop-filter: blur(8px);
}
.indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6b7280;
}
.indicator-dot.connected {
background: #10b981;
box-shadow: 0 0 8px #10b981;
}
.indicator-dot.connecting {
background: #f59e0b;
animation: pulse 1s infinite;
}
.indicator-dot.error {
background: #ef4444;
}
.indicator-text {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12px;
color: #9ca3af;
}
.help-text {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.75);
padding: 8px 16px;
border-radius: 6px;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 11px;
color: #6b7280;
backdrop-filter: blur(8px);
}
`}</style>
<div className="canvas-container">
<canvas
ref={canvasRef}
className="vnc-canvas"
width={config.width}
height={config.height}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
tabIndex={0}
/>
</div>
<div className="connection-indicator">
<div className={`indicator-dot ${
state.isConnected ? 'connected' : state.isConnecting ? 'connecting' : 'error'
}`} />
<span className="indicator-text">
{state.isConnected ? 'Connected' : state.isConnecting ? 'Connecting...' : 'Disconnected'}
</span>
</div>
<div className="status-overlay">
<div className="status-row">
<span className="status-label">FPS</span>
<span className="status-value">{state.fps}</span>
</div>
<div className="status-row">
<span className="status-label">Latency</span>
<span className="status-value">{state.latency}ms</span>
</div>
<div className="status-row">
<span className="status-label">Resolution</span>
<span className="status-value">{config.width}×{config.height}</span>
</div>
{state.error && (
<div className="status-row">
<span className="status-label">Status</span>
<span className="status-value error">{state.error}</span>
</div>
)}
</div>
<div className="help-text">
Click to focus • Mouse to navigate • Keyboard to type • Scroll to zoom
</div>
</div>
);
}