mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-16 08:10:40 +02:00
better porting
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user