shadimage
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 · light
media-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,
    };
}

More from ha-components

Browse all components in this library →