mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
improve ui/ux
This commit is contained in:
@@ -2,6 +2,7 @@ import { app, BrowserWindow, desktopCapturer, ipcMain, session } from 'electron'
|
||||
import { join } from 'path'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentSettingsHeight = 0
|
||||
|
||||
const WINDOW_DEFAULTS = {
|
||||
width: 900,
|
||||
@@ -33,6 +34,7 @@ function createWindow(): void {
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
currentSettingsHeight = 0
|
||||
})
|
||||
|
||||
// Load the renderer
|
||||
@@ -86,6 +88,7 @@ function setupIPC(): void {
|
||||
const [minW] = mainWindow.getMinimumSize()
|
||||
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + panelHeight)
|
||||
mainWindow.setSize(width, height + panelHeight, true)
|
||||
currentSettingsHeight = Math.max(0, currentSettingsHeight + Math.round(panelHeight))
|
||||
})
|
||||
|
||||
ipcMain.on('window:collapse-settings', (_event, panelHeight: number) => {
|
||||
@@ -94,6 +97,23 @@ function setupIPC(): void {
|
||||
const [minW] = mainWindow.getMinimumSize()
|
||||
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight)
|
||||
mainWindow.setSize(width, Math.max(WINDOW_DEFAULTS.minHeight, height - panelHeight), true)
|
||||
currentSettingsHeight = Math.max(0, currentSettingsHeight - Math.round(panelHeight))
|
||||
})
|
||||
|
||||
ipcMain.on('window:set-settings-height', (_event, panelHeight: number) => {
|
||||
if (!mainWindow) return
|
||||
|
||||
const nextHeight = Math.max(0, Math.round(panelHeight))
|
||||
const delta = nextHeight - currentSettingsHeight
|
||||
const [width, height] = mainWindow.getSize()
|
||||
const [minW] = mainWindow.getMinimumSize()
|
||||
|
||||
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + nextHeight)
|
||||
if (delta !== 0) {
|
||||
mainWindow.setSize(width, Math.max(WINDOW_DEFAULTS.minHeight, height + delta), true)
|
||||
}
|
||||
|
||||
currentSettingsHeight = nextHeight
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getDesktopSources: () => ipcRenderer.invoke('audio:get-desktop-sources') as Promise<{ id: string; name: string }[]>,
|
||||
expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight),
|
||||
collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight),
|
||||
setSettingsHeight: (panelHeight: number) => ipcRenderer.send('window:set-settings-height', panelHeight),
|
||||
onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, isOnTop: boolean): void => callback(isOnTop)
|
||||
ipcRenderer.on('window:always-on-top-changed', handler)
|
||||
|
||||
+18
-27
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useState, useRef, useCallback, useEffect, type JSX } from 'react'
|
||||
import Strip from './components/Strip'
|
||||
import Toolbar from './components/Toolbar'
|
||||
import SettingsPanel from './components/SettingsPanel'
|
||||
@@ -6,13 +6,14 @@ import { useSettingsStore } from './stores/settingsStore'
|
||||
import { useAudioStore } from './stores/audioStore'
|
||||
import { SCOPE_KINDS } from '../types/scope'
|
||||
|
||||
const SETTINGS_PANEL_HEIGHT = 200
|
||||
const DEFAULT_SETTINGS_PANEL_HEIGHT = 280
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const [toolbarVisible, setToolbarVisible] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [settingsPanelHeight, setSettingsPanelHeight] = useState(DEFAULT_SETTINGS_PANEL_HEIGHT)
|
||||
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const settingsExpandedRef = useRef(false)
|
||||
const appliedSettingsHeightRef = useRef(0)
|
||||
|
||||
const toggleScope = useSettingsStore((s) => s.toggleScope)
|
||||
|
||||
@@ -21,16 +22,13 @@ export default function App(): JSX.Element {
|
||||
useAudioStore.getState().startCapture()
|
||||
}, [])
|
||||
|
||||
// Settings panel window resize — single stable effect, no double-fire
|
||||
useEffect(() => {
|
||||
if (settingsOpen && !settingsExpandedRef.current) {
|
||||
settingsExpandedRef.current = true
|
||||
window.electronAPI.expandSettings(SETTINGS_PANEL_HEIGHT)
|
||||
} else if (!settingsOpen && settingsExpandedRef.current) {
|
||||
settingsExpandedRef.current = false
|
||||
window.electronAPI.collapseSettings(SETTINGS_PANEL_HEIGHT)
|
||||
const nextHeight = settingsOpen ? settingsPanelHeight : 0
|
||||
if (appliedSettingsHeightRef.current !== nextHeight) {
|
||||
window.electronAPI.setSettingsHeight(nextHeight)
|
||||
appliedSettingsHeightRef.current = nextHeight
|
||||
}
|
||||
}, [settingsOpen])
|
||||
}, [settingsOpen, settingsPanelHeight])
|
||||
|
||||
const showToolbar = useCallback(() => {
|
||||
if (hideTimeoutRef.current) {
|
||||
@@ -80,33 +78,26 @@ export default function App(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'column', position: 'relative' }}
|
||||
className="prism-app"
|
||||
onMouseEnter={showToolbar}
|
||||
onMouseLeave={scheduleHide}
|
||||
>
|
||||
{/* Toolbar overlay — fades in on hover */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
opacity: toolbarVisible ? 1 : 0,
|
||||
transition: 'opacity 150ms ease',
|
||||
pointerEvents: toolbarVisible ? 'auto' : 'none',
|
||||
}}
|
||||
className={`prism-toolbar-layer ${toolbarVisible ? 'is-visible' : ''}`.trim()}
|
||||
>
|
||||
<Toolbar onOpenSettings={handleToggleSettings} settingsOpen={settingsOpen} />
|
||||
</div>
|
||||
|
||||
{/* Scope strip — fills all available space */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className="prism-strip-region">
|
||||
<Strip />
|
||||
</div>
|
||||
|
||||
{/* Settings panel — expands below strip */}
|
||||
{settingsOpen && <SettingsPanel onClose={handleCloseSettings} />}
|
||||
{settingsOpen && (
|
||||
<SettingsPanel
|
||||
onClose={handleCloseSettings}
|
||||
onHeightChange={setSettingsPanelHeight}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
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'
|
||||
@@ -29,7 +29,16 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi
|
||||
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 }
|
||||
return {
|
||||
...base,
|
||||
fftSize: s.fftSize,
|
||||
tiltDbPerOctave: s.tiltDbPerOctave,
|
||||
heatmapFill: s.heatmap,
|
||||
heatmapTiltDbPerOctave: s.heatmapTiltDbPerOctave,
|
||||
showGrid: s.showGrid,
|
||||
fillGradient: s.fillGradient,
|
||||
smoothing: s.smoothing,
|
||||
}
|
||||
}
|
||||
case 'oscilloscope': {
|
||||
const s = settings as ScopeSettings['oscilloscope']
|
||||
@@ -37,7 +46,14 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi
|
||||
}
|
||||
case 'vectorscope': {
|
||||
const s = settings as ScopeSettings['vectorscope']
|
||||
return { ...base, mode: s.mode, multiband: s.multiband, showGrid: s.showGrid, persistence: s.persistence }
|
||||
return {
|
||||
...base,
|
||||
mode: s.mode,
|
||||
multiband: s.multiband,
|
||||
showGrid: s.showGrid,
|
||||
persistence: s.persistence,
|
||||
lineWidth: s.lineWidth,
|
||||
}
|
||||
}
|
||||
case 'spectrogram': {
|
||||
const s = settings as ScopeSettings['spectrogram']
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, type CSSProperties, type JSX, type ReactNode } 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',
|
||||
@@ -16,42 +14,467 @@ const SCOPE_LABELS: Record<ScopeKind, string> = {
|
||||
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',
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
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%',
|
||||
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`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
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 ScopeSettingsCard({
|
||||
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-card">
|
||||
<div className="settings-card__header">
|
||||
<div className="settings-card__title">{SCOPE_LABELS[kind]}</div>
|
||||
<div className="settings-card__summary">{scopeSummary(kind, scopeSettings)}</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-card__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>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsPanelProps {
|
||||
onClose: () => void
|
||||
onHeightChange?: (height: number) => void
|
||||
}
|
||||
|
||||
export default function SettingsPanel({ onClose }: SettingsPanelProps): JSX.Element {
|
||||
export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanelProps): JSX.Element {
|
||||
const {
|
||||
devices,
|
||||
selectedDeviceId,
|
||||
@@ -66,386 +489,153 @@ export default function SettingsPanel({ onClose }: SettingsPanelProps): JSX.Elem
|
||||
} = useAudioStore()
|
||||
const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder } = useSettingsStore()
|
||||
const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore()
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const visibleScopes = scopeOrder.filter((k) => !hiddenScopes.has(k))
|
||||
const visibleScopes = scopeOrder.filter((kind) => !hiddenScopes.has(kind))
|
||||
|
||||
useEffect(() => {
|
||||
refreshDevices()
|
||||
}, [])
|
||||
void refreshDevices()
|
||||
}, [refreshDevices])
|
||||
|
||||
useEffect(() => {
|
||||
const panel = panelRef.current
|
||||
if (!panel || !onHeightChange) return
|
||||
|
||||
const reportHeight = (): void => {
|
||||
const nextHeight = Math.ceil(panel.getBoundingClientRect().height)
|
||||
onHeightChange(nextHeight)
|
||||
}
|
||||
|
||||
reportHeight()
|
||||
|
||||
const observer = typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(() => reportHeight())
|
||||
|
||||
observer?.observe(panel)
|
||||
window.addEventListener('resize', reportHeight)
|
||||
|
||||
return () => {
|
||||
observer?.disconnect()
|
||||
window.removeEventListener('resize', reportHeight)
|
||||
}
|
||||
}, [onHeightChange, visibleScopes.length])
|
||||
|
||||
const handleSourceChange = async (value: string): Promise<void> => {
|
||||
if (value === '__system__') {
|
||||
setCaptureMode('system')
|
||||
await startCapture()
|
||||
} else {
|
||||
await selectDevice(value)
|
||||
await startCapture()
|
||||
return
|
||||
}
|
||||
|
||||
await selectDevice(value)
|
||||
await startCapture()
|
||||
}
|
||||
|
||||
const indicatorColor = isCapturing
|
||||
? '#22c55e'
|
||||
: captureStatus === 'error'
|
||||
? '#ef4444'
|
||||
: '#71717a'
|
||||
const indicatorLabel = isCapturing
|
||||
? 'Capturing'
|
||||
: captureStatus === 'connecting'
|
||||
? '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 className="settings-panel" ref={panelRef}>
|
||||
<div className="settings-panel__utility">
|
||||
<section className="settings-utility-card">
|
||||
<div className="settings-section-title">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>
|
||||
<label className="settings-control settings-control--stack">
|
||||
<span className="settings-control__label">Source</span>
|
||||
<select
|
||||
className="settings-control__select"
|
||||
value={captureMode === 'system' ? '__system__' : selectedDeviceId ?? ''}
|
||||
onChange={(event) => {
|
||||
void handleSourceChange(event.target.value)
|
||||
}}
|
||||
>
|
||||
<option value="__system__">System Audio</option>
|
||||
<optgroup label="Devices">
|
||||
{devices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label || `Input ${device.deviceId.slice(0, 8)}`}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* 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 className={`settings-status-pill is-${captureStatus}`.trim()}>
|
||||
<span className="settings-status-pill__dot" />
|
||||
<span>{indicatorLabel}</span>
|
||||
</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' }}>
|
||||
{captureError ? (
|
||||
<div className="settings-error-text">{captureError}</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="settings-utility-card">
|
||||
<div className="settings-section-title">Theme</div>
|
||||
|
||||
<div className="settings-theme-swatches">
|
||||
{PRESET_IDS.map((id) => {
|
||||
const p = PRESETS[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={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',
|
||||
}}
|
||||
title={preset.name}
|
||||
aria-label={preset.name}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
|
||||
<label className="settings-control settings-control--stack">
|
||||
<span className="settings-control__label">Custom Accent</span>
|
||||
<div className="settings-accent-row">
|
||||
<input
|
||||
className="settings-accent-input"
|
||||
type="color"
|
||||
value={accent}
|
||||
onChange={(event) => setCustomAccent(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-chip"
|
||||
onClick={() => setCustomAccent(null)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Per-scope settings */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<div className="settings-panel__scopes">
|
||||
{visibleScopes.map((kind) => (
|
||||
<ScopeSettingsColumn
|
||||
<ScopeSettingsCard
|
||||
key={kind}
|
||||
kind={kind}
|
||||
settings={scopeSettings}
|
||||
onUpdate={updateScopeSettings}
|
||||
accent={accent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
className="settings-panel__close"
|
||||
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,4 +1,4 @@
|
||||
import { Fragment, useCallback, useRef } from 'react'
|
||||
import { Fragment, useCallback, useRef, type JSX } from 'react'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
@@ -43,20 +43,8 @@ function ResizeHandle({ leftKind, rightKind }: { leftKind: ScopeKind; rightKind:
|
||||
}, [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 ref={handleRef} onMouseDown={onMouseDown} className="scope-strip__handle">
|
||||
<div className="scope-strip__handle-line" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -70,15 +58,7 @@ export default function Strip(): JSX.Element {
|
||||
const visibleScopes = scopeOrder.filter((k) => !hiddenScopes.has(k))
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="scope-strip">
|
||||
{visibleScopes.map((kind, i) => (
|
||||
<Fragment key={kind}>
|
||||
{i > 0 && (
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useState, useEffect, useCallback, type CSSProperties, type JSX } 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',
|
||||
@@ -14,12 +13,29 @@ const SCOPE_LABELS: Record<ScopeKind, string> = {
|
||||
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})`
|
||||
function SettingsIcon(): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="2.2" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
||||
<path d="M8 1.7v2M8 12.3v2M14.3 8h-2M3.7 8h-2M12.4 3.6l-1.4 1.4M5 11l-1.4 1.4M12.4 12.4 11 11M5 5 3.6 3.6" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PinIcon(): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M10.9 2.5 13 4.6 10.8 7v2.2l-1 1L8 8.4 4.8 11.6 4 10.8l3.2-3.2-1.8-1.8 1-1H8.6z" fill="none" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CloseIcon(): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M3.5 3.5 12.5 12.5M12.5 3.5 3.5 12.5" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface ToolbarProps {
|
||||
@@ -28,18 +44,14 @@ interface ToolbarProps {
|
||||
}
|
||||
|
||||
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 hiddenScopes = useSettingsStore((state) => state.hiddenScopes)
|
||||
const toggleScope = useSettingsStore((state) => state.toggleScope)
|
||||
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 unsubscribe = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop)
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
@@ -47,52 +59,24 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
}, [])
|
||||
|
||||
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 className="toolbar">
|
||||
<div
|
||||
style={{
|
||||
WebkitAppRegion: 'drag',
|
||||
flex: '0 0 40px',
|
||||
height: '100%',
|
||||
cursor: 'grab',
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
className="toolbar__brand"
|
||||
style={{ WebkitAppRegion: 'drag' } as CSSProperties}
|
||||
>
|
||||
<span className="toolbar__brand-mark" />
|
||||
<span className="toolbar__brand-text">Prism</span>
|
||||
</div>
|
||||
|
||||
{/* Scope toggles */}
|
||||
<div style={{ display: 'flex', gap: '2px', flex: 1 }}>
|
||||
<div className="toolbar__chips">
|
||||
{SCOPE_KINDS.map((kind) => {
|
||||
const active = !hiddenScopes.has(kind)
|
||||
return (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
className={`toolbar__chip ${active ? 'is-active' : ''}`.trim()}
|
||||
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>
|
||||
@@ -100,60 +84,35 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right side: settings, pin, close */}
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
<div className="toolbar__actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`toolbar__icon-button ${settingsOpen ? 'is-active' : ''}`.trim()}
|
||||
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"
|
||||
aria-label="Settings"
|
||||
>
|
||||
⚙
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`toolbar__icon-button ${isAlwaysOnTop ? 'is-active' : ''}`.trim()}
|
||||
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'}
|
||||
aria-label={isAlwaysOnTop ? 'Unpin from top' : 'Pin to top'}
|
||||
>
|
||||
📌
|
||||
<PinIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="toolbar__icon-button toolbar__icon-button--danger"
|
||||
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"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Vendored
+1
@@ -14,6 +14,7 @@ declare global {
|
||||
getDesktopSources: () => Promise<{ id: string; name: string }[]>
|
||||
expandSettings: (panelHeight: number) => void
|
||||
collapseSettings: (panelHeight: number) => void
|
||||
setSettingsHeight: (panelHeight: number) => void
|
||||
onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => () => void
|
||||
onToggleScope: (callback: (index: number) => void) => () => void
|
||||
onToggleCapture: (callback: () => void) => () => void
|
||||
|
||||
@@ -4,14 +4,22 @@
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: #050505;
|
||||
--bg-tertiary: #0a0a0a;
|
||||
--panel-surface: rgba(8, 11, 16, 0.92);
|
||||
--panel-surface-soft: rgba(12, 16, 22, 0.84);
|
||||
--panel-outline: rgba(255, 255, 255, 0.09);
|
||||
--panel-outline-strong: rgba(255, 255, 255, 0.16);
|
||||
--panel-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
|
||||
|
||||
--glass-bg: rgba(255, 255, 255, 0.03);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--glass-highlight: rgba(255, 255, 255, 0.045);
|
||||
|
||||
--text-primary: rgba(255, 255, 255, 0.95);
|
||||
--text-secondary: rgba(255, 255, 255, 0.6);
|
||||
--text-tertiary: rgba(255, 255, 255, 0.4);
|
||||
--text-secondary: rgba(255, 255, 255, 0.62);
|
||||
--text-tertiary: rgba(255, 255, 255, 0.42);
|
||||
--text-muted: rgba(255, 255, 255, 0.3);
|
||||
--danger: #f87171;
|
||||
--success: #22c55e;
|
||||
|
||||
--accent: #38bdf8;
|
||||
--accent-hover: #7dd3fc;
|
||||
@@ -25,19 +33,534 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-primary);
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(var(--accent-rgb), 0.09), transparent 28%),
|
||||
linear-gradient(180deg, #040506 0%, #000000 24%, #000000 100%);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.prism-app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.prism-toolbar-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.prism-toolbar-layer.is-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.prism-strip-region {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 4px 10px 0;
|
||||
gap: 10px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.78), rgba(0, 0, 0, 0.58));
|
||||
backdrop-filter: blur(16px) saturate(1.05);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.05);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.toolbar__brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 86px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.toolbar__brand-mark {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--accent-rgb), 0.92);
|
||||
box-shadow: 0 0 10px rgba(var(--accent-rgb), 0.32);
|
||||
}
|
||||
|
||||
.toolbar__brand-text,
|
||||
.toolbar__chip,
|
||||
.toolbar__icon-button,
|
||||
.settings-section-title,
|
||||
.settings-card__title,
|
||||
.settings-card__summary,
|
||||
.settings-control__label,
|
||||
.settings-chip,
|
||||
.settings-status-pill,
|
||||
.settings-panel__close {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toolbar__brand-text {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.toolbar__chips {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar__chip {
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 120ms ease,
|
||||
border-color 120ms ease,
|
||||
background-color 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.toolbar__chip:hover {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.toolbar__chip.is-active {
|
||||
color: var(--accent);
|
||||
border-color: rgba(var(--accent-rgb), 0.26);
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.08);
|
||||
}
|
||||
|
||||
.toolbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar__icon-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 120ms ease,
|
||||
border-color 120ms ease,
|
||||
background-color 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.toolbar__icon-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.toolbar__icon-button:hover {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.toolbar__icon-button.is-active {
|
||||
color: var(--accent);
|
||||
border-color: rgba(var(--accent-rgb), 0.24);
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
|
||||
.toolbar__icon-button--danger:hover {
|
||||
color: var(--danger);
|
||||
border-color: rgba(248, 113, 113, 0.22);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
|
||||
.scope-strip {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.scope-strip__handle {
|
||||
width: 6px;
|
||||
flex-shrink: 0;
|
||||
cursor: col-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.scope-strip__handle-line {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.12), transparent);
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
background: linear-gradient(180deg, rgba(6, 8, 11, 0.98), rgba(4, 5, 7, 0.98));
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.settings-panel__utility {
|
||||
width: 224px;
|
||||
padding: 12px;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-utility-card,
|
||||
.settings-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--panel-outline);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.018), rgba(255, 255, 255, 0.008)),
|
||||
var(--panel-surface-soft);
|
||||
box-shadow: var(--panel-shadow);
|
||||
}
|
||||
|
||||
.settings-utility-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.settings-panel__scopes {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(240px, 1fr);
|
||||
gap: 12px;
|
||||
padding: 12px 12px 36px;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
min-width: 240px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-card__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-card__title {
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.settings-card__summary {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.settings-card__controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 12px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.settings-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-control--full,
|
||||
.settings-control--stack {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-control.is-disabled {
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.settings-control__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.settings-control__value {
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.settings-control__select {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: var(--panel-surface);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
transition: border-color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
|
||||
.settings-control__select:hover,
|
||||
.settings-control__select:focus {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
background: rgba(10, 14, 20, 0.98);
|
||||
}
|
||||
|
||||
.settings-control__range {
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-control__range::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.12), rgba(var(--accent-rgb), 0.36));
|
||||
}
|
||||
|
||||
.settings-control__range::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: -4.5px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.5);
|
||||
background: rgba(7, 10, 14, 0.98);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.14);
|
||||
}
|
||||
|
||||
.settings-control__range::-moz-range-track {
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.12), rgba(var(--accent-rgb), 0.36));
|
||||
}
|
||||
|
||||
.settings-control__range::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.5);
|
||||
background: rgba(7, 10, 14, 0.98);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.14);
|
||||
}
|
||||
|
||||
.settings-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-chip {
|
||||
min-height: 30px;
|
||||
padding: 0 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
color 140ms ease,
|
||||
background-color 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.settings-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.settings-chip.is-active {
|
||||
color: var(--accent);
|
||||
border-color: rgba(var(--accent-rgb), 0.28);
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
|
||||
.settings-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.settings-status-pill__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.26);
|
||||
}
|
||||
|
||||
.settings-status-pill.is-capturing .settings-status-pill__dot {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
.settings-status-pill.is-error .settings-status-pill__dot {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 8px rgba(248, 113, 113, 0.36);
|
||||
}
|
||||
|
||||
.settings-error-text {
|
||||
margin-top: 8px;
|
||||
color: rgba(248, 113, 113, 0.88);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-theme-swatches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.settings-swatch {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background: var(--swatch-color);
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.settings-swatch:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.settings-swatch.is-active {
|
||||
border-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.settings-accent-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-accent-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: var(--panel-surface);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.settings-panel__close {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 10px;
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
color 140ms ease,
|
||||
background-color 140ms ease;
|
||||
}
|
||||
|
||||
.settings-panel__close:hover {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
|
||||
Reference in New Issue
Block a user