profile system, improved UI/UX

This commit is contained in:
Boof2015
2026-03-27 21:22:29 -04:00
parent a6221552c0
commit e126a264cd
11 changed files with 1162 additions and 240 deletions
+218
View File
@@ -0,0 +1,218 @@
import { useEffect, type CSSProperties, type JSX } from 'react'
import { useAudioStore } from '../stores/audioStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope'
import { SCOPE_KINDS } from '../../types/scope'
const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'Spectrum',
oscilloscope: 'Oscilloscope',
vectorscope: 'Vectorscope',
spectrogram: 'Spectrogram',
vumeter: 'VU Meter',
lufsmeter: 'LUFS Meter',
waveform: 'Waveform',
}
interface BottomBarProps {
onClose: () => void
}
export default function BottomBar({ onClose }: BottomBarProps): JSX.Element {
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const toggleScope = useSettingsStore((s) => s.toggleScope)
const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore()
const {
systemSources,
devices,
selectedSystemSourceId,
selectedDeviceId,
captureMode,
isCapturing,
captureStatus,
captureError,
inputGainDb,
refreshSystemSources,
refreshDevices,
refreshBackendSupport,
selectSystemSource,
selectDevice,
startCapture,
setInputGain,
} = useAudioStore()
useEffect(() => {
void refreshBackendSupport()
void refreshSystemSources()
void refreshDevices()
}, [refreshBackendSupport, refreshSystemSources, refreshDevices])
const handleSourceChange = async (value: string): Promise<void> => {
if (value.startsWith('system:')) {
const sourceId = value.slice('system:'.length)
await selectSystemSource(sourceId)
await startCapture()
return
}
if (value.startsWith('device:')) {
const deviceId = value.slice('device:'.length)
await selectDevice(deviceId)
await startCapture()
}
}
const selectedSourceValue = captureMode === 'system'
? `system:${selectedSystemSourceId ?? systemSources[0]?.id ?? '__default_system_output__'}`
: `device:${selectedDeviceId ?? ''}`
const visibleSystemSources = systemSources.length
? systemSources
: [{ id: '__default_system_output__', label: 'System Output', kind: 'system', isDefault: true }]
const showInputDevices = devices.length > 0
const indicatorLabel = isCapturing
? 'Capturing'
: captureStatus === 'connecting'
? 'Connecting'
: captureStatus === 'error'
? 'Capture Failed'
: 'Idle'
return (
<div className="bottom-bar">
<section className="bottom-bar__section">
<div className="bottom-bar__section-title">Modules</div>
<div className="bottom-bar__inline">
{SCOPE_KINDS.map((kind) => {
const active = !hiddenScopes.has(kind)
return (
<button
key={kind}
type="button"
className={`settings-chip ${active ? 'is-active' : ''}`.trim()}
onClick={() => toggleScope(kind)}
title={SCOPE_LABELS[kind]}
>
{SCOPE_LABELS[kind]}
</button>
)
})}
</div>
</section>
<div className="bottom-bar__divider" />
<section className="bottom-bar__section">
<div className="bottom-bar__section-title">Theme</div>
<div className="bottom-bar__inline">
{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 && (
<button
type="button"
className="settings-chip"
onClick={() => setCustomAccent(null)}
>
Reset
</button>
)}
</div>
</section>
<div className="bottom-bar__divider" />
<section className="bottom-bar__section">
<div className="bottom-bar__section-title">Audio Source</div>
<div className="bottom-bar__inline">
<select
className="settings-control__select"
value={selectedSourceValue}
onChange={(event) => {
void handleSourceChange(event.target.value)
}}
>
<optgroup label="Output Devices">
{visibleSystemSources.map((source) => (
<option key={source.id} value={`system:${source.id}`}>
{source.isDefault ? `${source.label} (Default)` : source.label}
</option>
))}
</optgroup>
{showInputDevices ? (
<optgroup label="Input Devices">
{devices.map((device) => (
<option key={device.deviceId} value={`device:${device.deviceId}`}>
{device.label || `Input ${device.deviceId.slice(0, 8)}`}
</option>
))}
</optgroup>
) : null}
</select>
<div className={`settings-status-pill is-${captureStatus}`.trim()}>
<span className="settings-status-pill__dot" />
<span>{indicatorLabel}</span>
</div>
</div>
{captureError ? (
<div className="settings-error-text">{captureError}</div>
) : null}
</section>
<div className="bottom-bar__divider" />
<section className="bottom-bar__section">
<div className="bottom-bar__section-title">Trim</div>
<div className="bottom-bar__inline">
<span className="bottom-bar__trim-value">
{inputGainDb > 0 ? '+' : ''}{inputGainDb.toFixed(1)}dB
</span>
<input
className="settings-control__range bottom-bar__trim-slider"
type="range"
min={-12}
max={12}
step={0.5}
value={inputGainDb}
onChange={(event) => setInputGain(Number(event.target.value))}
onDoubleClick={() => setInputGain(0)}
/>
</div>
</section>
<button
type="button"
className="settings-panel__close"
onClick={onClose}
>
Close
</button>
</div>
)
}
+3 -190
View File
@@ -1,7 +1,5 @@
import { useEffect, useMemo, useRef, type CSSProperties, type JSX, type ReactNode } from 'react'
import { useAudioStore } from '../stores/audioStore'
import { useMemo, type CSSProperties, type JSX, type ReactNode } from 'react'
import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope'
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
@@ -470,31 +468,8 @@ function ScopeSettingsSection({
)
}
interface SettingsPanelProps {
onClose: () => void
onHeightChange?: (height: number) => void
}
export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanelProps): JSX.Element {
const {
systemSources,
devices,
selectedSystemSourceId,
selectedDeviceId,
captureMode,
isCapturing,
captureStatus,
captureError,
refreshSystemSources,
refreshDevices,
refreshBackendSupport,
selectSystemSource,
selectDevice,
startCapture,
} = useAudioStore()
export default function SettingsPanel(): JSX.Element {
const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights } = useSettingsStore()
const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore()
const panelRef = useRef<HTMLDivElement | null>(null)
const visibleScopes = useMemo(
() => scopeOrder.filter((kind) => !hiddenScopes.has(kind)),
@@ -506,170 +481,8 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel
return { gridTemplateColumns } as CSSProperties
}, [visibleScopes, widthWeights])
useEffect(() => {
void refreshBackendSupport()
void refreshSystemSources()
void refreshDevices()
}, [refreshBackendSupport, refreshSystemSources, 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.startsWith('system:')) {
const sourceId = value.slice('system:'.length)
await selectSystemSource(sourceId)
await startCapture()
return
}
if (value.startsWith('device:')) {
const deviceId = value.slice('device:'.length)
await selectDevice(deviceId)
await startCapture()
}
}
const selectedSourceValue = captureMode === 'system'
? `system:${selectedSystemSourceId ?? systemSources[0]?.id ?? '__default_system_output__'}`
: `device:${selectedDeviceId ?? ''}`
const visibleSystemSources = systemSources.length
? systemSources
: [{ id: '__default_system_output__', label: 'System Output', kind: 'system', isDefault: true }]
const showInputDevices = devices.length > 0
const renderSystemSourceLabel = (label: string, isDefault?: boolean): string => (
isDefault ? `${label} (Default)` : label
)
const renderInputDeviceValue = (deviceId: string): string => `device:${deviceId}`
const renderSystemSourceValue = (sourceId: string): string => `system:${sourceId}`
const indicatorLabel = isCapturing
? 'Capturing'
: captureStatus === 'connecting'
? 'Connecting'
: captureStatus === 'error'
? 'Capture Failed'
: 'Idle'
return (
<div className="settings-panel" ref={panelRef}>
<div className="settings-panel__utility-row">
<section className="settings-utility-section settings-utility-section--source">
<div className="settings-section-title">Audio Source</div>
<label className="settings-control settings-control--stack">
<span className="settings-control__label">Source</span>
<select
className="settings-control__select"
value={selectedSourceValue}
onChange={(event) => {
void handleSourceChange(event.target.value)
}}
>
<optgroup label="Output Devices">
{visibleSystemSources.map((source) => (
<option key={source.id} value={renderSystemSourceValue(source.id)}>
{renderSystemSourceLabel(source.label, source.isDefault)}
</option>
))}
</optgroup>
{showInputDevices ? (
<optgroup label="Input Devices">
{devices.map((device) => (
<option key={device.deviceId} value={renderInputDeviceValue(device.deviceId)}>
{device.label || `Input ${device.deviceId.slice(0, 8)}`}
</option>
))}
</optgroup>
) : null}
</select>
</label>
<div className={`settings-status-pill is-${captureStatus}`.trim()}>
<span className="settings-status-pill__dot" />
<span>{indicatorLabel}</span>
</div>
{captureError ? (
<div className="settings-error-text">{captureError}</div>
) : null}
</section>
<section className="settings-utility-section settings-utility-section--theme">
<div className="settings-section-title">Theme</div>
<div className="settings-theme-swatches">
{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}
/>
)
})}
</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>
<button
type="button"
className="settings-panel__close"
onClick={onClose}
>
Close
</button>
</div>
<div className="settings-panel">
<div className="settings-panel__scope-track" style={scopeTrackStyle}>
{visibleScopes.map((kind) => (
<ScopeSettingsSection
+253 -25
View File
@@ -1,18 +1,6 @@
import { useState, useEffect, useCallback, type CSSProperties, type JSX } from 'react'
import type { ScopeKind } from '../../types/scope'
import { SCOPE_KINDS } from '../../types/scope'
import { useState, useEffect, useCallback, useRef, type CSSProperties, type JSX } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'SPEC',
oscilloscope: 'OSC',
vectorscope: 'VEC',
spectrogram: 'GRAM',
vumeter: 'VU',
lufsmeter: 'LUFS',
waveform: 'WAVE',
}
function SettingsIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
@@ -38,15 +26,63 @@ function CloseIcon(): JSX.Element {
)
}
function GripIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle cx="5.5" cy="4" r="1.2" fill="currentColor" />
<circle cx="10.5" cy="4" r="1.2" fill="currentColor" />
<circle cx="5.5" cy="8" r="1.2" fill="currentColor" />
<circle cx="10.5" cy="8" r="1.2" fill="currentColor" />
<circle cx="5.5" cy="12" r="1.2" fill="currentColor" />
<circle cx="10.5" cy="12" r="1.2" fill="currentColor" />
</svg>
)
}
function MinimizeIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3.5 8h9" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
)
}
function RepositionIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4 6l4-3 4 3M4 10l4 3 4-3" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function ChevronIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" style={{ width: 10, height: 10 }}>
<path d="M5 6l3 3 3-3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
interface ToolbarProps {
onOpenSettings: () => void
settingsOpen: boolean
}
export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): JSX.Element {
const hiddenScopes = useSettingsStore((state) => state.hiddenScopes)
const toggleScope = useSettingsStore((state) => state.toggleScope)
const profiles = useSettingsStore((s) => s.profiles)
const activeProfileId = useSettingsStore((s) => s.activeProfileId)
const saveProfile = useSettingsStore((s) => s.saveProfile)
const loadProfile = useSettingsStore((s) => s.loadProfile)
const deleteProfile = useSettingsStore((s) => s.deleteProfile)
const renameProfile = useSettingsStore((s) => s.renameProfile)
const updateActiveProfile = useSettingsStore((s) => s.updateActiveProfile)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(true)
const [showReposition, setShowReposition] = useState(false)
const [showProfileMenu, setShowProfileMenu] = useState(false)
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const profileMenuRef = useRef<HTMLDivElement>(null)
const renameInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
@@ -54,12 +90,72 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
return unsubscribe
}, [])
// Close profile menu on outside click
useEffect(() => {
if (!showProfileMenu) return
const handleClick = (e: MouseEvent): void => {
if (profileMenuRef.current && !profileMenuRef.current.contains(e.target as Node)) {
setShowProfileMenu(false)
setRenamingId(null)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [showProfileMenu])
// Focus rename input when it appears
useEffect(() => {
if (renamingId && renameInputRef.current) {
renameInputRef.current.focus()
renameInputRef.current.select()
}
}, [renamingId])
const handlePin = useCallback(() => {
window.electronAPI.toggleAlwaysOnTop()
}, [])
const handleReposition = useCallback((position: 'top' | 'bottom') => {
window.electronAPI.repositionWindow(position)
setShowReposition(false)
}, [])
const handleSaveNew = useCallback(() => {
const count = Object.keys(profiles).length
saveProfile(`Profile ${count}`)
setShowProfileMenu(false)
}, [profiles, saveProfile])
const handleSaveOverwrite = useCallback(() => {
updateActiveProfile()
setShowProfileMenu(false)
}, [updateActiveProfile])
const handleStartRename = useCallback((id: string, currentName: string) => {
setRenamingId(id)
setRenameValue(currentName)
}, [])
const handleFinishRename = useCallback(() => {
if (renamingId && renameValue.trim()) {
renameProfile(renamingId, renameValue.trim())
}
setRenamingId(null)
}, [renamingId, renameValue, renameProfile])
const profileIds = Object.keys(profiles)
const activeProfile = activeProfileId ? profiles[activeProfileId] : null
return (
<div className="toolbar">
<div
className="toolbar__grab"
style={{ WebkitAppRegion: 'drag' } as CSSProperties}
title="Drag to move window"
>
<GripIcon />
</div>
<div
className="toolbar__brand"
style={{ WebkitAppRegion: 'drag' } as CSSProperties}
@@ -68,23 +164,145 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
<span className="toolbar__brand-text">Prism</span>
</div>
<div className="toolbar__chips">
{SCOPE_KINDS.map((kind) => {
const active = !hiddenScopes.has(kind)
return (
<div className="toolbar__profile" ref={profileMenuRef}>
<button
type="button"
className={`toolbar__profile-button ${showProfileMenu ? 'is-active' : ''}`.trim()}
onClick={() => setShowProfileMenu((prev) => !prev)}
title="Presets"
>
<span className="toolbar__profile-name">
{activeProfile?.name ?? 'Presets'}
</span>
<ChevronIcon />
</button>
{showProfileMenu && (
<div className="toolbar__profile-menu">
<div className="toolbar__profile-menu-section">
<div className="toolbar__profile-menu-label">Presets</div>
{profileIds.map((id) => {
const profile = profiles[id]
const isActive = id === activeProfileId
const isDefault = id === 'profile_default'
if (renamingId === id) {
return (
<div key={id} className="toolbar__profile-menu-item">
<input
ref={renameInputRef}
className="toolbar__profile-rename-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishRename()
if (e.key === 'Escape') setRenamingId(null)
}}
/>
</div>
)
}
return (
<div
key={id}
className={`toolbar__profile-menu-item ${isActive ? 'is-active' : ''}`.trim()}
>
<button
type="button"
className="toolbar__profile-menu-item-name"
onClick={() => {
loadProfile(id)
setShowProfileMenu(false)
}}
>
{isActive && <span className="toolbar__profile-check">&#10003;</span>}
{profile.name}
</button>
{!isDefault && (
<div className="toolbar__profile-menu-item-actions">
<button
type="button"
className="toolbar__profile-menu-action"
onClick={() => handleStartRename(id, profile.name)}
title="Rename"
>
&#9998;
</button>
<button
type="button"
className="toolbar__profile-menu-action toolbar__profile-menu-action--danger"
onClick={() => deleteProfile(id)}
title="Delete"
>
&times;
</button>
</div>
)}
</div>
)
})}
</div>
<div className="toolbar__profile-menu-divider" />
<button
key={kind}
type="button"
className={`toolbar__chip ${active ? 'is-active' : ''}`.trim()}
onClick={() => toggleScope(kind)}
className="toolbar__profile-menu-action-row"
onClick={handleSaveNew}
>
{SCOPE_LABELS[kind]}
Save as New Preset
</button>
)
})}
{activeProfileId && (
<button
type="button"
className="toolbar__profile-menu-action-row"
onClick={handleSaveOverwrite}
>
Save to "{activeProfile?.name}"
</button>
)}
</div>
)}
</div>
<div
className="toolbar__spacer"
style={{ WebkitAppRegion: 'drag' } as CSSProperties}
/>
<div className="toolbar__actions">
<div className="toolbar__reposition-wrap">
<button
type="button"
className={`toolbar__icon-button ${showReposition ? 'is-active' : ''}`.trim()}
onClick={() => setShowReposition((prev) => !prev)}
title="Reposition window"
aria-label="Reposition window"
>
<RepositionIcon />
</button>
{showReposition && (
<div className="toolbar__reposition-menu">
<button
type="button"
className="toolbar__reposition-option"
onClick={() => handleReposition('top')}
>
Top
</button>
<button
type="button"
className="toolbar__reposition-option"
onClick={() => handleReposition('bottom')}
>
Bottom
</button>
</div>
)}
</div>
<button
type="button"
className={`toolbar__icon-button ${settingsOpen ? 'is-active' : ''}`.trim()}
@@ -105,6 +323,16 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
<PinIcon />
</button>
<button
type="button"
className="toolbar__icon-button"
onClick={() => window.electronAPI.minimize()}
title="Minimize"
aria-label="Minimize"
>
<MinimizeIcon />
</button>
<button
type="button"
className="toolbar__icon-button toolbar__icon-button--danger"