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