/** * StreamCrafter React Component * Self-contained browser-based WHIP streaming component * Uses slab design system with Tokyo Night colors * * @example * import { StreamCrafter } from '@livepeer-frameworks/streamcrafter-react'; * import '@livepeer-frameworks/streamcrafter-react/streamcrafter.css'; * * */ import React, { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { useStreamCrafterV2 } from "../hooks/useStreamCrafterV2"; import { useAudioLevels } from "../hooks/useAudioLevels"; import { useCompositor } from "../hooks/useCompositor"; import { useIngestEndpoints } from "../hooks/useIngestEndpoints"; import AdvancedPanel from "./AdvancedPanel"; import { CompositorControls } from "./CompositorControls"; import { VolumeSlider } from "./VolumeSlider"; import type { AudioProcessingSettings } from "./AdvancedPanel"; import type { IngestState, IngestStateContextV2, QualityProfile, MediaSource, ReconnectionState, EncoderOverrides, FwThemePreset, StudioThemeOverrides, } from "@livepeer-frameworks/streamcrafter-core"; import { getAudioConstraints, resolveStudioTheme, studioThemeOverridesToStyle, createStudioTranslator, type StudioLocale, type StudioTranslateFn, } from "@livepeer-frameworks/streamcrafter-core"; import { StudioI18nProvider } from "../context/StudioI18nContext"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "../ui/context-menu"; import { StreamCrafterProvider } from "../context/StreamCrafterContext"; // ============================================================================ // Types // ============================================================================ export interface StreamCrafterProps { /** Direct WHIP endpoint URL */ whipUrl?: string; /** Gateway URL for endpoint resolution (alternative to whipUrl) */ gatewayUrl?: string; /** Stream key for gateway mode */ streamKey?: string; /** Initial quality profile */ initialProfile?: QualityProfile; /** Auto-start camera on mount */ autoStartCamera?: boolean; /** Show settings panel by default */ showSettings?: boolean; /** Enable dev mode UI */ devMode?: boolean; /** Enable debug logging */ debug?: boolean; /** Enable compositor for multi-source composition */ enableCompositor?: boolean; /** Compositor configuration (renderer preference, resolution, etc.) */ compositorConfig?: { renderer?: "auto" | "webgpu" | "webgl" | "canvas2d"; width?: number; height?: number; frameRate?: number; }; /** Theme preset or custom overrides */ theme?: FwThemePreset | StudioThemeOverrides; /** Locale for i18n translations */ locale?: StudioLocale; /** Custom class name */ className?: string; /** State change callback */ onStateChange?: (state: IngestState, context?: IngestStateContextV2) => void; /** Error callback */ onError?: (error: string) => void; /** Composable children — replaces the default UI when provided. */ children?: React.ReactNode; } // ============================================================================ // Icons (inline SVG for zero dependencies) // ============================================================================ const CameraIcon = ({ size = 18 }: { size?: number }) => ( ); const MonitorIcon = ({ size = 18 }: { size?: number }) => ( ); const MicIcon = ({ size = 16, muted = false }: { size?: number; muted?: boolean }) => muted ? ( ) : ( ); const XIcon = ({ size = 14 }: { size?: number }) => ( ); const SettingsIcon = ({ size = 16 }: { size?: number }) => ( ); const ChevronsRightIcon = ({ size = 14 }: { size?: number }) => ( ); const ChevronsLeftIcon = ({ size = 14 }: { size?: number }) => ( ); const VideoIcon = ({ size = 14, active = false }: { size?: number; active?: boolean }) => ( ); // ============================================================================ // Quality Profile Options // ============================================================================ function getQualityProfiles( t: StudioTranslateFn ): { id: QualityProfile; label: string; description: string }[] { return [ { id: "professional", label: t("professional"), description: t("professionalDesc") }, { id: "broadcast", label: t("broadcast"), description: t("broadcastDesc") }, { id: "conference", label: t("conference"), description: t("conferenceDesc") }, ]; } // ============================================================================ // Helper Functions // ============================================================================ function cn(...classes: (string | undefined | false)[]): string { return classes.filter(Boolean).join(" "); } function getStatusText( state: IngestState, t: StudioTranslateFn, reconnectionState?: ReconnectionState | null ): string { if (reconnectionState?.isReconnecting) { return t("reconnectingAttempt", { attempt: reconnectionState.attemptNumber, max: 5 }); } switch (state) { case "idle": return t("idle"); case "requesting_permissions": return t("requestingPermissions"); case "capturing": return t("ready"); case "connecting": return t("connecting"); case "streaming": return t("live"); case "reconnecting": return t("reconnecting"); case "error": return t("error"); case "destroyed": return t("destroyed"); default: return state; } } function getStatusBadgeClass(state: IngestState, isReconnecting: boolean): string { if (state === "streaming") return "fw-sc-badge fw-sc-badge--live"; if (isReconnecting) return "fw-sc-badge fw-sc-badge--connecting"; if (state === "error") return "fw-sc-badge fw-sc-badge--error"; if (state === "capturing") return "fw-sc-badge fw-sc-badge--ready"; return "fw-sc-badge fw-sc-badge--idle"; } // ============================================================================ // VU Meter Component // ============================================================================ interface VUMeterProps { level: number; peakLevel: number; } const VUMeter: React.FC = ({ level, peakLevel }) => (
); // ============================================================================ // Source Row Component // ============================================================================ interface SourceRowProps { source: MediaSource; onMuteToggle: () => void; onVolumeChange: (volume: number) => void; onSetPrimary: () => void; onRemove: () => void; disabled: boolean; isStreaming: boolean; hasVideo: boolean; t: StudioTranslateFn; // Compositor-specific props isCompositorEnabled?: boolean; isVisibleInCompositor?: boolean; onVisibilityToggle?: () => void; } // Eye icon for visibility toggle const EyeIcon: React.FC<{ size: number; visible: boolean }> = ({ size, visible }) => ( {visible ? ( <> ) : ( <> )} ); const SourceRow: React.FC = ({ source, onMuteToggle, onVolumeChange, onSetPrimary, onRemove, disabled, isStreaming, hasVideo, t, isCompositorEnabled = false, isVisibleInCompositor = true, onVisibilityToggle, }) => (
{/* Visibility toggle for compositor mode */} {isCompositorEnabled && onVisibilityToggle && ( )}
{source.type === "camera" && } {source.type === "screen" && }
{source.label} {source.primaryVideo && !isCompositorEnabled && ( {t("primary")} )}
{source.type}
{/* Primary Video Button - only show when NOT in compositor mode */} {hasVideo && !isCompositorEnabled && ( )} {/* Volume Slider - supports up to 200% boost with popup and snap */} {Math.round(source.volume * 100)}%
); // ============================================================================ // Main Component // ============================================================================ const StreamCrafterInner: React.FC = ({ whipUrl, gatewayUrl, streamKey, initialProfile = "broadcast", autoStartCamera = false, showSettings: initialShowSettings = false, devMode = false, debug = false, enableCompositor = true, compositorConfig, theme, locale = "en", className, onStateChange, onError, children, }) => { const t = useMemo(() => createStudioTranslator({ locale }), [locale]); const themeStyle = useMemo(() => { if (!theme) return undefined; if (typeof theme === "object") return studioThemeOverridesToStyle(theme); const resolved = resolveStudioTheme(theme); return resolved ? studioThemeOverridesToStyle(resolved) : undefined; }, [theme]); const videoRef = useRef(null); const settingsDropdownRef = useRef(null); const settingsButtonRef = useRef(null); const [showSettings, setShowSettings] = useState(initialShowSettings); const [showSources, setShowSources] = useState(true); const [isAdvancedPanelOpen, setIsAdvancedPanelOpen] = useState(false); const [masterVolume, setMasterVolumeState] = useState(1); // Audio processing state - initialized from profile defaults const profileDefaults = getAudioConstraints(initialProfile); const [audioProcessing, setAudioProcessing] = useState({ echoCancellation: profileDefaults.echoCancellation, noiseSuppression: profileDefaults.noiseSuppression, autoGainControl: profileDefaults.autoGainControl, }); // Encoder overrides state - allows overriding profile encoder settings const [encoderOverrides, setEncoderOverrides] = useState({}); // Gateway-based ingest endpoint resolution (like useViewerEndpoints in player) const { endpoints: _gatewayEndpoints, status: endpointStatus, error: endpointError, whipUrl: gatewayWhipUrl, } = useIngestEndpoints(gatewayUrl && streamKey && !whipUrl ? { gatewayUrl, streamKey } : {}); // Priority: direct whipUrl prop > gateway-resolved > undefined const resolvedWhipUrl = useMemo(() => { if (whipUrl) return whipUrl; if (gatewayWhipUrl) return gatewayWhipUrl; return undefined; }, [whipUrl, gatewayWhipUrl]); // Track if we're waiting for gateway resolution const isResolvingEndpoint = !whipUrl && gatewayUrl && streamKey && endpointStatus === "loading"; // Click outside handler for settings dropdown useEffect(() => { if (!showSettings) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; if ( settingsDropdownRef.current && !settingsDropdownRef.current.contains(target) && settingsButtonRef.current && !settingsButtonRef.current.contains(target) ) { setShowSettings(false); } }; const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") setShowSettings(false); }; document.addEventListener("mousedown", handleClickOutside); document.addEventListener("keydown", handleEscape); return () => { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleEscape); }; }, [showSettings]); // Initialize StreamCrafter hook — capture the full return for context + destructure for local use const hookReturn = useStreamCrafterV2({ whipUrl: resolvedWhipUrl || "", profile: initialProfile, debug, reconnection: { enabled: true, maxAttempts: 5 }, audioMixing: true, }); const { state, stateContext, isStreaming, isCapturing, isReconnecting, error, mediaStream, sources, qualityProfile, setQualityProfile, reconnectionState, startCamera, startScreenShare, removeSource, setSourceMuted, setSourceVolume, setPrimaryVideoSource, setMasterVolume, stats, startStreaming, stopStreaming, getController, useWebCodecs, isWebCodecsActive, isWebCodecsAvailable, encoderStats, setUseWebCodecs, setEncoderOverrides: setEncoderOverridesHook, } = hookReturn; // Audio levels for VU meter const { levels, isMonitoring } = useAudioLevels({ controller: getController(), autoStart: true, }); // Compositor for multi-source composition const compositor = useCompositor({ controller: enableCompositor ? getController() : null, autoEnable: enableCompositor, config: compositorConfig, }); // Notify parent of state changes useEffect(() => { onStateChange?.(state, stateContext); }, [state, stateContext, onStateChange]); // Notify parent of errors useEffect(() => { if (error) { onError?.(error); } }, [error, onError]); // Sync encoder overrides to controller useEffect(() => { setEncoderOverridesHook(encoderOverrides); }, [encoderOverrides, setEncoderOverridesHook]); // Update video preview when stream changes useEffect(() => { if (videoRef.current && mediaStream) { videoRef.current.srcObject = mediaStream; videoRef.current.play().catch(() => {}); } else if (videoRef.current) { videoRef.current.srcObject = null; } }, [mediaStream]); // Auto-start camera if enabled useEffect(() => { if (autoStartCamera && resolvedWhipUrl && state === "idle") { startCamera().catch(console.error); } }, [autoStartCamera, resolvedWhipUrl, state, startCamera]); // Handlers const handleStartCamera = useCallback(async () => { try { await startCamera(); } catch (err) { console.error("Failed to start camera:", err); } }, [startCamera]); const handleStartScreenShare = useCallback(async () => { try { await startScreenShare({ audio: true }); } catch (err) { console.error("Failed to start screen share:", err); } }, [startScreenShare]); const handleGoLive = useCallback(async () => { if (!resolvedWhipUrl) { console.error("No WHIP endpoint configured"); return; } try { await startStreaming(); } catch (err) { console.error("Failed to start streaming:", err); } }, [resolvedWhipUrl, startStreaming]); const handleStopStreaming = useCallback(async () => { await stopStreaming(); }, [stopStreaming]); const toggleSourceMute = useCallback( (sourceId: string, currentMuted: boolean) => { setSourceMuted(sourceId, !currentMuted); }, [setSourceMuted] ); // Master volume handler const handleMasterVolumeChange = useCallback( (volume: number) => { setMasterVolume(volume); setMasterVolumeState(volume); }, [setMasterVolume] ); // Audio processing change handler - applies constraints to all audio tracks const handleAudioProcessingChange = useCallback( (newSettings: Partial) => { setAudioProcessing((prev) => { const updated = { ...prev, ...newSettings }; // Apply constraints to all audio tracks across all sources sources.forEach((source) => { source.stream.getAudioTracks().forEach((track) => { track .applyConstraints({ echoCancellation: updated.echoCancellation, noiseSuppression: updated.noiseSuppression, autoGainControl: updated.autoGainControl, }) .catch((err) => { console.warn("Failed to apply audio constraints:", err); }); }); }); return updated; }); }, [sources] ); const qualityProfiles = useMemo(() => getQualityProfiles(t), [t]); // Context menu actions const copyWhipUrl = useCallback(() => { if (resolvedWhipUrl) { navigator.clipboard.writeText(resolvedWhipUrl).catch(console.error); } }, [resolvedWhipUrl]); const copyStreamInfo = useCallback(() => { const profile = qualityProfiles.find((p) => p.id === qualityProfile); const info = [ `Status: ${state}`, `Quality: ${profile?.label ?? qualityProfile} (${profile?.description ?? ""})`, `Sources: ${sources.length}`, resolvedWhipUrl ? `WHIP: ${resolvedWhipUrl}` : null, ] .filter(Boolean) .join("\n"); navigator.clipboard.writeText(info).catch(console.error); }, [state, qualityProfile, qualityProfiles, sources.length, resolvedWhipUrl]); // Computed state const canAddSource = state !== "destroyed" && state !== "error"; const canStream = isCapturing && !isStreaming && resolvedWhipUrl; const hasCamera = sources.some((s) => s.type === "camera"); const _hasScreen = sources.some((s) => s.type === "screen"); const statusText = getStatusText(state, t, reconnectionState); const statusBadgeClass = getStatusBadgeClass(state, isReconnecting); // Composable mode: render children instead of the default UI if (children) { return (
{children}
); } return (
{/* Main content wrapper - takes remaining space when panel is open */}
{/* Header */}
{t("streamCrafter")}
{statusText}
{/* Content area (preview + mixer) - responsive layout */}
{/* Preview wrapper for flex sizing */}
{/* Video Preview (flush - no padding) */}
{/* Sources Mixer Section - moves to right on wide screens */} {sources.length > 0 && (
setShowSources(!showSources)} title={showSources ? t("collapseMixer") : t("expandMixer")} > {t("mixer")} ({sources.length}) {showSources ? ( ) : ( )}
{showSources && (
{sources.map((source: MediaSource) => { // Get visibility state for compositor mode const layer = compositor.activeScene?.layers.find( (l) => l.sourceId === source.id ); const isVisibleInCompositor = layer?.visible ?? false; return ( toggleSourceMute(source.id, source.muted)} onVolumeChange={(vol) => setSourceVolume(source.id, vol)} onSetPrimary={() => setPrimaryVideoSource(source.id)} onRemove={() => removeSource(source.id)} disabled={false} isStreaming={isStreaming} hasVideo={source.stream.getVideoTracks().length > 0} t={t} isCompositorEnabled={enableCompositor} isVisibleInCompositor={isVisibleInCompositor} onVisibilityToggle={() => { if (!compositor.activeSceneId) return; if (layer) { compositor.setLayerVisibility( compositor.activeSceneId, layer.id, !layer.visible ); } else { compositor.addLayer(compositor.activeSceneId, source.id); } }} /> ); })}
)}
)}
{/* VU Meter (horizontal bar under content area) */} {isCapturing && } {/* Error Display */} {(error || endpointError) && (
{t("error")}
{error || endpointError}
)} {/* No Endpoint Warning */} {!resolvedWhipUrl && !error && !endpointError && !isResolvingEndpoint && (
{t("warning")}
{t("configureWhipEndpoint")}
)} {/* Resolving Endpoint State */} {isResolvingEndpoint && (
{t("resolvingEndpoint")}
{t("resolvingEndpoint")}
)} {/* Action Bar */}
{/* Secondary actions: Camera, Screen, Settings */} {/* Settings button in action bar */}
{/* Settings Popup - positioned above button */} {showSettings && (
{/* Quality Section */}
{t("quality")}
{qualityProfiles.map((p) => ( ))}
{/* Dev Info Section */} {devMode && (
{t("debug")}
{t("state")} {state}
{t("audio")} {isMonitoring ? t("active") : t("inactive")}
{t("whip")} {resolvedWhipUrl ? t("ok") : t("notSet")}
)}
)}
{/* Primary action: Go Live / Stop */} {!isStreaming ? ( ) : ( )}
{/* Advanced Panel - side panel when open */} {devMode && isAdvancedPanelOpen && ( setIsAdvancedPanelOpen(false)} state={state} qualityProfile={qualityProfile} whipUrl={resolvedWhipUrl} sources={sources} stats={stats} mediaStream={mediaStream} masterVolume={masterVolume} onMasterVolumeChange={handleMasterVolumeChange} audioLevel={levels.level} audioMixingEnabled={true} error={error} audioProcessing={audioProcessing} onAudioProcessingChange={handleAudioProcessingChange} compositorEnabled={compositor.isEnabled} compositorRendererType={compositor.rendererType} compositorStats={compositor.stats} sceneCount={compositor.scenes.length} layerCount={compositor.activeScene?.layers.length ?? 0} useWebCodecs={useWebCodecs} isWebCodecsActive={isWebCodecsActive} isWebCodecsAvailable={isWebCodecsAvailable} encoderStats={encoderStats} onUseWebCodecsChange={setUseWebCodecs} encoderOverrides={encoderOverrides} onEncoderOverridesChange={setEncoderOverrides} /> )}
{/* Right-Click Context Menu */} {resolvedWhipUrl && ( {t("copyWhipUrl")} )} {t("copyStreamInfo")} {devMode && ( <> setIsAdvancedPanelOpen(!isAdvancedPanelOpen)} className="gap-2" > {isAdvancedPanelOpen ? t("hideAdvanced") : t("advanced")} )} ); }; // ============================================================================ // Main Component Export // ============================================================================ /** * Self-contained StreamCrafter component with slab design system. * Requires importing the CSS file separately. * * @example * import { StreamCrafter } from '@livepeer-frameworks/streamcrafter-react'; * import '@livepeer-frameworks/streamcrafter-react/streamcrafter.css'; * * // Direct WHIP endpoint * * * @example * // Via Gateway * */ const StreamCrafter = StreamCrafterInner; export default StreamCrafter; export { StreamCrafter };