import React, { useEffect, useRef, useState } from "react";
import { PlayCircle, StopCircle, Activity } from "lucide-react";
const FRAME_TYPE_KEY = 0x0001;
const FRAME_RATE = 15;
const WebTransportPlayer = () => {
const [isConnected, setIsConnected] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [error, setError] = useState(null);
const frameDurationUs = useRef(BigInt(Math.floor(1_000_000 / FRAME_RATE)));
const frameCounterRef = useRef(0n);
const canvasRef = useRef(null);
const transportRef = useRef(null);
const decoderRef = useRef(null);
const frameCountRef = useRef(0);
const animationFrameRef = useRef(null);
const initializeDecoder = () => {
if (decoderRef.current) {
decoderRef.current.close();
}
const decoder = new VideoDecoder({
output: (frame) => {
const canvas = canvasRef.current;
if (!canvas) {
frame.close();
return;
}
const ctx = canvas.getContext("2d");
if (
canvas.width !== frame.displayWidth ||
canvas.height !== frame.displayHeight
) {
canvas.width = frame.displayWidth;
canvas.height = frame.displayHeight;
}
ctx.drawImage(frame, 0, 0);
frame.close();
frameCountRef.current++;
},
error: (e) => {
console.error("Decoder error:", e);
setError(`Decoder error: ${e.message}`);
},
});
decoder.configure({
codec: "avc1.42E016", codedWidth: 640,
codedHeight: 360,
optimizeForLatency: true,
});
decoderRef.current = decoder;
};
const connectWebTransport = async () => {
try {
setError(null);
const transport = new WebTransport("https://localhost:4433");
await transport.ready;
transportRef.current = transport;
setIsConnected(true);
setIsPlaying(true);
initializeDecoder();
const stream = await transport.createBidirectionalStream();
const reader = stream.readable.getReader();
const readLoop = async () => {
try {
let pendingFrame = null; let buffer = new Uint8Array(0);
let bufferPending = false;
let pendingBufferSize = 0;
let pendingFrameType = 0;
while (true) {
const { value, done } = await reader.read();
if (done) {
console.log("Nothing more to read!");
break;
}
const newBuffer = new Uint8Array(buffer.length + value.length);
newBuffer.set(buffer);
newBuffer.set(value, buffer.length);
buffer = newBuffer;
while (true) {
if (!pendingFrame) {
if (buffer.length < 6) {
break;
}
const view = new DataView(buffer.buffer, buffer.byteOffset, 6);
const frameType = view.getUint16(0);
const frameSize = view.getUint32(2);
buffer = buffer.slice(6);
pendingFrame = {
type: frameType,
expectedSize: frameSize,
};
}
if (buffer.length < pendingFrame.expectedSize) {
break; }
const frameData = buffer.slice(0, pendingFrame.expectedSize);
const type =
pendingFrame.type === FRAME_TYPE_KEY ? "key" : "delta";
if (
decoderRef.current &&
decoderRef.current.state === "configured"
) {
const timestamp =
frameCounterRef.current * frameDurationUs.current;
try {
const chunk = new EncodedVideoChunk({
type,
timestamp: Number(timestamp),
data: frameData,
});
decoderRef.current.decode(chunk);
frameCounterRef.current++;
} catch (decodeErr) {
console.error(
"Decode error:",
decodeErr,
"Frame size:",
frameData.length,
"Type:",
type,
);
}
}
buffer = buffer.slice(pendingFrame.expectedSize);
pendingFrame = null;
}
}
} catch (err) {
if (err.name !== "AbortError") {
console.error("Read error:", err);
setError(`Connection error: ${err.message}`);
}
}
};
readLoop().catch((err) => {
console.error("Read loop error:", err);
setError(`Read error: ${err.message}`);
});
transport.closed
.then(() => {
setIsConnected(false);
setIsPlaying(false);
})
.catch((err) => {
console.error("Transport closed with error:", err);
setError(`Transport error: ${err.message}`);
setIsConnected(false);
setIsPlaying(false);
});
} catch (err) {
console.error("Connection failed:", err);
setError(`Failed to connect: ${err.message}`);
setIsConnected(false);
}
};
const disconnect = () => {
if (transportRef.current) {
transportRef.current.close();
transportRef.current = null;
}
if (decoderRef.current) {
decoderRef.current.close();
decoderRef.current = null;
}
setIsConnected(false);
setIsPlaying(false);
frameCounterRef.current = 0n;
};
useEffect(() => {
return () => {
disconnect();
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 text-white p-8">
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-4xl font-bold mb-2">WebTransport H.264 Player</h1>
<p className="text-gray-400">
Streaming video via WebTransport from GStreamer pipeline
</p>
</div>
{error && (
<div className="mb-6 bg-red-900/50 border border-red-700 rounded-lg p-4">
<p className="text-red-200">{error}</p>
</div>
)}
<div className="bg-gray-800 rounded-lg shadow-2xl overflow-hidden mb-6">
<div className="aspect-video bg-black flex items-center justify-center">
<canvas
ref={canvasRef}
className="max-w-full max-h-full"
style={{ imageRendering: "auto" }}
/>
{!isPlaying && (
<div className="absolute text-gray-500">
<Activity size={64} className="animate-pulse" />
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Connection</h2>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-gray-400">Status</span>
<span
className={`px-3 py-1 rounded-full text-sm ${
isConnected
? "bg-green-900/50 text-green-300"
: "bg-gray-700 text-gray-400"
}`}
>
{isConnected ? "Connected" : "Disconnected"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Server</span>
<span className="text-white">localhost:4433</span>
</div>
</div>
<div className="mt-6">
{!isConnected ? (
<button
onClick={connectWebTransport}
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-lg flex items-center justify-center gap-2 transition-colors"
>
<PlayCircle size={20} />
Connect & Play
</button>
) : (
<button
onClick={disconnect}
className="w-full bg-red-600 hover:bg-red-700 text-white py-3 rounded-lg flex items-center justify-center gap-2 transition-colors"
>
<StopCircle size={20} />
Disconnect
</button>
)}
</div>
</div>
</div>
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Requirements</h2>
<ul className="space-y-2 text-gray-300">
<li className="flex items-start gap-2">
<span className="text-green-400 mt-1">•</span>
<span>
Chrome/Edge browser (WebTransport & WebCodecs support required)
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-400 mt-1">•</span>
<span>
GStreamer pipeline running with quinnwtsink on localhost:4433
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-400 mt-1">•</span>
<span>
Valid TLS certificate for localhost (see setup instructions)
</span>
</li>
</ul>
</div>
</div>
</div>
);
};
export default WebTransportPlayer;