mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
profile system, improved UI/UX
This commit is contained in:
+117
-8
@@ -1,9 +1,12 @@
|
||||
import { app, BrowserWindow, desktopCapturer, ipcMain, session } from 'electron'
|
||||
import { app, BrowserWindow, desktopCapturer, ipcMain, screen, session } from 'electron'
|
||||
import { join } from 'path'
|
||||
import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentSettingsHeight = 0
|
||||
let moveInterval: ReturnType<typeof setInterval> | null = null
|
||||
let moveStartCursor: { x: number; y: number } | null = null
|
||||
let moveStartPosition: number[] | null = null
|
||||
|
||||
const WINDOW_DEFAULTS = {
|
||||
width: 900,
|
||||
@@ -21,8 +24,8 @@ function createWindow(): void {
|
||||
alwaysOnTop: true,
|
||||
autoHideMenuBar: true,
|
||||
resizable: true,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
maximizable: true,
|
||||
fullscreenable: true,
|
||||
title: 'Prism',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
@@ -104,6 +107,31 @@ function setupIPC(): void {
|
||||
mainWindow?.minimize()
|
||||
})
|
||||
|
||||
ipcMain.on('window:start-move', () => {
|
||||
if (!mainWindow) return
|
||||
const cursor = screen.getCursorScreenPoint()
|
||||
moveStartCursor = { x: cursor.x, y: cursor.y }
|
||||
moveStartPosition = mainWindow.getPosition()
|
||||
|
||||
if (moveInterval) clearInterval(moveInterval)
|
||||
moveInterval = setInterval(() => {
|
||||
if (!mainWindow || !moveStartCursor || !moveStartPosition) return
|
||||
const current = screen.getCursorScreenPoint()
|
||||
const dx = current.x - moveStartCursor.x
|
||||
const dy = current.y - moveStartCursor.y
|
||||
mainWindow.setPosition(moveStartPosition[0] + dx, moveStartPosition[1] + dy)
|
||||
}, 16)
|
||||
})
|
||||
|
||||
ipcMain.on('window:stop-move', () => {
|
||||
if (moveInterval) {
|
||||
clearInterval(moveInterval)
|
||||
moveInterval = null
|
||||
}
|
||||
moveStartCursor = null
|
||||
moveStartPosition = null
|
||||
})
|
||||
|
||||
ipcMain.on('window:close', () => {
|
||||
mainWindow?.close()
|
||||
})
|
||||
@@ -128,21 +156,76 @@ function setupIPC(): void {
|
||||
return getCaptureBackendSupport()
|
||||
})
|
||||
|
||||
ipcMain.on('window:set-bounds', (_event, bounds: { x: number; y: number; width: number; height: number }) => {
|
||||
if (!mainWindow) return
|
||||
// Saved bounds are base (without settings). Add back current settings height so scopes stay the same size.
|
||||
mainWindow.setBounds({
|
||||
...bounds,
|
||||
height: bounds.height + currentSettingsHeight,
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('window:get-bounds', () => {
|
||||
if (!mainWindow) return null
|
||||
const bounds = mainWindow.getBounds()
|
||||
// Strip settings height so we always save base bounds
|
||||
return {
|
||||
...bounds,
|
||||
height: bounds.height - currentSettingsHeight,
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('window:reposition', (_event, position: 'top' | 'bottom') => {
|
||||
if (!mainWindow) return
|
||||
const display = screen.getDisplayMatching(mainWindow.getBounds())
|
||||
const workArea = display.workArea
|
||||
const [, height] = mainWindow.getSize()
|
||||
|
||||
if (position === 'top') {
|
||||
mainWindow.setPosition(workArea.x, workArea.y)
|
||||
} else {
|
||||
mainWindow.setPosition(workArea.x, workArea.y + workArea.height - height)
|
||||
}
|
||||
mainWindow.setSize(workArea.width, height)
|
||||
})
|
||||
|
||||
ipcMain.on('window:expand-settings', (_event, panelHeight: number) => {
|
||||
if (!mainWindow) return
|
||||
const [width, height] = mainWindow.getSize()
|
||||
const bounds = mainWindow.getBounds()
|
||||
const [minW] = mainWindow.getMinimumSize()
|
||||
const newHeight = bounds.height + panelHeight
|
||||
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + panelHeight)
|
||||
mainWindow.setSize(width, height + panelHeight, true)
|
||||
|
||||
// Check if expanding would push window off screen bottom
|
||||
const display = screen.getDisplayMatching(bounds)
|
||||
const workArea = display.workArea
|
||||
const bottomEdge = bounds.y + newHeight
|
||||
if (bottomEdge > workArea.y + workArea.height) {
|
||||
const newY = Math.max(workArea.y, workArea.y + workArea.height - newHeight)
|
||||
mainWindow.setBounds({ x: bounds.x, y: newY, width: bounds.width, height: newHeight })
|
||||
} else {
|
||||
mainWindow.setSize(bounds.width, newHeight, true)
|
||||
}
|
||||
currentSettingsHeight = Math.max(0, currentSettingsHeight + Math.round(panelHeight))
|
||||
})
|
||||
|
||||
ipcMain.on('window:collapse-settings', (_event, panelHeight: number) => {
|
||||
if (!mainWindow) return
|
||||
const [width, height] = mainWindow.getSize()
|
||||
const bounds = mainWindow.getBounds()
|
||||
const [minW] = mainWindow.getMinimumSize()
|
||||
const newHeight = Math.max(WINDOW_DEFAULTS.minHeight, bounds.height - panelHeight)
|
||||
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight)
|
||||
mainWindow.setSize(width, Math.max(WINDOW_DEFAULTS.minHeight, height - panelHeight), true)
|
||||
|
||||
// If window was pushed up when expanding, push it back down
|
||||
const display = screen.getDisplayMatching(bounds)
|
||||
const workArea = display.workArea
|
||||
const wasAtBottom = bounds.y + bounds.height >= workArea.y + workArea.height - 10
|
||||
if (wasAtBottom) {
|
||||
const newY = Math.min(bounds.y + panelHeight, workArea.y + workArea.height - newHeight)
|
||||
mainWindow.setBounds({ x: bounds.x, y: newY, width: bounds.width, height: newHeight })
|
||||
} else {
|
||||
mainWindow.setSize(bounds.width, newHeight, true)
|
||||
}
|
||||
currentSettingsHeight = Math.max(0, currentSettingsHeight - Math.round(panelHeight))
|
||||
})
|
||||
|
||||
@@ -156,7 +239,33 @@ function setupIPC(): void {
|
||||
|
||||
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + nextHeight)
|
||||
if (delta !== 0) {
|
||||
mainWindow.setSize(width, Math.max(WINDOW_DEFAULTS.minHeight, height + delta), true)
|
||||
const newHeight = Math.max(WINDOW_DEFAULTS.minHeight, height + delta)
|
||||
// If expanding near the bottom of the screen, move the window up so it doesn't go off-screen
|
||||
const bounds = mainWindow.getBounds()
|
||||
const display = screen.getDisplayMatching(bounds)
|
||||
const workArea = display.workArea
|
||||
const bottomEdge = bounds.y + newHeight
|
||||
if (delta > 0 && bottomEdge > workArea.y + workArea.height) {
|
||||
const newY = Math.max(workArea.y, workArea.y + workArea.height - newHeight)
|
||||
mainWindow.setBounds({ x: bounds.x, y: newY, width, height: newHeight })
|
||||
} else if (delta < 0) {
|
||||
// Collapsing: if we moved the window up previously, move it back down
|
||||
const baseHeight = newHeight - nextHeight
|
||||
const naturalBottom = bounds.y + baseHeight
|
||||
if (naturalBottom < workArea.y + workArea.height) {
|
||||
// Push window down so it stays near the bottom
|
||||
const maxY = workArea.y + workArea.height - newHeight
|
||||
if (bounds.y < maxY) {
|
||||
mainWindow.setSize(width, newHeight, true)
|
||||
} else {
|
||||
mainWindow.setBounds({ x: bounds.x, y: maxY, width, height: newHeight })
|
||||
}
|
||||
} else {
|
||||
mainWindow.setSize(width, newHeight, true)
|
||||
}
|
||||
} else {
|
||||
mainWindow.setSize(width, newHeight, true)
|
||||
}
|
||||
}
|
||||
|
||||
currentSettingsHeight = nextHeight
|
||||
|
||||
@@ -10,6 +10,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
platform: process.platform,
|
||||
minimize: () => ipcRenderer.send('window:minimize'),
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
startWindowMove: () => ipcRenderer.send('window:start-move'),
|
||||
stopWindowMove: () => ipcRenderer.send('window:stop-move'),
|
||||
setWindowBounds: (bounds: { x: number; y: number; width: number; height: number }) => ipcRenderer.send('window:set-bounds', bounds),
|
||||
getWindowBounds: () => ipcRenderer.invoke('window:get-bounds') as Promise<{ x: number; y: number; width: number; height: number } | null>,
|
||||
repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position),
|
||||
toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'),
|
||||
isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'),
|
||||
getDesktopSources: () => ipcRenderer.invoke('audio:get-desktop-sources') as Promise<{ id: string; name: string }[]>,
|
||||
|
||||
+27
-12
@@ -2,18 +2,18 @@ import { useState, useRef, useCallback, useEffect, type JSX } from 'react'
|
||||
import Strip from './components/Strip'
|
||||
import Toolbar from './components/Toolbar'
|
||||
import SettingsPanel from './components/SettingsPanel'
|
||||
import BottomBar from './components/BottomBar'
|
||||
import { useSettingsStore } from './stores/settingsStore'
|
||||
import { useAudioStore } from './stores/audioStore'
|
||||
import { SCOPE_KINDS } from '../types/scope'
|
||||
|
||||
const DEFAULT_SETTINGS_PANEL_HEIGHT = 280
|
||||
const SETTINGS_EXPAND_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 appliedSettingsHeightRef = useRef(0)
|
||||
const prevSettingsOpenRef = useRef(false)
|
||||
|
||||
const toggleScope = useSettingsStore((s) => s.toggleScope)
|
||||
|
||||
@@ -25,13 +25,15 @@ export default function App(): JSX.Element {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Expand/collapse window by a fixed amount when settings toggle — no dynamic tracking
|
||||
useEffect(() => {
|
||||
const nextHeight = settingsOpen ? settingsPanelHeight : 0
|
||||
if (appliedSettingsHeightRef.current !== nextHeight) {
|
||||
window.electronAPI.setSettingsHeight(nextHeight)
|
||||
appliedSettingsHeightRef.current = nextHeight
|
||||
if (settingsOpen && !prevSettingsOpenRef.current) {
|
||||
window.electronAPI.expandSettings(SETTINGS_EXPAND_HEIGHT)
|
||||
} else if (!settingsOpen && prevSettingsOpenRef.current) {
|
||||
window.electronAPI.collapseSettings(SETTINGS_EXPAND_HEIGHT)
|
||||
}
|
||||
}, [settingsOpen, settingsPanelHeight])
|
||||
prevSettingsOpenRef.current = settingsOpen
|
||||
}, [settingsOpen])
|
||||
|
||||
const showToolbar = useCallback(() => {
|
||||
if (hideTimeoutRef.current) {
|
||||
@@ -56,6 +58,17 @@ export default function App(): JSX.Element {
|
||||
setSettingsOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleAltDragStart = useCallback((event: React.MouseEvent) => {
|
||||
if (event.altKey && event.button === 0) {
|
||||
event.preventDefault()
|
||||
window.electronAPI.startWindowMove()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAltDragEnd = useCallback(() => {
|
||||
window.electronAPI.stopWindowMove()
|
||||
}, [])
|
||||
|
||||
// Keyboard shortcuts from main process
|
||||
useEffect(() => {
|
||||
const unsubs = [
|
||||
@@ -84,6 +97,8 @@ export default function App(): JSX.Element {
|
||||
className="prism-app"
|
||||
onMouseEnter={showToolbar}
|
||||
onMouseLeave={scheduleHide}
|
||||
onMouseDown={handleAltDragStart}
|
||||
onMouseUp={handleAltDragEnd}
|
||||
>
|
||||
<div
|
||||
className={`prism-toolbar-layer ${toolbarVisible ? 'is-visible' : ''}`.trim()}
|
||||
@@ -96,10 +111,10 @@ export default function App(): JSX.Element {
|
||||
</div>
|
||||
|
||||
{settingsOpen && (
|
||||
<SettingsPanel
|
||||
onClose={handleCloseSettings}
|
||||
onHeightChange={setSettingsPanelHeight}
|
||||
/>
|
||||
<div className="prism-settings-region" style={{ height: SETTINGS_EXPAND_HEIGHT }}>
|
||||
<SettingsPanel />
|
||||
<BottomBar onClose={handleCloseSettings} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -111,6 +111,7 @@ class ElectronCaptureRuntime {
|
||||
private audioContext: AudioContext | null = null
|
||||
private stream: MediaStream | null = null
|
||||
private sourceNode: MediaStreamAudioSourceNode | null = null
|
||||
private gainNode: GainNode | null = null
|
||||
private workletNode: AudioWorkletNode | null = null
|
||||
private workletLoaded = false
|
||||
private chunkListeners = new Set<(chunk: CaptureChunk) => void>()
|
||||
@@ -120,6 +121,12 @@ class ElectronCaptureRuntime {
|
||||
private sampleRate = 48000
|
||||
private channelCount = 2
|
||||
|
||||
setInputGain(db: number): void {
|
||||
if (this.gainNode) {
|
||||
this.gainNode.gain.value = Math.pow(10, db / 20)
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (chunk: CaptureChunk) => void): () => void {
|
||||
this.chunkListeners.add(listener)
|
||||
return () => {
|
||||
@@ -193,6 +200,11 @@ class ElectronCaptureRuntime {
|
||||
this.workletLoaded = true
|
||||
}
|
||||
|
||||
if (!this.gainNode) {
|
||||
this.gainNode = this.audioContext.createGain()
|
||||
this.gainNode.gain.value = 1.0
|
||||
}
|
||||
|
||||
if (!this.workletNode) {
|
||||
this.workletNode = new AudioWorkletNode(this.audioContext, 'capture-processor', {
|
||||
numberOfInputs: 1,
|
||||
@@ -236,7 +248,12 @@ class ElectronCaptureRuntime {
|
||||
|
||||
this.stream = stream
|
||||
this.sourceNode = this.audioContext.createMediaStreamSource(stream)
|
||||
this.sourceNode.connect(this.workletNode)
|
||||
if (this.gainNode) {
|
||||
this.sourceNode.connect(this.gainNode)
|
||||
this.gainNode.connect(this.workletNode)
|
||||
} else {
|
||||
this.sourceNode.connect(this.workletNode)
|
||||
}
|
||||
|
||||
const audioTrack = stream.getAudioTracks()[0] ?? null
|
||||
const trackSettings = audioTrack?.getSettings()
|
||||
@@ -810,6 +827,10 @@ class AudioCapture {
|
||||
})
|
||||
}
|
||||
|
||||
setInputGain(db: number): void {
|
||||
this.electronRuntime.setInputGain(db)
|
||||
}
|
||||
|
||||
private emitStatus(): void {
|
||||
const status = this.getStatus()
|
||||
for (const listener of this.statusListeners) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">✓</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"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="toolbar__profile-menu-action toolbar__profile-menu-action--danger"
|
||||
onClick={() => deleteProfile(id)}
|
||||
title="Delete"
|
||||
>
|
||||
×
|
||||
</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"
|
||||
|
||||
Vendored
+5
@@ -12,6 +12,11 @@ declare global {
|
||||
platform: string
|
||||
minimize: () => void
|
||||
close: () => void
|
||||
startWindowMove: () => void
|
||||
stopWindowMove: () => void
|
||||
setWindowBounds: (bounds: { x: number; y: number; width: number; height: number }) => void
|
||||
getWindowBounds: () => Promise<{ x: number; y: number; width: number; height: number } | null>
|
||||
repositionWindow: (position: 'top' | 'bottom') => void
|
||||
toggleAlwaysOnTop: () => void
|
||||
isAlwaysOnTop: () => Promise<boolean>
|
||||
getDesktopSources: () => Promise<{ id: string; name: string }[]>
|
||||
|
||||
@@ -23,6 +23,8 @@ interface AudioState {
|
||||
captureError: string | null
|
||||
sampleRate: number
|
||||
channelCount: number
|
||||
inputGainDb: number
|
||||
setInputGain: (db: number) => void
|
||||
refreshSystemSources: () => Promise<void>
|
||||
refreshDevices: () => Promise<void>
|
||||
refreshBackendSupport: () => Promise<void>
|
||||
@@ -62,6 +64,12 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
||||
captureError: null,
|
||||
sampleRate: 48000,
|
||||
channelCount: 2,
|
||||
inputGainDb: 0,
|
||||
|
||||
setInputGain: (db: number) => {
|
||||
audioCapture.setInputGain(db)
|
||||
set({ inputGainDb: db })
|
||||
},
|
||||
|
||||
refreshSystemSources: async () => {
|
||||
const systemSources = await audioCapture.listSources('system')
|
||||
|
||||
@@ -63,6 +63,48 @@ const DEFAULT_SCOPE_SETTINGS: ScopeSettings = {
|
||||
const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter']
|
||||
|
||||
const STORAGE_KEY = 'prism:settings'
|
||||
const PROFILES_STORAGE_KEY = 'prism:profiles'
|
||||
const ACTIVE_PROFILE_KEY = 'prism:activeProfile'
|
||||
const DEFAULT_PROFILE_ID = 'profile_default'
|
||||
|
||||
export interface Profile {
|
||||
name: string
|
||||
scopeOrder: ScopeKind[]
|
||||
hiddenScopes: ScopeKind[]
|
||||
widthWeights: Record<ScopeKind, number>
|
||||
scopeSettings: ScopeSettings
|
||||
windowBounds?: { x: number; y: number; width: number; height: number }
|
||||
}
|
||||
|
||||
function loadProfiles(): Record<string, Profile> {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILES_STORAGE_KEY)
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch { /* ignore */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
function saveProfiles(profiles: Record<string, Profile>): void {
|
||||
try {
|
||||
localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(profiles))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function loadActiveProfileId(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ACTIVE_PROFILE_KEY)
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function saveActiveProfileId(id: string | null): void {
|
||||
try {
|
||||
if (id) {
|
||||
localStorage.setItem(ACTIVE_PROFILE_KEY, id)
|
||||
} else {
|
||||
localStorage.removeItem(ACTIVE_PROFILE_KEY)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
interface SettingsState {
|
||||
scopeOrder: ScopeKind[]
|
||||
@@ -73,11 +115,21 @@ interface SettingsState {
|
||||
// Derived
|
||||
visibleScopes: () => ScopeKind[]
|
||||
|
||||
// Profiles
|
||||
profiles: Record<string, Profile>
|
||||
activeProfileId: string | null
|
||||
|
||||
// Actions
|
||||
toggleScope: (kind: ScopeKind) => void
|
||||
moveScope: (kind: ScopeKind, direction: 'left' | 'right') => void
|
||||
setScopeWidthWeight: (kind: ScopeKind, weight: number) => void
|
||||
updateScopeSettings: <K extends ScopeKind>(kind: K, settings: Partial<ScopeSettings[K]>) => void
|
||||
saveProfile: (name: string) => string
|
||||
saveProfileAs: (name: string) => string
|
||||
updateActiveProfile: () => void
|
||||
loadProfile: (id: string) => void
|
||||
deleteProfile: (id: string) => void
|
||||
renameProfile: (id: string, name: string) => void
|
||||
}
|
||||
|
||||
function loadFromStorage(): Partial<{ scopeOrder: ScopeKind[]; hiddenScopes: ScopeKind[]; widthWeights: Record<ScopeKind, number>; scopeSettings: ScopeSettings }> {
|
||||
@@ -154,6 +206,24 @@ const defaultWeights: Record<ScopeKind, number> = {
|
||||
vumeter: 0.5, lufsmeter: 0.5, waveform: 1,
|
||||
}
|
||||
|
||||
// Ensure a default profile always exists
|
||||
function ensureDefaultProfile(profiles: Record<string, Profile>): Record<string, Profile> {
|
||||
if (profiles[DEFAULT_PROFILE_ID]) return profiles
|
||||
const defaultProfile: Profile = {
|
||||
name: 'Default',
|
||||
scopeOrder: [...SCOPE_KINDS],
|
||||
hiddenScopes: SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind)),
|
||||
widthWeights: { ...defaultWeights },
|
||||
scopeSettings: JSON.parse(JSON.stringify(DEFAULT_SCOPE_SETTINGS)),
|
||||
}
|
||||
const updated = { [DEFAULT_PROFILE_ID]: defaultProfile, ...profiles }
|
||||
saveProfiles(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
const initialProfiles = ensureDefaultProfile(loadProfiles())
|
||||
const initialActiveProfileId = loadActiveProfileId()
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
scopeOrder: normalizeScopeOrder(stored.scopeOrder),
|
||||
hiddenScopes: new Set<ScopeKind>(
|
||||
@@ -161,6 +231,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
),
|
||||
widthWeights: stored.widthWeights ?? { ...defaultWeights },
|
||||
scopeSettings: mergeScopeSettings(stored.scopeSettings),
|
||||
profiles: initialProfiles,
|
||||
activeProfileId: initialActiveProfileId,
|
||||
|
||||
visibleScopes: () => {
|
||||
const { scopeOrder, hiddenScopes } = get()
|
||||
@@ -219,4 +291,117 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
return newState
|
||||
})
|
||||
},
|
||||
|
||||
saveProfile: (name: string) => {
|
||||
const state = get()
|
||||
const id = `profile_${Date.now()}`
|
||||
const profile: Profile = {
|
||||
name,
|
||||
scopeOrder: [...state.scopeOrder],
|
||||
hiddenScopes: Array.from(state.hiddenScopes),
|
||||
widthWeights: { ...state.widthWeights },
|
||||
scopeSettings: JSON.parse(JSON.stringify(state.scopeSettings)),
|
||||
}
|
||||
// Capture window bounds asynchronously
|
||||
window.electronAPI.getWindowBounds().then((bounds) => {
|
||||
if (bounds) {
|
||||
const profiles = get().profiles
|
||||
const updated = { ...profiles, [id]: { ...profiles[id], windowBounds: bounds } }
|
||||
saveProfiles(updated)
|
||||
set({ profiles: updated })
|
||||
}
|
||||
})
|
||||
const profiles = { ...state.profiles, [id]: profile }
|
||||
saveProfiles(profiles)
|
||||
saveActiveProfileId(id)
|
||||
set({ profiles, activeProfileId: id })
|
||||
return id
|
||||
},
|
||||
|
||||
saveProfileAs: (name: string) => {
|
||||
// Same as saveProfile but always creates a new entry
|
||||
return get().saveProfile(name)
|
||||
},
|
||||
|
||||
updateActiveProfile: () => {
|
||||
const state = get()
|
||||
const id = state.activeProfileId
|
||||
if (!id || !state.profiles[id]) return
|
||||
const updated: Profile = {
|
||||
...state.profiles[id],
|
||||
scopeOrder: [...state.scopeOrder],
|
||||
hiddenScopes: Array.from(state.hiddenScopes),
|
||||
widthWeights: { ...state.widthWeights },
|
||||
scopeSettings: JSON.parse(JSON.stringify(state.scopeSettings)),
|
||||
}
|
||||
// Capture window bounds asynchronously
|
||||
window.electronAPI.getWindowBounds().then((bounds) => {
|
||||
if (bounds) {
|
||||
const profiles = get().profiles
|
||||
const withBounds = { ...profiles, [id]: { ...profiles[id], windowBounds: bounds } }
|
||||
saveProfiles(withBounds)
|
||||
set({ profiles: withBounds })
|
||||
}
|
||||
})
|
||||
const profiles = { ...state.profiles, [id]: updated }
|
||||
saveProfiles(profiles)
|
||||
set({ profiles })
|
||||
},
|
||||
|
||||
loadProfile: (id: string) => {
|
||||
const state = get()
|
||||
const profile = state.profiles[id]
|
||||
if (!profile) return
|
||||
const newState = {
|
||||
...state,
|
||||
scopeOrder: normalizeScopeOrder(profile.scopeOrder),
|
||||
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(profile.hiddenScopes)),
|
||||
widthWeights: profile.widthWeights ?? { ...defaultWeights },
|
||||
scopeSettings: mergeScopeSettings(profile.scopeSettings),
|
||||
activeProfileId: id,
|
||||
}
|
||||
saveToStorage(newState as SettingsState)
|
||||
saveActiveProfileId(id)
|
||||
set(newState)
|
||||
|
||||
if (profile.windowBounds) {
|
||||
window.electronAPI.setWindowBounds(profile.windowBounds)
|
||||
}
|
||||
},
|
||||
|
||||
deleteProfile: (id: string) => {
|
||||
// Prevent deleting the default profile
|
||||
if (id === DEFAULT_PROFILE_ID) return
|
||||
const state = get()
|
||||
const profiles = { ...state.profiles }
|
||||
delete profiles[id]
|
||||
saveProfiles(profiles)
|
||||
const nextActiveId = state.activeProfileId === id ? null : state.activeProfileId
|
||||
saveActiveProfileId(nextActiveId)
|
||||
set({
|
||||
profiles,
|
||||
activeProfileId: nextActiveId,
|
||||
})
|
||||
},
|
||||
|
||||
renameProfile: (id: string, name: string) => {
|
||||
if (id === DEFAULT_PROFILE_ID) return
|
||||
const state = get()
|
||||
const profile = state.profiles[id]
|
||||
if (!profile) return
|
||||
const profiles = { ...state.profiles, [id]: { ...profile, name } }
|
||||
saveProfiles(profiles)
|
||||
set({ profiles })
|
||||
},
|
||||
}))
|
||||
|
||||
// On startup, restore last active profile's settings (but not window bounds — those are handled by Electron)
|
||||
if (initialActiveProfileId && initialProfiles[initialActiveProfileId]) {
|
||||
const profile = initialProfiles[initialActiveProfileId]
|
||||
useSettingsStore.setState({
|
||||
scopeOrder: normalizeScopeOrder(profile.scopeOrder),
|
||||
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(profile.hiddenScopes)),
|
||||
widthWeights: profile.widthWeights ?? { ...defaultWeights },
|
||||
scopeSettings: mergeScopeSettings(profile.scopeSettings),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,6 +82,13 @@ select {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.prism-settings-region {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -97,6 +104,27 @@ select {
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.toolbar__grab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 28px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.toolbar__grab:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.toolbar__grab svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.toolbar__brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -137,11 +165,229 @@ select {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.toolbar__chips {
|
||||
.toolbar__profile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar__profile-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.toolbar__profile-button:hover,
|
||||
.toolbar__profile-button.is-active {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.toolbar__profile-name {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar__profile-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 200px;
|
||||
background: rgba(10, 10, 12, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 4px 0;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-section {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-label {
|
||||
padding: 4px 12px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 4px 0 0;
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-item.is-active .toolbar__profile-menu-item-name {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 5px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-item-name:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.toolbar__profile-check {
|
||||
margin-right: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-item-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-item:hover .toolbar__profile-menu-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-action {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-action:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-action--danger:hover {
|
||||
color: #ff5f57;
|
||||
background: rgba(255, 95, 87, 0.1);
|
||||
}
|
||||
|
||||
.toolbar__profile-rename-input {
|
||||
flex: 1;
|
||||
margin: 2px 8px;
|
||||
padding: 3px 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.toolbar__profile-rename-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-divider {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-action-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toolbar__profile-menu-action-row:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.toolbar__spacer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.toolbar__reposition-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar__reposition-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(8, 11, 16, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.toolbar__reposition-option {
|
||||
padding: 6px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 120ms ease, background-color 120ms ease;
|
||||
}
|
||||
|
||||
.toolbar__reposition-option:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.toolbar__chip {
|
||||
@@ -268,10 +514,13 @@ select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
padding: 12px 0 14px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.settings-panel__utility-row {
|
||||
@@ -591,6 +840,72 @@ select {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 8px 0;
|
||||
background: linear-gradient(180deg, rgba(4, 5, 7, 0.98), rgba(2, 3, 4, 0.98));
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bottom-bar__section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bottom-bar__section-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bottom-bar__inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bottom-bar__divider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bottom-bar__trim-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.06em;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bottom-bar__trim-slider {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.bottom-bar .settings-control__select {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.bottom-bar .settings-panel__close {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
||||
Reference in New Issue
Block a user