better porting

This commit is contained in:
Boof2015
2026-03-21 16:40:03 -04:00
parent ecf13c2857
commit 311f35f795
19 changed files with 1515 additions and 189 deletions
+83 -15
View File
@@ -1,56 +1,124 @@
import { useEffect, useRef } from 'react'
import type { ScopeKind } from '../../types/scope'
import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
import { SpectrumAnalyzer } from '../visualizers/SpectrumAnalyzer'
import { Oscilloscope } from '../visualizers/Oscilloscope'
import { Vectorscope } from '../visualizers/Vectorscope'
import { Spectrogram } from '../visualizers/Spectrogram'
import { VUMeter } from '../visualizers/VUMeter'
import { LUFSMeter } from '../visualizers/LUFSMeter'
import { Waveform } from '../visualizers/Waveform'
interface ScopeModuleProps {
scopeKind: ScopeKind
lineColor?: string
widthWeight?: number
}
type Visualizer = SpectrumAnalyzer | Oscilloscope | Vectorscope
interface Visualizer {
start(): void
stop(): void
dispose(): void
resize(): void
setOptions(options: Record<string, unknown>): void
}
function createVisualizer(scopeKind: ScopeKind, canvas: HTMLCanvasElement, lineColor: string): Visualizer | null {
/** Maps settingsStore scope settings to the visualizer's setOptions format */
function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKind], lineColor: string): Record<string, unknown> {
const base = { lineColor }
switch (kind) {
case 'spectrum': {
const s = settings as ScopeSettings['spectrum']
return { ...base, fftSize: s.fftSize, tiltDbPerOctave: s.tiltDbPerOctave, heatmapFill: s.heatmap, heatmapTiltDbPerOctave: s.heatmapTiltDbPerOctave, showGrid: s.showGrid, fillGradient: s.fillGradient }
}
case 'oscilloscope': {
const s = settings as ScopeSettings['oscilloscope']
return { ...base, pitchLock: s.pitchLock, underfillEnabled: s.underfillEnabled, showGrid: s.showGrid, lineWidth: s.lineWidth }
}
case 'vectorscope': {
const s = settings as ScopeSettings['vectorscope']
return { ...base, mode: s.mode, multiband: s.multiband, showGrid: s.showGrid, persistence: s.persistence }
}
case 'spectrogram': {
const s = settings as ScopeSettings['spectrogram']
return { ...base, fftSize: s.fftSize, scrollSpeed: s.scrollSpeed, clarityMode: s.clarityMode, scaleMode: s.scaleMode, colorScheme: s.colorScheme }
}
case 'vumeter': {
const s = settings as ScopeSettings['vumeter']
return { ...base, mode: s.mode, orientation: s.orientation }
}
case 'lufsmeter': {
const s = settings as ScopeSettings['lufsmeter']
return { ...base, mode: s.mode }
}
case 'waveform': {
const s = settings as ScopeSettings['waveform']
return { ...base, scrollSpeed: s.scrollSpeed, gainDb: s.gainDb, multiband: s.multiband }
}
default:
return base
}
}
function createVisualizer(scopeKind: ScopeKind, canvas: HTMLCanvasElement, mySettings: ScopeSettings[ScopeKind], lineColor: string): Visualizer | null {
const opts = scopeSettingsToOptions(scopeKind, mySettings, lineColor)
switch (scopeKind) {
case 'spectrum':
return new SpectrumAnalyzer(canvas, { lineColor })
return new SpectrumAnalyzer(canvas, opts)
case 'oscilloscope':
return new Oscilloscope(canvas, { lineColor })
return new Oscilloscope(canvas, opts)
case 'vectorscope':
return new Vectorscope(canvas, { lineColor })
return new Vectorscope(canvas, opts)
case 'spectrogram':
return new Spectrogram(canvas, opts)
case 'vumeter':
return new VUMeter(canvas, opts)
case 'lufsmeter':
return new LUFSMeter(canvas, opts)
case 'waveform':
return new Waveform(canvas, opts)
default:
console.warn(`Scope type "${scopeKind}" not yet implemented`)
return null
}
}
export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeModuleProps): JSX.Element {
export default function ScopeModule({ scopeKind, lineColor = '#38bdf8', widthWeight = 1 }: ScopeModuleProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const visualizerRef = useRef<Visualizer | null>(null)
const initializedRef = useRef(false)
// Initialize and manage visualizer lifecycle
// Subscribe to ONLY this scope's settings — avoids triggering setOptions when other scopes change
const mySettings = useSettingsStore((s) => s.scopeSettings[scopeKind])
// Initialize visualizer
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const viz = createVisualizer(scopeKind, canvas, lineColor)
initializedRef.current = false
const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor)
if (!viz) return
visualizerRef.current = viz
viz.start()
// Mark as initialized after a frame so the settings effect skips the first run
requestAnimationFrame(() => { initializedRef.current = true })
return () => {
viz.dispose()
visualizerRef.current = null
initializedRef.current = false
}
}, [scopeKind]) // Only recreate when scope type changes
}, [scopeKind])
// Update lineColor without recreating
// Push settings + lineColor changes to live visualizer (skip initial — constructor already handled it)
useEffect(() => {
visualizerRef.current?.setOptions({ lineColor })
}, [lineColor])
if (!visualizerRef.current || !initializedRef.current) return
const opts = scopeSettingsToOptions(scopeKind, mySettings, lineColor)
visualizerRef.current.setOptions(opts)
}, [mySettings, lineColor])
// ResizeObserver for DPI-aware canvas sizing
useEffect(() => {
@@ -74,7 +142,7 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeM
const observer = new ResizeObserver(resizeCanvas)
observer.observe(container)
resizeCanvas() // Initial size
resizeCanvas()
return () => observer.disconnect()
}, [])
@@ -83,7 +151,7 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeM
<div
ref={containerRef}
style={{
flex: 1,
flex: widthWeight,
minWidth: 0,
height: '100%',
position: 'relative',
+451
View File
@@ -0,0 +1,451 @@
import { useEffect } from 'react'
import { useAudioStore } from '../stores/audioStore'
import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope'
const PANEL_HEIGHT = 200
const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'Spectrum',
oscilloscope: 'Oscilloscope',
vectorscope: 'Vectorscope',
spectrogram: 'Spectrogram',
vumeter: 'VU Meter',
lufsmeter: 'LUFS Meter',
waveform: 'Waveform',
}
const labelStyle: React.CSSProperties = {
fontSize: '9px',
fontFamily: "'JetBrains Mono', monospace",
color: 'rgba(255, 255, 255, 0.45)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: '4px',
}
const selectStyle: React.CSSProperties = {
backgroundColor: '#0a0a0a',
color: 'rgba(255, 255, 255, 0.8)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '3px',
padding: '4px 6px',
fontSize: '11px',
fontFamily: 'Inter, sans-serif',
outline: 'none',
width: '100%',
}
const checkboxRowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '11px',
color: 'rgba(255, 255, 255, 0.7)',
fontFamily: 'Inter, sans-serif',
cursor: 'pointer',
}
interface SettingsPanelProps {
onClose: () => void
}
export default function SettingsPanel({ onClose }: SettingsPanelProps): JSX.Element {
const {
devices,
selectedDeviceId,
captureMode,
isCapturing,
captureStatus,
captureError,
refreshDevices,
selectDevice,
setCaptureMode,
startCapture,
} = useAudioStore()
const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder } = useSettingsStore()
const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore()
const visibleScopes = scopeOrder.filter((k) => !hiddenScopes.has(k))
useEffect(() => {
refreshDevices()
}, [])
const handleSourceChange = async (value: string): Promise<void> => {
if (value === '__system__') {
setCaptureMode('system')
await startCapture()
} else {
await selectDevice(value)
await startCapture()
}
}
const indicatorColor = isCapturing
? '#22c55e'
: captureStatus === 'error'
? '#ef4444'
: '#71717a'
const indicatorLabel = isCapturing
? 'Capturing'
: captureStatus === 'connecting'
? 'Connecting...'
: captureStatus === 'error'
? 'Capture Failed'
: 'Idle'
return (
<div
style={{
height: `${PANEL_HEIGHT}px`,
backgroundColor: '#050505',
borderTop: '1px solid rgba(255, 255, 255, 0.08)',
display: 'flex',
flexDirection: 'row',
overflow: 'hidden',
flexShrink: 0,
}}
>
{/* Audio Source section */}
<div
style={{
width: '200px',
padding: '12px',
borderRight: '1px solid rgba(255, 255, 255, 0.06)',
display: 'flex',
flexDirection: 'column',
gap: '10px',
flexShrink: 0,
}}
>
<div style={{ ...labelStyle, marginBottom: 0, fontSize: '10px', color: 'rgba(255, 255, 255, 0.55)' }}>
Audio Source
</div>
<div>
<div style={labelStyle}>Source</div>
<select
value={captureMode === 'system' ? '__system__' : selectedDeviceId ?? ''}
onChange={(e) => {
void handleSourceChange(e.target.value)
}}
style={selectStyle}
>
<option value="__system__">System Audio</option>
<optgroup label="Devices">
{devices.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || `Input ${d.deviceId.slice(0, 8)}`}
</option>
))}
</optgroup>
</select>
</div>
{/* Signal indicator */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '10px', color: 'rgba(255, 255, 255, 0.5)' }}>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: indicatorColor,
boxShadow: isCapturing ? '0 0 6px rgba(34, 197, 94, 0.4)' : 'none',
}}
/>
{indicatorLabel}
</div>
{captureError ? (
<div style={{ fontSize: '10px', color: 'rgba(239, 68, 68, 0.8)', lineHeight: 1.4 }}>
{captureError}
</div>
) : null}
{/* Theme section */}
<div style={{ borderTop: '1px solid rgba(255, 255, 255, 0.06)', paddingTop: '10px', marginTop: '2px' }}>
<div style={{ ...labelStyle, marginBottom: '6px', fontSize: '10px', color: 'rgba(255, 255, 255, 0.55)' }}>
Theme
</div>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{PRESET_IDS.map((id) => {
const p = PRESETS[id]
const active = presetId === id && !customAccent
return (
<button
key={id}
onClick={() => setPreset(id)}
title={p.name}
style={{
width: '18px',
height: '18px',
borderRadius: '50%',
backgroundColor: p.accent,
border: active ? '2px solid #fff' : '2px solid transparent',
cursor: 'pointer',
padding: 0,
outline: 'none',
transition: 'border-color 120ms',
}}
/>
)
})}
</div>
<div style={{ marginTop: '6px' }}>
<div style={labelStyle}>Custom</div>
<input
type="color"
value={accent}
onChange={(e) => setCustomAccent(e.target.value)}
style={{
width: '100%',
height: '24px',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '3px',
backgroundColor: '#0a0a0a',
cursor: 'pointer',
padding: '2px',
}}
/>
</div>
</div>
</div>
{/* Per-scope settings */}
<div
style={{
flex: 1,
padding: '12px',
overflowX: 'auto',
overflowY: 'hidden',
display: 'flex',
gap: '16px',
}}
>
{visibleScopes.map((kind) => (
<ScopeSettingsColumn
key={kind}
kind={kind}
settings={scopeSettings}
onUpdate={updateScopeSettings}
accent={accent}
/>
))}
</div>
{/* Close button */}
<button
onClick={onClose}
style={{
position: 'absolute',
right: '8px',
bottom: '8px',
background: 'transparent',
border: '1px solid rgba(255, 255, 255, 0.1)',
color: 'rgba(255, 255, 255, 0.4)',
borderRadius: '3px',
padding: '2px 8px',
fontSize: '9px',
fontFamily: "'JetBrains Mono', monospace",
cursor: 'pointer',
textTransform: 'uppercase',
}}
>
Close
</button>
</div>
)
}
function ScopeSettingsColumn({ kind, settings, onUpdate, accent }: {
kind: ScopeKind
settings: ScopeSettings
onUpdate: <K extends ScopeKind>(kind: K, s: Partial<ScopeSettings[K]>) => void
accent: string
}): JSX.Element {
const s = settings[kind]
return (
<div style={{ minWidth: '140px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ ...labelStyle, fontSize: '10px', color: accent, marginBottom: 0 }}>
{SCOPE_LABELS[kind]}
</div>
{kind === 'spectrum' && (() => {
const ss = s as ScopeSettings['spectrum']
return (
<>
<div>
<div style={labelStyle}>FFT Size</div>
<select value={ss.fftSize} onChange={(e) => onUpdate('spectrum', { fftSize: Number(e.target.value) })} style={selectStyle}>
{[1024, 2048, 4096, 8192, 16384].map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
<div>
<div style={labelStyle}>Tilt (dB/oct)</div>
<input type="range" min="0" max="6" step="0.5" value={ss.tiltDbPerOctave} onChange={(e) => onUpdate('spectrum', { tiltDbPerOctave: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.fillGradient} onChange={(e) => onUpdate('spectrum', { fillGradient: e.target.checked })} />
Fill
</label>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.heatmap} onChange={(e) => onUpdate('spectrum', { heatmap: e.target.checked })} />
Heatmap
</label>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.showGrid} onChange={(e) => onUpdate('spectrum', { showGrid: e.target.checked })} />
Grid
</label>
</>
)
})()}
{kind === 'oscilloscope' && (() => {
const ss = s as ScopeSettings['oscilloscope']
return (
<>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.pitchLock} onChange={(e) => onUpdate('oscilloscope', { pitchLock: e.target.checked })} />
Pitch Lock
</label>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.showGrid} onChange={(e) => onUpdate('oscilloscope', { showGrid: e.target.checked })} />
Grid
</label>
<div>
<div style={labelStyle}>Line Width</div>
<input type="range" min="0.5" max="4" step="0.5" value={ss.lineWidth} onChange={(e) => onUpdate('oscilloscope', { lineWidth: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
</>
)
})()}
{kind === 'vectorscope' && (() => {
const ss = s as ScopeSettings['vectorscope']
return (
<>
<div>
<div style={labelStyle}>Mode</div>
<select value={ss.mode} onChange={(e) => onUpdate('vectorscope', { mode: e.target.value as ScopeSettings['vectorscope']['mode'] })} style={selectStyle}>
<option value="lissajous">Lissajous</option>
<option value="polar-unipolar">Polar (Uni)</option>
<option value="polar-bipolar">Polar (Bi)</option>
<option value="linear-unipolar">Linear (Uni)</option>
<option value="linear-bipolar">Linear (Bi)</option>
</select>
</div>
<div>
<div style={labelStyle}>Persistence</div>
<input type="range" min="0" max="0.5" step="0.01" value={ss.persistence} onChange={(e) => onUpdate('vectorscope', { persistence: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.multiband} onChange={(e) => onUpdate('vectorscope', { multiband: e.target.checked })} />
Multiband
</label>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.showGrid} onChange={(e) => onUpdate('vectorscope', { showGrid: e.target.checked })} />
Grid
</label>
</>
)
})()}
{kind === 'spectrogram' && (() => {
const ss = s as ScopeSettings['spectrogram']
return (
<>
<div>
<div style={labelStyle}>FFT Size</div>
<select value={ss.fftSize} onChange={(e) => onUpdate('spectrogram', { fftSize: Number(e.target.value) })} style={selectStyle}>
{[512, 1024, 2048, 4096].map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
<div>
<div style={labelStyle}>Scale</div>
<select value={ss.scaleMode} onChange={(e) => onUpdate('spectrogram', { scaleMode: e.target.value as ScopeSettings['spectrogram']['scaleMode'] })} style={selectStyle}>
<option value="log">Log</option>
<option value="mel">Mel</option>
<option value="linear">Linear</option>
</select>
</div>
<div>
<div style={labelStyle}>Clarity</div>
<select value={ss.clarityMode} onChange={(e) => onUpdate('spectrogram', { clarityMode: e.target.value as ScopeSettings['spectrogram']['clarityMode'] })} style={selectStyle}>
<option value="classic">Classic</option>
<option value="sharp">Sharp</option>
<option value="sharper">Sharper</option>
</select>
</div>
<div>
<div style={labelStyle}>Color</div>
<select value={ss.colorScheme} onChange={(e) => onUpdate('spectrogram', { colorScheme: e.target.value as 'heat' | 'mono' })} style={selectStyle}>
<option value="heat">Heat</option>
<option value="mono">Mono</option>
</select>
</div>
<div>
<div style={labelStyle}>Speed</div>
<input type="range" min="1" max="8" step="1" value={ss.scrollSpeed} onChange={(e) => onUpdate('spectrogram', { scrollSpeed: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
</>
)
})()}
{kind === 'vumeter' && (() => {
const ss = s as ScopeSettings['vumeter']
return (
<>
<div>
<div style={labelStyle}>Mode</div>
<select value={ss.mode} onChange={(e) => onUpdate('vumeter', { mode: e.target.value as ScopeSettings['vumeter']['mode'] })} style={selectStyle}>
<option value="bar">Bar</option>
<option value="needle">Needle</option>
</select>
</div>
<div>
<div style={labelStyle}>Orientation</div>
<select value={ss.orientation} onChange={(e) => onUpdate('vumeter', { orientation: e.target.value as ScopeSettings['vumeter']['orientation'] })} style={selectStyle}>
<option value="horizontal">Horizontal</option>
<option value="vertical">Vertical</option>
</select>
</div>
</>
)
})()}
{kind === 'lufsmeter' && (() => {
const ss = s as ScopeSettings['lufsmeter']
return (
<div>
<div style={labelStyle}>Mode</div>
<select value={ss.mode} onChange={(e) => onUpdate('lufsmeter', { mode: e.target.value as ScopeSettings['lufsmeter']['mode'] })} style={selectStyle}>
<option value="bar">Bar</option>
</select>
</div>
)
})()}
{kind === 'waveform' && (() => {
const ss = s as ScopeSettings['waveform']
return (
<>
<div>
<div style={labelStyle}>Gain (dB)</div>
<input type="range" min="-12" max="12" step="1" value={ss.gainDb} onChange={(e) => onUpdate('waveform', { gainDb: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
<div>
<div style={labelStyle}>Speed</div>
<input type="range" min="1" max="8" step="1" value={ss.scrollSpeed} onChange={(e) => onUpdate('waveform', { scrollSpeed: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.multiband} onChange={(e) => onUpdate('waveform', { multiband: e.target.checked })} />
Multiband
</label>
</>
)
})()}
</div>
)
}
+80 -5
View File
@@ -1,6 +1,74 @@
import { Fragment, useCallback, useRef } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope'
import ScopeModule from './ScopeModule'
function ResizeHandle({ leftKind, rightKind }: { leftKind: ScopeKind; rightKind: ScopeKind }): JSX.Element {
const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight)
const handleRef = useRef<HTMLDivElement>(null)
const onMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
const startX = e.clientX
const container = handleRef.current?.parentElement
if (!container) return
const totalWidth = container.getBoundingClientRect().width
const { widthWeights } = useSettingsStore.getState()
const startLeftWeight = widthWeights[leftKind] ?? 1
const startRightWeight = widthWeights[rightKind] ?? 1
const totalWeight = startLeftWeight + startRightWeight
const onMouseMove = (ev: MouseEvent): void => {
const delta = ev.clientX - startX
const ratio = delta / totalWidth * totalWeight * 2
const newLeft = Math.max(0.15, startLeftWeight + ratio)
const newRight = Math.max(0.15, startRightWeight - ratio)
setScopeWidthWeight(leftKind, newLeft)
setScopeWidthWeight(rightKind, newRight)
}
const onMouseUp = (): void => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}, [leftKind, rightKind, setScopeWidthWeight])
return (
<div
ref={handleRef}
onMouseDown={onMouseDown}
style={{
width: '5px',
flexShrink: 0,
cursor: 'col-resize',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
}}
>
<div style={{ width: '1px', height: '100%', backgroundColor: 'rgba(255, 255, 255, 0.08)' }} />
</div>
)
}
export default function Strip(): JSX.Element {
const scopeOrder = useSettingsStore((s) => s.scopeOrder)
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const widthWeights = useSettingsStore((s) => s.widthWeights)
const accent = useThemeStore((s) => s.accent)
const visibleScopes = scopeOrder.filter((k) => !hiddenScopes.has(k))
return (
<div
style={{
@@ -11,11 +79,18 @@ export default function Strip(): JSX.Element {
backgroundColor: 'var(--bg-primary)',
}}
>
<ScopeModule scopeKind="spectrum" lineColor="var(--accent, #38bdf8)" />
<div style={{ width: '1px', flexShrink: 0, backgroundColor: 'var(--glass-border)' }} />
<ScopeModule scopeKind="oscilloscope" lineColor="var(--accent, #38bdf8)" />
<div style={{ width: '1px', flexShrink: 0, backgroundColor: 'var(--glass-border)' }} />
<ScopeModule scopeKind="vectorscope" lineColor="var(--accent, #38bdf8)" />
{visibleScopes.map((kind, i) => (
<Fragment key={kind}>
{i > 0 && (
<ResizeHandle leftKind={visibleScopes[i - 1]} rightKind={kind} />
)}
<ScopeModule
scopeKind={kind}
lineColor={accent}
widthWeight={widthWeights[kind] ?? 1}
/>
</Fragment>
))}
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import type { ScopeKind } from '../../types/scope'
import { SCOPE_KINDS } from '../../types/scope'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'SPEC',
oscilloscope: 'OSC',
vectorscope: 'VEC',
spectrogram: 'GRAM',
vumeter: 'VU',
lufsmeter: 'LUFS',
waveform: 'WAVE',
}
function hexToRgba(hex: string, alpha: number): string {
const h = hex.replace('#', '')
const r = parseInt(h.substring(0, 2), 16)
const g = parseInt(h.substring(2, 4), 16)
const b = parseInt(h.substring(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
interface ToolbarProps {
onOpenSettings: () => void
settingsOpen: boolean
}
export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): JSX.Element {
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const toggleScope = useSettingsStore((s) => s.toggleScope)
const accent = useThemeStore((s) => s.accent)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(true)
const accentBg = useMemo(() => hexToRgba(accent, 0.15), [accent])
const accentBorder = useMemo(() => hexToRgba(accent, 0.3), [accent])
useEffect(() => {
window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
const unsub = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop)
return unsub
}, [])
const handlePin = useCallback(() => {
window.electronAPI.toggleAlwaysOnTop()
}, [])
return (
<div
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
height: '36px',
padding: '0 8px',
gap: '2px',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderBottom: '1px solid rgba(255, 255, 255, 0.06)',
}}
>
{/* Drag region */}
<div
style={{
WebkitAppRegion: 'drag',
flex: '0 0 40px',
height: '100%',
cursor: 'grab',
} as React.CSSProperties}
/>
{/* Scope toggles */}
<div style={{ display: 'flex', gap: '2px', flex: 1 }}>
{SCOPE_KINDS.map((kind) => {
const active = !hiddenScopes.has(kind)
return (
<button
key={kind}
onClick={() => toggleScope(kind)}
style={{
background: active ? accentBg : 'transparent',
border: `1px solid ${active ? accentBorder : 'rgba(255, 255, 255, 0.08)'}`,
borderRadius: '3px',
color: active ? accent : 'rgba(255, 255, 255, 0.35)',
fontSize: '9px',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 400,
letterSpacing: '0.05em',
padding: '3px 6px',
cursor: 'pointer',
transition: 'all 120ms',
lineHeight: 1,
}}
>
{SCOPE_LABELS[kind]}
</button>
)
})}
</div>
{/* Right side: settings, pin, close */}
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<button
onClick={onOpenSettings}
style={{
background: settingsOpen ? accentBg : 'transparent',
border: 'none',
color: settingsOpen ? accent : 'rgba(255, 255, 255, 0.5)',
fontSize: '14px',
cursor: 'pointer',
padding: '4px',
borderRadius: '3px',
lineHeight: 1,
transition: 'color 120ms',
}}
title="Settings"
>
</button>
<button
onClick={handlePin}
style={{
background: isAlwaysOnTop ? accentBg : 'transparent',
border: 'none',
color: isAlwaysOnTop ? accent : 'rgba(255, 255, 255, 0.5)',
fontSize: '12px',
cursor: 'pointer',
padding: '4px',
borderRadius: '3px',
lineHeight: 1,
transition: 'color 120ms',
}}
title={isAlwaysOnTop ? 'Unpin from top' : 'Pin to top'}
>
📌
</button>
<button
onClick={() => window.electronAPI.close()}
style={{
background: 'transparent',
border: 'none',
color: 'rgba(255, 255, 255, 0.5)',
fontSize: '12px',
cursor: 'pointer',
padding: '4px',
borderRadius: '3px',
lineHeight: 1,
transition: 'color 120ms',
}}
title="Close"
>
</button>
</div>
</div>
)
}