import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX, type WheelEvent } from 'react' import { useAstraStore } from '../stores/astraStore' import { useAudioStore } from '../stores/audioStore' import { usePerformanceStore } from '../stores/performanceStore' import { useSettingsStore } from '../stores/settingsStore' import { useThemeStore } from '../stores/themeStore' import { useUiStore } from '../stores/uiStore' import { getHorizontalWheelScrollResult } from '../utils/horizontalWheelScroll' import type { ScopeKind } from '../../types/scope' import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance' import { SCOPE_KINDS } from '../../types/scope' import type { AstraIntegrationConfig } from '../../types/astra' import ThemedSelect from './ThemedSelect' const SCOPE_LABELS: Record = { spectrum: 'Spectrum', oscilloscope: 'Oscilloscope', vectorscope: 'Vectorscope', spectrogram: 'Spectrogram', vumeter: 'VU Meter', lufsmeter: 'LUFS Meter', waveform: 'Waveform', astra: 'Astra', } interface BottomBarProps { onClose: () => void onHeightChange?: (height: number) => void } const FRAME_TARGET_LABELS: Record = { 10: '10', 30: '30', 60: '60', 120: '120', 144: '144', 'display-sync': 'Sync', } const DEFAULT_INPUT_DEVICE_ID = '__default_input__' function getErrorMessage(error: unknown, fallback: string): string { return error instanceof Error && error.message ? error.message : fallback } export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): JSX.Element { const rootRef = useRef(null) const [astraBaseUrlInput, setAstraBaseUrlInput] = useState('') const [astraTokenInput, setAstraTokenInput] = useState('') const [isRefreshingThemes, setIsRefreshingThemes] = useState(false) const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) const scopeOrder = useSettingsStore((s) => s.scopeOrder) const toggleScope = useSettingsStore((s) => s.toggleScope) const frameTarget = usePerformanceStore((s) => s.frameTarget) const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps) const setFrameTarget = usePerformanceStore((s) => s.setFrameTarget) const setThemeId = useSettingsStore((s) => s.setThemeId) const { themes, activeThemeId, loadTheme, reloadThemes, showThemesFolder, } = useThemeStore() const astraState = useAstraStore((s) => s.integrationState) const saveAstraConfig = useAstraStore((s) => s.saveConfig) const { systemSources, devices, selectedSystemSourceId, selectedDeviceId, captureMode, isCapturing, captureStatus, captureError, captureNotice, inputGainDb, clearCaptureNotice, refreshSystemSources, refreshDevices, refreshBackendSupport, selectSystemSource, selectDevice, startCapture, setInputGain, } = useAudioStore() const showBanner = useUiStore((s) => s.showBanner) const setSettingsOpen = useUiStore((s) => s.setSettingsOpen) useEffect(() => { void refreshBackendSupport() void refreshSystemSources() void refreshDevices() }, [refreshBackendSupport, refreshSystemSources, refreshDevices]) useEffect(() => { setAstraBaseUrlInput(astraState.config.baseUrl) setAstraTokenInput(astraState.config.token) }, [astraState.config.baseUrl, astraState.config.token]) useLayoutEffect(() => { if (!onHeightChange || !rootRef.current) return const rootElement = rootRef.current let frameId = 0 const reportHeight = (): void => { cancelAnimationFrame(frameId) frameId = requestAnimationFrame(() => { onHeightChange(Math.ceil(rootElement.getBoundingClientRect().height)) }) } reportHeight() const resizeObserver = new ResizeObserver(() => { reportHeight() }) resizeObserver.observe(rootElement) return () => { cancelAnimationFrame(frameId) resizeObserver.disconnect() } }, [onHeightChange]) const handleSourceChange = async (value: string): Promise => { if (value.startsWith('system:')) { const sourceId = value.slice('system:'.length) await selectSystemSource(sourceId) await startCapture() return } if (value.startsWith('device:')) { const deviceId = value.slice('device:'.length) await selectDevice(deviceId === DEFAULT_INPUT_DEVICE_ID ? null : deviceId) await startCapture() } } const visibleSystemSources = systemSources.length ? systemSources : [{ id: '__default_system_output__', label: 'Default Output', kind: 'system', isDefault: true }] const defaultSystemSourceId = visibleSystemSources[0]?.id ?? '__default_system_output__' const selectedSourceValue = captureMode === 'system' ? `system:${selectedSystemSourceId ?? defaultSystemSourceId}` : `device:${selectedDeviceId ?? DEFAULT_INPUT_DEVICE_ID}` const indicatorLabel = isCapturing ? 'Capturing' : captureStatus === 'connecting' ? 'Connecting' : captureStatus === 'error' ? 'Capture Failed' : 'Idle' const trimPercent = Math.min(100, Math.max(0, ((inputGainDb + 12) / 24) * 100)) const roundedDockedRenderFps = Math.max(0, Math.round(dockedRenderFps)) const themeEntries = Object.entries(themes) const handleThemeChange = async (value: string): Promise => { await loadTheme(value) setThemeId(value) } const handleSaveAstraConfig = async (): Promise => { const nextConfig: AstraIntegrationConfig = { baseUrl: astraBaseUrlInput, token: astraTokenInput, } try { await saveAstraConfig(nextConfig) } catch (error) { showBanner({ tone: 'error', message: getErrorMessage(error, 'Could not save the Astra settings.'), actions: [], }) } } const handleRetryCapture = async (): Promise => { clearCaptureNotice() await startCapture() } const handleUseDefaultSource = async (): Promise => { clearCaptureNotice() if (captureMode === 'system') { await selectSystemSource(defaultSystemSourceId) } else { await selectDevice(null) } await startCapture() } const handleRetryAstra = async (): Promise => { await handleSaveAstraConfig() } const handleShowThemesFolder = async (): Promise => { try { await showThemesFolder() } catch (error) { showBanner({ tone: 'error', message: getErrorMessage(error, 'Could not open the themes folder.'), actions: [], }) } } const handleReloadThemes = async (): Promise => { if (isRefreshingThemes) { return } setIsRefreshingThemes(true) try { await reloadThemes() } catch (error) { showBanner({ tone: 'error', message: getErrorMessage(error, 'Could not refresh themes.'), actions: [], }) } finally { setIsRefreshingThemes(false) } } const handleRailWheel = (event: WheelEvent): void => { const railElement = event.currentTarget const target = event.target const isTargetExcluded = target instanceof Element && target.closest('input[type="range"], select, .settings-control__select') !== null const scrollResult = getHorizontalWheelScrollResult({ clientWidth: railElement.clientWidth, deltaMode: event.deltaMode, deltaX: event.deltaX, deltaY: event.deltaY, isTargetExcluded, scrollLeft: railElement.scrollLeft, scrollWidth: railElement.scrollWidth, }) if (!scrollResult) return railElement.scrollLeft = scrollResult.nextScrollLeft event.preventDefault() } const astraStatusLabel = astraState.connectionState === 'connected' ? 'Connected' : astraState.connectionState === 'connecting' ? 'Connecting' : astraState.connectionState === 'error' ? 'Error' : 'Off' const captureMessage = captureError ?? captureNotice const astraErrorMessage = astraState.lastError ?? astraState.lastControlError const canUseDefaultSource = captureMode === 'system' ? selectedSystemSourceId !== defaultSystemSourceId : selectedDeviceId !== null return (
Modules
{SCOPE_KINDS.map((kind) => { const active = scopeOrder.includes(kind) && !hiddenScopes.has(kind) return ( ) })}
Theme
{ void handleThemeChange(event.target.value) }} className="bottom-bar__select" > {themeEntries.map(([id, theme]) => ( ))}
Astra
setAstraBaseUrlInput(event.target.value)} /> setAstraTokenInput(event.target.value)} />
{astraStatusLabel}
{astraErrorMessage ? ( <>
{astraErrorMessage}
) : null}
Audio Source
{ void handleSourceChange(event.target.value) }} className="bottom-bar__select" > {visibleSystemSources.map((source) => ( ))} {devices.map((device) => ( ))}
{indicatorLabel}
{captureMessage ? ( <>
{captureMessage}
{canUseDefaultSource ? ( ) : null} {!captureError && captureNotice ? ( ) : null}
) : null}
Performance
{VISUALIZER_FRAME_TARGETS.map((target) => ( ))}
{roundedDockedRenderFps} FPS
Trim
{inputGainDb > 0 ? '+' : ''}{inputGainDb.toFixed(1)}dB setInputGain(Number(event.target.value))} onDoubleClick={() => setInputGain(0)} />
) }