diff --git a/main.js b/main.js index e7a5f2fd1..de4736998 100644 --- a/main.js +++ b/main.js @@ -556,6 +556,19 @@ async function startApp() { environmentManager.savePanelStartPosition(position); }); + // Recording overlay + ipcMain.on("recording-overlay-update", (_event, data) => { + windowManager.sendRecordingOverlayUpdate(data); + }); + + ipcMain.handle("recording-overlay-cancel", async () => { + windowManager.sendStopDictation(); + }); + + ipcMain.on("recording-overlay-enabled-changed", (_event, enabled) => { + windowManager.setRecordingOverlayEnabled(enabled); + }); + if (process.platform === "darwin") { app.setActivationPolicy("regular"); } @@ -573,6 +586,9 @@ async function startApp() { await windowManager.createControlPanelWindow(); } + // Create recording overlay (hidden) for instant show on hotkey + await windowManager.createRecordingOverlay(); + // Create agent window (hidden) and set up agent hotkey await windowManager.createAgentWindow(); diff --git a/preload.js b/preload.js index ea7e49c4e..9784bccb3 100644 --- a/preload.js +++ b/preload.js @@ -555,6 +555,12 @@ contextBridge.exposeInMainWorld("electronAPI", { // Start minimized notifyStartMinimizedChanged: (enabled) => ipcRenderer.send("start-minimized-changed", enabled), + // Recording overlay + sendRecordingOverlayUpdate: (data) => ipcRenderer.send('recording-overlay-update', data), + onRecordingOverlayUpdate: registerListener('recording-overlay-update', (callback) => (_event, data) => callback(_event, data)), + cancelRecordingFromOverlay: () => ipcRenderer.invoke('recording-overlay-cancel'), + notifyRecordingOverlayEnabledChanged: (enabled) => ipcRenderer.send('recording-overlay-enabled-changed', enabled), + // Auto-start management getAutoStartEnabled: () => ipcRenderer.invoke("get-auto-start-enabled"), setAutoStartEnabled: (enabled) => ipcRenderer.invoke("set-auto-start-enabled", enabled), diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index ec0e44329..16fdbe433 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -160,6 +160,7 @@ export default function AgentOverlay() { setPartialTranscript(text); }, onStreamingCommit: undefined, + onAudioLevel: undefined, }); audioManagerRef.current = am; return () => { diff --git a/src/components/RecordingOverlay.css b/src/components/RecordingOverlay.css new file mode 100644 index 000000000..fbbe0d6dc --- /dev/null +++ b/src/components/RecordingOverlay.css @@ -0,0 +1,107 @@ +.recording-overlay-root { + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: flex-start; + padding-top: 8px; + background: transparent; + -webkit-app-region: no-drag; +} + +.recording-overlay-pill { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 16px; + background: rgba(28, 28, 46, 0.92); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + animation: overlay-fade-in 200ms ease-out; +} + +@media (prefers-color-scheme: light) { + .recording-overlay-pill { + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); + } + .recording-overlay-status { + color: #171717 !important; + } + .recording-overlay-cancel { + color: #737373 !important; + } + .recording-overlay-cancel:hover { + background: rgba(0, 0, 0, 0.06) !important; + color: #171717 !important; + } +} + +.recording-overlay-canvas { + width: 120px; + height: 32px; + flex-shrink: 0; +} + +.recording-overlay-status { + display: flex; + align-items: center; + gap: 6px; + font-family: "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.85); + white-space: nowrap; + user-select: none; +} + +.recording-overlay-cancel { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 50%; + background: transparent; + color: rgba(255, 255, 255, 0.5); + font-size: 18px; + line-height: 1; + cursor: pointer; + transition: background 150ms, color 150ms; + flex-shrink: 0; +} + +.recording-overlay-cancel:hover { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); +} + +.recording-overlay-spinner { + width: 14px; + height: 14px; + border: 2px solid transparent; + border-top-color: rgb(147, 51, 234); + border-radius: 50%; + animation: overlay-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; + flex-shrink: 0; +} + +@keyframes overlay-fade-in { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes overlay-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .recording-overlay-pill { animation: none; } + .recording-overlay-spinner { animation: none; opacity: 0.5; } +} diff --git a/src/components/RecordingOverlay.tsx b/src/components/RecordingOverlay.tsx new file mode 100644 index 000000000..26f9846e2 --- /dev/null +++ b/src/components/RecordingOverlay.tsx @@ -0,0 +1,129 @@ +import React, { useEffect, useRef, useState, useCallback } from "react"; +import "./RecordingOverlay.css"; + +const BUFFER_SIZE = 40; +const BAR_WIDTH = 3; +const BAR_GAP = 2; +const HIDE_DELAY_MS = 300; + +export default function RecordingOverlay() { + const canvasRef = useRef(null); + const levelsRef = useRef(new Array(BUFFER_SIZE).fill(0)); + const animFrameRef = useRef(0); + const [isRecording, setIsRecording] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [visible, setVisible] = useState(false); + const hideTimerRef = useRef | null>(null); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const w = canvas.clientWidth; + const h = canvas.clientHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + const levels = levelsRef.current; + const totalBarWidth = BUFFER_SIZE * (BAR_WIDTH + BAR_GAP) - BAR_GAP; + const startX = (w - totalBarWidth) / 2; + const centerY = h / 2; + const maxBarHeight = h * 0.8; + + for (let i = 0; i < BUFFER_SIZE; i++) { + const level = levels[i]; + const barHeight = Math.max(2, level * maxBarHeight); + const x = startX + i * (BAR_WIDTH + BAR_GAP); + const y = centerY - barHeight / 2; + + ctx.fillStyle = isRecording + ? `rgba(59, 130, 246, ${0.5 + level * 0.5})` + : `rgba(147, 51, 234, ${0.5 + level * 0.5})`; + ctx.beginPath(); + ctx.roundRect(x, y, BAR_WIDTH, barHeight, 1.5); + ctx.fill(); + } + + animFrameRef.current = requestAnimationFrame(draw); + }, [isRecording]); + + useEffect(() => { + const cleanup = window.electronAPI?.onRecordingOverlayUpdate?.( + (_event: unknown, data: { level: number; isRecording: boolean; isProcessing: boolean }) => { + const { level, isRecording: rec, isProcessing: proc } = data; + + setIsRecording(rec); + setIsProcessing(proc); + + // Push level into rolling buffer + levelsRef.current.shift(); + levelsRef.current.push(Math.min(1, level * 3)); // amplify for visibility + + if (rec || proc) { + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + setVisible(true); + } else { + if (!hideTimerRef.current) { + hideTimerRef.current = setTimeout(() => { + setVisible(false); + hideTimerRef.current = null; + }, HIDE_DELAY_MS); + } + } + } + ); + + return () => cleanup?.(); + }, []); + + // Start/stop animation loop + useEffect(() => { + if (visible) { + animFrameRef.current = requestAnimationFrame(draw); + } else { + cancelAnimationFrame(animFrameRef.current); + } + return () => cancelAnimationFrame(animFrameRef.current); + }, [visible, draw]); + + const handleCancel = useCallback(() => { + window.electronAPI?.cancelRecordingFromOverlay?.(); + }, []); + + if (!visible) { + return
; + } + + return ( +
+
+ + + {isProcessing ? ( + <> + + Processing… + + ) : ( + "Recording…" + )} + + +
+
+ ); +} diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index 4a06bb1f9..3be55dc16 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -701,6 +701,8 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage setKeepTranscriptionInClipboard, floatingIconAutoHide, setFloatingIconAutoHide, + showRecordingOverlay, + setShowRecordingOverlay, startMinimized, setStartMinimized, panelStartPosition, @@ -2174,6 +2176,14 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage + + + + + this._peakRms) this._peakRms = rms; + if (typeof this.onAudioLevel === 'function') { + this.onAudioLevel(rms); + } }, 100); } catch (e) { logger.warn("Silence detection setup failed, skipping", { error: e.message }, "audio"); @@ -2630,6 +2636,7 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); this.onTranscriptionComplete = null; this.onPartialTranscript = null; this.onStreamingCommit = null; + this.onAudioLevel = null; if (this._onApiKeyChanged) { window.removeEventListener("api-key-changed", this._onApiKeyChanged); } diff --git a/src/helpers/windowConfig.js b/src/helpers/windowConfig.js index 7b4bde12e..64a12c7fc 100644 --- a/src/helpers/windowConfig.js +++ b/src/helpers/windowConfig.js @@ -152,6 +152,16 @@ class WindowPositionUtil { return { x, y, width, height }; } + static getRecordingOverlayPosition(display) { + const width = 320; + const height = 64; + const MARGIN = 16; + const workArea = display.workArea || display.bounds; + const x = Math.round(workArea.x + (workArea.width - width) / 2); + const y = workArea.y + MARGIN; + return { x, y, width, height }; + } + static setupAlwaysOnTop(window) { if (process.platform === "darwin") { // macOS: Use panel level for proper floating behavior @@ -207,11 +217,34 @@ const AGENT_OVERLAY_CONFIG = { }, }; +const RECORDING_OVERLAY_CONFIG = { + width: 320, + height: 64, + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + focusable: false, + hasShadow: false, + show: false, + webPreferences: { + preload: path.join(__dirname, "..", "..", "preload.js"), + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + visibleOnAllWorkspaces: process.platform !== "win32", + type: + process.platform === "darwin" ? "panel" : process.platform === "linux" ? "toolbar" : "normal", +}; + module.exports = { MAIN_WINDOW_CONFIG, CONTROL_PANEL_CONFIG, AGENT_OVERLAY_CONFIG, NOTIFICATION_WINDOW_CONFIG, + RECORDING_OVERLAY_CONFIG, WINDOW_SIZES, WindowPositionUtil, }; diff --git a/src/helpers/windowManager.js b/src/helpers/windowManager.js index 4803f0ef2..3f7928824 100644 --- a/src/helpers/windowManager.js +++ b/src/helpers/windowManager.js @@ -12,6 +12,7 @@ const { CONTROL_PANEL_CONFIG, AGENT_OVERLAY_CONFIG, NOTIFICATION_WINDOW_CONFIG, + RECORDING_OVERLAY_CONFIG, WINDOW_SIZES, WindowPositionUtil, } = require("./windowConfig"); @@ -37,6 +38,8 @@ class WindowManager { this._agentAnimationState = null; this._panelStartPosition = "bottom-right"; this._isDictatingToggle = false; + this.recordingOverlayWindow = null; + this._recordingOverlayEnabled = true; app.on("before-quit", () => { this.isQuitting = true; @@ -417,6 +420,7 @@ class WindowManager { } if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.showDictationPanel(); + this.showRecordingOverlay(); this.mainWindow.webContents.send("toggle-dictation"); this._isDictatingToggle = !this._isDictatingToggle; this.meetingDetectionEngine?.setUserRecording(this._isDictatingToggle); @@ -429,6 +433,7 @@ class WindowManager { } if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.showDictationPanel(); + this.showRecordingOverlay(); this.mainWindow.webContents.send("start-dictation"); this.meetingDetectionEngine?.setUserRecording(true); } @@ -1172,6 +1177,65 @@ class WindowManager { } } + async createRecordingOverlay() { + if (this.recordingOverlayWindow && !this.recordingOverlayWindow.isDestroyed()) { + return; + } + + const display = screen.getPrimaryDisplay(); + const position = WindowPositionUtil.getRecordingOverlayPosition(display); + + this.recordingOverlayWindow = new BrowserWindow({ + ...RECORDING_OVERLAY_CONFIG, + ...position, + }); + + WindowPositionUtil.setupAlwaysOnTop(this.recordingOverlayWindow); + + this.recordingOverlayWindow.on("closed", () => { + this.recordingOverlayWindow = null; + }); + + if (process.env.NODE_ENV === "development") { + await DevServerManager.waitForDevServer(); + await this.recordingOverlayWindow.loadURL( + `${DevServerManager.DEV_SERVER_URL}?recording-overlay=true` + ); + } else { + const fileInfo = DevServerManager.getAppFilePath(false); + await this.recordingOverlayWindow.loadFile(fileInfo.path, { + query: { ...fileInfo.query, "recording-overlay": "true" }, + }); + } + } + + showRecordingOverlay() { + if (!this._recordingOverlayEnabled) return; + if (!this.recordingOverlayWindow || this.recordingOverlayWindow.isDestroyed()) return; + if (typeof this.recordingOverlayWindow.showInactive === "function") { + this.recordingOverlayWindow.showInactive(); + } else { + this.recordingOverlayWindow.show(); + } + } + + hideRecordingOverlay() { + if (!this.recordingOverlayWindow || this.recordingOverlayWindow.isDestroyed()) return; + this.recordingOverlayWindow.hide(); + } + + sendRecordingOverlayUpdate(data) { + if (!this.recordingOverlayWindow || this.recordingOverlayWindow.isDestroyed()) return; + this.recordingOverlayWindow.webContents.send("recording-overlay-update", data); + } + + setRecordingOverlayEnabled(enabled) { + this._recordingOverlayEnabled = Boolean(enabled); + if (!enabled) { + this.hideRecordingOverlay(); + } + } + refreshLocalizedUi() { MenuManager.setupMainMenu(); diff --git a/src/hooks/useAudioRecording.js b/src/hooks/useAudioRecording.js index 7a703574c..e970f85ed 100644 --- a/src/hooks/useAudioRecording.js +++ b/src/hooks/useAudioRecording.js @@ -89,6 +89,18 @@ export const useAudioRecording = (toast, options = {}) => { if (!isStreaming) { setPartialTranscript(""); } + window.electronAPI?.sendRecordingOverlayUpdate?.({ + level: 0, + isRecording, + isProcessing, + }); + }, + onAudioLevel: (level) => { + window.electronAPI?.sendRecordingOverlayUpdate?.({ + level, + isRecording: true, + isProcessing: false, + }); }, onError: (error) => { const title = getRecordingErrorTitle(error, t); diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 7c563be18..b533f106f 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -231,6 +231,8 @@ function useSettingsInternal() { setPauseMediaOnDictation: store.setPauseMediaOnDictation, floatingIconAutoHide: store.floatingIconAutoHide, setFloatingIconAutoHide: store.setFloatingIconAutoHide, + showRecordingOverlay: store.showRecordingOverlay, + setShowRecordingOverlay: store.setShowRecordingOverlay, startMinimized: store.startMinimized, setStartMinimized: store.setStartMinimized, panelStartPosition: store.panelStartPosition, diff --git a/src/main.jsx b/src/main.jsx index f73406cee..ab5fa7ca4 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -24,6 +24,7 @@ const AgentOverlay = React.lazy(agentOverlayImport); const PermissionsGate = React.lazy(permissionsGateImport); import MeetingNotificationOverlay from "./components/MeetingNotificationOverlay.tsx"; import UpdateNotificationOverlay from "./components/UpdateNotificationOverlay.tsx"; +import RecordingOverlay from "./components/RecordingOverlay.tsx"; let root = null; @@ -286,6 +287,10 @@ function AppRouter() { return ; } + if (params.includes("recording-overlay=true")) { + return ; + } + return ; } diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 85321b4cc..e33e231cd 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -58,6 +58,7 @@ const BOOLEAN_SETTINGS = new Set([ "audioCuesEnabled", "pauseMediaOnDictation", "floatingIconAutoHide", + "showRecordingOverlay", "startMinimized", "meetingProcessDetection", "meetingAudioDetection", @@ -97,6 +98,7 @@ export interface SettingsState audioCuesEnabled: boolean; pauseMediaOnDictation: boolean; floatingIconAutoHide: boolean; + showRecordingOverlay: boolean; startMinimized: boolean; gcalAccounts: GoogleCalendarAccount[]; gcalConnected: boolean; @@ -150,6 +152,7 @@ export interface SettingsState setAudioCuesEnabled: (value: boolean) => void; setPauseMediaOnDictation: (value: boolean) => void; setFloatingIconAutoHide: (enabled: boolean) => void; + setShowRecordingOverlay: (enabled: boolean) => void; setStartMinimized: (enabled: boolean) => void; setGcalAccounts: (accounts: GoogleCalendarAccount[]) => void; setMeetingProcessDetection: (value: boolean) => void; @@ -285,6 +288,7 @@ export const useSettingsStore = create()((set, get) => ({ audioCuesEnabled: readBoolean("audioCuesEnabled", true), pauseMediaOnDictation: readBoolean("pauseMediaOnDictation", false), floatingIconAutoHide: readBoolean("floatingIconAutoHide", false), + showRecordingOverlay: readBoolean("showRecordingOverlay", true), startMinimized: readBoolean("startMinimized", false), ...(() => { let accounts: GoogleCalendarAccount[] = []; @@ -470,6 +474,15 @@ export const useSettingsStore = create()((set, get) => ({ } }, + setShowRecordingOverlay: (enabled: boolean) => { + if (get().showRecordingOverlay === enabled) return; + if (isBrowser) localStorage.setItem("showRecordingOverlay", String(enabled)); + set({ showRecordingOverlay: enabled }); + if (isBrowser) { + window.electronAPI?.notifyRecordingOverlayEnabledChanged?.(enabled); + } + }, + setStartMinimized: (enabled: boolean) => { if (get().startMinimized === enabled) return; if (isBrowser) localStorage.setItem("startMinimized", String(enabled)); diff --git a/src/types/electron.ts b/src/types/electron.ts index d5e1ede12..af66e3ec4 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -1210,6 +1210,11 @@ declare global { } | null>; updateNotificationReady?: () => Promise; updateNotificationRespond?: (action: string) => Promise<{ success: boolean }>; + + // Recording overlay + onRecordingOverlayUpdate: (callback: (event: unknown, data: { isRecording: boolean; isProcessing: boolean; audioLevel?: number }) => void) => () => void; + cancelRecordingFromOverlay: () => Promise; + notifyRecordingOverlayEnabledChanged: (enabled: boolean) => void; }; api?: {