From 85c0cecfd20ca1acf280a1ad3e8bc4de9e7cfc49 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:01:46 -0400 Subject: [PATCH] UI/UX improvements, scope popouts --- src/main/index.ts | 549 +++++++++++++++--- src/preload/index.ts | 86 ++- src/renderer/App.tsx | 19 +- src/renderer/components/ScopeModule.tsx | 88 ++- src/renderer/components/ScopePopoutBridge.tsx | 188 ++++++ .../components/ScopeSettingsSection.tsx | 459 +++++++++++++++ src/renderer/components/SettingsPanel.tsx | 487 +--------------- src/renderer/components/Strip.tsx | 78 ++- src/renderer/components/Toolbar.tsx | 222 +++---- src/renderer/env.d.ts | 34 +- src/renderer/main.tsx | 18 +- src/renderer/popouts/ScopePopoutDataSource.ts | 128 ++++ src/renderer/popouts/ScopePopoutWindow.tsx | 231 ++++++++ src/renderer/stores/settingsStore.ts | 181 ++++-- src/renderer/stores/themeStore.ts | 8 +- src/renderer/styles/globals.css | 356 +++++++----- src/renderer/visualizers/LUFSMeter.ts | 8 +- src/renderer/visualizers/Oscilloscope.ts | 38 +- src/renderer/visualizers/Spectrogram.ts | 8 +- src/renderer/visualizers/SpectrumAnalyzer.ts | 10 +- src/renderer/visualizers/VUMeter.ts | 10 +- src/renderer/visualizers/Vectorscope.ts | 40 +- src/renderer/visualizers/Waveform.ts | 8 +- src/renderer/visualizers/dataSource.ts | 14 + src/types/popout.ts | 48 ++ src/types/profileMenu.ts | 12 + src/types/scope.ts | 10 + src/types/settings.ts | 58 ++ 28 files changed, 2396 insertions(+), 1000 deletions(-) create mode 100644 src/renderer/components/ScopePopoutBridge.tsx create mode 100644 src/renderer/components/ScopeSettingsSection.tsx create mode 100644 src/renderer/popouts/ScopePopoutDataSource.ts create mode 100644 src/renderer/popouts/ScopePopoutWindow.tsx create mode 100644 src/renderer/visualizers/dataSource.ts create mode 100644 src/types/popout.ts create mode 100644 src/types/profileMenu.ts create mode 100644 src/types/settings.ts diff --git a/src/main/index.ts b/src/main/index.ts index 307a306..cf583d3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,16 @@ -import { app, BrowserWindow, desktopCapturer, ipcMain, screen, session } from 'electron' +import { app, BrowserWindow, desktopCapturer, ipcMain, Menu, screen, session } from 'electron' +import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, WebContents } from 'electron' import { join } from 'path' import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture' +import type { + ScopePopoutAudioBatch, + ScopePopoutSessionState, + ScopePopoutSnapshot, + ScopePopoutSyncStateMap, + WindowBounds, +} from '../types/popout' +import type { ProfileMenuRequest } from '../types/profileMenu' +import { SCOPE_KINDS, type ScopeKind } from '../types/scope' let mainWindow: BrowserWindow | null = null let currentSettingsHeight = 0 @@ -8,6 +18,10 @@ let moveInterval: ReturnType | null = null let moveStartCursor: { x: number; y: number } | null = null let moveStartPosition: number[] | null = null +const scopePopoutWindows = new Map() +const scopePopoutCloseAllowed = new Set() +const popoutBoundsTimers = new Map>() + const WINDOW_DEFAULTS = { width: 900, height: 180, @@ -15,7 +29,145 @@ const WINDOW_DEFAULTS = { minHeight: 100, } -function createWindow(): void { +const POPOUT_DEFAULTS = { + width: 360, + height: 240, + minWidth: 220, + minHeight: 160, +} + +function isScopeKind(value: unknown): value is ScopeKind { + return typeof value === 'string' && SCOPE_KINDS.includes(value as ScopeKind) +} + +function getWindowFromSender(sender: WebContents): BrowserWindow | null { + return BrowserWindow.fromWebContents(sender) +} + +function isMainRendererWindow(window: BrowserWindow | null): boolean { + return window !== null && window === mainWindow +} + +function sendToRenderer(sender: WebContents, channel: string, ...args: unknown[]): void { + if (!sender.isDestroyed()) { + sender.send(channel, ...args) + } +} + +function normalizeProfileMenuRequest(raw: unknown): ProfileMenuRequest | null { + if (typeof raw !== 'object' || raw === null) return null + + const candidate = raw as Partial + if ( + typeof candidate.x !== 'number' + || typeof candidate.y !== 'number' + || !Array.isArray(candidate.profiles) + ) { + return null + } + + const profiles = candidate.profiles + .filter((profile): profile is ProfileMenuRequest['profiles'][number] => { + return typeof profile?.id === 'string' + && typeof profile?.name === 'string' + && typeof profile?.isDefault === 'boolean' + }) + .map((profile) => ({ + id: profile.id, + name: profile.name, + isDefault: profile.isDefault, + })) + + return { + x: Math.round(candidate.x), + y: Math.round(candidate.y), + profiles, + activeProfileId: typeof candidate.activeProfileId === 'string' ? candidate.activeProfileId : null, + } +} + +function buildProfileMenuTemplate( + request: ProfileMenuRequest, + sender: WebContents, +): MenuItemConstructorOptions[] { + const activeProfile = request.activeProfileId + ? request.profiles.find((profile) => profile.id === request.activeProfileId) ?? null + : null + + const template: MenuItemConstructorOptions[] = [ + { label: 'Presets', enabled: false }, + ...request.profiles.map((profile) => ({ + type: 'checkbox' as const, + checked: profile.id === request.activeProfileId, + label: profile.name, + click: () => sendToRenderer(sender, 'profile-menu:load', profile.id), + })), + { type: 'separator' }, + { + label: 'Save as New Preset', + click: () => sendToRenderer(sender, 'profile-menu:save-new'), + }, + ] + + if (activeProfile) { + template.push({ + label: `Save to "${activeProfile.name}"`, + click: () => sendToRenderer(sender, 'profile-menu:save-overwrite'), + }) + } + + if (activeProfile && !activeProfile.isDefault) { + template.push( + { type: 'separator' }, + { + label: `Rename "${activeProfile.name}"...`, + click: () => sendToRenderer(sender, 'profile-menu:rename-active', activeProfile.id), + }, + { + label: `Delete "${activeProfile.name}"`, + click: () => sendToRenderer(sender, 'profile-menu:delete-active', activeProfile.id), + }, + ) + } + + return template +} + +function normalizeBounds(raw: unknown, fallback: WindowBounds): WindowBounds { + if (typeof raw !== 'object' || raw === null) return fallback + + const candidate = raw as Partial + if ( + typeof candidate.x !== 'number' + || typeof candidate.y !== 'number' + || typeof candidate.width !== 'number' + || typeof candidate.height !== 'number' + ) { + return fallback + } + + return { + x: Math.round(candidate.x), + y: Math.round(candidate.y), + width: Math.max(POPOUT_DEFAULTS.minWidth, Math.round(candidate.width)), + height: Math.max(POPOUT_DEFAULTS.minHeight, Math.round(candidate.height)), + } +} + +function loadRendererTarget(window: BrowserWindow, query: Record): void { + if (process.env.ELECTRON_RENDERER_URL) { + const url = new URL(process.env.ELECTRON_RENDERER_URL) + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value) + } + void window.loadURL(url.toString()) + return + } + + void window.loadFile(join(__dirname, '../renderer/index.html'), { query }) +} + +function createMainWindow(): void { mainWindow = new BrowserWindow({ ...WINDOW_DEFAULTS, frame: false, @@ -39,13 +191,165 @@ function createWindow(): void { mainWindow.on('closed', () => { mainWindow = null currentSettingsHeight = 0 + + for (const kind of SCOPE_KINDS) { + destroyScopePopoutWindow(kind) + } }) - // Load the renderer - if (process.env.ELECTRON_RENDERER_URL) { - mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) - } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + loadRendererTarget(mainWindow, { window: 'main' }) +} + +function setAllWindowsAlwaysOnTop(next: boolean): void { + mainWindow?.setAlwaysOnTop(next) + for (const window of scopePopoutWindows.values()) { + window.setAlwaysOnTop(next) + } +} + +function emitPopoutBoundsChanged(kind: ScopeKind, window: BrowserWindow): void { + if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed()) return + + const existingTimer = popoutBoundsTimers.get(kind) + if (existingTimer) { + clearTimeout(existingTimer) + } + + const timer = setTimeout(() => { + popoutBoundsTimers.delete(kind) + + if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed()) return + const bounds = window.getBounds() + mainWindow.webContents.send('scope-popout:bounds-changed', kind, bounds) + }, 80) + + popoutBoundsTimers.set(kind, timer) +} + +function destroyScopePopoutWindow(kind: ScopeKind): void { + const window = scopePopoutWindows.get(kind) + if (!window) return + + const pendingTimer = popoutBoundsTimers.get(kind) + if (pendingTimer) { + clearTimeout(pendingTimer) + popoutBoundsTimers.delete(kind) + } + + scopePopoutCloseAllowed.add(kind) + scopePopoutWindows.delete(kind) + + if (!window.isDestroyed()) { + window.close() + } + + scopePopoutCloseAllowed.delete(kind) +} + +function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): BrowserWindow | null { + if (!mainWindow) return null + + const existing = scopePopoutWindows.get(kind) + if (existing && !existing.isDestroyed()) { + return existing + } + + const mainBounds = mainWindow.getBounds() + const fallbackBounds: WindowBounds = { + x: mainBounds.x + 40, + y: mainBounds.y + 40, + width: POPOUT_DEFAULTS.width, + height: POPOUT_DEFAULTS.height, + } + const bounds = normalizeBounds(rawBounds, fallbackBounds) + + const options: BrowserWindowConstructorOptions = { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + minWidth: POPOUT_DEFAULTS.minWidth, + minHeight: POPOUT_DEFAULTS.minHeight, + frame: false, + transparent: false, + backgroundColor: '#000000', + resizable: true, + fullscreenable: false, + maximizable: false, + minimizable: true, + skipTaskbar: true, + autoHideMenuBar: true, + title: `Prism ${kind}`, + parent: mainWindow, + alwaysOnTop: mainWindow.isAlwaysOnTop(), + show: false, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false, + contextIsolation: true, + nodeIntegration: false, + backgroundThrottling: false, + }, + } + + const popoutWindow = new BrowserWindow(options) + scopePopoutWindows.set(kind, popoutWindow) + + popoutWindow.once('ready-to-show', () => { + if (!popoutWindow.isDestroyed()) { + popoutWindow.show() + } + }) + + popoutWindow.on('close', (event) => { + if (scopePopoutCloseAllowed.has(kind) || !mainWindow || mainWindow.isDestroyed()) { + return + } + + event.preventDefault() + mainWindow.webContents.send('scope-popout:close-requested', kind) + }) + + popoutWindow.on('closed', () => { + scopePopoutWindows.delete(kind) + scopePopoutCloseAllowed.delete(kind) + + const pendingTimer = popoutBoundsTimers.get(kind) + if (pendingTimer) { + clearTimeout(pendingTimer) + popoutBoundsTimers.delete(kind) + } + }) + + popoutWindow.on('move', () => emitPopoutBoundsChanged(kind, popoutWindow)) + popoutWindow.on('resize', () => emitPopoutBoundsChanged(kind, popoutWindow)) + + loadRendererTarget(popoutWindow, { window: 'scope-popout', scope: kind }) + return popoutWindow +} + +function syncScopePopouts(nextState: ScopePopoutSyncStateMap): void { + for (const kind of SCOPE_KINDS) { + const desired = nextState[kind] + if (!desired?.shouldBeOpen) { + destroyScopePopoutWindow(kind) + continue + } + + const popoutWindow = createScopePopoutWindow(kind, desired.bounds) + if (!popoutWindow || popoutWindow.isDestroyed()) continue + + const currentBounds = popoutWindow.getBounds() + const nextBounds = normalizeBounds(desired.bounds, currentBounds) + const hasBoundsDelta = + currentBounds.x !== nextBounds.x + || currentBounds.y !== nextBounds.y + || currentBounds.width !== nextBounds.width + || currentBounds.height !== nextBounds.height + + if (hasBoundsDelta) { + popoutWindow.setBounds(nextBounds) + } } } @@ -90,7 +394,6 @@ function getCaptureBackendSupport(): CaptureBackendSupport { } } -// Auto-grant media (microphone) permission for audio capture function setupPermissions(): void { session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { if (permission === 'media' || permission === 'display-capture') { @@ -101,25 +404,26 @@ function setupPermissions(): void { }) } -// IPC handlers function setupIPC(): void { - ipcMain.on('window:minimize', () => { - mainWindow?.minimize() + ipcMain.on('window:minimize', (event) => { + getWindowFromSender(event.sender)?.minimize() }) - ipcMain.on('window:start-move', () => { - if (!mainWindow) return + ipcMain.on('window:start-move', (event) => { + const targetWindow = getWindowFromSender(event.sender) + if (!targetWindow) return + const cursor = screen.getCursorScreenPoint() moveStartCursor = { x: cursor.x, y: cursor.y } - moveStartPosition = mainWindow.getPosition() + moveStartPosition = targetWindow.getPosition() if (moveInterval) clearInterval(moveInterval) moveInterval = setInterval(() => { - if (!mainWindow || !moveStartCursor || !moveStartPosition) return + if (!targetWindow || targetWindow.isDestroyed() || !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) + targetWindow.setPosition(moveStartPosition[0] + dx, moveStartPosition[1] + dy) }, 16) }) @@ -132,19 +436,27 @@ function setupIPC(): void { moveStartPosition = null }) - ipcMain.on('window:close', () => { - mainWindow?.close() + ipcMain.on('window:close', (event) => { + getWindowFromSender(event.sender)?.close() }) - ipcMain.on('window:toggle-always-on-top', () => { - if (!mainWindow) return - const current = mainWindow.isAlwaysOnTop() - mainWindow.setAlwaysOnTop(!current) - mainWindow.webContents.send('window:always-on-top-changed', !current) + ipcMain.on('window:toggle-always-on-top', (event) => { + const targetWindow = getWindowFromSender(event.sender) + if (!targetWindow) return + + const current = targetWindow.isAlwaysOnTop() + const next = !current + if (isMainRendererWindow(targetWindow)) { + setAllWindowsAlwaysOnTop(next) + mainWindow?.webContents.send('window:always-on-top-changed', next) + return + } + + targetWindow.setAlwaysOnTop(next) }) - ipcMain.handle('window:is-always-on-top', () => { - return mainWindow?.isAlwaysOnTop() ?? true + ipcMain.handle('window:is-always-on-top', (event) => { + return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? true }) ipcMain.handle('audio:get-desktop-sources', async () => { @@ -156,126 +468,203 @@ 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.on('profile-menu:open', (event, rawRequest: unknown) => { + const request = normalizeProfileMenuRequest(rawRequest) + if (!request) return + + const targetWindow = getWindowFromSender(event.sender) + const menu = Menu.buildFromTemplate(buildProfileMenuTemplate(request, event.sender)) + menu.popup({ + window: targetWindow ?? undefined, + x: request.x, + y: request.y, + callback: () => sendToRenderer(event.sender, 'profile-menu:closed'), }) }) - ipcMain.handle('window:get-bounds', () => { - if (!mainWindow) return null - const bounds = mainWindow.getBounds() - // Strip settings height so we always save base bounds + ipcMain.on('window:set-bounds', (event, bounds: WindowBounds) => { + const targetWindow = getWindowFromSender(event.sender) + if (!targetWindow) return + + if (isMainRendererWindow(targetWindow)) { + targetWindow.setBounds({ + ...bounds, + height: bounds.height + currentSettingsHeight, + }) + return + } + + targetWindow.setBounds(bounds) + }) + + ipcMain.handle('window:get-bounds', (event) => { + const targetWindow = getWindowFromSender(event.sender) + if (!targetWindow) return null + + const bounds = targetWindow.getBounds() + if (!isMainRendererWindow(targetWindow)) { + return bounds + } + return { ...bounds, height: bounds.height - currentSettingsHeight, } }) - ipcMain.on('window:reposition', (_event, position: 'top' | 'bottom') => { - if (!mainWindow) return - const display = screen.getDisplayMatching(mainWindow.getBounds()) + ipcMain.on('window:reposition', (event, position: 'top' | 'bottom') => { + const targetWindow = getWindowFromSender(event.sender) + if (!targetWindow) return + + const display = screen.getDisplayMatching(targetWindow.getBounds()) const workArea = display.workArea - const [, height] = mainWindow.getSize() + const [, height] = targetWindow.getSize() if (position === 'top') { - mainWindow.setPosition(workArea.x, workArea.y) + targetWindow.setPosition(workArea.x, workArea.y) } else { - mainWindow.setPosition(workArea.x, workArea.y + workArea.height - height) + targetWindow.setPosition(workArea.x, workArea.y + workArea.height - height) } - mainWindow.setSize(workArea.width, height) + targetWindow.setSize(workArea.width, height) }) - ipcMain.on('window:expand-settings', (_event, panelHeight: number) => { - if (!mainWindow) return - const bounds = mainWindow.getBounds() - const [minW] = mainWindow.getMinimumSize() - const newHeight = bounds.height + panelHeight - mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + panelHeight) + ipcMain.on('window:expand-settings', (event, panelHeight: number) => { + const targetWindow = getWindowFromSender(event.sender) + if (!targetWindow || !isMainRendererWindow(targetWindow)) return + + const bounds = targetWindow.getBounds() + const [minW] = targetWindow.getMinimumSize() + const newHeight = bounds.height + panelHeight + targetWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + panelHeight) - // 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 }) + targetWindow.setBounds({ x: bounds.x, y: newY, width: bounds.width, height: newHeight }) } else { - mainWindow.setSize(bounds.width, newHeight, true) + targetWindow.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 bounds = mainWindow.getBounds() - const [minW] = mainWindow.getMinimumSize() - const newHeight = Math.max(WINDOW_DEFAULTS.minHeight, bounds.height - panelHeight) - mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight) + ipcMain.on('window:collapse-settings', (event, panelHeight: number) => { + const targetWindow = getWindowFromSender(event.sender) + if (!targetWindow || !isMainRendererWindow(targetWindow)) return + + const bounds = targetWindow.getBounds() + const [minW] = targetWindow.getMinimumSize() + const newHeight = Math.max(WINDOW_DEFAULTS.minHeight, bounds.height - panelHeight) + targetWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight) - // 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 }) + targetWindow.setBounds({ x: bounds.x, y: newY, width: bounds.width, height: newHeight }) } else { - mainWindow.setSize(bounds.width, newHeight, true) + targetWindow.setSize(bounds.width, newHeight, true) } currentSettingsHeight = Math.max(0, currentSettingsHeight - Math.round(panelHeight)) }) - ipcMain.on('window:set-settings-height', (_event, panelHeight: number) => { - if (!mainWindow) return + ipcMain.on('window:set-settings-height', (event, panelHeight: number) => { + const targetWindow = getWindowFromSender(event.sender) + if (!targetWindow || !isMainRendererWindow(targetWindow)) return const nextHeight = Math.max(0, Math.round(panelHeight)) const delta = nextHeight - currentSettingsHeight - const [width, height] = mainWindow.getSize() - const [minW] = mainWindow.getMinimumSize() + const [width, height] = targetWindow.getSize() + const [minW] = targetWindow.getMinimumSize() - mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + nextHeight) + targetWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + nextHeight) if (delta !== 0) { 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 bounds = targetWindow.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 }) + targetWindow.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) + targetWindow.setSize(width, newHeight, true) } else { - mainWindow.setBounds({ x: bounds.x, y: maxY, width, height: newHeight }) + targetWindow.setBounds({ x: bounds.x, y: maxY, width, height: newHeight }) } } else { - mainWindow.setSize(width, newHeight, true) + targetWindow.setSize(width, newHeight, true) } } else { - mainWindow.setSize(width, newHeight, true) + targetWindow.setSize(width, newHeight, true) } } currentSettingsHeight = nextHeight }) + + ipcMain.on('scope-popout:sync', (event, state: ScopePopoutSyncStateMap) => { + const targetWindow = getWindowFromSender(event.sender) + if (!isMainRendererWindow(targetWindow)) return + syncScopePopouts(state) + }) + + ipcMain.on('scope-popout:snapshot', (event, snapshot: ScopePopoutSnapshot) => { + const targetWindow = getWindowFromSender(event.sender) + if (!isMainRendererWindow(targetWindow) || !isScopeKind(snapshot?.kind)) return + + const popoutWindow = scopePopoutWindows.get(snapshot.kind) + if (!popoutWindow || popoutWindow.isDestroyed()) return + popoutWindow.webContents.send('scope-popout:snapshot', snapshot) + }) + + ipcMain.on('scope-popout:audio', (event, kind: ScopeKind, batch: ScopePopoutAudioBatch) => { + const targetWindow = getWindowFromSender(event.sender) + if (!isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return + + const popoutWindow = scopePopoutWindows.get(kind) + if (!popoutWindow || popoutWindow.isDestroyed()) return + popoutWindow.webContents.send('scope-popout:audio', kind, batch) + }) + + ipcMain.on('scope-popout:session', (event, kind: ScopeKind, sessionState: ScopePopoutSessionState) => { + const targetWindow = getWindowFromSender(event.sender) + if (!isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return + + const popoutWindow = scopePopoutWindows.get(kind) + if (!popoutWindow || popoutWindow.isDestroyed()) return + popoutWindow.webContents.send('scope-popout:session', kind, sessionState) + }) + + ipcMain.on('scope-popout:ready', (event, kind: ScopeKind) => { + const targetWindow = getWindowFromSender(event.sender) + if (!targetWindow || isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return + mainWindow?.webContents.send('scope-popout:ready', kind) + }) + + ipcMain.on('scope-popout:request-pop-in', (event, kind: ScopeKind) => { + const targetWindow = getWindowFromSender(event.sender) + if (!targetWindow || isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return + mainWindow?.webContents.send('scope-popout:close-requested', kind) + }) + + ipcMain.on('scope-popout:settings-update', (event, kind: ScopeKind, partial: unknown) => { + const targetWindow = getWindowFromSender(event.sender) + if (!targetWindow || isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return + mainWindow?.webContents.send('scope-popout:settings-update', kind, partial) + }) } function setupShortcuts(): void { if (!mainWindow) return - // Scope toggles 1-7 const scopeKeys = ['1', '2', '3', '4', '5', '6', '7'] scopeKeys.forEach((key) => { mainWindow!.webContents.on('before-input-event', (_event, input) => { @@ -285,23 +674,21 @@ function setupShortcuts(): void { }) }) - // T = toggle always-on-top mainWindow.webContents.on('before-input-event', (_event, input) => { if (input.type === 'keyDown' && input.key === 't' && !input.alt && !input.control && !input.meta && !input.shift) { const current = mainWindow!.isAlwaysOnTop() - mainWindow!.setAlwaysOnTop(!current) - mainWindow!.webContents.send('window:always-on-top-changed', !current) + const next = !current + setAllWindowsAlwaysOnTop(next) + mainWindow!.webContents.send('window:always-on-top-changed', next) } }) - // Space = toggle capture mainWindow.webContents.on('before-input-event', (_event, input) => { if (input.type === 'keyDown' && input.key === ' ' && !input.alt && !input.control && !input.meta && !input.shift) { mainWindow?.webContents.send('shortcut:toggle-capture') } }) - // Comma (Cmd+,) = toggle settings mainWindow.webContents.on('before-input-event', (_event, input) => { if (input.type === 'keyDown' && input.key === ',' && input.meta && !input.alt && !input.control && !input.shift) { mainWindow?.webContents.send('shortcut:toggle-settings') @@ -312,7 +699,7 @@ function setupShortcuts(): void { app.whenReady().then(() => { setupPermissions() setupIPC() - createWindow() + createMainWindow() setupShortcuts() }) diff --git a/src/preload/index.ts b/src/preload/index.ts index d8ae9ee..d009a29 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,15 @@ import { contextBridge, ipcRenderer } from 'electron' import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture' import type { NativeCaptureAPI } from '../types/nativeCapture' +import type { + ScopePopoutAudioBatch, + ScopePopoutSessionState, + ScopePopoutSnapshot, + ScopePopoutSyncStateMap, + WindowBounds, +} from '../types/popout' +import type { ProfileMenuRequest } from '../types/profileMenu' +import type { ScopeKind } from '../types/scope' import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' type NativeAddonModule = VisualizerDSP & NativeCaptureAPI @@ -12,8 +21,8 @@ contextBridge.exposeInMainWorld('electronAPI', { 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>, + setWindowBounds: (bounds: WindowBounds) => ipcRenderer.send('window:set-bounds', bounds), + getWindowBounds: () => ipcRenderer.invoke('window:get-bounds') as Promise, 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'), @@ -28,6 +37,14 @@ contextBridge.exposeInMainWorld('electronAPI', { expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight), collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight), setSettingsHeight: (panelHeight: number) => ipcRenderer.send('window:set-settings-height', panelHeight), + openProfileMenu: (request: ProfileMenuRequest) => ipcRenderer.send('profile-menu:open', request), + syncScopePopouts: (state: ScopePopoutSyncStateMap) => ipcRenderer.send('scope-popout:sync', state), + sendScopePopoutSnapshot: (snapshot: ScopePopoutSnapshot) => ipcRenderer.send('scope-popout:snapshot', snapshot), + sendScopePopoutAudio: (kind: ScopeKind, batch: ScopePopoutAudioBatch) => ipcRenderer.send('scope-popout:audio', kind, batch), + sendScopePopoutSession: (kind: ScopeKind, session: ScopePopoutSessionState) => ipcRenderer.send('scope-popout:session', kind, session), + notifyScopePopoutReady: (kind: ScopeKind) => ipcRenderer.send('scope-popout:ready', kind), + requestScopePopIn: (kind: ScopeKind) => ipcRenderer.send('scope-popout:request-pop-in', kind), + sendScopePopoutSettingsUpdate: (kind: ScopeKind, partial: unknown) => ipcRenderer.send('scope-popout:settings-update', kind, partial), onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => { const handler = (_event: Electron.IpcRendererEvent, isOnTop: boolean): void => callback(isOnTop) ipcRenderer.on('window:always-on-top-changed', handler) @@ -48,6 +65,71 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('shortcut:toggle-settings', handler) return () => ipcRenderer.removeListener('shortcut:toggle-settings', handler) }, + onProfileMenuClosed: (callback: () => void) => { + const handler = (): void => callback() + ipcRenderer.on('profile-menu:closed', handler) + return () => ipcRenderer.removeListener('profile-menu:closed', handler) + }, + onProfileMenuLoad: (callback: (id: string) => void) => { + const handler = (_event: Electron.IpcRendererEvent, id: string): void => callback(id) + ipcRenderer.on('profile-menu:load', handler) + return () => ipcRenderer.removeListener('profile-menu:load', handler) + }, + onProfileMenuSaveNew: (callback: () => void) => { + const handler = (): void => callback() + ipcRenderer.on('profile-menu:save-new', handler) + return () => ipcRenderer.removeListener('profile-menu:save-new', handler) + }, + onProfileMenuSaveOverwrite: (callback: () => void) => { + const handler = (): void => callback() + ipcRenderer.on('profile-menu:save-overwrite', handler) + return () => ipcRenderer.removeListener('profile-menu:save-overwrite', handler) + }, + onProfileMenuRenameActive: (callback: (id: string) => void) => { + const handler = (_event: Electron.IpcRendererEvent, id: string): void => callback(id) + ipcRenderer.on('profile-menu:rename-active', handler) + return () => ipcRenderer.removeListener('profile-menu:rename-active', handler) + }, + onProfileMenuDeleteActive: (callback: (id: string) => void) => { + const handler = (_event: Electron.IpcRendererEvent, id: string): void => callback(id) + ipcRenderer.on('profile-menu:delete-active', handler) + return () => ipcRenderer.removeListener('profile-menu:delete-active', handler) + }, + onScopePopoutReady: (callback: (kind: ScopeKind) => void) => { + const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind): void => callback(kind) + ipcRenderer.on('scope-popout:ready', handler) + return () => ipcRenderer.removeListener('scope-popout:ready', handler) + }, + onScopePopoutCloseRequested: (callback: (kind: ScopeKind) => void) => { + const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind): void => callback(kind) + ipcRenderer.on('scope-popout:close-requested', handler) + return () => ipcRenderer.removeListener('scope-popout:close-requested', handler) + }, + onScopePopoutBoundsChanged: (callback: (kind: ScopeKind, bounds: WindowBounds) => void) => { + const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind, bounds: WindowBounds): void => callback(kind, bounds) + ipcRenderer.on('scope-popout:bounds-changed', handler) + return () => ipcRenderer.removeListener('scope-popout:bounds-changed', handler) + }, + onScopePopoutSettingsUpdate: (callback: (kind: ScopeKind, partial: unknown) => void) => { + const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind, partial: unknown): void => callback(kind, partial) + ipcRenderer.on('scope-popout:settings-update', handler) + return () => ipcRenderer.removeListener('scope-popout:settings-update', handler) + }, + onScopePopoutSnapshot: (callback: (snapshot: ScopePopoutSnapshot) => void) => { + const handler = (_event: Electron.IpcRendererEvent, snapshot: ScopePopoutSnapshot): void => callback(snapshot) + ipcRenderer.on('scope-popout:snapshot', handler) + return () => ipcRenderer.removeListener('scope-popout:snapshot', handler) + }, + onScopePopoutAudio: (callback: (kind: ScopeKind, batch: ScopePopoutAudioBatch) => void) => { + const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind, batch: ScopePopoutAudioBatch): void => callback(kind, batch) + ipcRenderer.on('scope-popout:audio', handler) + return () => ipcRenderer.removeListener('scope-popout:audio', handler) + }, + onScopePopoutSession: (callback: (kind: ScopeKind, session: ScopePopoutSessionState) => void) => { + const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind, session: ScopePopoutSessionState): void => callback(kind, session) + ipcRenderer.on('scope-popout:session', handler) + return () => ipcRenderer.removeListener('scope-popout:session', handler) + }, }) // Native DSP module — load if available, gracefully degrade if not diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 578a72f..8bd993f 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,8 +1,9 @@ -import { useState, useRef, useCallback, useEffect, type JSX } from 'react' +import { useState, useRef, useCallback, useEffect, useLayoutEffect, 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 ScopePopoutBridge from './components/ScopePopoutBridge' import { useSettingsStore } from './stores/settingsStore' import { useAudioStore } from './stores/audioStore' import { SCOPE_KINDS } from '../types/scope' @@ -14,8 +15,22 @@ export default function App(): JSX.Element { const [settingsOpen, setSettingsOpen] = useState(false) const hideTimeoutRef = useRef | null>(null) const prevSettingsOpenRef = useRef(false) + const startupBoundsAppliedRef = useRef(false) const toggleScope = useSettingsStore((s) => s.toggleScope) + const profiles = useSettingsStore((s) => s.profiles) + const activeProfileId = useSettingsStore((s) => s.activeProfileId) + + useLayoutEffect(() => { + if (startupBoundsAppliedRef.current) return + + const profile = activeProfileId ? profiles[activeProfileId] : null + startupBoundsAppliedRef.current = true + + if (profile?.windowBounds) { + window.electronAPI.setWindowBounds(profile.windowBounds) + } + }, [activeProfileId, profiles]) // Auto-capture on launch useEffect(() => { @@ -110,6 +125,8 @@ export default function App(): JSX.Element { + + {settingsOpen && (
diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 6b3d7b4..e678814 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -1,17 +1,27 @@ import { useEffect, useRef, type JSX } from 'react' import type { ScopeKind } from '../../types/scope' -import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore' -import { SpectrumAnalyzer } from '../visualizers/SpectrumAnalyzer' -import { Oscilloscope } from '../visualizers/Oscilloscope' -import { Vectorscope } from '../visualizers/Vectorscope' -import { Spectrogram } from '../visualizers/Spectrogram' -import { VUMeter } from '../visualizers/VUMeter' -import { LUFSMeter } from '../visualizers/LUFSMeter' -import { Waveform } from '../visualizers/Waveform' +import type { ScopeSettings } from '../../types/settings' +import { useSettingsStore } from '../stores/settingsStore' +import { SpectrumAnalyzer, type SpectrumAnalyzerDataSource } from '../visualizers/SpectrumAnalyzer' +import { Oscilloscope, type OscilloscopeDataSource } from '../visualizers/Oscilloscope' +import { Vectorscope, type VectorscopeDataSource } from '../visualizers/Vectorscope' +import { Spectrogram, type SpectrogramDataSource } from '../visualizers/Spectrogram' +import { VUMeter, type VUMeterDataSource } from '../visualizers/VUMeter' +import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter' +import { Waveform, type WaveformDataSource } from '../visualizers/Waveform' interface ScopeModuleProps { scopeKind: ScopeKind lineColor?: string + settings?: ScopeSettings[ScopeKind] + dataSource?: + | SpectrumAnalyzerDataSource + | OscilloscopeDataSource + | VectorscopeDataSource + | SpectrogramDataSource + | VUMeterDataSource + | LUFSMeterDataSource + | WaveformDataSource } interface Visualizer { @@ -75,36 +85,68 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi } } -function createVisualizer(scopeKind: ScopeKind, canvas: HTMLCanvasElement, mySettings: ScopeSettings[ScopeKind], lineColor: string): Visualizer | null { +function createVisualizer( + scopeKind: ScopeKind, + canvas: HTMLCanvasElement, + mySettings: ScopeSettings[ScopeKind], + lineColor: string, + dataSource?: ScopeModuleProps['dataSource'], +): Visualizer | null { const opts = scopeSettingsToOptions(scopeKind, mySettings, lineColor) switch (scopeKind) { case 'spectrum': - return new SpectrumAnalyzer(canvas, opts) + return new SpectrumAnalyzer(canvas, { + ...opts, + ...(dataSource ? { dataSource: dataSource as SpectrumAnalyzerDataSource } : {}), + }) case 'oscilloscope': - return new Oscilloscope(canvas, opts) + return new Oscilloscope(canvas, { + ...opts, + ...(dataSource ? { dataSource: dataSource as OscilloscopeDataSource } : {}), + }) case 'vectorscope': - return new Vectorscope(canvas, opts) + return new Vectorscope(canvas, { + ...opts, + ...(dataSource ? { dataSource: dataSource as VectorscopeDataSource } : {}), + }) case 'spectrogram': - return new Spectrogram(canvas, opts) + return new Spectrogram(canvas, { + ...opts, + ...(dataSource ? { dataSource: dataSource as SpectrogramDataSource } : {}), + }) case 'vumeter': - return new VUMeter(canvas, opts) + return new VUMeter(canvas, { + ...opts, + ...(dataSource ? { dataSource: dataSource as VUMeterDataSource } : {}), + }) case 'lufsmeter': - return new LUFSMeter(canvas, opts) + return new LUFSMeter(canvas, { + ...opts, + ...(dataSource ? { dataSource: dataSource as LUFSMeterDataSource } : {}), + }) case 'waveform': - return new Waveform(canvas, opts) + return new Waveform(canvas, { + ...opts, + ...(dataSource ? { dataSource: dataSource as WaveformDataSource } : {}), + }) default: return null } } -export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeModuleProps): JSX.Element { +export default function ScopeModule({ + scopeKind, + lineColor = '#38bdf8', + settings, + dataSource, +}: ScopeModuleProps): JSX.Element { const containerRef = useRef(null) const canvasRef = useRef(null) const visualizerRef = useRef(null) const initializedRef = useRef(false) - // Subscribe to ONLY this scope's settings — avoids triggering setOptions when other scopes change - const mySettings = useSettingsStore((s) => s.scopeSettings[scopeKind]) + const storeSettings = useSettingsStore((s) => s.scopeSettings[scopeKind]) + const mySettings = settings ?? storeSettings // Initialize visualizer useEffect(() => { @@ -112,7 +154,7 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeM if (!canvas) return initializedRef.current = false - const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor) + const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor, dataSource) if (!viz) return visualizerRef.current = viz @@ -126,14 +168,14 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeM visualizerRef.current = null initializedRef.current = false } - }, [scopeKind]) + }, [dataSource, scopeKind]) // Push settings + lineColor changes to live visualizer (skip initial — constructor already handled it) useEffect(() => { if (!visualizerRef.current || !initializedRef.current) return - const opts = scopeSettingsToOptions(scopeKind, mySettings, lineColor) + const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, lineColor), ...(dataSource ? { dataSource } : {}) } visualizerRef.current.setOptions(opts) - }, [mySettings, lineColor]) + }, [dataSource, lineColor, mySettings, scopeKind]) // ResizeObserver for DPI-aware canvas sizing useEffect(() => { diff --git a/src/renderer/components/ScopePopoutBridge.tsx b/src/renderer/components/ScopePopoutBridge.tsx new file mode 100644 index 0000000..6e902c9 --- /dev/null +++ b/src/renderer/components/ScopePopoutBridge.tsx @@ -0,0 +1,188 @@ +import { useEffect, useMemo, useRef } from 'react' +import { audioRouter } from '../audio/AudioRouter' +import { useSettingsStore } from '../stores/settingsStore' +import { useThemeStore } from '../stores/themeStore' +import type { + ScopePopoutAudioBatch, + ScopePopoutSessionState, + ScopePopoutSnapshot, + ScopePopoutSyncStateMap, +} from '../../types/popout' +import { SCOPE_KINDS, SCOPE_LABELS, type ScopeKind } from '../../types/scope' +import type { ScopeSettings } from '../../types/settings' + +function buildConsumerDemand(kind: ScopeKind): Record { + return SCOPE_KINDS.reduce((acc, currentKind) => { + acc[currentKind] = currentKind === kind + return acc + }, {} as Record) +} + +function flushScopeAudioBatch(kind: ScopeKind): ScopePopoutAudioBatch { + switch (kind) { + case 'spectrum': + return audioRouter.flushPendingSpectrumSamples() + case 'oscilloscope': + return audioRouter.flushPendingOscilloscopeSamples() + case 'vectorscope': + return audioRouter.flushPendingVectorscopeSamples() + case 'spectrogram': + return audioRouter.flushPendingSpectrogramSamples() + case 'vumeter': + return audioRouter.flushPendingVUMeterSamples() + case 'lufsmeter': + return audioRouter.flushPendingLUFSMeterSamples() + case 'waveform': + return audioRouter.flushPendingWaveformSamples() + } +} + +function toPopoutSessionState(state: ScopePopoutSessionState): ScopePopoutSessionState { + return { + sessionId: state.sessionId, + sampleRate: state.sampleRate, + channelCount: state.channelCount, + capturing: state.capturing, + backendKind: state.backendKind, + } +} + +function isPartialSettings(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export default function ScopePopoutBridge(): null { + const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) + const scopePopouts = useSettingsStore((s) => s.scopePopouts) + const scopeSettings = useSettingsStore((s) => s.scopeSettings) + const popInScope = useSettingsStore((s) => s.popInScope) + const updatePopoutBounds = useSettingsStore((s) => s.updatePopoutBounds) + const updateScopeSettings = useSettingsStore((s) => s.updateScopeSettings) + const accent = useThemeStore((s) => s.accent) + + const activePopoutKinds = useMemo( + () => SCOPE_KINDS.filter((kind) => scopePopouts[kind]?.poppedOut && !hiddenScopes.has(kind)), + [hiddenScopes, scopePopouts], + ) + const activePopoutKindsRef = useRef(activePopoutKinds) + + useEffect(() => { + activePopoutKindsRef.current = activePopoutKinds + }, [activePopoutKinds]) + + useEffect(() => { + const syncState = SCOPE_KINDS.reduce((acc, kind) => { + acc[kind] = { + shouldBeOpen: scopePopouts[kind]?.poppedOut === true && !hiddenScopes.has(kind), + bounds: scopePopouts[kind]?.windowBounds, + } + return acc + }, {} as ScopePopoutSyncStateMap) + + window.electronAPI.syncScopePopouts(syncState) + }, [hiddenScopes, scopePopouts]) + + useEffect(() => { + for (const kind of activePopoutKinds) { + const snapshot: ScopePopoutSnapshot = { + kind, + label: SCOPE_LABELS[kind], + accent, + settings: scopeSettings[kind], + } + window.electronAPI.sendScopePopoutSnapshot(snapshot) + } + }, [accent, activePopoutKinds, scopeSettings]) + + useEffect(() => { + const sessionState = toPopoutSessionState(audioRouter.getSessionState()) + for (const kind of activePopoutKinds) { + window.electronAPI.sendScopePopoutSession(kind, sessionState) + } + }, [activePopoutKinds]) + + useEffect(() => { + const unsubscribeCloseRequested = window.electronAPI.onScopePopoutCloseRequested((kind) => { + popInScope(kind) + }) + const unsubscribeBoundsChanged = window.electronAPI.onScopePopoutBoundsChanged((kind, bounds) => { + updatePopoutBounds(kind, bounds) + }) + const unsubscribeSettingsUpdate = window.electronAPI.onScopePopoutSettingsUpdate((kind, partial) => { + if (!isPartialSettings(partial)) return + updateScopeSettings(kind, partial as Partial) + }) + const unsubscribeReady = window.electronAPI.onScopePopoutReady((kind) => { + const nextHiddenScopes = useSettingsStore.getState().hiddenScopes + const nextPopouts = useSettingsStore.getState().scopePopouts + if (!nextPopouts[kind]?.poppedOut || nextHiddenScopes.has(kind)) return + + window.electronAPI.sendScopePopoutSnapshot({ + kind, + label: SCOPE_LABELS[kind], + accent: useThemeStore.getState().accent, + settings: useSettingsStore.getState().scopeSettings[kind], + }) + window.electronAPI.sendScopePopoutSession(kind, toPopoutSessionState(audioRouter.getSessionState())) + }) + + return () => { + unsubscribeCloseRequested() + unsubscribeBoundsChanged() + unsubscribeSettingsUpdate() + unsubscribeReady() + } + }, [popInScope, updatePopoutBounds, updateScopeSettings]) + + useEffect(() => { + for (const kind of SCOPE_KINDS) { + const consumerId = `popout:${kind}` + if (activePopoutKinds.includes(kind)) { + audioRouter.setVisualizerConsumerDemand(consumerId, buildConsumerDemand(kind)) + } else { + audioRouter.clearVisualizerConsumerDemand(consumerId) + } + } + + return () => { + for (const kind of SCOPE_KINDS) { + audioRouter.clearVisualizerConsumerDemand(`popout:${kind}`) + } + } + }, [activePopoutKinds]) + + useEffect(() => { + let frameId = 0 + + const flushFrame = (): void => { + for (const kind of activePopoutKindsRef.current) { + const batch = flushScopeAudioBatch(kind) + if (batch.length > 0) { + window.electronAPI.sendScopePopoutAudio(kind, batch) + } + } + frameId = window.requestAnimationFrame(flushFrame) + } + + if (activePopoutKinds.length > 0) { + frameId = window.requestAnimationFrame(flushFrame) + } + + return () => { + if (frameId) { + window.cancelAnimationFrame(frameId) + } + } + }, [activePopoutKinds]) + + useEffect(() => { + return audioRouter.subscribeToSessionChanges((state) => { + const nextSessionState = toPopoutSessionState(state) + for (const kind of activePopoutKindsRef.current) { + window.electronAPI.sendScopePopoutSession(kind, nextSessionState) + } + }) + }, []) + + return null +} diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx new file mode 100644 index 0000000..f717c2b --- /dev/null +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -0,0 +1,459 @@ +import type { JSX, ReactNode } from 'react' +import type { ScopeKind } from '../../types/scope' +import { SCOPE_LABELS } from '../../types/scope' +import type { ScopeSettings } from '../../types/settings' + +function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string { + switch (mode) { + case 'lissajous': + return 'Lissajous' + case 'polar-unipolar': + return 'Polar Uni' + case 'polar-bipolar': + return 'Polar Bi' + case 'linear-unipolar': + return 'Linear Uni' + case 'linear-bipolar': + return 'Linear Bi' + } +} + +export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string { + switch (kind) { + case 'spectrum': { + const scopeSettings = settings as ScopeSettings['spectrum'] + return `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}` + } + case 'oscilloscope': { + const scopeSettings = settings as ScopeSettings['oscilloscope'] + const mode = scopeSettings.pitchLock ? 'Pitch Lock' : 'Free Run' + return scopeSettings.underfillEnabled ? `${mode} · Fill` : mode + } + case 'vectorscope': { + const scopeSettings = settings as ScopeSettings['vectorscope'] + return scopeSettings.multiband + ? `${vectorscopeModeLabel(scopeSettings.mode)} · RGB` + : vectorscopeModeLabel(scopeSettings.mode) + } + case 'spectrogram': { + const scopeSettings = settings as ScopeSettings['spectrogram'] + return `${scopeSettings.scaleMode.toUpperCase()} · ${scopeSettings.clarityMode}` + } + case 'vumeter': { + const scopeSettings = settings as ScopeSettings['vumeter'] + return `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.orientation.toUpperCase()}` + } + case 'lufsmeter': + return 'Bar Meter' + case 'waveform': { + const scopeSettings = settings as ScopeSettings['waveform'] + return scopeSettings.multiband + ? `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB · RGB` + : `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB` + } + } +} + +function ToggleChip({ + label, + active, + onClick, +}: { + label: string + active: boolean + onClick: () => void +}): JSX.Element { + return ( + + ) +} + +function SelectControl({ + label, + value, + children, + onChange, +}: { + label: string + value: string | number + children: ReactNode + onChange: (value: string) => void +}): JSX.Element { + return ( + + ) +} + +function RangeControl({ + label, + value, + valueLabel, + min, + max, + step, + fullWidth = true, + disabled = false, + onChange, +}: { + label: string + value: number + valueLabel: string + min: number + max: number + step: number + fullWidth?: boolean + disabled?: boolean + onChange: (value: number) => void +}): JSX.Element { + return ( + + ) +} + +interface ScopeSettingsSectionProps { + kind: ScopeKind + settings: ScopeSettings[ScopeKind] + onUpdate: (kind: K, partial: Partial) => void +} + +export default function ScopeSettingsSection({ + kind, + settings, + onUpdate, +}: ScopeSettingsSectionProps): JSX.Element { + return ( +
+
+
{SCOPE_LABELS[kind]}
+
{scopeSummary(kind, settings)}
+
+ +
+ {kind === 'spectrum' && (() => { + const current = settings as ScopeSettings['spectrum'] + return ( + <> + onUpdate('spectrum', { fftSize: Number(value) })} + > + {[1024, 2048, 4096, 8192, 16384].map((option) => ( + + ))} + + +
+ onUpdate('spectrum', { fillGradient: !current.fillGradient })} + /> + onUpdate('spectrum', { heatmap: !current.heatmap })} + /> + onUpdate('spectrum', { showGrid: !current.showGrid })} + /> +
+ + onUpdate('spectrum', { tiltDbPerOctave: value })} + /> + + onUpdate('spectrum', { heatmapTiltDbPerOctave: value })} + /> + + onUpdate('spectrum', { smoothing: value })} + /> + + ) + })()} + + {kind === 'oscilloscope' && (() => { + const current = settings as ScopeSettings['oscilloscope'] + return ( + <> +
+ onUpdate('oscilloscope', { pitchLock: !current.pitchLock })} + /> + onUpdate('oscilloscope', { underfillEnabled: !current.underfillEnabled })} + /> + onUpdate('oscilloscope', { showGrid: !current.showGrid })} + /> +
+ + onUpdate('oscilloscope', { lineWidth: value })} + /> + + ) + })()} + + {kind === 'vectorscope' && (() => { + const current = settings as ScopeSettings['vectorscope'] + return ( + <> + onUpdate('vectorscope', { mode: value as ScopeSettings['vectorscope']['mode'] })} + > + + + + + + + +
+ onUpdate('vectorscope', { multiband: !current.multiband })} + /> + onUpdate('vectorscope', { showGrid: !current.showGrid })} + /> +
+ + onUpdate('vectorscope', { persistence: value })} + /> + + onUpdate('vectorscope', { lineWidth: value })} + /> + + ) + })()} + + {kind === 'spectrogram' && (() => { + const current = settings as ScopeSettings['spectrogram'] + return ( + <> + onUpdate('spectrogram', { fftSize: Number(value) })} + > + {[512, 1024, 2048, 4096].map((option) => ( + + ))} + + + onUpdate('spectrogram', { scaleMode: value as ScopeSettings['spectrogram']['scaleMode'] })} + > + + + + + + onUpdate('spectrogram', { clarityMode: value as ScopeSettings['spectrogram']['clarityMode'] })} + > + + + + + + onUpdate('spectrogram', { colorScheme: value as ScopeSettings['spectrogram']['colorScheme'] })} + > + + + + + onUpdate('spectrogram', { scrollSpeed: value })} + /> + + ) + })()} + + {kind === 'vumeter' && (() => { + const current = settings as ScopeSettings['vumeter'] + return ( + <> + onUpdate('vumeter', { mode: value as ScopeSettings['vumeter']['mode'] })} + > + + + + + onUpdate('vumeter', { orientation: value as ScopeSettings['vumeter']['orientation'] })} + > + + + + + ) + })()} + + {kind === 'lufsmeter' && (() => { + const current = settings as ScopeSettings['lufsmeter'] + return ( + onUpdate('lufsmeter', { mode: value as ScopeSettings['lufsmeter']['mode'] })} + > + + + ) + })()} + + {kind === 'waveform' && (() => { + const current = settings as ScopeSettings['waveform'] + return ( + <> +
+ onUpdate('waveform', { multiband: !current.multiband })} + /> +
+ + 0 ? '+' : ''}${current.gainDb.toFixed(0)} dB`} + min={-12} + max={12} + step={1} + fullWidth={false} + onChange={(value) => onUpdate('waveform', { gainDb: value })} + /> + + onUpdate('waveform', { scrollSpeed: value })} + /> + + ) + })()} +
+
+ ) +} diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 5f1826e..e48df2d 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1,494 +1,29 @@ -import { useMemo, type CSSProperties, type JSX, type ReactNode } from 'react' -import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore' -import type { ScopeKind } from '../../types/scope' +import { useMemo, type CSSProperties, type JSX } from 'react' import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout' - -const SCOPE_LABELS: Record = { - spectrum: 'Spectrum', - oscilloscope: 'Oscilloscope', - vectorscope: 'Vectorscope', - spectrogram: 'Spectrogram', - vumeter: 'VU Meter', - lufsmeter: 'LUFS Meter', - waveform: 'Waveform', -} - -function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string { - switch (mode) { - case 'lissajous': - return 'Lissajous' - case 'polar-unipolar': - return 'Polar Uni' - case 'polar-bipolar': - return 'Polar Bi' - case 'linear-unipolar': - return 'Linear Uni' - case 'linear-bipolar': - return 'Linear Bi' - } -} - -function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string { - switch (kind) { - case 'spectrum': { - const scopeSettings = settings as ScopeSettings['spectrum'] - return `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}` - } - case 'oscilloscope': { - const scopeSettings = settings as ScopeSettings['oscilloscope'] - const mode = scopeSettings.pitchLock ? 'Pitch Lock' : 'Free Run' - return scopeSettings.underfillEnabled ? `${mode} · Fill` : mode - } - case 'vectorscope': { - const scopeSettings = settings as ScopeSettings['vectorscope'] - return scopeSettings.multiband - ? `${vectorscopeModeLabel(scopeSettings.mode)} · RGB` - : vectorscopeModeLabel(scopeSettings.mode) - } - case 'spectrogram': { - const scopeSettings = settings as ScopeSettings['spectrogram'] - return `${scopeSettings.scaleMode.toUpperCase()} · ${scopeSettings.clarityMode}` - } - case 'vumeter': { - const scopeSettings = settings as ScopeSettings['vumeter'] - return `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.orientation.toUpperCase()}` - } - case 'lufsmeter': - return 'Bar Meter' - case 'waveform': { - const scopeSettings = settings as ScopeSettings['waveform'] - return scopeSettings.multiband - ? `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB · RGB` - : `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB` - } - } -} - -function ToggleChip({ - label, - active, - onClick, -}: { - label: string - active: boolean - onClick: () => void -}): JSX.Element { - return ( - - ) -} - -function SelectControl({ - label, - value, - children, - onChange, -}: { - label: string - value: string | number - children: ReactNode - onChange: (value: string) => void -}): JSX.Element { - return ( - - ) -} - -function RangeControl({ - label, - value, - valueLabel, - min, - max, - step, - fullWidth = true, - disabled = false, - onChange, -}: { - label: string - value: number - valueLabel: string - min: number - max: number - step: number - fullWidth?: boolean - disabled?: boolean - onChange: (value: number) => void -}): JSX.Element { - return ( - - ) -} - -function ScopeSettingsSection({ - kind, - settings, - onUpdate, -}: { - kind: ScopeKind - settings: ScopeSettings - onUpdate: (kind: K, partial: Partial) => void -}): JSX.Element { - const scopeSettings = settings[kind] - - return ( -
-
-
{SCOPE_LABELS[kind]}
-
{scopeSummary(kind, scopeSettings)}
-
- -
- {kind === 'spectrum' && (() => { - const current = scopeSettings as ScopeSettings['spectrum'] - return ( - <> - onUpdate('spectrum', { fftSize: Number(value) })} - > - {[1024, 2048, 4096, 8192, 16384].map((option) => ( - - ))} - - -
- onUpdate('spectrum', { fillGradient: !current.fillGradient })} - /> - onUpdate('spectrum', { heatmap: !current.heatmap })} - /> - onUpdate('spectrum', { showGrid: !current.showGrid })} - /> -
- - onUpdate('spectrum', { tiltDbPerOctave: value })} - /> - - onUpdate('spectrum', { heatmapTiltDbPerOctave: value })} - /> - - onUpdate('spectrum', { smoothing: value })} - /> - - ) - })()} - - {kind === 'oscilloscope' && (() => { - const current = scopeSettings as ScopeSettings['oscilloscope'] - return ( - <> -
- onUpdate('oscilloscope', { pitchLock: !current.pitchLock })} - /> - onUpdate('oscilloscope', { underfillEnabled: !current.underfillEnabled })} - /> - onUpdate('oscilloscope', { showGrid: !current.showGrid })} - /> -
- - onUpdate('oscilloscope', { lineWidth: value })} - /> - - ) - })()} - - {kind === 'vectorscope' && (() => { - const current = scopeSettings as ScopeSettings['vectorscope'] - return ( - <> - onUpdate('vectorscope', { mode: value as ScopeSettings['vectorscope']['mode'] })} - > - - - - - - - -
- onUpdate('vectorscope', { multiband: !current.multiband })} - /> - onUpdate('vectorscope', { showGrid: !current.showGrid })} - /> -
- - onUpdate('vectorscope', { persistence: value })} - /> - - onUpdate('vectorscope', { lineWidth: value })} - /> - - ) - })()} - - {kind === 'spectrogram' && (() => { - const current = scopeSettings as ScopeSettings['spectrogram'] - return ( - <> - onUpdate('spectrogram', { fftSize: Number(value) })} - > - {[512, 1024, 2048, 4096].map((option) => ( - - ))} - - - onUpdate('spectrogram', { scaleMode: value as ScopeSettings['spectrogram']['scaleMode'] })} - > - - - - - - onUpdate('spectrogram', { clarityMode: value as ScopeSettings['spectrogram']['clarityMode'] })} - > - - - - - - onUpdate('spectrogram', { colorScheme: value as ScopeSettings['spectrogram']['colorScheme'] })} - > - - - - - onUpdate('spectrogram', { scrollSpeed: value })} - /> - - ) - })()} - - {kind === 'vumeter' && (() => { - const current = scopeSettings as ScopeSettings['vumeter'] - return ( - <> - onUpdate('vumeter', { mode: value as ScopeSettings['vumeter']['mode'] })} - > - - - - - onUpdate('vumeter', { orientation: value as ScopeSettings['vumeter']['orientation'] })} - > - - - - - ) - })()} - - {kind === 'lufsmeter' && (() => { - const current = scopeSettings as ScopeSettings['lufsmeter'] - return ( - onUpdate('lufsmeter', { mode: value as ScopeSettings['lufsmeter']['mode'] })} - > - - - ) - })()} - - {kind === 'waveform' && (() => { - const current = scopeSettings as ScopeSettings['waveform'] - return ( - <> -
- onUpdate('waveform', { multiband: !current.multiband })} - /> -
- - 0 ? '+' : ''}${current.gainDb.toFixed(0)} dB`} - min={-12} - max={12} - step={1} - fullWidth={false} - onChange={(value) => onUpdate('waveform', { gainDb: value })} - /> - - onUpdate('waveform', { scrollSpeed: value })} - /> - - ) - })()} -
-
- ) -} +import ScopeSettingsSection from './ScopeSettingsSection' +import { useSettingsStore } from '../stores/settingsStore' export default function SettingsPanel(): JSX.Element { - const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights } = useSettingsStore() + const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights, scopePopouts } = useSettingsStore() - const visibleScopes = useMemo( - () => scopeOrder.filter((kind) => !hiddenScopes.has(kind)), - [scopeOrder, hiddenScopes], + const dockedScopes = useMemo( + () => scopeOrder.filter((kind) => !hiddenScopes.has(kind) && !scopePopouts[kind]?.poppedOut), + [hiddenScopes, scopeOrder, scopePopouts], ) const scopeTrackStyle = useMemo(() => { - const gridTemplateColumns = buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights) + const gridTemplateColumns = buildAnalyzerGridTemplateColumns(dockedScopes, widthWeights) if (!gridTemplateColumns) return undefined return { gridTemplateColumns } as CSSProperties - }, [visibleScopes, widthWeights]) + }, [dockedScopes, widthWeights]) return (
- {visibleScopes.map((kind) => ( + {dockedScopes.map((kind) => ( ))} diff --git a/src/renderer/components/Strip.tsx b/src/renderer/components/Strip.tsx index a79df52..48018fa 100644 --- a/src/renderer/components/Strip.tsx +++ b/src/renderer/components/Strip.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react' import { useSettingsStore } from '../stores/settingsStore' import { useThemeStore } from '../stores/themeStore' -import type { ScopeKind } from '../../types/scope' +import { SCOPE_LABELS, type ScopeKind } from '../../types/scope' +import type { WindowBounds } from '../../types/popout' import ScopeModule from './ScopeModule' import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout' import { audioRouter } from '../audio/AudioRouter' @@ -9,44 +10,46 @@ import { audioRouter } from '../audio/AudioRouter' export default function Strip(): JSX.Element { const scopeOrder = useSettingsStore((s) => s.scopeOrder) const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) + const scopePopouts = useSettingsStore((s) => s.scopePopouts) const widthWeights = useSettingsStore((s) => s.widthWeights) const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight) + const popOutScope = useSettingsStore((s) => s.popOutScope) const accent = useThemeStore((s) => s.accent) const stripRef = useRef(null) const gridRef = useRef(null) const scopeRefs = useRef>>({}) const [handleOffsets, setHandleOffsets] = useState([]) - const visibleScopes = useMemo( - () => scopeOrder.filter((k) => !hiddenScopes.has(k)), - [hiddenScopes, scopeOrder], + const dockedScopes = useMemo( + () => scopeOrder.filter((k) => !hiddenScopes.has(k) && !scopePopouts[k]?.poppedOut), + [hiddenScopes, scopeOrder, scopePopouts], ) - const visibleScopeKey = useMemo(() => visibleScopes.join('|'), [visibleScopes]) + const visibleScopeKey = useMemo(() => dockedScopes.join('|'), [dockedScopes]) const gridTemplateColumns = useMemo(() => { - return buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights) - }, [visibleScopes, widthWeights]) + return buildAnalyzerGridTemplateColumns(dockedScopes, widthWeights) + }, [dockedScopes, widthWeights]) const gridStyle = useMemo(() => { if (!gridTemplateColumns) return undefined return { gridTemplateColumns } as CSSProperties }, [gridTemplateColumns]) const updateHandleOffsets = useCallback((): void => { - if (visibleScopes.length < 2) { + if (dockedScopes.length < 2) { setHandleOffsets([]) return } const nextOffsets: number[] = [] - for (let index = 0; index < visibleScopes.length - 1; index += 1) { - const leftElement = scopeRefs.current[visibleScopes[index]] + for (let index = 0; index < dockedScopes.length - 1; index += 1) { + const leftElement = scopeRefs.current[dockedScopes[index]] if (!leftElement) continue nextOffsets.push(leftElement.offsetLeft + leftElement.offsetWidth) } setHandleOffsets(nextOffsets) - }, [visibleScopes]) + }, [dockedScopes]) useEffect(() => { - const visibleScopeSet = new Set(visibleScopes) + const visibleScopeSet = new Set(dockedScopes) audioRouter.setVisualizerConsumerDemand('docked-strip', { spectrum: visibleScopeSet.has('spectrum'), oscilloscope: visibleScopeSet.has('oscilloscope'), @@ -60,7 +63,7 @@ export default function Strip(): JSX.Element { return () => { audioRouter.clearVisualizerConsumerDemand('docked-strip') } - }, [visibleScopeKey, visibleScopes]) + }, [visibleScopeKey, dockedScopes]) useEffect(() => { const strip = stripRef.current @@ -97,7 +100,7 @@ export default function Strip(): JSX.Element { observer?.observe(gridRef.current) } - for (const scope of visibleScopes) { + for (const scope of dockedScopes) { const element = scopeRefs.current[scope] if (element) { observer?.observe(element) @@ -110,11 +113,11 @@ export default function Strip(): JSX.Element { observer?.disconnect() window.removeEventListener('resize', updateHandleOffsets) } - }, [gridTemplateColumns, updateHandleOffsets, visibleScopes]) + }, [dockedScopes, gridTemplateColumns, updateHandleOffsets]) const startResizeDrag = useCallback((handleIndex: number, event: React.MouseEvent) => { - const leftKind = visibleScopes[handleIndex] - const rightKind = visibleScopes[handleIndex + 1] + const leftKind = dockedScopes[handleIndex] + const rightKind = dockedScopes[handleIndex + 1] if (!leftKind || !rightKind) return const leftElement = scopeRefs.current[leftKind] @@ -164,12 +167,30 @@ export default function Strip(): JSX.Element { document.body.style.userSelect = 'none' document.addEventListener('mousemove', onMouseMove) document.addEventListener('mouseup', onMouseUp) - }, [setScopeWidthWeight, visibleScopes]) + }, [dockedScopes, setScopeWidthWeight]) + + const handlePopoutScope = useCallback(async (kind: ScopeKind): Promise => { + const element = scopeRefs.current[kind] + const rect = element?.getBoundingClientRect() + const windowBounds = await window.electronAPI.getWindowBounds() + + let nextBounds: WindowBounds | undefined + if (rect && windowBounds) { + nextBounds = { + x: Math.round(windowBounds.x + rect.left), + y: Math.round(windowBounds.y + rect.top), + width: Math.max(220, Math.round(rect.width)), + height: Math.max(160, Math.round(rect.height)), + } + } + + popOutScope(kind, nextBounds) + }, [popOutScope]) return (
- {visibleScopes.map((kind) => ( + {dockedScopes.map((kind) => (
{ @@ -177,6 +198,19 @@ export default function Strip(): JSX.Element { }} className="scope-strip__cell" > + - {visibleScopes.length > 1 && handleOffsets.map((offset, index) => ( + {dockedScopes.length > 1 && handleOffsets.map((offset, index) => ( diff --git a/src/renderer/components/Toolbar.tsx b/src/renderer/components/Toolbar.tsx index 51192a3..f87cd52 100644 --- a/src/renderer/components/Toolbar.tsx +++ b/src/renderer/components/Toolbar.tsx @@ -68,6 +68,8 @@ interface ToolbarProps { settingsOpen: boolean } +const DEFAULT_PROFILE_ID = 'profile_default' + export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): JSX.Element { const profiles = useSettingsStore((s) => s.profiles) const activeProfileId = useSettingsStore((s) => s.activeProfileId) @@ -78,11 +80,8 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): 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(null) - const [renameValue, setRenameValue] = useState('') - const profileMenuRef = useRef(null) - const renameInputRef = useRef(null) + const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false) + const profileButtonRef = useRef(null) useEffect(() => { window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop) @@ -90,26 +89,69 @@ 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]) + const handleSaveNew = useCallback(() => { + const count = Object.keys(useSettingsStore.getState().profiles).length + saveProfile(`Profile ${count}`) + setIsProfileMenuOpen(false) + }, [saveProfile]) - // Focus rename input when it appears - useEffect(() => { - if (renamingId && renameInputRef.current) { - renameInputRef.current.focus() - renameInputRef.current.select() + const handleSaveOverwrite = useCallback(() => { + updateActiveProfile() + setIsProfileMenuOpen(false) + }, [updateActiveProfile]) + + const handleRenameActive = useCallback((id: string) => { + const profile = useSettingsStore.getState().profiles[id] + if (!profile || id === DEFAULT_PROFILE_ID) { + setIsProfileMenuOpen(false) + return } - }, [renamingId]) + + const nextName = window.prompt('Rename preset', profile.name)?.trim() + if (nextName) { + renameProfile(id, nextName) + } + setIsProfileMenuOpen(false) + }, [renameProfile]) + + const handleDeleteActive = useCallback((id: string) => { + const profile = useSettingsStore.getState().profiles[id] + if (!profile || id === DEFAULT_PROFILE_ID) { + setIsProfileMenuOpen(false) + return + } + + if (!window.confirm(`Delete "${profile.name}"?`)) { + setIsProfileMenuOpen(false) + return + } + + deleteProfile(id) + setIsProfileMenuOpen(false) + }, [deleteProfile]) + + useEffect(() => { + const offClosed = window.electronAPI.onProfileMenuClosed(() => { + setIsProfileMenuOpen(false) + }) + const offLoad = window.electronAPI.onProfileMenuLoad((id) => { + loadProfile(id) + setIsProfileMenuOpen(false) + }) + const offSaveNew = window.electronAPI.onProfileMenuSaveNew(handleSaveNew) + const offSaveOverwrite = window.electronAPI.onProfileMenuSaveOverwrite(handleSaveOverwrite) + const offRename = window.electronAPI.onProfileMenuRenameActive(handleRenameActive) + const offDelete = window.electronAPI.onProfileMenuDeleteActive(handleDeleteActive) + + return () => { + offClosed() + offLoad() + offSaveNew() + offSaveOverwrite() + offRename() + offDelete() + } + }, [handleDeleteActive, handleRenameActive, handleSaveNew, handleSaveOverwrite, loadProfile]) const handlePin = useCallback(() => { window.electronAPI.toggleAlwaysOnTop() @@ -120,30 +162,24 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): setShowReposition(false) }, []) - const handleSaveNew = useCallback(() => { - const count = Object.keys(profiles).length - saveProfile(`Profile ${count}`) - setShowProfileMenu(false) - }, [profiles, saveProfile]) + const handleOpenProfileMenu = useCallback(() => { + const buttonRect = profileButtonRef.current?.getBoundingClientRect() + if (!buttonRect) return - const handleSaveOverwrite = useCallback(() => { - updateActiveProfile() - setShowProfileMenu(false) - }, [updateActiveProfile]) + setShowReposition(false) + setIsProfileMenuOpen(true) + window.electronAPI.openProfileMenu({ + x: Math.round(buttonRect.left), + y: Math.round(buttonRect.bottom + 4), + activeProfileId, + profiles: Object.entries(profiles).map(([id, profile]) => ({ + id, + name: profile.name, + isDefault: id === DEFAULT_PROFILE_ID, + })), + }) + }, [activeProfileId, profiles]) - 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 ( @@ -164,11 +200,12 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): Prism
-
+
- - {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 && ( - - )} -
- )}
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> + setWindowBounds: (bounds: WindowBounds) => void + getWindowBounds: () => Promise repositionWindow: (position: 'top' | 'bottom') => void toggleAlwaysOnTop: () => void isAlwaysOnTop: () => Promise @@ -24,10 +33,31 @@ declare global { expandSettings: (panelHeight: number) => void collapseSettings: (panelHeight: number) => void setSettingsHeight: (panelHeight: number) => void + openProfileMenu: (request: ProfileMenuRequest) => void + syncScopePopouts: (state: ScopePopoutSyncStateMap) => void + sendScopePopoutSnapshot: (snapshot: ScopePopoutSnapshot) => void + sendScopePopoutAudio: (kind: ScopeKind, batch: ScopePopoutAudioBatch) => void + sendScopePopoutSession: (kind: ScopeKind, session: ScopePopoutSessionState) => void + notifyScopePopoutReady: (kind: ScopeKind) => void + requestScopePopIn: (kind: ScopeKind) => void + sendScopePopoutSettingsUpdate: (kind: ScopeKind, partial: unknown) => void onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => () => void onToggleScope: (callback: (index: number) => void) => () => void onToggleCapture: (callback: () => void) => () => void onToggleSettings: (callback: () => void) => () => void + onProfileMenuClosed: (callback: () => void) => () => void + onProfileMenuLoad: (callback: (id: string) => void) => () => void + onProfileMenuSaveNew: (callback: () => void) => () => void + onProfileMenuSaveOverwrite: (callback: () => void) => () => void + onProfileMenuRenameActive: (callback: (id: string) => void) => () => void + onProfileMenuDeleteActive: (callback: (id: string) => void) => () => void + onScopePopoutReady: (callback: (kind: ScopeKind) => void) => () => void + onScopePopoutCloseRequested: (callback: (kind: ScopeKind) => void) => () => void + onScopePopoutBoundsChanged: (callback: (kind: ScopeKind, bounds: WindowBounds) => void) => () => void + onScopePopoutSettingsUpdate: (callback: (kind: ScopeKind, partial: unknown) => void) => () => void + onScopePopoutSnapshot: (callback: (snapshot: ScopePopoutSnapshot) => void) => () => void + onScopePopoutAudio: (callback: (kind: ScopeKind, batch: ScopePopoutAudioBatch) => void) => () => void + onScopePopoutSession: (callback: (kind: ScopeKind, session: ScopePopoutSessionState) => void) => () => void } } } diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 729d5a5..c576328 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -1,14 +1,30 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' +import ScopePopoutWindow from './popouts/ScopePopoutWindow' import './styles/globals.css' import '@fontsource/inter/400.css' import '@fontsource/inter/500.css' import '@fontsource/inter/600.css' import '@fontsource/jetbrains-mono/400.css' +import { SCOPE_KINDS, type ScopeKind } from '../types/scope' + +function isScopeKind(value: string | null): value is ScopeKind { + return value !== null && SCOPE_KINDS.includes(value as ScopeKind) +} + +const params = new URLSearchParams(window.location.search) +const windowRole = params.get('window') +const scopeKind = params.get('scope') + +const root = windowRole === 'scope-popout' + ? isScopeKind(scopeKind) + ? + :
Invalid scope popout
+ : ReactDOM.createRoot(document.getElementById('root')!).render( - + {root} ) diff --git a/src/renderer/popouts/ScopePopoutDataSource.ts b/src/renderer/popouts/ScopePopoutDataSource.ts new file mode 100644 index 0000000..1a0e227 --- /dev/null +++ b/src/renderer/popouts/ScopePopoutDataSource.ts @@ -0,0 +1,128 @@ +import type { + ScopePopoutAudioBatch, + ScopePopoutSessionState, + ScopePopoutStereoBatch, +} from '../../types/popout' +import type { ScopeKind } from '../../types/scope' +import type { LUFSMeterDataSource } from '../visualizers/LUFSMeter' +import type { OscilloscopeDataSource } from '../visualizers/Oscilloscope' +import type { SpectrogramDataSource } from '../visualizers/Spectrogram' +import type { SpectrumAnalyzerDataSource } from '../visualizers/SpectrumAnalyzer' +import type { VectorscopeDataSource } from '../visualizers/Vectorscope' +import type { VUMeterDataSource } from '../visualizers/VUMeter' +import type { WaveformDataSource } from '../visualizers/Waveform' + +type AnyScopeDataSource = + & SpectrumAnalyzerDataSource + & OscilloscopeDataSource + & VectorscopeDataSource + & SpectrogramDataSource + & VUMeterDataSource + & LUFSMeterDataSource + & WaveformDataSource + +const INITIAL_SESSION_STATE: ScopePopoutSessionState = { + sessionId: 0, + sampleRate: 48000, + channelCount: 2, + capturing: false, + backendKind: null, +} + +function isStereoBatch(batch: ScopePopoutAudioBatch): batch is ScopePopoutStereoBatch { + return batch.length > 0 && typeof batch[0] === 'object' && batch[0] !== null && 'left' in batch[0] && 'right' in batch[0] +} + +function isStereoScope(kind: ScopeKind): boolean { + return kind === 'vectorscope' || kind === 'vumeter' || kind === 'lufsmeter' +} + +export class ScopePopoutDataSource implements AnyScopeDataSource { + private monoQueue: Float32Array[] = [] + private stereoQueue: ScopePopoutStereoBatch = [] + private sessionState: ScopePopoutSessionState = INITIAL_SESSION_STATE + private readonly listeners = new Set<(state: ScopePopoutSessionState) => void>() + + constructor(private readonly scopeKind: ScopeKind) {} + + pushAudioBatch(batch: ScopePopoutAudioBatch): void { + if (isStereoScope(this.scopeKind)) { + if (!isStereoBatch(batch)) return + this.stereoQueue.push(...batch) + return + } + + if (isStereoBatch(batch)) return + this.monoQueue.push(...batch) + } + + setSessionState(nextState: ScopePopoutSessionState): void { + this.sessionState = nextState + if (!nextState.capturing) { + this.monoQueue = [] + this.stereoQueue = [] + } + + for (const listener of this.listeners) { + listener(this.sessionState) + } + } + + getSampleRate(): number { + return this.sessionState.sampleRate + } + + isPlaying(): boolean { + return this.sessionState.capturing + } + + subscribeToSessionChanges(listener: (state: ScopePopoutSessionState) => void): () => void { + this.listeners.add(listener) + listener(this.sessionState) + return () => { + this.listeners.delete(listener) + } + } + + getPendingSpectrumSamples(): Float32Array[] { + const batch = this.monoQueue + this.monoQueue = [] + return this.scopeKind === 'spectrum' ? batch : [] + } + + getPendingOscilloscopeSamples(): Float32Array[] { + const batch = this.monoQueue + this.monoQueue = [] + return this.scopeKind === 'oscilloscope' ? batch : [] + } + + getPendingSpectrogramSamples(): Float32Array[] { + const batch = this.monoQueue + this.monoQueue = [] + return this.scopeKind === 'spectrogram' ? batch : [] + } + + getPendingWaveformSamples(): Float32Array[] { + const batch = this.monoQueue + this.monoQueue = [] + return this.scopeKind === 'waveform' ? batch : [] + } + + getPendingVectorscopeSamples(): ScopePopoutStereoBatch { + const batch = this.stereoQueue + this.stereoQueue = [] + return this.scopeKind === 'vectorscope' ? batch : [] + } + + getPendingVUMeterSamples(): ScopePopoutStereoBatch { + const batch = this.stereoQueue + this.stereoQueue = [] + return this.scopeKind === 'vumeter' ? batch : [] + } + + getPendingLUFSMeterSamples(): ScopePopoutStereoBatch { + const batch = this.stereoQueue + this.stereoQueue = [] + return this.scopeKind === 'lufsmeter' ? batch : [] + } +} diff --git a/src/renderer/popouts/ScopePopoutWindow.tsx b/src/renderer/popouts/ScopePopoutWindow.tsx new file mode 100644 index 0000000..4586c45 --- /dev/null +++ b/src/renderer/popouts/ScopePopoutWindow.tsx @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type JSX, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from 'react' +import type { ScopePopoutSnapshot } from '../../types/popout' +import { SCOPE_LABELS, type ScopeKind } from '../../types/scope' +import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../../types/settings' +import ScopeModule from '../components/ScopeModule' +import ScopeSettingsSection from '../components/ScopeSettingsSection' +import { applyAccentToDOM } from '../stores/themeStore' +import { ScopePopoutDataSource } from './ScopePopoutDataSource' + +function PopInIcon(): JSX.Element { + return ( + + ) +} + +function SettingsIcon(): JSX.Element { + return ( + + ) +} + +function GripIcon(): JSX.Element { + return ( + + ) +} + +interface ScopePopoutWindowProps { + scopeKind: ScopeKind +} + +export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps): JSX.Element { + const [snapshot, setSnapshot] = useState | null>(null) + const [miniSettingsOpen, setMiniSettingsOpen] = useState(false) + const [chromeHeight, setChromeHeight] = useState(0) + const chromeRef = useRef(null) + const dataSource = useMemo(() => new ScopePopoutDataSource(scopeKind), [scopeKind]) + + useEffect(() => { + const unsubscribeSnapshot = window.electronAPI.onScopePopoutSnapshot((nextSnapshot) => { + if (nextSnapshot.kind !== scopeKind) return + setSnapshot(nextSnapshot) + applyAccentToDOM(nextSnapshot.accent) + }) + const unsubscribeAudio = window.electronAPI.onScopePopoutAudio((kind, batch) => { + if (kind !== scopeKind) return + dataSource.pushAudioBatch(batch) + }) + const unsubscribeSession = window.electronAPI.onScopePopoutSession((kind, sessionState) => { + if (kind !== scopeKind) return + dataSource.setSessionState(sessionState) + }) + + window.electronAPI.notifyScopePopoutReady(scopeKind) + + return () => { + unsubscribeSnapshot() + unsubscribeAudio() + unsubscribeSession() + } + }, [dataSource, scopeKind]) + + const effectiveAccent = snapshot?.accent ?? '#38bdf8' + const effectiveSettings = (snapshot?.settings ?? DEFAULT_SCOPE_SETTINGS[scopeKind]) as ScopeSettings[ScopeKind] + + const handleUpdateScopeSettings = (kind: K, partial: Partial): void => { + if (kind !== scopeKind) return + + setSnapshot((prev) => { + if (!prev) return prev + return { + ...prev, + settings: { + ...prev.settings, + ...partial, + } as ScopeSettings[K], + } + }) + window.electronAPI.sendScopePopoutSettingsUpdate(kind, partial) + } + + const handleDragStart = useCallback((event: ReactPointerEvent): void => { + if (event.button !== 0) return + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + window.electronAPI.startWindowMove() + }, []) + + const handleDragEnd = useCallback((event: ReactPointerEvent): void => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + window.electronAPI.stopWindowMove() + }, []) + + const handleAltDragStart = useCallback((event: ReactMouseEvent): void => { + if (!event.altKey || event.button !== 0) return + + const target = event.target + if (target instanceof Element && target.closest('.scope-popout__drag-handle')) { + return + } + + event.preventDefault() + window.electronAPI.startWindowMove() + }, []) + + const handleAltDragEnd = useCallback((): void => { + window.electronAPI.stopWindowMove() + }, []) + + useEffect(() => { + const chrome = chromeRef.current + if (!chrome) return + + const updateHeight = (): void => { + setChromeHeight(chrome.scrollHeight) + } + + updateHeight() + + const observer = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => updateHeight()) + observer?.observe(chrome) + + return () => observer?.disconnect() + }, [miniSettingsOpen, snapshot]) + + useEffect(() => { + return () => { + window.electronAPI.stopWindowMove() + } + }, []) + + return ( +
+
+
+
+ +
+ {snapshot?.label ?? SCOPE_LABELS[scopeKind]} + Detached Scope +
+
+ +
+ + +
+
+ + {miniSettingsOpen && ( +
+ +
+ )} +
+ +
+
+ +
+
+
+ ) +} diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 876d19b..4bffce9 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -1,64 +1,9 @@ import { create } from 'zustand' import { SCOPE_KINDS, type ScopeKind } from '../../types/scope' -import type { VectorscopeMode } from '../visualizers/Vectorscope' -import type { SpectrogramClarityMode, SpectrogramScaleMode } from '../../types/spectrogram' -import type { VUMeterMode, VUMeterOrientation } from '../../types/vumeter' -import type { LUFSMeterMode } from '../../types/lufsmeter' +import type { ScopePopoutStateMap, WindowBounds } from '../../types/popout' +import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../../types/settings' -// Per-scope settings (mirrors Astra's AnalyzerProfileScopeSettings) -export interface ScopeSettings { - spectrum: { - fftSize: number - tiltDbPerOctave: number - heatmap: boolean - heatmapTiltDbPerOctave: number - showGrid: boolean - smoothing: number - fillGradient: boolean - } - oscilloscope: { - pitchLock: boolean - underfillEnabled: boolean - showGrid: boolean - lineWidth: number - } - vectorscope: { - mode: VectorscopeMode - multiband: boolean - showGrid: boolean - persistence: number - lineWidth: number - } - spectrogram: { - fftSize: number - scrollSpeed: number - clarityMode: SpectrogramClarityMode - scaleMode: SpectrogramScaleMode - colorScheme: 'heat' | 'mono' - } - vumeter: { - mode: VUMeterMode - orientation: VUMeterOrientation - } - lufsmeter: { - mode: LUFSMeterMode - } - waveform: { - scrollSpeed: number - gainDb: number - multiband: boolean - } -} - -const DEFAULT_SCOPE_SETTINGS: ScopeSettings = { - spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, showGrid: true, smoothing: 0.9, fillGradient: true }, - oscilloscope: { pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, - vectorscope: { mode: 'lissajous', multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 }, - spectrogram: { fftSize: 2048, scrollSpeed: 2, clarityMode: 'sharper', scaleMode: 'log', colorScheme: 'heat' }, - vumeter: { mode: 'bar', orientation: 'horizontal' }, - lufsmeter: { mode: 'bar' }, - waveform: { scrollSpeed: 1, gainDb: 0, multiband: false }, -} +export type { ScopeSettings } from '../../types/settings' const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter'] @@ -73,7 +18,8 @@ export interface Profile { hiddenScopes: ScopeKind[] widthWeights: Record scopeSettings: ScopeSettings - windowBounds?: { x: number; y: number; width: number; height: number } + scopePopouts: ScopePopoutStateMap + windowBounds?: WindowBounds } function loadProfiles(): Record { @@ -111,6 +57,7 @@ interface SettingsState { hiddenScopes: Set widthWeights: Record scopeSettings: ScopeSettings + scopePopouts: ScopePopoutStateMap // Derived visibleScopes: () => ScopeKind[] @@ -124,6 +71,9 @@ interface SettingsState { moveScope: (kind: ScopeKind, direction: 'left' | 'right') => void setScopeWidthWeight: (kind: ScopeKind, weight: number) => void updateScopeSettings: (kind: K, settings: Partial) => void + popOutScope: (kind: ScopeKind, bounds?: WindowBounds) => void + popInScope: (kind: ScopeKind) => void + updatePopoutBounds: (kind: ScopeKind, bounds: WindowBounds) => void saveProfile: (name: string) => string saveProfileAs: (name: string) => string updateActiveProfile: () => void @@ -132,7 +82,13 @@ interface SettingsState { renameProfile: (id: string, name: string) => void } -function loadFromStorage(): Partial<{ scopeOrder: ScopeKind[]; hiddenScopes: ScopeKind[]; widthWeights: Record; scopeSettings: ScopeSettings }> { +function loadFromStorage(): Partial<{ + scopeOrder: ScopeKind[] + hiddenScopes: ScopeKind[] + widthWeights: Record + scopeSettings: ScopeSettings + scopePopouts: ScopePopoutStateMap +}> { try { const raw = localStorage.getItem(STORAGE_KEY) if (raw) return JSON.parse(raw) @@ -147,6 +103,7 @@ function saveToStorage(state: SettingsState): void { hiddenScopes: Array.from(state.hiddenScopes), widthWeights: state.widthWeights, scopeSettings: state.scopeSettings, + scopePopouts: state.scopePopouts, })) } catch { /* ignore */ } } @@ -199,6 +156,53 @@ function mergeScopeSettings(raw: unknown): ScopeSettings { } } +function createDefaultScopePopouts(): ScopePopoutStateMap { + return SCOPE_KINDS.reduce((acc, kind) => { + acc[kind] = { poppedOut: false } + return acc + }, {} as ScopePopoutStateMap) +} + +function normalizeWindowBounds(raw: unknown): WindowBounds | undefined { + if (typeof raw !== 'object' || raw === null) return undefined + const candidate = raw as Partial + if ( + typeof candidate.x !== 'number' + || typeof candidate.y !== 'number' + || typeof candidate.width !== 'number' + || typeof candidate.height !== 'number' + ) { + return undefined + } + return { + x: Math.round(candidate.x), + y: Math.round(candidate.y), + width: Math.max(120, Math.round(candidate.width)), + height: Math.max(80, Math.round(candidate.height)), + } +} + +function normalizeScopePopouts(raw: unknown): ScopePopoutStateMap { + const defaults = createDefaultScopePopouts() + const parsed = typeof raw === 'object' && raw !== null + ? raw as Partial>> + : {} + + for (const kind of SCOPE_KINDS) { + const value = parsed[kind] + defaults[kind] = { + poppedOut: Boolean(value?.poppedOut), + windowBounds: normalizeWindowBounds(value?.windowBounds), + } + } + + return defaults +} + +function cloneScopeSettings(settings: ScopeSettings): ScopeSettings { + return JSON.parse(JSON.stringify(settings)) as ScopeSettings +} + const stored = loadFromStorage() const defaultWeights: Record = { @@ -214,7 +218,8 @@ function ensureDefaultProfile(profiles: Record): Record !DEFAULT_VISIBLE.includes(kind)), widthWeights: { ...defaultWeights }, - scopeSettings: JSON.parse(JSON.stringify(DEFAULT_SCOPE_SETTINGS)), + scopeSettings: cloneScopeSettings(DEFAULT_SCOPE_SETTINGS), + scopePopouts: createDefaultScopePopouts(), } const updated = { [DEFAULT_PROFILE_ID]: defaultProfile, ...profiles } saveProfiles(updated) @@ -231,6 +236,7 @@ export const useSettingsStore = create((set, get) => ({ ), widthWeights: stored.widthWeights ?? { ...defaultWeights }, scopeSettings: mergeScopeSettings(stored.scopeSettings), + scopePopouts: normalizeScopePopouts(stored.scopePopouts), profiles: initialProfiles, activeProfileId: initialActiveProfileId, @@ -292,6 +298,51 @@ export const useSettingsStore = create((set, get) => ({ }) }, + popOutScope: (kind: ScopeKind, bounds?: WindowBounds) => { + set((state) => { + const nextPopout = { + ...state.scopePopouts, + [kind]: { + poppedOut: true, + windowBounds: bounds ?? state.scopePopouts[kind]?.windowBounds, + }, + } + const newState = { ...state, scopePopouts: nextPopout } + saveToStorage(newState as SettingsState) + return newState + }) + }, + + popInScope: (kind: ScopeKind) => { + set((state) => { + const nextPopout = { + ...state.scopePopouts, + [kind]: { + ...state.scopePopouts[kind], + poppedOut: false, + }, + } + const newState = { ...state, scopePopouts: nextPopout } + saveToStorage(newState as SettingsState) + return newState + }) + }, + + updatePopoutBounds: (kind: ScopeKind, bounds: WindowBounds) => { + set((state) => { + const nextPopout = { + ...state.scopePopouts, + [kind]: { + ...state.scopePopouts[kind], + windowBounds: normalizeWindowBounds(bounds), + }, + } + const newState = { ...state, scopePopouts: nextPopout } + saveToStorage(newState as SettingsState) + return newState + }) + }, + saveProfile: (name: string) => { const state = get() const id = `profile_${Date.now()}` @@ -300,7 +351,8 @@ export const useSettingsStore = create((set, get) => ({ scopeOrder: [...state.scopeOrder], hiddenScopes: Array.from(state.hiddenScopes), widthWeights: { ...state.widthWeights }, - scopeSettings: JSON.parse(JSON.stringify(state.scopeSettings)), + scopeSettings: cloneScopeSettings(state.scopeSettings), + scopePopouts: normalizeScopePopouts(state.scopePopouts), } // Capture window bounds asynchronously window.electronAPI.getWindowBounds().then((bounds) => { @@ -332,7 +384,8 @@ export const useSettingsStore = create((set, get) => ({ scopeOrder: [...state.scopeOrder], hiddenScopes: Array.from(state.hiddenScopes), widthWeights: { ...state.widthWeights }, - scopeSettings: JSON.parse(JSON.stringify(state.scopeSettings)), + scopeSettings: cloneScopeSettings(state.scopeSettings), + scopePopouts: normalizeScopePopouts(state.scopePopouts), } // Capture window bounds asynchronously window.electronAPI.getWindowBounds().then((bounds) => { @@ -358,6 +411,7 @@ export const useSettingsStore = create((set, get) => ({ hiddenScopes: new Set(normalizeHiddenScopes(profile.hiddenScopes)), widthWeights: profile.widthWeights ?? { ...defaultWeights }, scopeSettings: mergeScopeSettings(profile.scopeSettings), + scopePopouts: normalizeScopePopouts(profile.scopePopouts), activeProfileId: id, } saveToStorage(newState as SettingsState) @@ -403,5 +457,6 @@ if (initialActiveProfileId && initialProfiles[initialActiveProfileId]) { hiddenScopes: new Set(normalizeHiddenScopes(profile.hiddenScopes)), widthWeights: profile.widthWeights ?? { ...defaultWeights }, scopeSettings: mergeScopeSettings(profile.scopeSettings), + scopePopouts: normalizeScopePopouts(profile.scopePopouts), }) } diff --git a/src/renderer/stores/themeStore.ts b/src/renderer/stores/themeStore.ts index a5eb98e..350fa3e 100644 --- a/src/renderer/stores/themeStore.ts +++ b/src/renderer/stores/themeStore.ts @@ -90,7 +90,7 @@ function loadTheme(): { presetId: string; customAccent: string | null } { return { presetId: 'default', customAccent: null } } -function applyToDOM(accent: string): void { +export function applyAccentToDOM(accent: string): void { const rgb = hexToRgb(accent) const root = document.documentElement root.style.setProperty('--accent', accent) @@ -104,7 +104,7 @@ const initialPreset = PRESETS[stored.presetId] ?? PRESETS.default const initialAccent = stored.customAccent ?? initialPreset.accent // Apply immediately on load -applyToDOM(initialAccent) +applyAccentToDOM(initialAccent) export const useThemeStore = create((set) => ({ presetId: stored.presetId, @@ -113,7 +113,7 @@ export const useThemeStore = create((set) => ({ setPreset: (id: string) => { const preset = PRESETS[id] ?? PRESETS.default - applyToDOM(preset.accent) + applyAccentToDOM(preset.accent) const state = { presetId: id, customAccent: null, accent: preset.accent } localStorage.setItem(STORAGE_KEY, JSON.stringify({ presetId: id, customAccent: null })) set(state) @@ -123,7 +123,7 @@ export const useThemeStore = create((set) => ({ set((prev) => { const preset = PRESETS[prev.presetId] ?? PRESETS.default const accent = hex ?? preset.accent - applyToDOM(accent) + applyAccentToDOM(accent) localStorage.setItem(STORAGE_KEY, JSON.stringify({ presetId: prev.presetId, customAccent: hex })) return { ...prev, customAccent: hex, accent } }) diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 0ddc201..bb581b2 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -203,148 +203,6 @@ select { 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; @@ -486,6 +344,46 @@ select { overflow: hidden; } +.scope-strip__popout-button { + position: absolute; + bottom: 8px; + right: 8px; + z-index: 3; + width: 28px; + height: 28px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(8, 12, 18, 0.74); + color: rgba(255, 255, 255, 0.76); + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + cursor: pointer; + opacity: 0; + transform: translateY(-2px); + transition: opacity 120ms ease, transform 120ms ease, border-color 120ms ease, color 120ms ease; + backdrop-filter: blur(14px) saturate(1.04); + -webkit-backdrop-filter: blur(14px) saturate(1.04); +} + +.scope-strip__cell:hover .scope-strip__popout-button, +.scope-strip__popout-button:focus-visible { + opacity: 1; + transform: translateY(0); +} + +.scope-strip__popout-button:hover { + color: var(--accent); + border-color: rgba(var(--accent-rgb), 0.28); +} + +.scope-strip__popout-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + .scope-strip__resize-handle { position: absolute; top: 0; @@ -906,6 +804,180 @@ select { margin-right: 14px; } +.scope-popout { + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; + background: + radial-gradient(circle at top, rgba(var(--accent-rgb), 0.1), transparent 26%), + linear-gradient(180deg, rgba(3, 4, 6, 0.98), rgba(0, 0, 0, 1)); + color: var(--text-primary); +} + +.scope-popout__chrome { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; + max-height: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; + transform: translateY(-8px); + transition: max-height 160ms ease, opacity 120ms ease, transform 160ms ease; +} + +.scope-popout:hover .scope-popout__chrome, +.scope-popout__chrome:hover, +.scope-popout__chrome.is-expanded { + max-height: 58px; + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.scope-popout__chrome.is-expanded { + max-height: 420px; +} + +.scope-popout__header { + display: flex; + align-items: center; + gap: 10px; + min-height: 42px; + padding: 8px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(7, 10, 14, 0.92); +} + +.scope-popout__drag { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 10px; +} + +.scope-popout__drag-handle { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: inherit; + cursor: grab; + -webkit-app-region: no-drag; +} + +.scope-popout__drag-handle:active { + cursor: grabbing; +} + +.scope-popout__drag-icon { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.32); +} + +.scope-popout__drag-icon svg { + width: 14px; + height: 14px; +} + +.scope-popout__title-group { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.scope-popout__title { + font-family: 'JetBrains Mono', monospace; + letter-spacing: 0.12em; + text-transform: uppercase; + font-size: 10px; + color: var(--accent); +} + +.scope-popout__subtitle { + font-size: 10px; + color: var(--text-tertiary); +} + +.scope-popout__actions { + display: flex; + align-items: center; + gap: 6px; + -webkit-app-region: no-drag; +} + +.scope-popout__button { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.03); + color: rgba(255, 255, 255, 0.64); + cursor: pointer; + transition: color 120ms ease, border-color 120ms ease, background-color 120ms ease, transform 120ms ease; +} + +.scope-popout__button svg { + width: 14px; + height: 14px; +} + +.scope-popout__button:hover, +.scope-popout__button.is-active { + color: var(--accent); + border-color: rgba(var(--accent-rgb), 0.28); + background: rgba(var(--accent-rgb), 0.1); + transform: translateY(-1px); +} + +.scope-popout__settings-panel { + flex-shrink: 0; + overflow-y: auto; + overflow-x: hidden; + background: linear-gradient(180deg, rgba(6, 8, 11, 0.98), rgba(4, 5, 7, 0.98)); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + padding: 12px 0 8px; +} + +.scope-popout__settings-panel .settings-scope-section { + border-left: 0; + padding: 0 14px 12px; +} + +.scope-popout__content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + transition: padding-top 160ms ease; +} + +.scope-popout__canvas-region { + flex: 1; + min-height: 0; + position: relative; +} + ::-webkit-scrollbar { width: 6px; height: 6px; diff --git a/src/renderer/visualizers/LUFSMeter.ts b/src/renderer/visualizers/LUFSMeter.ts index 83a2046..d42a0ec 100644 --- a/src/renderer/visualizers/LUFSMeter.ts +++ b/src/renderer/visualizers/LUFSMeter.ts @@ -1,10 +1,9 @@ import { audioRouter } from '../audio/AudioRouter' import type { LUFSMeterMode } from '../../types/lufsmeter' +import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' -export interface LUFSMeterDataSource { +export interface LUFSMeterDataSource extends VisualizerSessionSource { getPendingLUFSMeterSamples: () => Array<{ left: Float32Array; right: Float32Array }> - getSampleRate: () => number - isPlaying: () => boolean } export interface LUFSMeterOptions { @@ -22,8 +21,7 @@ const defaultOptions: ResolvedLUFSMeterOptions = { const defaultLUFSMeterDataSource: LUFSMeterDataSource = { getPendingLUFSMeterSamples: () => audioRouter.flushPendingLUFSMeterSamples(), - getSampleRate: () => audioRouter.getSampleRate(), - isPlaying: () => audioRouter.isCapturing(), + ...defaultVisualizerSessionSource, } // ---- Constants ---- diff --git a/src/renderer/visualizers/Oscilloscope.ts b/src/renderer/visualizers/Oscilloscope.ts index 0dbb26b..025fbd5 100644 --- a/src/renderer/visualizers/Oscilloscope.ts +++ b/src/renderer/visualizers/Oscilloscope.ts @@ -5,6 +5,11 @@ import { isNativeAvailable } from '../audio/native' import { getNormalizedOscilloscopeDisplaySamples } from '../audio/native/oscilloscopeDisplaySamples' +import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' + +export interface OscilloscopeDataSource extends VisualizerSessionSource { + getPendingOscilloscopeSamples: () => Float32Array[] +} export interface OscilloscopeOptions { lineColor?: string @@ -14,9 +19,12 @@ export interface OscilloscopeOptions { gridColor?: string pitchLock?: boolean underfillEnabled?: boolean + dataSource?: OscilloscopeDataSource } -const defaultOptions: Required = { +type ResolvedOscilloscopeOptions = Required> + +const defaultOptions: ResolvedOscilloscopeOptions = { lineColor: '#00ffff', lineWidth: 2, backgroundColor: 'transparent', @@ -26,6 +34,11 @@ const defaultOptions: Required = { underfillEnabled: false } +const defaultOscilloscopeDataSource: OscilloscopeDataSource = { + getPendingOscilloscopeSamples: () => audioRouter.flushPendingOscilloscopeSamples(), + ...defaultVisualizerSessionSource, +} + function parseRgbChannels(color: string): string | null { const normalized = color.trim() @@ -90,7 +103,8 @@ function highContrastUnderfillColor(accentColor: string, alpha: number): string export class Oscilloscope { private canvas: HTMLCanvasElement private ctx: CanvasRenderingContext2D - private options: Required + private options: ResolvedOscilloscopeOptions + private dataSource: OscilloscopeDataSource private animationId: number | null = null private isRunning: boolean = false private nativeInitialized: boolean = false @@ -104,11 +118,13 @@ export class Oscilloscope { const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Could not get 2D context') this.ctx = ctx - this.options = { ...defaultOptions, ...options } + const { dataSource, ...optionOverrides } = options + this.options = { ...defaultOptions, ...optionOverrides } + this.dataSource = dataSource ?? defaultOscilloscopeDataSource // Initialize native module this.initNative() - this.unsubscribeSessionChange = audioRouter.subscribeToSessionChanges(() => { + this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { this.reset() }) } @@ -118,7 +134,7 @@ export class Oscilloscope { // Initialize with current sample rate, but set lastSampleRate to 0 so // updateSampleRateIfNeeded() always fires once the real capture rate is known. // This prevents stale-rate issues when capture starts after initialization. - const sampleRate = audioRouter.getSampleRate() + const sampleRate = this.dataSource.getSampleRate() this.lastSampleRate = 0 nativeOscilloscope.setSampleRate(sampleRate) nativeOscilloscope.setPitchLock(this.options.pitchLock) @@ -133,7 +149,7 @@ export class Oscilloscope { // Update sample rate if AudioContext changes (called from draw loop) private updateSampleRateIfNeeded(): void { if (!isNativeAvailable()) return - const currentRate = audioRouter.getSampleRate() + const currentRate = this.dataSource.getSampleRate() if (currentRate !== this.lastSampleRate && currentRate > 0) { this.lastSampleRate = currentRate nativeOscilloscope.setSampleRate(currentRate) @@ -143,7 +159,11 @@ export class Oscilloscope { } setOptions(options: Partial): void { - this.options = { ...this.options, ...options } + const { dataSource, ...optionUpdates } = options + this.options = { ...this.options, ...optionUpdates } + if (dataSource) { + this.dataSource = dataSource + } // Update native module settings if (isNativeAvailable() && options.pitchLock !== undefined) { @@ -196,13 +216,13 @@ export class Oscilloscope { // Check if sample rate needs updating (AudioContext may have initialized after us) this.updateSampleRateIfNeeded() - if (!audioRouter.isCapturing()) { + if (!this.dataSource.isPlaying()) { this.animationId = requestAnimationFrame(this.draw) return } // Flush ALL pending samples to native C++ (prevents sample loss) - const pendingSamples = audioRouter.flushPendingOscilloscopeSamples() + const pendingSamples = this.dataSource.getPendingOscilloscopeSamples() for (const chunk of pendingSamples) { nativeOscilloscope.pushSamples(chunk) this.samplesReceived += chunk.length diff --git a/src/renderer/visualizers/Spectrogram.ts b/src/renderer/visualizers/Spectrogram.ts index c711667..a712007 100644 --- a/src/renderer/visualizers/Spectrogram.ts +++ b/src/renderer/visualizers/Spectrogram.ts @@ -1,4 +1,5 @@ import { audioRouter } from '../audio/AudioRouter' +import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { DEFAULT_SPECTROGRAM_CLARITY_MODE, DEFAULT_SPECTROGRAM_SCALE_MODE, @@ -10,10 +11,8 @@ import { type SpectrogramScaleMode, } from '../../types/spectrogram' -export interface SpectrogramDataSource { +export interface SpectrogramDataSource extends VisualizerSessionSource { getPendingSpectrogramSamples: () => Float32Array[] - getSampleRate: () => number - isPlaying: () => boolean } export interface SpectrogramOptions { @@ -53,8 +52,7 @@ const defaultOptions: ResolvedSpectrogramOptions = { const defaultSpectrogramDataSource: SpectrogramDataSource = { getPendingSpectrogramSamples: () => audioRouter.flushPendingSpectrogramSamples(), - getSampleRate: () => audioRouter.getSampleRate(), - isPlaying: () => audioRouter.isCapturing(), + ...defaultVisualizerSessionSource, } function getClarityProfile(mode: SpectrogramClarityMode): SpectrogramClarityProfile { diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index 5d9fb40..c6957de 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -1,5 +1,6 @@ import { audioRouter } from '../audio/AudioRouter' import { spectrum as nativeSpectrum, isNativeAvailable } from '../audio/native' +import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE, DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE, @@ -7,10 +8,8 @@ import { clampSpectrumHeatmapTiltDbPerOctave, } from '../../types/spectrum' -export interface SpectrumAnalyzerDataSource { +export interface SpectrumAnalyzerDataSource extends VisualizerSessionSource { getPendingSpectrumSamples: () => Float32Array[] - getSampleRate: () => number - isPlaying: () => boolean } export interface SpectrumAnalyzerOptions { @@ -90,8 +89,7 @@ const defaultOptions: ResolvedSpectrumAnalyzerOptions = { const defaultSpectrumDataSource: SpectrumAnalyzerDataSource = { getPendingSpectrumSamples: () => audioRouter.flushPendingSpectrumSamples(), - getSampleRate: () => audioRouter.getSampleRate(), - isPlaying: () => audioRouter.isCapturing(), + ...defaultVisualizerSessionSource, } export class SpectrumAnalyzer { @@ -127,7 +125,7 @@ export class SpectrumAnalyzer { // Initialize native module this.initNative() - this.unsubscribeSessionChange = audioRouter.subscribeToSessionChanges(() => { + this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { this.resetState() }) } diff --git a/src/renderer/visualizers/VUMeter.ts b/src/renderer/visualizers/VUMeter.ts index afca5d1..55b664c 100644 --- a/src/renderer/visualizers/VUMeter.ts +++ b/src/renderer/visualizers/VUMeter.ts @@ -1,14 +1,13 @@ import { audioRouter } from '../audio/AudioRouter' +import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { DEFAULT_VU_METER_ORIENTATION, type VUMeterMode, type VUMeterOrientation, } from '../../types/vumeter' -export interface VUMeterDataSource { +export interface VUMeterDataSource extends VisualizerSessionSource { getPendingVUMeterSamples: () => Array<{ left: Float32Array; right: Float32Array }> - getSampleRate: () => number - isPlaying: () => boolean } export interface VUMeterOptions { @@ -28,8 +27,7 @@ const defaultOptions: ResolvedVUMeterOptions = { const defaultVUMeterDataSource: VUMeterDataSource = { getPendingVUMeterSamples: () => audioRouter.flushPendingVUMeterSamples(), - getSampleRate: () => audioRouter.getSampleRate(), - isPlaying: () => audioRouter.isCapturing(), + ...defaultVisualizerSessionSource, } // ---- Meter constants ---- @@ -85,7 +83,7 @@ export class VUMeter { const { dataSource, ...optionOverrides } = options this.options = { ...defaultOptions, ...optionOverrides } this.dataSource = dataSource ?? defaultVUMeterDataSource - this.unsubscribeSessionChange = audioRouter.subscribeToSessionChanges(() => { + this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { this.resetMeters() }) } diff --git a/src/renderer/visualizers/Vectorscope.ts b/src/renderer/visualizers/Vectorscope.ts index 941f987..32426d7 100644 --- a/src/renderer/visualizers/Vectorscope.ts +++ b/src/renderer/visualizers/Vectorscope.ts @@ -2,9 +2,14 @@ import { audioRouter } from '../audio/AudioRouter' import { vectorscope as nativeVectorscope, isNativeAvailable } from '../audio/native' import { transformPoint, drawVectorscopeGridForMode, getVectorscopeLayout } from './vectorscopeGrids' import { MultibandSplitter, MultibandBuffer, BAND_COLORS } from './multibandSplitter' +import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' export type VectorscopeMode = 'lissajous' | 'polar-unipolar' | 'polar-bipolar' | 'linear-unipolar' | 'linear-bipolar' +export interface VectorscopeDataSource extends VisualizerSessionSource { + getPendingVectorscopeSamples: () => Array<{ left: Float32Array; right: Float32Array }> +} + export interface VectorscopeOptions { lineColor?: string lineWidth?: number @@ -15,9 +20,12 @@ export interface VectorscopeOptions { displayPoints?: number // how many points to request from native, default 4096 mode?: VectorscopeMode multiband?: boolean + dataSource?: VectorscopeDataSource } -const defaultOptions: Required = { +type ResolvedVectorscopeOptions = Required> + +const defaultOptions: ResolvedVectorscopeOptions = { lineColor: '#00ffff', lineWidth: 1.5, backgroundColor: 'transparent', @@ -29,6 +37,11 @@ const defaultOptions: Required = { multiband: false, } +const defaultVectorscopeDataSource: VectorscopeDataSource = { + getPendingVectorscopeSamples: () => audioRouter.flushPendingVectorscopeSamples(), + ...defaultVisualizerSessionSource, +} + const BAND_ORDER = ['low', 'mid', 'high'] as const export class Vectorscope { @@ -36,7 +49,8 @@ export class Vectorscope { private ctx: CanvasRenderingContext2D private offscreenCanvas: HTMLCanvasElement private offscreenCtx: CanvasRenderingContext2D - private options: Required + private options: ResolvedVectorscopeOptions + private dataSource: VectorscopeDataSource private animationId: number | null = null private isRunning: boolean = false private nativeInitialized: boolean = false @@ -50,7 +64,9 @@ export class Vectorscope { const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Could not get 2D context') this.ctx = ctx - this.options = { ...defaultOptions, ...options } + const { dataSource, ...optionOverrides } = options + this.options = { ...defaultOptions, ...optionOverrides } + this.dataSource = dataSource ?? defaultVectorscopeDataSource // Create offscreen canvas for persistence/fade this.offscreenCanvas = document.createElement('canvas') @@ -62,14 +78,14 @@ export class Vectorscope { // Initialize native module if available this.initNative() - this.unsubscribeSessionChange = audioRouter.subscribeToSessionChanges(() => { + this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { this.resetDisplay() }) } private initNative(): void { if (isNativeAvailable() && !this.nativeInitialized) { - const sampleRate = audioRouter.getSampleRate() + const sampleRate = this.dataSource.getSampleRate() this.lastSampleRate = sampleRate nativeVectorscope.setSampleRate(sampleRate) this.nativeInitialized = true @@ -80,7 +96,7 @@ export class Vectorscope { } private updateSampleRateIfNeeded(): void { - const currentRate = audioRouter.getSampleRate() + const currentRate = this.dataSource.getSampleRate() if (currentRate !== this.lastSampleRate && currentRate > 0) { this.lastSampleRate = currentRate if (isNativeAvailable()) { @@ -101,7 +117,11 @@ export class Vectorscope { } setOptions(options: Partial): void { - this.options = { ...this.options, ...options } + const { dataSource, ...optionUpdates } = options + this.options = { ...this.options, ...optionUpdates } + if (dataSource) { + this.dataSource = dataSource + } } start(): void { @@ -144,7 +164,7 @@ export class Vectorscope { // Update sample rate if changed this.updateSampleRateIfNeeded() - if (!audioRouter.isCapturing()) { + if (!this.dataSource.isPlaying()) { ctx.clearRect(0, 0, width, height) if (options.backgroundColor !== 'transparent') { ctx.fillStyle = options.backgroundColor @@ -165,7 +185,7 @@ export class Vectorscope { offscreenCtx.globalCompositeOperation = 'source-over' // ---- FLUSH SAMPLES ---- - const pendingSamples = audioRouter.flushPendingVectorscopeSamples() + const pendingSamples = this.dataSource.getPendingVectorscopeSamples() if (options.multiband) { // Multiband path: split into 3 bands, render each with its own color @@ -293,7 +313,7 @@ export class Vectorscope { const dotSize = options.lineWidth * dpr // Ensure splitter is configured - const sampleRate = audioRouter.getSampleRate() + const sampleRate = this.dataSource.getSampleRate() if (sampleRate > 0) { this.splitter.configure(sampleRate) } diff --git a/src/renderer/visualizers/Waveform.ts b/src/renderer/visualizers/Waveform.ts index 78b65a2..10296a6 100644 --- a/src/renderer/visualizers/Waveform.ts +++ b/src/renderer/visualizers/Waveform.ts @@ -1,4 +1,5 @@ import { audioRouter } from '../audio/AudioRouter' +import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { DEFAULT_WAVEFORM_GAIN_DB, DEFAULT_WAVEFORM_SCROLL_SPEED, @@ -7,10 +8,8 @@ import { } from '../../types/waveform' import { MultibandSplitter } from './multibandSplitter' -export interface WaveformDataSource { +export interface WaveformDataSource extends VisualizerSessionSource { getPendingWaveformSamples: () => Float32Array[] - getSampleRate: () => number - isPlaying: () => boolean } export interface WaveformOptions { @@ -42,8 +41,7 @@ const MULTIBAND_EDGE_ALPHA = 1.0 const defaultWaveformDataSource: WaveformDataSource = { getPendingWaveformSamples: () => audioRouter.flushPendingWaveformSamples(), - getSampleRate: () => audioRouter.getSampleRate(), - isPlaying: () => audioRouter.isCapturing(), + ...defaultVisualizerSessionSource, } // Calibrate 1.0x to the prior 8s window at roughly 512px wide, diff --git a/src/renderer/visualizers/dataSource.ts b/src/renderer/visualizers/dataSource.ts new file mode 100644 index 0000000..8dfb308 --- /dev/null +++ b/src/renderer/visualizers/dataSource.ts @@ -0,0 +1,14 @@ +import { audioRouter } from '../audio/AudioRouter' +import type { ScopePopoutSessionState } from '../../types/popout' + +export interface VisualizerSessionSource { + getSampleRate: () => number + isPlaying: () => boolean + subscribeToSessionChanges: (listener: (state: ScopePopoutSessionState) => void) => () => void +} + +export const defaultVisualizerSessionSource: VisualizerSessionSource = { + getSampleRate: () => audioRouter.getSampleRate(), + isPlaying: () => audioRouter.isCapturing(), + subscribeToSessionChanges: (listener) => audioRouter.subscribeToSessionChanges((state) => listener(state)), +} diff --git a/src/types/popout.ts b/src/types/popout.ts new file mode 100644 index 0000000..1eeacf4 --- /dev/null +++ b/src/types/popout.ts @@ -0,0 +1,48 @@ +import type { CaptureBackendKind } from './capture' +import type { ScopeKind } from './scope' +import type { ScopeSettings } from './settings' + +export interface WindowBounds { + x: number + y: number + width: number + height: number +} + +export interface ScopePopoutState { + poppedOut: boolean + windowBounds?: WindowBounds +} + +export type ScopePopoutStateMap = Record + +export interface ScopePopoutSyncState { + shouldBeOpen: boolean + bounds?: WindowBounds +} + +export type ScopePopoutSyncStateMap = Record + +export interface ScopePopoutSessionState { + sessionId: number + sampleRate: number + channelCount: number + capturing: boolean + backendKind: CaptureBackendKind | null +} + +export interface ScopePopoutStereoChunk { + left: Float32Array + right: Float32Array +} + +export type ScopePopoutMonoBatch = Float32Array[] +export type ScopePopoutStereoBatch = ScopePopoutStereoChunk[] +export type ScopePopoutAudioBatch = ScopePopoutMonoBatch | ScopePopoutStereoBatch + +export interface ScopePopoutSnapshot { + kind: K + label: string + accent: string + settings: ScopeSettings[K] +} diff --git a/src/types/profileMenu.ts b/src/types/profileMenu.ts new file mode 100644 index 0000000..1744010 --- /dev/null +++ b/src/types/profileMenu.ts @@ -0,0 +1,12 @@ +export interface ProfileMenuProfileSummary { + id: string + name: string + isDefault: boolean +} + +export interface ProfileMenuRequest { + x: number + y: number + profiles: ProfileMenuProfileSummary[] + activeProfileId: string | null +} diff --git a/src/types/scope.ts b/src/types/scope.ts index f563f3d..ff8d4be 100644 --- a/src/types/scope.ts +++ b/src/types/scope.ts @@ -9,3 +9,13 @@ export const SCOPE_KINDS: ScopeKind[] = [ 'lufsmeter', 'waveform', ] + +export const SCOPE_LABELS: Record = { + spectrum: 'Spectrum', + oscilloscope: 'Oscilloscope', + vectorscope: 'Vectorscope', + spectrogram: 'Spectrogram', + vumeter: 'VU Meter', + lufsmeter: 'LUFS Meter', + waveform: 'Waveform', +} diff --git a/src/types/settings.ts b/src/types/settings.ts new file mode 100644 index 0000000..f35826a --- /dev/null +++ b/src/types/settings.ts @@ -0,0 +1,58 @@ +import type { VectorscopeMode } from '../renderer/visualizers/Vectorscope' +import type { SpectrogramClarityMode, SpectrogramScaleMode } from './spectrogram' +import type { VUMeterMode, VUMeterOrientation } from './vumeter' +import type { LUFSMeterMode } from './lufsmeter' + +export interface ScopeSettings { + spectrum: { + fftSize: number + tiltDbPerOctave: number + heatmap: boolean + heatmapTiltDbPerOctave: number + showGrid: boolean + smoothing: number + fillGradient: boolean + } + oscilloscope: { + pitchLock: boolean + underfillEnabled: boolean + showGrid: boolean + lineWidth: number + } + vectorscope: { + mode: VectorscopeMode + multiband: boolean + showGrid: boolean + persistence: number + lineWidth: number + } + spectrogram: { + fftSize: number + scrollSpeed: number + clarityMode: SpectrogramClarityMode + scaleMode: SpectrogramScaleMode + colorScheme: 'heat' | 'mono' + } + vumeter: { + mode: VUMeterMode + orientation: VUMeterOrientation + } + lufsmeter: { + mode: LUFSMeterMode + } + waveform: { + scrollSpeed: number + gainDb: number + multiband: boolean + } +} + +export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = { + spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, showGrid: true, smoothing: 0.9, fillGradient: true }, + oscilloscope: { pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, + vectorscope: { mode: 'lissajous', multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 }, + spectrogram: { fftSize: 2048, scrollSpeed: 2, clarityMode: 'sharper', scaleMode: 'log', colorScheme: 'heat' }, + vumeter: { mode: 'bar', orientation: 'horizontal' }, + lufsmeter: { mode: 'bar' }, + waveform: { scrollSpeed: 1, gainDb: 0, multiband: false }, +}