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 { 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', } export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): JSX.Element { const rootRef = useRef(null) const [astraBaseUrlInput, setAstraBaseUrlInput] = useState('') const [astraTokenInput, setAstraTokenInput] = useState('') 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 themeId = useSettingsStore((s) => s.themeId) const setThemeId = useSettingsStore((s) => s.setThemeId) const { themes, activeThemeId, loadTheme, renameTheme, deleteTheme, reloadThemes, importThemeFromDialog, showThemesFolder, } = useThemeStore() const astraState = useAstraStore((s) => s.integrationState) const saveAstraConfig = useAstraStore((s) => s.saveConfig) const { systemSources, devices, selectedSystemSourceId, selectedDeviceId, captureMode, isCapturing, captureStatus, captureError, inputGainDb, refreshSystemSources, refreshDevices, refreshBackendSupport, selectSystemSource, selectDevice, startCapture, setInputGain, } = useAudioStore() 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) await startCapture() } } const selectedSourceValue = captureMode === 'system' ? `system:${selectedSystemSourceId ?? systemSources[0]?.id ?? '__default_system_output__'}` : `device:${selectedDeviceId ?? ''}` const visibleSystemSources = systemSources.length ? systemSources : [{ id: '__default_system_output__', label: 'System Output', kind: 'system', isDefault: true }] const showInputDevices = devices.length > 0 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 handleRenameTheme = async (): Promise => { if (!activeThemeId || activeThemeId === 'theme_default') return const activeTheme = themes[activeThemeId] if (!activeTheme) return const nextName = window.prompt('Rename theme', activeTheme.name)?.trim() if (!nextName) return await renameTheme(activeThemeId, nextName) } const handleDeleteTheme = async (): Promise => { if (!activeThemeId || activeThemeId === 'theme_default') return const activeTheme = themes[activeThemeId] if (!activeTheme) return if (!window.confirm(`Delete "${activeTheme.name}"?`)) return await deleteTheme(activeThemeId) setThemeId(useThemeStore.getState().activeThemeId) } const handleSaveAstraConfig = async (): Promise => { const nextConfig: AstraIntegrationConfig = { baseUrl: astraBaseUrlInput, token: astraTokenInput, } await saveAstraConfig(nextConfig) } 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' 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]) => ( ))} {activeThemeId && activeThemeId !== 'theme_default' ? ( ) : null} {activeThemeId && activeThemeId !== 'theme_default' ? ( ) : null}
{themeId ? 'Saved With Profile' : 'Not Linked'}
Astra
setAstraBaseUrlInput(event.target.value)} /> setAstraTokenInput(event.target.value)} />
{astraStatusLabel}
{astraState.lastError ? (
{astraState.lastError}
) : null}
Audio Source
{ void handleSourceChange(event.target.value) }} className="bottom-bar__select" > {visibleSystemSources.map((source) => ( ))} {showInputDevices ? ( {devices.map((device) => ( ))} ) : null}
{indicatorLabel}
{captureError ? (
{captureError}
) : null}
Performance
{VISUALIZER_FRAME_TARGETS.map((target) => ( ))}
{roundedDockedRenderFps} FPS
Trim
{inputGainDb > 0 ? '+' : ''}{inputGainDb.toFixed(1)}dB setInputGain(Number(event.target.value))} onDoubleClick={() => setInputGain(0)} />
) }