ha-components
blockcamerasource: ok · hit
Simple Camera Component
A WebRTC Camera component for go2RTC
npx shadcn add "https://hacomponents.keshuac.com/r/camera.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightmedia-chrome
Registry dependencies
tooltipbutton
Files4 · source cached 10/14/2026, 12:08:21 PM
components/ha-ui/camera.tsx
"use client";
import { useEffect, useRef } from "react";
import {
MediaController,
MediaControlBar,
MediaVolumeRange,
MediaPlayButton,
MediaMuteButton,
MediaFullscreenButton,
} from "media-chrome/react";
import { ENV } from "@/lib/haAuth";
import { useWebRTC, type WebRTCConnection } from "@/lib/video-rtc";
import { StatusIndicator } from "@/components/ha-ui/ui/status-indicator";
import { RetryButton } from "@/components/ha-ui/ui/retry-button";
import type { EntityId } from "@/types/entity-types";
import { cn } from "@/lib/utils";
type AspectRatio = "1/1" | "4/3" | "16/9";
export interface CameraFeedProps {
/**
* HomeAssistant Entity Name, used to generate Websocket URL for go2rtc stream
*/
entity: EntityId;
/**
* Optional: Explicit WebSocket override URL (e.g. ws://localhost:5555/websocketVideo)
*/
wsURL?: string;
/**
* Optional: Proxy server to forward websocket requests over (e.g. ws://localhost:5555/proxy)
* (Default: None)
*/
proxyURL?: string;
/**
* Optional: Disable video controls
* (Default: False)
*/
disableControls?: boolean;
/**
* Optional: Aspect Ratio for component to follow,
* Will Letterbox to meet ratio
* (Default: 16/9)
* */
aspectRatio?: AspectRatio;
/**
* Optional: Camera Source override
* Will ignore entity, wsURL and proxyURl args.
*/
externalCameraSource?: WebRTCConnection;
}
export function Camera({
entity,
wsURL,
proxyURL,
externalCameraSource,
disableControls = false,
aspectRatio = "16/9",
}: CameraFeedProps) {
const webRTC = useWebRTC({
wsSrc: wsURL || `ws://${ENV.HA_HOST}:11984/api/ws?src=${entity}`,
proxy: proxyURL,
media: "video,audio",
enabled: !externalCameraSource,
autoReconnect: true,
retryDelay: 5000,
maxReconnectAttempts: 5,
});
// Use external source if provided, otherwise use hook's stream
const mediaStream = externalCameraSource?.mediaStream || webRTC.mediaStream;
const status = externalCameraSource ? externalCameraSource.status : webRTC.status;
const error = externalCameraSource ? externalCameraSource.error : webRTC.error;
const videoRef = useRef<HTMLVideoElement>(null);
// Attach stream to video element
useEffect(() => {
if (mediaStream && videoRef.current) {
videoRef.current.srcObject = mediaStream;
} else if (!mediaStream && videoRef.current) {
videoRef.current.srcObject = null;
}
}, [mediaStream]);
const aspectRatioMap: Record<AspectRatio, string> = {
"1/1": "aspect-square",
"16/9": "aspect-video",
"4/3": "aspect-[4/3]",
};
return (
<MediaController className={cn("group w-full", aspectRatioMap[aspectRatio])}>
{/* Video Element */}
<video
slot="media"
ref={videoRef}
autoPlay
muted
playsInline
controls={false}
className={aspectRatioMap[aspectRatio]}
/>
{/* Status Indicator (top right) */}
<StatusIndicator status={status} error={error} />
{/* Retry Button (centered overlay) */}
{externalCameraSource ? (
<RetryButton onRetry={externalCameraSource.retry} status={status} error={error} autoReconnect={true} />
) : (
<RetryButton onRetry={webRTC.retry} status={status} error={error} autoReconnect={true} />
)}
{/* Video Controls Overlay */}
{!disableControls && (
<MediaControlBar className="relative flex w-full justify-between bg-black px-4 transition-opacity duration-200 md:absolute md:right-0 md:bottom-0 md:left-0 md:z-10 md:opacity-0 md:group-hover:opacity-100">
<MediaPlayButton className="bg-black px-2 hover:bg-slate-800" />
<div className="text-white">
<MediaMuteButton className="bg-black p-2 hover:bg-slate-800"></MediaMuteButton>
<MediaVolumeRange className="bg-black px-2 hover:bg-slate-800"></MediaVolumeRange>
<MediaFullscreenButton className="h-full bg-black px-2 hover:bg-slate-800"></MediaFullscreenButton>
</div>
</MediaControlBar>
)}
</MediaController>
);
}
components/ha-ui/ui/retry-button.tsx
"use client";
import { Button } from "@/components/ui/button";
import type { ConnectionStatus } from "./status-indicator";
export interface RetryButtonProps {
onRetry: () => void;
status: ConnectionStatus;
error?: Error | null;
autoReconnect?: boolean;
}
export function RetryButton({ onRetry, status, error, autoReconnect = false }: RetryButtonProps) {
const showButton = status === "error" || (status === "disconnected" && !autoReconnect);
if (!showButton) return null;
return (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/50">
<div className="flex flex-col items-center gap-4 text-white">
{status === "error" && error && <p className="max-w-md px-4 text-center">{error.message}</p>}
<Button onClick={onRetry} size="lg" className="min-w-32">
Retry Connection
</Button>
</div>
</div>
);
}
components/ha-ui/ui/status-indicator.tsx
"use client";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export interface StatusIndicatorProps {
status: ConnectionStatus;
error?: Error | null;
}
export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "error";
export function StatusIndicator({ status, error }: StatusIndicatorProps) {
const statusConfig = {
connecting: { color: "bg-yellow-500", text: "Connecting..." },
connected: { color: "bg-green-500", text: "Connected" },
disconnected: { color: "bg-orange-500", text: "Disconnected" },
error: { color: "bg-red-500", text: error?.message || "Connection error" },
};
const config = statusConfig[status];
return (
<Tooltip>
<TooltipTrigger>
<div className={`absolute top-2 right-2 h-3 w-3 rounded-full ${config.color} cursor-help`} />
</TooltipTrigger>
<TooltipContent>
<p>{config.text}</p>
</TooltipContent>
</Tooltip>
);
}
lib/video-rtc.tsx
import { useEffect, useRef, useState } from "react";
export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "error";
export interface UseWebRTCOptions {
/**
* WebSocket URL for go2rtc stream (e.g. ws://localhost:5555/websocketVideo)
*/
wsSrc: string;
/**
* Optional Proxy server to forward websocket requests over (e.g. ws://localhost:5555/proxy)
*/
proxy?: string;
/**
* Media types to receive (default: "video,audio")
* Supports: "video", "audio", "video,audio"
*/
media?: string;
/**
* Whether the connection should be active (default: true)
*/
enabled?: boolean;
/**
* Whether to automatically reconnect on disconnection (default: false)
*/
autoReconnect?: boolean;
/**
* Delay before retrying connection in ms (default: 5000)
*/
retryDelay?: number;
/**
* Maximum number of reconnect attempts (default: 5)
*/
maxReconnectAttempts?: number;
}
export interface WebRTCConnection {
/**
* Current connection status
*/
status: ConnectionStatus;
/**
* Last error that occurred, if any
*/
error: Error | null;
/**
* The MediaStream received from the remote peer
* Can be attached directly to a video element via srcObject
*/
mediaStream: MediaStream | null;
/**
* Number of reconnect attempts made
*/
reconnectAttempts: number;
/**
* Function to manually trigger a reconnection attempt
*/
retry: () => void;
}
const DEFAULT_PEERCONNECTION_CONFIG: RTCConfiguration = {
bundlePolicy: "max-bundle",
iceServers: [{ urls: "stun:stun.cloudflare.com:3478" }, { urls: "stun:stun.l.google.com:19302" }],
};
export function useWebRTC({
wsSrc,
proxy,
media = "video,audio",
enabled = true,
autoReconnect = false,
retryDelay = 5000,
maxReconnectAttempts = 5,
}: UseWebRTCOptions): WebRTCConnection {
const [status, setStatus] = useState<ConnectionStatus>(enabled ? "connecting" : "disconnected");
const [error, setError] = useState<Error | null>(null);
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
// Refs to avoid stale closures
const wsRef = useRef<WebSocket | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const reconnectTimerRef = useRef<number | null>(null);
const isConnectingRef = useRef(false);
const optionsRef = useRef({ enabled, autoReconnect, maxReconnectAttempts, retryDelay });
const mediaStreamRef = useRef<MediaStream | null>(null);
const reconnectAttemptsRef = useRef(0);
// Update options ref when they change
useEffect(() => {
optionsRef.current = { enabled, autoReconnect, maxReconnectAttempts, retryDelay };
}, [enabled, autoReconnect, maxReconnectAttempts, retryDelay]);
useEffect(() => {
mediaStreamRef.current = mediaStream;
}, [mediaStream]);
useEffect(() => {
reconnectAttemptsRef.current = reconnectAttempts;
}, [reconnectAttempts]);
const connect = async () => {
const opts = optionsRef.current;
if (!opts.enabled || isConnectingRef.current) return;
isConnectingRef.current = true;
try {
// 1. Create RTCPeerConnection
const pc = new RTCPeerConnection(DEFAULT_PEERCONNECTION_CONFIG);
pcRef.current = pc;
// 2. Add transceivers BEFORE offer
if (media.includes("video")) pc.addTransceiver("video", { direction: "recvonly" });
if (media.includes("audio")) pc.addTransceiver("audio", { direction: "recvonly" });
// 3. Set up ICE candidate handler BEFORE WebSocket
pc.onicecandidate = (ev) => {
if (ev.candidate && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: "webrtc/candidate",
value: ev.candidate.candidate,
}),
);
}
};
// 4. Handle tracks
pc.ontrack = (ev) => {
const stream = ev.streams[0] || new MediaStream([ev.track]);
setMediaStream(stream);
setStatus("connected");
setError(null);
setReconnectAttempts(0);
};
pc.onconnectionstatechange = () => {
if (
pc.connectionState === "failed" ||
pc.connectionState === "disconnected" ||
pc.connectionState === "closed"
) {
handleDisconnect("WebRTC connection failed");
}
};
// 5. Create WebSocket AFTER PeerConnection is fully configured
let wsUrl = wsSrc;
if (proxy) wsUrl = `${proxy}?target=${encodeURIComponent(wsUrl)}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
// 6. WebSocket event handlers
ws.onopen = async () => {
try {
// Create and send offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(
JSON.stringify({
type: "webrtc/offer",
value: offer.sdp,
}),
);
} catch (err) {
handleError(err as Error);
}
};
ws.onmessage = async (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "webrtc/answer") {
await pc.setRemoteDescription({ type: "answer", sdp: msg.value });
} else if (msg.type === "webrtc/candidate") {
await pc.addIceCandidate({ candidate: msg.value, sdpMid: "0" });
}
} catch (err) {
console.warn("[useWebRTC] Failed to parse message:", err);
}
};
ws.onclose = () => handleDisconnect("WebSocket closed");
ws.onerror = () => handleDisconnect("WebSocket error");
} catch (err) {
handleError(err as Error);
} finally {
isConnectingRef.current = false;
}
};
const handleDisconnect = (reason: string) => {
const opts = optionsRef.current;
cleanup();
if (opts.autoReconnect && reconnectAttemptsRef.current < opts.maxReconnectAttempts) {
setStatus("disconnected");
setError(new Error(reason));
reconnectTimerRef.current = window.setTimeout(() => {
setReconnectAttempts((prev) => prev + 1);
connect();
}, opts.retryDelay);
} else {
setStatus("error");
setError(new Error(reason));
}
};
const handleError = (err: Error) => {
console.error("[useWebRTC] Error:", err);
cleanup();
setStatus("error");
setError(err);
};
const cleanup = () => {
if (wsRef.current) {
try {
wsRef.current.close();
} catch {
// ignore
}
wsRef.current = null;
}
if (pcRef.current) {
try {
pcRef.current.close();
} catch {
// ignore
}
pcRef.current = null;
}
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
setMediaStream(null);
}
};
const retry = () => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
setReconnectAttempts(0);
setStatus("connecting");
setError(null);
connect();
};
// Connect on mount and when enabled changes
useEffect(() => {
if (enabled) {
connect();
}
return () => {
cleanup();
};
}, [enabled, wsSrc, proxy, media]);
return {
status,
error,
mediaStream,
reconnectAttempts,
retry,
};
}