initial commit for theme engine

This commit is contained in:
Boof2015
2026-03-28 18:00:12 -04:00
parent a38b54b5e2
commit ee234cad7c
30 changed files with 2585 additions and 337 deletions
+103 -28
View File
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, type CSSProperties, type JSX } from
import { useAudioStore } from '../stores/audioStore'
import { usePerformanceStore } from '../stores/performanceStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore'
import { useThemeStore } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope'
import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance'
import { SCOPE_KINDS } from '../../types/scope'
@@ -39,7 +39,18 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
const frameTarget = usePerformanceStore((s) => s.frameTarget)
const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps)
const setFrameTarget = usePerformanceStore((s) => s.setFrameTarget)
const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore()
const themeId = useSettingsStore((s) => s.themeId)
const setThemeId = useSettingsStore((s) => s.setThemeId)
const {
themes,
activeThemeId,
loadTheme,
renameTheme,
deleteTheme,
reloadThemes,
importThemeFromDialog,
showThemesFolder,
} = useThemeStore()
const {
systemSources,
@@ -128,6 +139,32 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
const trimPercent = Math.min(100, Math.max(0, ((inputGainDb + 12) / 24) * 100))
const roundedDockedRenderFps = Math.max(0, Math.round(dockedRenderFps))
const themeEntries = Object.entries(themes)
const handleThemeChange = async (value: string): Promise<void> => {
await loadTheme(value)
setThemeId(value)
}
const handleRenameTheme = async (): Promise<void> => {
if (!activeThemeId || activeThemeId === 'theme_default') return
const activeTheme = themes[activeThemeId]
if (!activeTheme) return
const nextName = window.prompt('Rename theme', activeTheme.name)?.trim()
if (!nextName) return
await renameTheme(activeThemeId, nextName)
}
const handleDeleteTheme = async (): Promise<void> => {
if (!activeThemeId || activeThemeId === 'theme_default') return
const activeTheme = themes[activeThemeId]
if (!activeTheme) return
if (!window.confirm(`Delete "${activeTheme.name}"?`)) return
await deleteTheme(activeThemeId)
setThemeId(useThemeStore.getState().activeThemeId)
}
return (
<div className="bottom-bar" ref={rootRef}>
@@ -161,37 +198,75 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
<div className="bottom-bar__section-title">Theme</div>
<div className="bottom-bar__section-body">
<div className="bottom-bar__inline bottom-bar__inline--theme">
{PRESET_IDS.map((id) => {
const preset = PRESETS[id]
const active = presetId === id && !customAccent
return (
<button
key={id}
type="button"
className={`settings-swatch ${active ? 'is-active' : ''}`.trim()}
style={{ '--swatch-color': preset.accent } as CSSProperties}
onClick={() => setPreset(id)}
title={preset.name}
aria-label={preset.name}
/>
)
})}
<input
className="settings-accent-input"
type="color"
value={accent}
onChange={(event) => setCustomAccent(event.target.value)}
title="Custom accent color"
/>
{customAccent && (
<select
className="settings-control__select"
value={activeThemeId ?? ''}
onChange={(event) => {
void handleThemeChange(event.target.value)
}}
>
{themeEntries.map(([id, theme]) => (
<option key={id} value={id}>
{theme.name}
</option>
))}
</select>
<button
type="button"
className="settings-chip"
onClick={() => {
void (async () => {
await importThemeFromDialog()
setThemeId(useThemeStore.getState().activeThemeId)
})()
}}
>
Import
</button>
<button
type="button"
className="settings-chip"
onClick={() => {
void reloadThemes()
}}
>
Reload
</button>
<button
type="button"
className="settings-chip"
onClick={() => {
void showThemesFolder()
}}
>
Folder
</button>
{activeThemeId && activeThemeId !== 'theme_default' ? (
<button
type="button"
className="settings-chip"
onClick={() => setCustomAccent(null)}
onClick={() => {
void handleRenameTheme()
}}
>
Reset
Rename
</button>
)}
) : null}
{activeThemeId && activeThemeId !== 'theme_default' ? (
<button
type="button"
className="settings-chip"
onClick={() => {
void handleDeleteTheme()
}}
>
Delete
</button>
) : null}
<div className="settings-status-pill">
<span className="settings-status-pill__dot" />
<span>{themeId ? 'Saved With Profile' : 'Not Linked'}</span>
</div>
</div>
</div>
</section>
+111 -24
View File
@@ -1,7 +1,18 @@
import { useEffect, useRef, type JSX } from 'react'
import type { ScopeKind } from '../../types/scope'
import type { ScopeSettings } from '../../types/settings'
import type {
PrismResolvedTheme,
ResolvedLUFSMeterTheme,
ResolvedOscilloscopeTheme,
ResolvedSpectrogramTheme,
ResolvedSpectrumTheme,
ResolvedVectorscopeTheme,
ResolvedVUMeterTheme,
ResolvedWaveformTheme,
} from '../../types/theme'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import { SpectrumAnalyzer, type SpectrumAnalyzerDataSource } from '../visualizers/SpectrumAnalyzer'
import { Oscilloscope, type OscilloscopeDataSource } from '../visualizers/Oscilloscope'
import { Vectorscope, type VectorscopeDataSource } from '../visualizers/Vectorscope'
@@ -11,9 +22,18 @@ import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter'
import { Waveform, type WaveformDataSource } from '../visualizers/Waveform'
import type { FrameScheduler } from '../visualizers/frameScheduler'
type ScopeModuleTheme =
| ResolvedSpectrumTheme
| ResolvedOscilloscopeTheme
| ResolvedVectorscopeTheme
| ResolvedSpectrogramTheme
| ResolvedVUMeterTheme
| ResolvedLUFSMeterTheme
| ResolvedWaveformTheme
interface ScopeModuleProps {
scopeKind: ScopeKind
lineColor?: string
theme?: ScopeModuleTheme
settings?: ScopeSettings[ScopeKind]
frameScheduler?: FrameScheduler
dataSource?:
@@ -34,14 +54,25 @@ interface Visualizer {
setOptions(options: Record<string, unknown>): void
}
/** 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 }
function getScopeTheme(theme: PrismResolvedTheme, kind: ScopeKind): ScopeModuleTheme {
return theme[kind] as ScopeModuleTheme
}
function scopeSettingsToOptions(
kind: ScopeKind,
settings: ScopeSettings[ScopeKind],
theme: ScopeModuleTheme,
): Record<string, unknown> {
switch (kind) {
case 'spectrum': {
const s = settings as ScopeSettings['spectrum']
const t = theme as ResolvedSpectrumTheme
return {
...base,
lineColor: t.primary,
gradientColors: t.fillGradient,
heatColors: t.heatColors,
backgroundColor: t.background,
gridColor: t.guides,
fftSize: s.fftSize,
tiltDbPerOctave: s.tiltDbPerOctave,
heatmapFill: s.heatmap,
@@ -53,12 +84,30 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi
}
case 'oscilloscope': {
const s = settings as ScopeSettings['oscilloscope']
return { ...base, pitchLock: s.pitchLock, underfillEnabled: s.underfillEnabled, showGrid: s.showGrid, lineWidth: s.lineWidth }
const t = theme as ResolvedOscilloscopeTheme
return {
lineColor: t.primary,
backgroundColor: t.background,
gridColor: t.guides,
underfillColor: t.fill,
pitchLock: s.pitchLock,
underfillEnabled: s.underfillEnabled,
showGrid: s.showGrid,
lineWidth: s.lineWidth,
}
}
case 'vectorscope': {
const s = settings as ScopeSettings['vectorscope']
const t = theme as ResolvedVectorscopeTheme
return {
...base,
lineColor: t.primary,
backgroundColor: t.background,
gridColor: t.guides,
bandColors: {
low: t.lowBand,
mid: t.midBand,
high: t.highBand,
},
mode: s.mode,
multiband: s.multiband,
showGrid: s.showGrid,
@@ -68,22 +117,60 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi
}
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 }
const t = theme as ResolvedSpectrogramTheme
return {
lineColor: t.primary,
heatColors: t.heatColors,
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 }
const t = theme as ResolvedVUMeterTheme
return {
lineColor: t.primary,
peakColor: t.peak,
clipColor: t.clip,
scaleColor: t.guides,
labelColor: t.text,
mode: s.mode,
orientation: s.orientation,
}
}
case 'lufsmeter': {
const s = settings as ScopeSettings['lufsmeter']
return { ...base, mode: s.mode }
const t = theme as ResolvedLUFSMeterTheme
return {
lineColor: t.primary,
targetColor: t.target,
scaleColor: t.guides,
labelColor: t.text,
mode: s.mode,
}
}
case 'waveform': {
const s = settings as ScopeSettings['waveform']
return { ...base, scrollSpeed: s.scrollSpeed, gainDb: s.gainDb, multiband: s.multiband }
const t = theme as ResolvedWaveformTheme
return {
lineColor: t.primary,
gridMajorColor: t.guides,
gridMinorColor: t.guides,
bandColors: {
low: t.lowBand,
mid: t.midBand,
high: t.highBand,
},
scrollSpeed: s.scrollSpeed,
gainDb: s.gainDb,
multiband: s.multiband,
}
}
default:
return base
return {}
}
}
@@ -91,11 +178,11 @@ function createVisualizer(
scopeKind: ScopeKind,
canvas: HTMLCanvasElement,
mySettings: ScopeSettings[ScopeKind],
lineColor: string,
theme: ScopeModuleTheme,
frameScheduler?: FrameScheduler,
dataSource?: ScopeModuleProps['dataSource'],
): Visualizer | null {
const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, lineColor), frameScheduler }
const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, theme), frameScheduler }
switch (scopeKind) {
case 'spectrum':
return new SpectrumAnalyzer(canvas, {
@@ -139,7 +226,7 @@ function createVisualizer(
export default function ScopeModule({
scopeKind,
lineColor = '#38bdf8',
theme,
settings,
frameScheduler,
dataSource,
@@ -150,42 +237,42 @@ export default function ScopeModule({
const initializedRef = useRef(false)
const storeSettings = useSettingsStore((s) => s.scopeSettings[scopeKind])
const activeTheme = useThemeStore((s) => s.activeTheme)
const mySettings = settings ?? storeSettings
const myTheme = theme ?? getScopeTheme(activeTheme, scopeKind)
// Initialize visualizer
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
initializedRef.current = false
const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor, frameScheduler, dataSource)
const viz = createVisualizer(scopeKind, canvas, mySettings, myTheme, frameScheduler, dataSource)
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 })
requestAnimationFrame(() => {
initializedRef.current = true
})
return () => {
viz.dispose()
visualizerRef.current = null
initializedRef.current = false
}
}, [dataSource, frameScheduler, scopeKind])
}, [dataSource, frameScheduler, myTheme, mySettings, 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),
...scopeSettingsToOptions(scopeKind, mySettings, myTheme),
frameScheduler,
...(dataSource ? { dataSource } : {}),
}
visualizerRef.current.setOptions(opts)
}, [dataSource, frameScheduler, lineColor, mySettings, scopeKind])
}, [dataSource, frameScheduler, mySettings, myTheme, scopeKind])
// ResizeObserver for DPI-aware canvas sizing
useEffect(() => {
const container = containerRef.current
const canvas = canvasRef.current
@@ -60,7 +60,7 @@ export default function ScopePopoutBridge(): null {
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 activeTheme = useThemeStore((s) => s.activeTheme)
const frameTarget = usePerformanceStore((s) => s.frameTarget)
const activePopoutKinds = useMemo(
@@ -96,12 +96,13 @@ export default function ScopePopoutBridge(): null {
const snapshot: ScopePopoutSnapshot = {
kind,
label: SCOPE_LABELS[kind],
accent,
interfaceTheme: activeTheme.interface,
scopeTheme: activeTheme[kind],
settings: scopeSettings[kind],
}
window.electronAPI.sendScopePopoutSnapshot(snapshot)
}
}, [accent, activePopoutKinds, scopeSettings])
}, [activePopoutKinds, activeTheme, scopeSettings])
useEffect(() => {
const sessionState = toPopoutSessionState(audioRouter.getSessionState())
@@ -129,7 +130,8 @@ export default function ScopePopoutBridge(): null {
window.electronAPI.sendScopePopoutSnapshot({
kind,
label: SCOPE_LABELS[kind],
accent: useThemeStore.getState().accent,
interfaceTheme: useThemeStore.getState().activeTheme.interface,
scopeTheme: useThemeStore.getState().activeTheme[kind],
settings: useSettingsStore.getState().scopeSettings[kind],
})
window.electronAPI.sendScopePopoutSession(kind, toPopoutSessionState(audioRouter.getSessionState()))
-3
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import { SCOPE_LABELS, type ScopeKind } from '../../types/scope'
import type { WindowBounds } from '../../types/popout'
import ScopeModule from './ScopeModule'
@@ -17,7 +16,6 @@ export default function Strip(): JSX.Element {
const moveDockedScope = useSettingsStore((s) => s.moveDockedScope)
const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight)
const popOutScope = useSettingsStore((s) => s.popOutScope)
const accent = useThemeStore((s) => s.accent)
const frameTarget = usePerformanceStore((s) => s.frameTarget)
const setDockedRenderFps = usePerformanceStore((s) => s.setDockedRenderFps)
const frameScheduler = useMemo(() => new FrameScheduler({ frameTarget }), [])
@@ -262,7 +260,6 @@ export default function Strip(): JSX.Element {
</button>
<ScopeModule
scopeKind={kind}
lineColor={accent}
frameScheduler={frameScheduler}
/>
</div>