/** * AdvancedPanel - Sidebar panel for advanced StreamCrafter settings * Matches Player's DevModePanel styling exactly * * Tabs: * - Audio: Master gain, per-source volume, audio processing info * - Stats: Connection info, WebRTC stats * - Info: WHIP URL, profile, sources */ import React, { useState } from "react"; import type { IngestState, IngestStats, QualityProfile, MediaSource, RendererType, RendererStats, EncoderOverrides, } from "@livepeer-frameworks/streamcrafter-core"; import { createEncoderConfig, getAudioConstraints, getEncoderSettings, } from "@livepeer-frameworks/streamcrafter-core"; import { VolumeSlider } from "./VolumeSlider"; import { useStudioTranslate } from "../context/StudioI18nContext"; // ============================================================================ // Types // ============================================================================ export interface AudioProcessingSettings { echoCancellation: boolean; noiseSuppression: boolean; autoGainControl: boolean; } // Encoder stats interface export interface EncoderStats { video: { framesEncoded: number; framesPending: number; bytesEncoded: number; lastFrameTime: number; }; audio: { samplesEncoded: number; samplesPending: number; bytesEncoded: number; lastSampleTime: number; }; timestamp: number; } export interface AdvancedPanelProps { /** Whether the panel is open */ isOpen: boolean; /** Callback when panel should close */ onClose: () => void; /** Current ingest state */ state: IngestState; /** Quality profile */ qualityProfile: QualityProfile; /** WHIP URL */ whipUrl?: string; /** Sources */ sources: MediaSource[]; /** Stats */ stats: IngestStats | null; /** Media stream for actual track settings */ mediaStream?: MediaStream | null; /** Master volume (0-2) */ masterVolume: number; /** Callback to set master volume */ onMasterVolumeChange: (volume: number) => void; /** Audio level (0-1) */ audioLevel: number; /** Is audio mixing enabled */ audioMixingEnabled: boolean; /** Error */ error: string | null; /** Audio processing overrides (null = use profile defaults) */ audioProcessing: AudioProcessingSettings; /** Callback to change audio processing settings */ onAudioProcessingChange: (settings: Partial) => void; /** Compositor enabled */ compositorEnabled?: boolean; /** Compositor renderer type */ compositorRendererType?: RendererType | null; /** Compositor stats */ compositorStats?: RendererStats | null; /** Scene count */ sceneCount?: number; /** Layer count */ layerCount?: number; /** Encoder: useWebCodecs setting */ useWebCodecs?: boolean; /** Encoder: is WebCodecs actually active (transform attached) */ isWebCodecsActive?: boolean; /** Encoder: stats from WebCodecs encoder */ encoderStats?: EncoderStats | null; /** Encoder: callback to toggle useWebCodecs */ onUseWebCodecsChange?: (enabled: boolean) => void; /** Whether WebCodecs encoding path is available (requires RTCRtpScriptTransform) */ isWebCodecsAvailable?: boolean; /** Encoder settings overrides (partial values override profile defaults) */ encoderOverrides?: EncoderOverrides; /** Callback to change encoder overrides */ onEncoderOverridesChange?: (overrides: EncoderOverrides) => void; } // ============================================================================ // Helper Functions // ============================================================================ function formatBitrate(bps: number): string { if (bps >= 1_000_000) { return `${(bps / 1_000_000).toFixed(1)} Mbps`; } return `${(bps / 1000).toFixed(0)} kbps`; } // ============================================================================ // Toggle Switch Component // ============================================================================ interface ToggleSwitchProps { checked: boolean; onChange: (checked: boolean) => void; disabled?: boolean; } const ToggleSwitch: React.FC = ({ checked, onChange, disabled }) => { return ( ); }; // ============================================================================ // Setting Select Component // ============================================================================ interface SettingSelectOption { value: T; label: string; } interface SettingSelectProps { value: T; options: SettingSelectOption[]; onChange: (value: T) => void; disabled?: boolean; isOverridden?: boolean; } function SettingSelect({ value, options, onChange, disabled = false, isOverridden = false, }: SettingSelectProps) { return ( ); } // Preset options for encoder settings const RESOLUTION_OPTIONS: SettingSelectOption[] = [ { value: "3840x2160", label: "3840×2160 (4K)" }, { value: "2560x1440", label: "2560×1440 (1440p)" }, { value: "1920x1080", label: "1920×1080 (1080p)" }, { value: "1280x720", label: "1280×720 (720p)" }, { value: "854x480", label: "854×480 (480p)" }, { value: "640x360", label: "640×360 (360p)" }, ]; const VIDEO_BITRATE_OPTIONS: SettingSelectOption[] = [ { value: 50_000_000, label: "50 Mbps" }, { value: 35_000_000, label: "35 Mbps" }, { value: 25_000_000, label: "25 Mbps" }, { value: 15_000_000, label: "15 Mbps" }, { value: 10_000_000, label: "10 Mbps" }, { value: 8_000_000, label: "8 Mbps" }, { value: 6_000_000, label: "6 Mbps" }, { value: 4_000_000, label: "4 Mbps" }, { value: 2_500_000, label: "2.5 Mbps" }, { value: 2_000_000, label: "2 Mbps" }, { value: 1_500_000, label: "1.5 Mbps" }, { value: 1_000_000, label: "1 Mbps" }, { value: 500_000, label: "500 kbps" }, ]; const FRAMERATE_OPTIONS: SettingSelectOption[] = [ { value: 120, label: "120 fps" }, { value: 60, label: "60 fps" }, { value: 30, label: "30 fps" }, { value: 24, label: "24 fps" }, { value: 15, label: "15 fps" }, ]; const AUDIO_BITRATE_OPTIONS: SettingSelectOption[] = [ { value: 320_000, label: "320 kbps" }, { value: 256_000, label: "256 kbps" }, { value: 192_000, label: "192 kbps" }, { value: 128_000, label: "128 kbps" }, { value: 96_000, label: "96 kbps" }, { value: 64_000, label: "64 kbps" }, ]; // ============================================================================ // Audio Processing Controls Component // ============================================================================ interface AudioProcessingControlsProps { profile: QualityProfile; settings: AudioProcessingSettings; onChange: (settings: Partial) => void; } const AudioProcessingControls: React.FC = ({ profile, settings, onChange, }) => { const t = useStudioTranslate(); const profileDefaults = getAudioConstraints(profile); const toggles = [ { key: "echoCancellation" as const, label: t("echoCancellation"), description: t("echoCancellationDesc"), defaultValue: profileDefaults.echoCancellation, }, { key: "noiseSuppression" as const, label: t("noiseSuppression"), description: t("noiseSuppressionDesc"), defaultValue: profileDefaults.noiseSuppression, }, { key: "autoGainControl" as const, label: t("autoGainControl"), description: t("autoGainControlDesc"), defaultValue: profileDefaults.autoGainControl, }, ]; return (
{toggles.map(({ key, label, description, defaultValue }, idx) => { const isModified = settings[key] !== defaultValue; return (
0 ? "1px solid hsl(var(--fw-sc-border) / 0.2)" : undefined, }} >
{label} {isModified && ( {t("modified")} )}
{description}
onChange({ [key]: checked })} />
); })}
{t("sampleRate")} {profileDefaults.sampleRate} Hz
{t("channels")} {profileDefaults.channelCount}
); }; // ============================================================================ // Main Component // ============================================================================ const AdvancedPanel: React.FC = ({ isOpen, onClose, state, qualityProfile, whipUrl, sources, stats, mediaStream, masterVolume, onMasterVolumeChange, audioLevel, audioMixingEnabled, error, audioProcessing, onAudioProcessingChange, compositorEnabled = false, compositorRendererType, compositorStats, sceneCount = 0, layerCount = 0, useWebCodecs = true, isWebCodecsActive = false, encoderStats, onUseWebCodecsChange, isWebCodecsAvailable = true, encoderOverrides, onEncoderOverridesChange, }) => { const t = useStudioTranslate(); const [activeTab, setActiveTab] = useState<"audio" | "stats" | "info" | "compositor">("audio"); const profileEncoderSettings = getEncoderSettings(qualityProfile); const effectiveEncoderConfig = createEncoderConfig( qualityProfile === "auto" ? "broadcast" : qualityProfile, encoderOverrides ); const videoTrackSettings = mediaStream?.getVideoTracks?.()[0]?.getSettings?.(); if (!isOpen) return null; // Styles matching DevModePanel exactly const panelStyle: React.CSSProperties = { background: "hsl(var(--fw-sc-surface-deep))", borderLeft: "1px solid hsl(var(--fw-sc-border) / 0.5)", color: "hsl(var(--fw-sc-text-muted))", fontSize: "12px", fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", width: "280px", display: "flex", flexDirection: "column", height: "100%", flexShrink: 0, zIndex: 40, }; const tabStyle = (isActive: boolean): React.CSSProperties => ({ padding: "8px 12px", fontSize: "10px", textTransform: "uppercase", letterSpacing: "0.05em", fontWeight: 600, transition: "all 0.15s", borderRight: "1px solid hsl(var(--fw-sc-border) / 0.3)", background: isActive ? "hsl(var(--fw-sc-surface-deep))" : "transparent", color: isActive ? "hsl(var(--fw-sc-text))" : "hsl(var(--fw-sc-text-faint))", cursor: "pointer", border: "none", }); const sectionHeaderStyle: React.CSSProperties = { fontSize: "10px", color: "hsl(var(--fw-sc-text-faint))", textTransform: "uppercase", letterSpacing: "0.05em", fontWeight: 600, marginBottom: "8px", }; const rowStyle: React.CSSProperties = { display: "flex", justifyContent: "space-between", padding: "8px 12px", borderTop: "1px solid hsl(var(--fw-sc-border) / 0.2)", }; return (
{/* Header with tabs - slab-header style */}
{compositorEnabled && ( )}
{/* Audio Tab */} {activeTab === "audio" && (
{/* Master Volume */}
{t("masterVolume")}
1 ? "hsl(var(--fw-sc-warning))" : masterVolume === 1 ? "hsl(var(--fw-sc-success))" : "hsl(var(--fw-sc-text))", }} > {Math.round(masterVolume * 100)}%
{masterVolume > 1 && (
+{((masterVolume - 1) * 100).toFixed(0)}% boost
)}
{/* Audio Level Meter */}
{t("outputLevel")}
0.9 ? "hsl(var(--fw-sc-danger))" : audioLevel > 0.7 ? "hsl(var(--fw-sc-warning))" : "hsl(var(--fw-sc-success))", }} />
-60dB 0dB
{/* Audio Mixing Status */}
{t("audioMixing")} {audioMixingEnabled ? t("on") : t("off")}
{audioMixingEnabled && (
{t("compressorLimiterActive")}
)}
{/* Audio Processing Controls */}
{t("processing")} profile: {qualityProfile}
)} {/* Stats Tab */} {activeTab === "stats" && (
{/* Connection State */}
{t("connection")}
{state.charAt(0).toUpperCase() + state.slice(1)}
{/* Stats */} {stats && (
{t("bitrate")} {formatBitrate(stats.video.bitrate + stats.audio.bitrate)}
{t("video")} {formatBitrate(stats.video.bitrate)}
{t("audio")} {formatBitrate(stats.audio.bitrate)}
{t("frameRate")} {stats.video.framesPerSecond.toFixed(0)} fps
{t("framesEncoded")} {stats.video.framesEncoded}
{(stats.video.packetsLost > 0 || stats.audio.packetsLost > 0) && (
{t("packetsLost")} {stats.video.packetsLost + stats.audio.packetsLost}
)}
{t("rtt")} 200 ? "hsl(var(--fw-sc-warning))" : "hsl(var(--fw-sc-text))", }} > {stats.connection.rtt.toFixed(0)} ms
{t("iceState")} {stats.connection.iceState}
)} {!stats && (
{state === "streaming" ? t("waitingForStats") : t("startStreamingForStats")}
)} {/* Error */} {error && (
{t("error")}
{error}
)}
)} {/* Info Tab */} {activeTab === "info" && (
{/* Quality Profile */}
{t("qualityProfile")}
{qualityProfile}
{profileEncoderSettings.video.width}x{profileEncoderSettings.video.height} @{" "} {formatBitrate(profileEncoderSettings.video.bitrate)}
{/* WHIP URL */}
{t("whipEndpoint")}
{whipUrl || t("notConfigured")}
{whipUrl && ( )}
{/* Encoder Settings */}
{t("encoder")} {(encoderOverrides?.video || encoderOverrides?.audio) && ( )}
{t("videoCodec")} {effectiveEncoderConfig.video.codec}
{t("resolution")} { const [w, h] = value.split("x").map(Number); const isProfileDefault = w === profileEncoderSettings.video.width && h === profileEncoderSettings.video.height; onEncoderOverridesChange?.({ ...encoderOverrides, video: { ...encoderOverrides?.video, width: isProfileDefault ? undefined : w, height: isProfileDefault ? undefined : h, }, }); }} />
{videoTrackSettings?.width && videoTrackSettings?.height && (
{t("actualResolution")} {Math.round(videoTrackSettings.width)}x{Math.round(videoTrackSettings.height)}
)}
{t("framerate")} { const isProfileDefault = value === profileEncoderSettings.video.framerate; onEncoderOverridesChange?.({ ...encoderOverrides, video: { ...encoderOverrides?.video, framerate: isProfileDefault ? undefined : value, }, }); }} />
{videoTrackSettings?.frameRate && (
{t("actualFramerate")} {Math.round(videoTrackSettings.frameRate)} fps
)}
{t("videoBitrate")} { const isProfileDefault = value === profileEncoderSettings.video.bitrate; onEncoderOverridesChange?.({ ...encoderOverrides, video: { ...encoderOverrides?.video, bitrate: isProfileDefault ? undefined : value, }, }); }} />
{t("audioCodec")} {effectiveEncoderConfig.audio.codec}
{t("audioBitrate")} { const isProfileDefault = value === profileEncoderSettings.audio.bitrate; onEncoderOverridesChange?.({ ...encoderOverrides, audio: { ...encoderOverrides?.audio, bitrate: isProfileDefault ? undefined : value, }, }); }} />
{state === "streaming" && (
{t("settingsLockedWhileStreaming")}
)}
{/* Sources */}
{t("sources")} ({sources.length})
{sources.length > 0 ? (
{sources.map((source, idx) => (
0 ? "1px solid hsl(var(--fw-sc-border) / 0.2)" : undefined, }} >
{source.type} {source.label}
Vol: {Math.round(source.volume * 100)}% {source.muted && ( Muted )} {!source.active && ( Inactive )}
))}
) : (
{t("noSourcesAdded")}
)}
)} {/* Compositor Tab */} {activeTab === "compositor" && compositorEnabled && (
{/* Renderer Info */}
{t("renderer")}
{compositorRendererType === "webgpu" && "WebGPU"} {compositorRendererType === "webgl" && "WebGL"} {compositorRendererType === "canvas2d" && "Canvas2D"} {!compositorRendererType && t("notInitialized")}
{t("setRendererHint")}
{/* Stats */} {compositorStats && (
{t("performance")}
{t("frameRate")} {compositorStats.fps} fps
{t("frameTime")} 16 ? "hsl(var(--fw-sc-warning))" : "hsl(var(--fw-sc-text))", fontFamily: "monospace", }} > {compositorStats.frameTimeMs.toFixed(2)} ms
{compositorStats.gpuMemoryMB !== undefined && (
{t("gpuMemory")} {compositorStats.gpuMemoryMB.toFixed(1)} MB
)}
)} {/* Scenes & Layers */}
{t("composition")}
{t("scenes")} {sceneCount}
{t("layers")} {layerCount}
{/* Encoder Section */}
{t("encoder")}
{t("type")} {useWebCodecs && isWebCodecsAvailable ? t("webCodecs") : t("browser")} {state === "streaming" && ( {isWebCodecsActive ? "(active)" : "(pending)"} )}
{t("useWebCodecs")} onUseWebCodecsChange?.(checked)} disabled={state === "streaming" || !isWebCodecsAvailable} />
{!isWebCodecsAvailable && (
{t("webCodecsUnsupported")}
)} {isWebCodecsAvailable && state === "streaming" && useWebCodecs !== isWebCodecsActive && (
{t("changeTakesEffect")}
)}
{/* WebCodecs Encoder Stats */} {isWebCodecsActive && encoderStats && (
{t("encoderStats")}
{t("videoFrames")} {encoderStats.video.framesEncoded}
{t("videoPending")} 5 ? "hsl(var(--fw-sc-warning))" : "hsl(var(--fw-sc-text))", fontFamily: "monospace", }} > {encoderStats.video.framesPending}
Video Bytes {(encoderStats.video.bytesEncoded / 1024 / 1024).toFixed(2)} MB
Audio Samples {encoderStats.audio.samplesEncoded}
Audio Bytes {(encoderStats.audio.bytesEncoded / 1024).toFixed(1)} KB
)} {/* Info */}
{useWebCodecs && isWebCodecsAvailable ? "WebCodecs encoder via RTCRtpScriptTransform provides lower latency and better encoding control." : "Browser's built-in MediaStream encoder. Enable WebCodecs toggle for advanced encoding."}
)}
); }; export default AdvancedPanel; export { AdvancedPanel };