UI/UX improvements, scope popouts

This commit is contained in:
Boof2015
2026-03-27 23:01:46 -04:00
parent e126a264cd
commit 85c0cecfd2
28 changed files with 2396 additions and 1000 deletions
+65 -23
View File
@@ -1,17 +1,27 @@
import { useEffect, useRef, type JSX } 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'
import type { ScopeSettings } from '../../types/settings'
import { useSettingsStore } from '../stores/settingsStore'
import { SpectrumAnalyzer, type SpectrumAnalyzerDataSource } from '../visualizers/SpectrumAnalyzer'
import { Oscilloscope, type OscilloscopeDataSource } from '../visualizers/Oscilloscope'
import { Vectorscope, type VectorscopeDataSource } from '../visualizers/Vectorscope'
import { Spectrogram, type SpectrogramDataSource } from '../visualizers/Spectrogram'
import { VUMeter, type VUMeterDataSource } from '../visualizers/VUMeter'
import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter'
import { Waveform, type WaveformDataSource } from '../visualizers/Waveform'
interface ScopeModuleProps {
scopeKind: ScopeKind
lineColor?: string
settings?: ScopeSettings[ScopeKind]
dataSource?:
| SpectrumAnalyzerDataSource
| OscilloscopeDataSource
| VectorscopeDataSource
| SpectrogramDataSource
| VUMeterDataSource
| LUFSMeterDataSource
| WaveformDataSource
}
interface Visualizer {
@@ -75,36 +85,68 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi
}
}
function createVisualizer(scopeKind: ScopeKind, canvas: HTMLCanvasElement, mySettings: ScopeSettings[ScopeKind], lineColor: string): Visualizer | null {
function createVisualizer(
scopeKind: ScopeKind,
canvas: HTMLCanvasElement,
mySettings: ScopeSettings[ScopeKind],
lineColor: string,
dataSource?: ScopeModuleProps['dataSource'],
): Visualizer | null {
const opts = scopeSettingsToOptions(scopeKind, mySettings, lineColor)
switch (scopeKind) {
case 'spectrum':
return new SpectrumAnalyzer(canvas, opts)
return new SpectrumAnalyzer(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as SpectrumAnalyzerDataSource } : {}),
})
case 'oscilloscope':
return new Oscilloscope(canvas, opts)
return new Oscilloscope(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as OscilloscopeDataSource } : {}),
})
case 'vectorscope':
return new Vectorscope(canvas, opts)
return new Vectorscope(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as VectorscopeDataSource } : {}),
})
case 'spectrogram':
return new Spectrogram(canvas, opts)
return new Spectrogram(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as SpectrogramDataSource } : {}),
})
case 'vumeter':
return new VUMeter(canvas, opts)
return new VUMeter(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as VUMeterDataSource } : {}),
})
case 'lufsmeter':
return new LUFSMeter(canvas, opts)
return new LUFSMeter(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as LUFSMeterDataSource } : {}),
})
case 'waveform':
return new Waveform(canvas, opts)
return new Waveform(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as WaveformDataSource } : {}),
})
default:
return null
}
}
export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeModuleProps): JSX.Element {
export default function ScopeModule({
scopeKind,
lineColor = '#38bdf8',
settings,
dataSource,
}: ScopeModuleProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const visualizerRef = useRef<Visualizer | null>(null)
const initializedRef = useRef(false)
// Subscribe to ONLY this scope's settings — avoids triggering setOptions when other scopes change
const mySettings = useSettingsStore((s) => s.scopeSettings[scopeKind])
const storeSettings = useSettingsStore((s) => s.scopeSettings[scopeKind])
const mySettings = settings ?? storeSettings
// Initialize visualizer
useEffect(() => {
@@ -112,7 +154,7 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeM
if (!canvas) return
initializedRef.current = false
const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor)
const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor, dataSource)
if (!viz) return
visualizerRef.current = viz
@@ -126,14 +168,14 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeM
visualizerRef.current = null
initializedRef.current = false
}
}, [scopeKind])
}, [dataSource, scopeKind])
// Push settings + lineColor changes to live visualizer (skip initial — constructor already handled it)
useEffect(() => {
if (!visualizerRef.current || !initializedRef.current) return
const opts = scopeSettingsToOptions(scopeKind, mySettings, lineColor)
const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, lineColor), ...(dataSource ? { dataSource } : {}) }
visualizerRef.current.setOptions(opts)
}, [mySettings, lineColor])
}, [dataSource, lineColor, mySettings, scopeKind])
// ResizeObserver for DPI-aware canvas sizing
useEffect(() => {
@@ -0,0 +1,188 @@
import { useEffect, useMemo, useRef } from 'react'
import { audioRouter } from '../audio/AudioRouter'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import type {
ScopePopoutAudioBatch,
ScopePopoutSessionState,
ScopePopoutSnapshot,
ScopePopoutSyncStateMap,
} from '../../types/popout'
import { SCOPE_KINDS, SCOPE_LABELS, type ScopeKind } from '../../types/scope'
import type { ScopeSettings } from '../../types/settings'
function buildConsumerDemand(kind: ScopeKind): Record<ScopeKind, boolean> {
return SCOPE_KINDS.reduce((acc, currentKind) => {
acc[currentKind] = currentKind === kind
return acc
}, {} as Record<ScopeKind, boolean>)
}
function flushScopeAudioBatch(kind: ScopeKind): ScopePopoutAudioBatch {
switch (kind) {
case 'spectrum':
return audioRouter.flushPendingSpectrumSamples()
case 'oscilloscope':
return audioRouter.flushPendingOscilloscopeSamples()
case 'vectorscope':
return audioRouter.flushPendingVectorscopeSamples()
case 'spectrogram':
return audioRouter.flushPendingSpectrogramSamples()
case 'vumeter':
return audioRouter.flushPendingVUMeterSamples()
case 'lufsmeter':
return audioRouter.flushPendingLUFSMeterSamples()
case 'waveform':
return audioRouter.flushPendingWaveformSamples()
}
}
function toPopoutSessionState(state: ScopePopoutSessionState): ScopePopoutSessionState {
return {
sessionId: state.sessionId,
sampleRate: state.sampleRate,
channelCount: state.channelCount,
capturing: state.capturing,
backendKind: state.backendKind,
}
}
function isPartialSettings(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export default function ScopePopoutBridge(): null {
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const scopePopouts = useSettingsStore((s) => s.scopePopouts)
const scopeSettings = useSettingsStore((s) => s.scopeSettings)
const popInScope = useSettingsStore((s) => s.popInScope)
const updatePopoutBounds = useSettingsStore((s) => s.updatePopoutBounds)
const updateScopeSettings = useSettingsStore((s) => s.updateScopeSettings)
const accent = useThemeStore((s) => s.accent)
const activePopoutKinds = useMemo(
() => SCOPE_KINDS.filter((kind) => scopePopouts[kind]?.poppedOut && !hiddenScopes.has(kind)),
[hiddenScopes, scopePopouts],
)
const activePopoutKindsRef = useRef<ScopeKind[]>(activePopoutKinds)
useEffect(() => {
activePopoutKindsRef.current = activePopoutKinds
}, [activePopoutKinds])
useEffect(() => {
const syncState = SCOPE_KINDS.reduce((acc, kind) => {
acc[kind] = {
shouldBeOpen: scopePopouts[kind]?.poppedOut === true && !hiddenScopes.has(kind),
bounds: scopePopouts[kind]?.windowBounds,
}
return acc
}, {} as ScopePopoutSyncStateMap)
window.electronAPI.syncScopePopouts(syncState)
}, [hiddenScopes, scopePopouts])
useEffect(() => {
for (const kind of activePopoutKinds) {
const snapshot: ScopePopoutSnapshot = {
kind,
label: SCOPE_LABELS[kind],
accent,
settings: scopeSettings[kind],
}
window.electronAPI.sendScopePopoutSnapshot(snapshot)
}
}, [accent, activePopoutKinds, scopeSettings])
useEffect(() => {
const sessionState = toPopoutSessionState(audioRouter.getSessionState())
for (const kind of activePopoutKinds) {
window.electronAPI.sendScopePopoutSession(kind, sessionState)
}
}, [activePopoutKinds])
useEffect(() => {
const unsubscribeCloseRequested = window.electronAPI.onScopePopoutCloseRequested((kind) => {
popInScope(kind)
})
const unsubscribeBoundsChanged = window.electronAPI.onScopePopoutBoundsChanged((kind, bounds) => {
updatePopoutBounds(kind, bounds)
})
const unsubscribeSettingsUpdate = window.electronAPI.onScopePopoutSettingsUpdate((kind, partial) => {
if (!isPartialSettings(partial)) return
updateScopeSettings(kind, partial as Partial<ScopeSettings[typeof kind]>)
})
const unsubscribeReady = window.electronAPI.onScopePopoutReady((kind) => {
const nextHiddenScopes = useSettingsStore.getState().hiddenScopes
const nextPopouts = useSettingsStore.getState().scopePopouts
if (!nextPopouts[kind]?.poppedOut || nextHiddenScopes.has(kind)) return
window.electronAPI.sendScopePopoutSnapshot({
kind,
label: SCOPE_LABELS[kind],
accent: useThemeStore.getState().accent,
settings: useSettingsStore.getState().scopeSettings[kind],
})
window.electronAPI.sendScopePopoutSession(kind, toPopoutSessionState(audioRouter.getSessionState()))
})
return () => {
unsubscribeCloseRequested()
unsubscribeBoundsChanged()
unsubscribeSettingsUpdate()
unsubscribeReady()
}
}, [popInScope, updatePopoutBounds, updateScopeSettings])
useEffect(() => {
for (const kind of SCOPE_KINDS) {
const consumerId = `popout:${kind}`
if (activePopoutKinds.includes(kind)) {
audioRouter.setVisualizerConsumerDemand(consumerId, buildConsumerDemand(kind))
} else {
audioRouter.clearVisualizerConsumerDemand(consumerId)
}
}
return () => {
for (const kind of SCOPE_KINDS) {
audioRouter.clearVisualizerConsumerDemand(`popout:${kind}`)
}
}
}, [activePopoutKinds])
useEffect(() => {
let frameId = 0
const flushFrame = (): void => {
for (const kind of activePopoutKindsRef.current) {
const batch = flushScopeAudioBatch(kind)
if (batch.length > 0) {
window.electronAPI.sendScopePopoutAudio(kind, batch)
}
}
frameId = window.requestAnimationFrame(flushFrame)
}
if (activePopoutKinds.length > 0) {
frameId = window.requestAnimationFrame(flushFrame)
}
return () => {
if (frameId) {
window.cancelAnimationFrame(frameId)
}
}
}, [activePopoutKinds])
useEffect(() => {
return audioRouter.subscribeToSessionChanges((state) => {
const nextSessionState = toPopoutSessionState(state)
for (const kind of activePopoutKindsRef.current) {
window.electronAPI.sendScopePopoutSession(kind, nextSessionState)
}
})
}, [])
return null
}
@@ -0,0 +1,459 @@
import type { JSX, ReactNode } from 'react'
import type { ScopeKind } from '../../types/scope'
import { SCOPE_LABELS } from '../../types/scope'
import type { ScopeSettings } from '../../types/settings'
function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string {
switch (mode) {
case 'lissajous':
return 'Lissajous'
case 'polar-unipolar':
return 'Polar Uni'
case 'polar-bipolar':
return 'Polar Bi'
case 'linear-unipolar':
return 'Linear Uni'
case 'linear-bipolar':
return 'Linear Bi'
}
}
export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string {
switch (kind) {
case 'spectrum': {
const scopeSettings = settings as ScopeSettings['spectrum']
return `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}`
}
case 'oscilloscope': {
const scopeSettings = settings as ScopeSettings['oscilloscope']
const mode = scopeSettings.pitchLock ? 'Pitch Lock' : 'Free Run'
return scopeSettings.underfillEnabled ? `${mode} · Fill` : mode
}
case 'vectorscope': {
const scopeSettings = settings as ScopeSettings['vectorscope']
return scopeSettings.multiband
? `${vectorscopeModeLabel(scopeSettings.mode)} · RGB`
: vectorscopeModeLabel(scopeSettings.mode)
}
case 'spectrogram': {
const scopeSettings = settings as ScopeSettings['spectrogram']
return `${scopeSettings.scaleMode.toUpperCase()} · ${scopeSettings.clarityMode}`
}
case 'vumeter': {
const scopeSettings = settings as ScopeSettings['vumeter']
return `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.orientation.toUpperCase()}`
}
case 'lufsmeter':
return 'Bar Meter'
case 'waveform': {
const scopeSettings = settings as ScopeSettings['waveform']
return scopeSettings.multiband
? `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB · RGB`
: `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB`
}
}
}
function ToggleChip({
label,
active,
onClick,
}: {
label: string
active: boolean
onClick: () => void
}): JSX.Element {
return (
<button
type="button"
className={`settings-chip ${active ? 'is-active' : ''}`.trim()}
onClick={onClick}
>
{label}
</button>
)
}
function SelectControl({
label,
value,
children,
onChange,
}: {
label: string
value: string | number
children: ReactNode
onChange: (value: string) => void
}): JSX.Element {
return (
<label className="settings-control">
<span className="settings-control__label">{label}</span>
<select
className="settings-control__select"
value={value}
onChange={(event) => onChange(event.target.value)}
>
{children}
</select>
</label>
)
}
function RangeControl({
label,
value,
valueLabel,
min,
max,
step,
fullWidth = true,
disabled = false,
onChange,
}: {
label: string
value: number
valueLabel: string
min: number
max: number
step: number
fullWidth?: boolean
disabled?: boolean
onChange: (value: number) => void
}): JSX.Element {
return (
<label className={`settings-control ${fullWidth ? 'settings-control--full' : ''} ${disabled ? 'is-disabled' : ''}`.trim()}>
<span className="settings-control__label">
{label}
<span className="settings-control__value">{valueLabel}</span>
</span>
<input
className="settings-control__range"
type="range"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
)
}
interface ScopeSettingsSectionProps {
kind: ScopeKind
settings: ScopeSettings[ScopeKind]
onUpdate: <K extends ScopeKind>(kind: K, partial: Partial<ScopeSettings[K]>) => void
}
export default function ScopeSettingsSection({
kind,
settings,
onUpdate,
}: ScopeSettingsSectionProps): JSX.Element {
return (
<section className="settings-scope-section">
<div className="settings-scope-section__header">
<div className="settings-scope-section__title">{SCOPE_LABELS[kind]}</div>
<div className="settings-scope-section__summary">{scopeSummary(kind, settings)}</div>
</div>
<div className="settings-scope-section__controls">
{kind === 'spectrum' && (() => {
const current = settings as ScopeSettings['spectrum']
return (
<>
<SelectControl
label="FFT Size"
value={current.fftSize}
onChange={(value) => onUpdate('spectrum', { fftSize: Number(value) })}
>
{[1024, 2048, 4096, 8192, 16384].map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</SelectControl>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Fill"
active={current.fillGradient}
onClick={() => onUpdate('spectrum', { fillGradient: !current.fillGradient })}
/>
<ToggleChip
label="Heatmap"
active={current.heatmap}
onClick={() => onUpdate('spectrum', { heatmap: !current.heatmap })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('spectrum', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Tilt"
value={current.tiltDbPerOctave}
valueLabel={`${current.tiltDbPerOctave.toFixed(1)} dB/oct`}
min={0}
max={6}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('spectrum', { tiltDbPerOctave: value })}
/>
<RangeControl
label="Heat Tilt"
value={current.heatmapTiltDbPerOctave}
valueLabel={`${current.heatmapTiltDbPerOctave.toFixed(1)} dB/oct`}
min={0}
max={6}
step={0.5}
fullWidth={false}
disabled={!current.heatmap}
onChange={(value) => onUpdate('spectrum', { heatmapTiltDbPerOctave: value })}
/>
<RangeControl
label="Smoothing"
value={current.smoothing}
valueLabel={current.smoothing.toFixed(2)}
min={0}
max={0.99}
step={0.01}
fullWidth={false}
onChange={(value) => onUpdate('spectrum', { smoothing: value })}
/>
</>
)
})()}
{kind === 'oscilloscope' && (() => {
const current = settings as ScopeSettings['oscilloscope']
return (
<>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Pitch Lock"
active={current.pitchLock}
onClick={() => onUpdate('oscilloscope', { pitchLock: !current.pitchLock })}
/>
<ToggleChip
label="Underfill"
active={current.underfillEnabled}
onClick={() => onUpdate('oscilloscope', { underfillEnabled: !current.underfillEnabled })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('oscilloscope', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Line Width"
value={current.lineWidth}
valueLabel={`${current.lineWidth.toFixed(1)} px`}
min={0.5}
max={4}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('oscilloscope', { lineWidth: value })}
/>
</>
)
})()}
{kind === 'vectorscope' && (() => {
const current = settings as ScopeSettings['vectorscope']
return (
<>
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('vectorscope', { mode: value as ScopeSettings['vectorscope']['mode'] })}
>
<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>
</SelectControl>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="RGB"
active={current.multiband}
onClick={() => onUpdate('vectorscope', { multiband: !current.multiband })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('vectorscope', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Persistence"
value={current.persistence}
valueLabel={current.persistence.toFixed(2)}
min={0}
max={0.5}
step={0.01}
fullWidth={false}
onChange={(value) => onUpdate('vectorscope', { persistence: value })}
/>
<RangeControl
label="Line Width"
value={current.lineWidth}
valueLabel={`${current.lineWidth.toFixed(1)} px`}
min={0.5}
max={4}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('vectorscope', { lineWidth: value })}
/>
</>
)
})()}
{kind === 'spectrogram' && (() => {
const current = settings as ScopeSettings['spectrogram']
return (
<>
<SelectControl
label="FFT Size"
value={current.fftSize}
onChange={(value) => onUpdate('spectrogram', { fftSize: Number(value) })}
>
{[512, 1024, 2048, 4096].map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</SelectControl>
<SelectControl
label="Scale"
value={current.scaleMode}
onChange={(value) => onUpdate('spectrogram', { scaleMode: value as ScopeSettings['spectrogram']['scaleMode'] })}
>
<option value="log">Log</option>
<option value="mel">Mel</option>
<option value="linear">Linear</option>
</SelectControl>
<SelectControl
label="Clarity"
value={current.clarityMode}
onChange={(value) => onUpdate('spectrogram', { clarityMode: value as ScopeSettings['spectrogram']['clarityMode'] })}
>
<option value="classic">Classic</option>
<option value="sharp">Sharp</option>
<option value="sharper">Sharper</option>
</SelectControl>
<SelectControl
label="Color"
value={current.colorScheme}
onChange={(value) => onUpdate('spectrogram', { colorScheme: value as ScopeSettings['spectrogram']['colorScheme'] })}
>
<option value="heat">Heat</option>
<option value="mono">Mono</option>
</SelectControl>
<RangeControl
label="Speed"
value={current.scrollSpeed}
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
min={1}
max={8}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('spectrogram', { scrollSpeed: value })}
/>
</>
)
})()}
{kind === 'vumeter' && (() => {
const current = settings as ScopeSettings['vumeter']
return (
<>
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('vumeter', { mode: value as ScopeSettings['vumeter']['mode'] })}
>
<option value="bar">Bar</option>
<option value="needle">Needle</option>
</SelectControl>
<SelectControl
label="Orientation"
value={current.orientation}
onChange={(value) => onUpdate('vumeter', { orientation: value as ScopeSettings['vumeter']['orientation'] })}
>
<option value="horizontal">Horizontal</option>
<option value="vertical">Vertical</option>
</SelectControl>
</>
)
})()}
{kind === 'lufsmeter' && (() => {
const current = settings as ScopeSettings['lufsmeter']
return (
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('lufsmeter', { mode: value as ScopeSettings['lufsmeter']['mode'] })}
>
<option value="bar">Bar</option>
</SelectControl>
)
})()}
{kind === 'waveform' && (() => {
const current = settings as ScopeSettings['waveform']
return (
<>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Multiband"
active={current.multiband}
onClick={() => onUpdate('waveform', { multiband: !current.multiband })}
/>
</div>
<RangeControl
label="Gain"
value={current.gainDb}
valueLabel={`${current.gainDb > 0 ? '+' : ''}${current.gainDb.toFixed(0)} dB`}
min={-12}
max={12}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('waveform', { gainDb: value })}
/>
<RangeControl
label="Speed"
value={current.scrollSpeed}
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
min={1}
max={8}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('waveform', { scrollSpeed: value })}
/>
</>
)
})()}
</div>
</section>
)
}
+11 -476
View File
@@ -1,494 +1,29 @@
import { useMemo, type CSSProperties, type JSX, type ReactNode } from 'react'
import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
import type { ScopeKind } from '../../types/scope'
import { useMemo, type CSSProperties, type JSX } from 'react'
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'Spectrum',
oscilloscope: 'Oscilloscope',
vectorscope: 'Vectorscope',
spectrogram: 'Spectrogram',
vumeter: 'VU Meter',
lufsmeter: 'LUFS Meter',
waveform: 'Waveform',
}
function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string {
switch (mode) {
case 'lissajous':
return 'Lissajous'
case 'polar-unipolar':
return 'Polar Uni'
case 'polar-bipolar':
return 'Polar Bi'
case 'linear-unipolar':
return 'Linear Uni'
case 'linear-bipolar':
return 'Linear Bi'
}
}
function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string {
switch (kind) {
case 'spectrum': {
const scopeSettings = settings as ScopeSettings['spectrum']
return `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}`
}
case 'oscilloscope': {
const scopeSettings = settings as ScopeSettings['oscilloscope']
const mode = scopeSettings.pitchLock ? 'Pitch Lock' : 'Free Run'
return scopeSettings.underfillEnabled ? `${mode} · Fill` : mode
}
case 'vectorscope': {
const scopeSettings = settings as ScopeSettings['vectorscope']
return scopeSettings.multiband
? `${vectorscopeModeLabel(scopeSettings.mode)} · RGB`
: vectorscopeModeLabel(scopeSettings.mode)
}
case 'spectrogram': {
const scopeSettings = settings as ScopeSettings['spectrogram']
return `${scopeSettings.scaleMode.toUpperCase()} · ${scopeSettings.clarityMode}`
}
case 'vumeter': {
const scopeSettings = settings as ScopeSettings['vumeter']
return `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.orientation.toUpperCase()}`
}
case 'lufsmeter':
return 'Bar Meter'
case 'waveform': {
const scopeSettings = settings as ScopeSettings['waveform']
return scopeSettings.multiband
? `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB · RGB`
: `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB`
}
}
}
function ToggleChip({
label,
active,
onClick,
}: {
label: string
active: boolean
onClick: () => void
}): JSX.Element {
return (
<button
type="button"
className={`settings-chip ${active ? 'is-active' : ''}`.trim()}
onClick={onClick}
>
{label}
</button>
)
}
function SelectControl({
label,
value,
children,
onChange,
}: {
label: string
value: string | number
children: ReactNode
onChange: (value: string) => void
}): JSX.Element {
return (
<label className="settings-control">
<span className="settings-control__label">{label}</span>
<select
className="settings-control__select"
value={value}
onChange={(event) => onChange(event.target.value)}
>
{children}
</select>
</label>
)
}
function RangeControl({
label,
value,
valueLabel,
min,
max,
step,
fullWidth = true,
disabled = false,
onChange,
}: {
label: string
value: number
valueLabel: string
min: number
max: number
step: number
fullWidth?: boolean
disabled?: boolean
onChange: (value: number) => void
}): JSX.Element {
return (
<label className={`settings-control ${fullWidth ? 'settings-control--full' : ''} ${disabled ? 'is-disabled' : ''}`.trim()}>
<span className="settings-control__label">
{label}
<span className="settings-control__value">{valueLabel}</span>
</span>
<input
className="settings-control__range"
type="range"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
)
}
function ScopeSettingsSection({
kind,
settings,
onUpdate,
}: {
kind: ScopeKind
settings: ScopeSettings
onUpdate: <K extends ScopeKind>(kind: K, partial: Partial<ScopeSettings[K]>) => void
}): JSX.Element {
const scopeSettings = settings[kind]
return (
<section className="settings-scope-section">
<div className="settings-scope-section__header">
<div className="settings-scope-section__title">{SCOPE_LABELS[kind]}</div>
<div className="settings-scope-section__summary">{scopeSummary(kind, scopeSettings)}</div>
</div>
<div className="settings-scope-section__controls">
{kind === 'spectrum' && (() => {
const current = scopeSettings as ScopeSettings['spectrum']
return (
<>
<SelectControl
label="FFT Size"
value={current.fftSize}
onChange={(value) => onUpdate('spectrum', { fftSize: Number(value) })}
>
{[1024, 2048, 4096, 8192, 16384].map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</SelectControl>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Fill"
active={current.fillGradient}
onClick={() => onUpdate('spectrum', { fillGradient: !current.fillGradient })}
/>
<ToggleChip
label="Heatmap"
active={current.heatmap}
onClick={() => onUpdate('spectrum', { heatmap: !current.heatmap })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('spectrum', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Tilt"
value={current.tiltDbPerOctave}
valueLabel={`${current.tiltDbPerOctave.toFixed(1)} dB/oct`}
min={0}
max={6}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('spectrum', { tiltDbPerOctave: value })}
/>
<RangeControl
label="Heat Tilt"
value={current.heatmapTiltDbPerOctave}
valueLabel={`${current.heatmapTiltDbPerOctave.toFixed(1)} dB/oct`}
min={0}
max={6}
step={0.5}
fullWidth={false}
disabled={!current.heatmap}
onChange={(value) => onUpdate('spectrum', { heatmapTiltDbPerOctave: value })}
/>
<RangeControl
label="Smoothing"
value={current.smoothing}
valueLabel={current.smoothing.toFixed(2)}
min={0}
max={0.99}
step={0.01}
fullWidth={false}
onChange={(value) => onUpdate('spectrum', { smoothing: value })}
/>
</>
)
})()}
{kind === 'oscilloscope' && (() => {
const current = scopeSettings as ScopeSettings['oscilloscope']
return (
<>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Pitch Lock"
active={current.pitchLock}
onClick={() => onUpdate('oscilloscope', { pitchLock: !current.pitchLock })}
/>
<ToggleChip
label="Underfill"
active={current.underfillEnabled}
onClick={() => onUpdate('oscilloscope', { underfillEnabled: !current.underfillEnabled })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('oscilloscope', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Line Width"
value={current.lineWidth}
valueLabel={`${current.lineWidth.toFixed(1)} px`}
min={0.5}
max={4}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('oscilloscope', { lineWidth: value })}
/>
</>
)
})()}
{kind === 'vectorscope' && (() => {
const current = scopeSettings as ScopeSettings['vectorscope']
return (
<>
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('vectorscope', { mode: value as ScopeSettings['vectorscope']['mode'] })}
>
<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>
</SelectControl>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="RGB"
active={current.multiband}
onClick={() => onUpdate('vectorscope', { multiband: !current.multiband })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('vectorscope', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Persistence"
value={current.persistence}
valueLabel={current.persistence.toFixed(2)}
min={0}
max={0.5}
step={0.01}
fullWidth={false}
onChange={(value) => onUpdate('vectorscope', { persistence: value })}
/>
<RangeControl
label="Line Width"
value={current.lineWidth}
valueLabel={`${current.lineWidth.toFixed(1)} px`}
min={0.5}
max={4}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('vectorscope', { lineWidth: value })}
/>
</>
)
})()}
{kind === 'spectrogram' && (() => {
const current = scopeSettings as ScopeSettings['spectrogram']
return (
<>
<SelectControl
label="FFT Size"
value={current.fftSize}
onChange={(value) => onUpdate('spectrogram', { fftSize: Number(value) })}
>
{[512, 1024, 2048, 4096].map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</SelectControl>
<SelectControl
label="Scale"
value={current.scaleMode}
onChange={(value) => onUpdate('spectrogram', { scaleMode: value as ScopeSettings['spectrogram']['scaleMode'] })}
>
<option value="log">Log</option>
<option value="mel">Mel</option>
<option value="linear">Linear</option>
</SelectControl>
<SelectControl
label="Clarity"
value={current.clarityMode}
onChange={(value) => onUpdate('spectrogram', { clarityMode: value as ScopeSettings['spectrogram']['clarityMode'] })}
>
<option value="classic">Classic</option>
<option value="sharp">Sharp</option>
<option value="sharper">Sharper</option>
</SelectControl>
<SelectControl
label="Color"
value={current.colorScheme}
onChange={(value) => onUpdate('spectrogram', { colorScheme: value as ScopeSettings['spectrogram']['colorScheme'] })}
>
<option value="heat">Heat</option>
<option value="mono">Mono</option>
</SelectControl>
<RangeControl
label="Speed"
value={current.scrollSpeed}
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
min={1}
max={8}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('spectrogram', { scrollSpeed: value })}
/>
</>
)
})()}
{kind === 'vumeter' && (() => {
const current = scopeSettings as ScopeSettings['vumeter']
return (
<>
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('vumeter', { mode: value as ScopeSettings['vumeter']['mode'] })}
>
<option value="bar">Bar</option>
<option value="needle">Needle</option>
</SelectControl>
<SelectControl
label="Orientation"
value={current.orientation}
onChange={(value) => onUpdate('vumeter', { orientation: value as ScopeSettings['vumeter']['orientation'] })}
>
<option value="horizontal">Horizontal</option>
<option value="vertical">Vertical</option>
</SelectControl>
</>
)
})()}
{kind === 'lufsmeter' && (() => {
const current = scopeSettings as ScopeSettings['lufsmeter']
return (
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('lufsmeter', { mode: value as ScopeSettings['lufsmeter']['mode'] })}
>
<option value="bar">Bar</option>
</SelectControl>
)
})()}
{kind === 'waveform' && (() => {
const current = scopeSettings as ScopeSettings['waveform']
return (
<>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Multiband"
active={current.multiband}
onClick={() => onUpdate('waveform', { multiband: !current.multiband })}
/>
</div>
<RangeControl
label="Gain"
value={current.gainDb}
valueLabel={`${current.gainDb > 0 ? '+' : ''}${current.gainDb.toFixed(0)} dB`}
min={-12}
max={12}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('waveform', { gainDb: value })}
/>
<RangeControl
label="Speed"
value={current.scrollSpeed}
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
min={1}
max={8}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('waveform', { scrollSpeed: value })}
/>
</>
)
})()}
</div>
</section>
)
}
import ScopeSettingsSection from './ScopeSettingsSection'
import { useSettingsStore } from '../stores/settingsStore'
export default function SettingsPanel(): JSX.Element {
const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights } = useSettingsStore()
const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights, scopePopouts } = useSettingsStore()
const visibleScopes = useMemo(
() => scopeOrder.filter((kind) => !hiddenScopes.has(kind)),
[scopeOrder, hiddenScopes],
const dockedScopes = useMemo(
() => scopeOrder.filter((kind) => !hiddenScopes.has(kind) && !scopePopouts[kind]?.poppedOut),
[hiddenScopes, scopeOrder, scopePopouts],
)
const scopeTrackStyle = useMemo(() => {
const gridTemplateColumns = buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights)
const gridTemplateColumns = buildAnalyzerGridTemplateColumns(dockedScopes, widthWeights)
if (!gridTemplateColumns) return undefined
return { gridTemplateColumns } as CSSProperties
}, [visibleScopes, widthWeights])
}, [dockedScopes, widthWeights])
return (
<div className="settings-panel">
<div className="settings-panel__scope-track" style={scopeTrackStyle}>
{visibleScopes.map((kind) => (
{dockedScopes.map((kind) => (
<ScopeSettingsSection
key={kind}
kind={kind}
settings={scopeSettings}
settings={scopeSettings[kind]}
onUpdate={updateScopeSettings}
/>
))}
+56 -22
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope'
import { SCOPE_LABELS, type ScopeKind } from '../../types/scope'
import type { WindowBounds } from '../../types/popout'
import ScopeModule from './ScopeModule'
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
import { audioRouter } from '../audio/AudioRouter'
@@ -9,44 +10,46 @@ import { audioRouter } from '../audio/AudioRouter'
export default function Strip(): JSX.Element {
const scopeOrder = useSettingsStore((s) => s.scopeOrder)
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const scopePopouts = useSettingsStore((s) => s.scopePopouts)
const widthWeights = useSettingsStore((s) => s.widthWeights)
const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight)
const popOutScope = useSettingsStore((s) => s.popOutScope)
const accent = useThemeStore((s) => s.accent)
const stripRef = useRef<HTMLDivElement>(null)
const gridRef = useRef<HTMLDivElement>(null)
const scopeRefs = useRef<Partial<Record<ScopeKind, HTMLDivElement | null>>>({})
const [handleOffsets, setHandleOffsets] = useState<number[]>([])
const visibleScopes = useMemo(
() => scopeOrder.filter((k) => !hiddenScopes.has(k)),
[hiddenScopes, scopeOrder],
const dockedScopes = useMemo(
() => scopeOrder.filter((k) => !hiddenScopes.has(k) && !scopePopouts[k]?.poppedOut),
[hiddenScopes, scopeOrder, scopePopouts],
)
const visibleScopeKey = useMemo(() => visibleScopes.join('|'), [visibleScopes])
const visibleScopeKey = useMemo(() => dockedScopes.join('|'), [dockedScopes])
const gridTemplateColumns = useMemo(() => {
return buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights)
}, [visibleScopes, widthWeights])
return buildAnalyzerGridTemplateColumns(dockedScopes, widthWeights)
}, [dockedScopes, widthWeights])
const gridStyle = useMemo(() => {
if (!gridTemplateColumns) return undefined
return { gridTemplateColumns } as CSSProperties
}, [gridTemplateColumns])
const updateHandleOffsets = useCallback((): void => {
if (visibleScopes.length < 2) {
if (dockedScopes.length < 2) {
setHandleOffsets([])
return
}
const nextOffsets: number[] = []
for (let index = 0; index < visibleScopes.length - 1; index += 1) {
const leftElement = scopeRefs.current[visibleScopes[index]]
for (let index = 0; index < dockedScopes.length - 1; index += 1) {
const leftElement = scopeRefs.current[dockedScopes[index]]
if (!leftElement) continue
nextOffsets.push(leftElement.offsetLeft + leftElement.offsetWidth)
}
setHandleOffsets(nextOffsets)
}, [visibleScopes])
}, [dockedScopes])
useEffect(() => {
const visibleScopeSet = new Set(visibleScopes)
const visibleScopeSet = new Set(dockedScopes)
audioRouter.setVisualizerConsumerDemand('docked-strip', {
spectrum: visibleScopeSet.has('spectrum'),
oscilloscope: visibleScopeSet.has('oscilloscope'),
@@ -60,7 +63,7 @@ export default function Strip(): JSX.Element {
return () => {
audioRouter.clearVisualizerConsumerDemand('docked-strip')
}
}, [visibleScopeKey, visibleScopes])
}, [visibleScopeKey, dockedScopes])
useEffect(() => {
const strip = stripRef.current
@@ -97,7 +100,7 @@ export default function Strip(): JSX.Element {
observer?.observe(gridRef.current)
}
for (const scope of visibleScopes) {
for (const scope of dockedScopes) {
const element = scopeRefs.current[scope]
if (element) {
observer?.observe(element)
@@ -110,11 +113,11 @@ export default function Strip(): JSX.Element {
observer?.disconnect()
window.removeEventListener('resize', updateHandleOffsets)
}
}, [gridTemplateColumns, updateHandleOffsets, visibleScopes])
}, [dockedScopes, gridTemplateColumns, updateHandleOffsets])
const startResizeDrag = useCallback((handleIndex: number, event: React.MouseEvent<HTMLButtonElement>) => {
const leftKind = visibleScopes[handleIndex]
const rightKind = visibleScopes[handleIndex + 1]
const leftKind = dockedScopes[handleIndex]
const rightKind = dockedScopes[handleIndex + 1]
if (!leftKind || !rightKind) return
const leftElement = scopeRefs.current[leftKind]
@@ -164,12 +167,30 @@ export default function Strip(): JSX.Element {
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}, [setScopeWidthWeight, visibleScopes])
}, [dockedScopes, setScopeWidthWeight])
const handlePopoutScope = useCallback(async (kind: ScopeKind): Promise<void> => {
const element = scopeRefs.current[kind]
const rect = element?.getBoundingClientRect()
const windowBounds = await window.electronAPI.getWindowBounds()
let nextBounds: WindowBounds | undefined
if (rect && windowBounds) {
nextBounds = {
x: Math.round(windowBounds.x + rect.left),
y: Math.round(windowBounds.y + rect.top),
width: Math.max(220, Math.round(rect.width)),
height: Math.max(160, Math.round(rect.height)),
}
}
popOutScope(kind, nextBounds)
}, [popOutScope])
return (
<div ref={stripRef} className="scope-strip">
<div ref={gridRef} className="scope-strip__grid" style={gridStyle}>
{visibleScopes.map((kind) => (
{dockedScopes.map((kind) => (
<div
key={kind}
ref={(element) => {
@@ -177,6 +198,19 @@ export default function Strip(): JSX.Element {
}}
className="scope-strip__cell"
>
<button
type="button"
className="scope-strip__popout-button"
onClick={() => {
void handlePopoutScope(kind)
}}
aria-label={`Pop out ${SCOPE_LABELS[kind]}`}
title={`Pop out ${SCOPE_LABELS[kind]}`}
>
<span className="scope-strip__popout-icon" aria-hidden="true">
&#8599;
</span>
</button>
<ScopeModule
scopeKind={kind}
lineColor={accent}
@@ -185,14 +219,14 @@ export default function Strip(): JSX.Element {
))}
</div>
{visibleScopes.length > 1 && handleOffsets.map((offset, index) => (
{dockedScopes.length > 1 && handleOffsets.map((offset, index) => (
<button
key={`${visibleScopes[index]}:${visibleScopes[index + 1]}`}
key={`${dockedScopes[index]}:${dockedScopes[index + 1]}`}
type="button"
className="scope-strip__resize-handle"
style={{ left: `${offset}px` }}
onMouseDown={(event) => startResizeDrag(index, event)}
aria-label={`Resize between ${visibleScopes[index]} and ${visibleScopes[index + 1]}`}
aria-label={`Resize between ${dockedScopes[index]} and ${dockedScopes[index + 1]}`}
>
<span className="scope-strip__resize-handle-grip" aria-hidden="true" />
</button>
+85 -137
View File
@@ -68,6 +68,8 @@ interface ToolbarProps {
settingsOpen: boolean
}
const DEFAULT_PROFILE_ID = 'profile_default'
export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): JSX.Element {
const profiles = useSettingsStore((s) => s.profiles)
const activeProfileId = useSettingsStore((s) => s.activeProfileId)
@@ -78,11 +80,8 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
const updateActiveProfile = useSettingsStore((s) => s.updateActiveProfile)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(true)
const [showReposition, setShowReposition] = useState(false)
const [showProfileMenu, setShowProfileMenu] = useState(false)
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const profileMenuRef = useRef<HTMLDivElement>(null)
const renameInputRef = useRef<HTMLInputElement>(null)
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false)
const profileButtonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
@@ -90,26 +89,69 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
return unsubscribe
}, [])
// Close profile menu on outside click
useEffect(() => {
if (!showProfileMenu) return
const handleClick = (e: MouseEvent): void => {
if (profileMenuRef.current && !profileMenuRef.current.contains(e.target as Node)) {
setShowProfileMenu(false)
setRenamingId(null)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [showProfileMenu])
const handleSaveNew = useCallback(() => {
const count = Object.keys(useSettingsStore.getState().profiles).length
saveProfile(`Profile ${count}`)
setIsProfileMenuOpen(false)
}, [saveProfile])
// Focus rename input when it appears
useEffect(() => {
if (renamingId && renameInputRef.current) {
renameInputRef.current.focus()
renameInputRef.current.select()
const handleSaveOverwrite = useCallback(() => {
updateActiveProfile()
setIsProfileMenuOpen(false)
}, [updateActiveProfile])
const handleRenameActive = useCallback((id: string) => {
const profile = useSettingsStore.getState().profiles[id]
if (!profile || id === DEFAULT_PROFILE_ID) {
setIsProfileMenuOpen(false)
return
}
}, [renamingId])
const nextName = window.prompt('Rename preset', profile.name)?.trim()
if (nextName) {
renameProfile(id, nextName)
}
setIsProfileMenuOpen(false)
}, [renameProfile])
const handleDeleteActive = useCallback((id: string) => {
const profile = useSettingsStore.getState().profiles[id]
if (!profile || id === DEFAULT_PROFILE_ID) {
setIsProfileMenuOpen(false)
return
}
if (!window.confirm(`Delete "${profile.name}"?`)) {
setIsProfileMenuOpen(false)
return
}
deleteProfile(id)
setIsProfileMenuOpen(false)
}, [deleteProfile])
useEffect(() => {
const offClosed = window.electronAPI.onProfileMenuClosed(() => {
setIsProfileMenuOpen(false)
})
const offLoad = window.electronAPI.onProfileMenuLoad((id) => {
loadProfile(id)
setIsProfileMenuOpen(false)
})
const offSaveNew = window.electronAPI.onProfileMenuSaveNew(handleSaveNew)
const offSaveOverwrite = window.electronAPI.onProfileMenuSaveOverwrite(handleSaveOverwrite)
const offRename = window.electronAPI.onProfileMenuRenameActive(handleRenameActive)
const offDelete = window.electronAPI.onProfileMenuDeleteActive(handleDeleteActive)
return () => {
offClosed()
offLoad()
offSaveNew()
offSaveOverwrite()
offRename()
offDelete()
}
}, [handleDeleteActive, handleRenameActive, handleSaveNew, handleSaveOverwrite, loadProfile])
const handlePin = useCallback(() => {
window.electronAPI.toggleAlwaysOnTop()
@@ -120,30 +162,24 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
setShowReposition(false)
}, [])
const handleSaveNew = useCallback(() => {
const count = Object.keys(profiles).length
saveProfile(`Profile ${count}`)
setShowProfileMenu(false)
}, [profiles, saveProfile])
const handleOpenProfileMenu = useCallback(() => {
const buttonRect = profileButtonRef.current?.getBoundingClientRect()
if (!buttonRect) return
const handleSaveOverwrite = useCallback(() => {
updateActiveProfile()
setShowProfileMenu(false)
}, [updateActiveProfile])
setShowReposition(false)
setIsProfileMenuOpen(true)
window.electronAPI.openProfileMenu({
x: Math.round(buttonRect.left),
y: Math.round(buttonRect.bottom + 4),
activeProfileId,
profiles: Object.entries(profiles).map(([id, profile]) => ({
id,
name: profile.name,
isDefault: id === DEFAULT_PROFILE_ID,
})),
})
}, [activeProfileId, profiles])
const handleStartRename = useCallback((id: string, currentName: string) => {
setRenamingId(id)
setRenameValue(currentName)
}, [])
const handleFinishRename = useCallback(() => {
if (renamingId && renameValue.trim()) {
renameProfile(renamingId, renameValue.trim())
}
setRenamingId(null)
}, [renamingId, renameValue, renameProfile])
const profileIds = Object.keys(profiles)
const activeProfile = activeProfileId ? profiles[activeProfileId] : null
return (
@@ -164,11 +200,12 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
<span className="toolbar__brand-text">Prism</span>
</div>
<div className="toolbar__profile" ref={profileMenuRef}>
<div className="toolbar__profile">
<button
ref={profileButtonRef}
type="button"
className={`toolbar__profile-button ${showProfileMenu ? 'is-active' : ''}`.trim()}
onClick={() => setShowProfileMenu((prev) => !prev)}
className={`toolbar__profile-button ${isProfileMenuOpen ? 'is-active' : ''}`.trim()}
onClick={handleOpenProfileMenu}
title="Presets"
>
<span className="toolbar__profile-name">
@@ -176,95 +213,6 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
</span>
<ChevronIcon />
</button>
{showProfileMenu && (
<div className="toolbar__profile-menu">
<div className="toolbar__profile-menu-section">
<div className="toolbar__profile-menu-label">Presets</div>
{profileIds.map((id) => {
const profile = profiles[id]
const isActive = id === activeProfileId
const isDefault = id === 'profile_default'
if (renamingId === id) {
return (
<div key={id} className="toolbar__profile-menu-item">
<input
ref={renameInputRef}
className="toolbar__profile-rename-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishRename()
if (e.key === 'Escape') setRenamingId(null)
}}
/>
</div>
)
}
return (
<div
key={id}
className={`toolbar__profile-menu-item ${isActive ? 'is-active' : ''}`.trim()}
>
<button
type="button"
className="toolbar__profile-menu-item-name"
onClick={() => {
loadProfile(id)
setShowProfileMenu(false)
}}
>
{isActive && <span className="toolbar__profile-check">&#10003;</span>}
{profile.name}
</button>
{!isDefault && (
<div className="toolbar__profile-menu-item-actions">
<button
type="button"
className="toolbar__profile-menu-action"
onClick={() => handleStartRename(id, profile.name)}
title="Rename"
>
&#9998;
</button>
<button
type="button"
className="toolbar__profile-menu-action toolbar__profile-menu-action--danger"
onClick={() => deleteProfile(id)}
title="Delete"
>
&times;
</button>
</div>
)}
</div>
)
})}
</div>
<div className="toolbar__profile-menu-divider" />
<button
type="button"
className="toolbar__profile-menu-action-row"
onClick={handleSaveNew}
>
Save as New Preset
</button>
{activeProfileId && (
<button
type="button"
className="toolbar__profile-menu-action-row"
onClick={handleSaveOverwrite}
>
Save to "{activeProfile?.name}"
</button>
)}
</div>
)}
</div>
<div