diff --git a/src/main/index.ts b/src/main/index.ts index 34a630e..307a306 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 | 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 diff --git a/src/preload/index.ts b/src/preload/index.ts index e2f7ffb..d8ae9ee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 }[]>, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c319124..578a72f 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 | 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} >
{settingsOpen && ( - +
+ + +
)}
) diff --git a/src/renderer/audio/AudioCapture.ts b/src/renderer/audio/AudioCapture.ts index 0d1e7b7..e6d2046 100644 --- a/src/renderer/audio/AudioCapture.ts +++ b/src/renderer/audio/AudioCapture.ts @@ -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) { diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx new file mode 100644 index 0000000..3877397 --- /dev/null +++ b/src/renderer/components/BottomBar.tsx @@ -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 = { + 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 => { + 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 ( +
+
+
Modules
+
+ {SCOPE_KINDS.map((kind) => { + const active = !hiddenScopes.has(kind) + return ( + + ) + })} +
+
+ +
+ +
+
Theme
+
+ {PRESET_IDS.map((id) => { + const preset = PRESETS[id] + const active = presetId === id && !customAccent + return ( + + )} +
+
+ +
+ +
+
Audio Source
+
+ + +
+ + {indicatorLabel} +
+
+ + {captureError ? ( +
{captureError}
+ ) : null} +
+ +
+ +
+
Trim
+
+ + {inputGainDb > 0 ? '+' : ''}{inputGainDb.toFixed(1)}dB + + setInputGain(Number(event.target.value))} + onDoubleClick={() => setInputGain(0)} + /> +
+
+ + +
+ ) +} diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 4089d3d..5f1826e 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -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(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 => { - 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 ( -
-
-
-
Audio Source
- - - -
- - {indicatorLabel} -
- - {captureError ? ( -
{captureError}
- ) : null} -
- -
-
Theme
- -
- {PRESET_IDS.map((id) => { - const preset = PRESETS[id] - const active = presetId === id && !customAccent - return ( -
- - -
- - -
- +
{visibleScopes.map((kind) => ( = { - spectrum: 'SPEC', - oscilloscope: 'OSC', - vectorscope: 'VEC', - spectrogram: 'GRAM', - vumeter: 'VU', - lufsmeter: 'LUFS', - waveform: 'WAVE', -} - function SettingsIcon(): JSX.Element { return (
+
+ +
+
Prism
-
- {SCOPE_KINDS.map((kind) => { - const active = !hiddenScopes.has(kind) - return ( +
+ + + {showProfileMenu && ( +
+
+
Presets
+ {profileIds.map((id) => { + const profile = profiles[id] + const isActive = id === activeProfileId + const isDefault = id === 'profile_default' + + if (renamingId === id) { + return ( +
+ setRenameValue(e.target.value)} + onBlur={handleFinishRename} + onKeyDown={(e) => { + if (e.key === 'Enter') handleFinishRename() + if (e.key === 'Escape') setRenamingId(null) + }} + /> +
+ ) + } + + return ( +
+ + {!isDefault && ( +
+ + +
+ )} +
+ ) + })} +
+ +
+ - ) - })} + {activeProfileId && ( + + )} +
+ )}
+
+
+
+ + {showReposition && ( +
+ + +
+ )} +
+ + +