diff --git a/dist-electron/main.js b/dist-electron/main.js index 0cc07fd..aab9e19 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -1,4 +1,4 @@ -import { BrowserWindow, screen, ipcMain, desktopCapturer, app } from "electron"; +import { BrowserWindow, screen, ipcMain, desktopCapturer, app, nativeImage, Tray, Menu } from "electron"; import { fileURLToPath } from "node:url"; import path from "node:path"; import fs from "node:fs/promises"; @@ -213,7 +213,7 @@ function cleanupMouseTracking() { } } let selectedSource = null; -function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow) { +function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) { ipcMain.handle("get-sources", async (_, opts) => { const sources = await desktopCapturer.getSources(opts); return sources.map((source) => ({ @@ -312,6 +312,12 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g return { success: false, message: "Failed to get video path", error: String(error) }; } }); + ipcMain.handle("set-recording-state", (_, recording) => { + const source = selectedSource || { name: "Screen" }; + if (onRecordingStateChange) { + onRecordingStateChange(recording, source.name); + } + }); } const __dirname = path.dirname(fileURLToPath(import.meta.url)); const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); @@ -347,9 +353,34 @@ const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist"); process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST; let mainWindow = null; let sourceSelectorWindow = null; +let tray = null; +let selectedSourceName = ""; function createWindow() { mainWindow = createHudOverlayWindow(); } +function createTray() { + const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, "rec-button.png"); + let icon = nativeImage.createFromPath(iconPath); + icon = icon.resize({ width: 24, height: 24, quality: "best" }); + tray = new Tray(icon); + updateTrayMenu(); +} +function updateTrayMenu() { + if (!tray) return; + const menuTemplate = [ + { + label: "Stop Recording", + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("stop-recording-from-tray"); + } + } + } + ]; + const contextMenu = Menu.buildFromTemplate(menuTemplate); + tray.setContextMenu(contextMenu); + tray.setToolTip(`Recording: ${selectedSourceName}`); +} function createEditorWindowWrapper() { if (mainWindow) { mainWindow.close(); @@ -388,7 +419,21 @@ app.whenReady().then(async () => { createEditorWindowWrapper, createSourceSelectorWindowWrapper, () => mainWindow, - () => sourceSelectorWindow + () => sourceSelectorWindow, + (recording, sourceName) => { + selectedSourceName = sourceName; + if (recording) { + if (!tray) createTray(); + updateTrayMenu(); + if (mainWindow) mainWindow.minimize(); + } else { + if (tray) { + tray.destroy(); + tray = null; + } + if (mainWindow) mainWindow.restore(); + } + } ); createWindow(); }); diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index 1202937..caae4a3 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -30,5 +30,13 @@ electron.contextBridge.exposeInMainWorld("electronAPI", { }, getRecordedVideoPath: () => { return electron.ipcRenderer.invoke("get-recorded-video-path"); + }, + setRecordingState: (recording) => { + return electron.ipcRenderer.invoke("set-recording-state", recording); + }, + onStopRecordingFromTray: (callback) => { + const listener = () => callback(); + electron.ipcRenderer.on("stop-recording-from-tray", listener); + return () => electron.ipcRenderer.removeListener("stop-recording-from-tray", listener); } }); diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f0cd796..ddeaa0c 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -24,6 +24,25 @@ declare namespace NodeJS { // Used in Renderer process, expose in `preload.ts` interface Window { electronAPI: { - getSources: (opts: Electron.SourcesOptions) => Promise + getSources: (opts: Electron.SourcesOptions) => Promise + switchToEditor: () => Promise + openSourceSelector: () => Promise + selectSource: (source: any) => Promise + getSelectedSource: () => Promise + startMouseTracking: () => Promise + stopMouseTracking: () => Promise + storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }> + storeMouseTrackingData: (fileName: string) => Promise<{ success: boolean; path?: string; eventCount?: number; message?: string }> + getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }> + setRecordingState: (recording: boolean) => Promise + onStopRecordingFromTray: (callback: () => void) => () => void } } + +interface ProcessedDesktopSource { + id: string + name: string + display_id: string + thumbnail: string | null + appIcon: string | null +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 24d225f..9d2e361 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -10,7 +10,8 @@ export function registerIpcHandlers( createEditorWindow: () => void, createSourceSelectorWindow: () => BrowserWindow, getMainWindow: () => BrowserWindow | null, - getSourceSelectorWindow: () => BrowserWindow | null + getSourceSelectorWindow: () => BrowserWindow | null, + onRecordingStateChange?: (recording: boolean, sourceName: string) => void ) { ipcMain.handle('get-sources', async (_, opts) => { const sources = await desktopCapturer.getSources(opts) @@ -126,4 +127,11 @@ export function registerIpcHandlers( return { success: false, message: 'Failed to get video path', error: String(error) } } }) + + ipcMain.handle('set-recording-state', (_, recording: boolean) => { + const source = selectedSource || { name: 'Screen' } + if (onRecordingStateChange) { + onRecordingStateChange(recording, source.name) + } + }) } diff --git a/electron/main.ts b/electron/main.ts index 98bb337..532e977 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow } from 'electron' +import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron' import { fileURLToPath } from 'node:url' import path from 'node:path' import fs from 'node:fs/promises' @@ -61,11 +61,39 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, // Window references let mainWindow: BrowserWindow | null = null let sourceSelectorWindow: BrowserWindow | null = null +let tray: Tray | null = null +let isRecording = false +let selectedSourceName = '' function createWindow() { mainWindow = createHudOverlayWindow() } +function createTray() { + const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, 'rec-button.png'); + let icon = nativeImage.createFromPath(iconPath); + icon = icon.resize({ width: 24, height: 24, quality: 'best' }); + tray = new Tray(icon); + updateTrayMenu(); +} + +function updateTrayMenu() { + if (!tray) return; + const menuTemplate = [ + { + label: 'Stop Recording', + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('stop-recording-from-tray'); + } + } + } + ]; + const contextMenu = Menu.buildFromTemplate(menuTemplate); + tray.setContextMenu(contextMenu); + tray.setToolTip(`Recording: ${selectedSourceName}`); +} + function createEditorWindowWrapper() { if (mainWindow) { mainWindow.close() @@ -118,7 +146,22 @@ app.whenReady().then(async () => { createEditorWindowWrapper, createSourceSelectorWindowWrapper, () => mainWindow, - () => sourceSelectorWindow + () => sourceSelectorWindow, + (recording: boolean, sourceName: string) => { + isRecording = recording + selectedSourceName = sourceName + if (recording) { + if (!tray) createTray(); + updateTrayMenu(); + if (mainWindow) mainWindow.minimize(); + } else { + if (tray) { + tray.destroy(); + tray = null; + } + if (mainWindow) mainWindow.restore(); + } + } ) createWindow() }) diff --git a/electron/preload.ts b/electron/preload.ts index f355cdf..46a09fa 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -30,5 +30,13 @@ contextBridge.exposeInMainWorld('electronAPI', { }, getRecordedVideoPath: () => { return ipcRenderer.invoke('get-recorded-video-path') + }, + setRecordingState: (recording: boolean) => { + return ipcRenderer.invoke('set-recording-state', recording) + }, + onStopRecordingFromTray: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on('stop-recording-from-tray', listener) + return () => ipcRenderer.removeListener('stop-recording-from-tray', listener) } }) \ No newline at end of file diff --git a/public/rec-button.png b/public/rec-button.png new file mode 100644 index 0000000..5e6bbc4 Binary files /dev/null and b/public/rec-button.png differ diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index bb250da..636f31d 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -13,8 +13,30 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const chunks = useRef([]); const startTime = useRef(0); + const stopRecording = useRef(() => { + if (mediaRecorder.current?.state === "recording") { + if (stream.current) { + stream.current.getTracks().forEach(track => track.stop()); + } + mediaRecorder.current.stop(); + setRecording(false); + window.electronAPI.stopMouseTracking(); + window.electronAPI?.setRecordingState(false); + } + }); + useEffect(() => { + let cleanup: (() => void) | undefined; + + if (window.electronAPI?.onStopRecordingFromTray) { + cleanup = window.electronAPI.onStopRecordingFromTray(() => { + stopRecording.current(); + }); + } + return () => { + if (cleanup) cleanup(); + if (mediaRecorder.current?.state === "recording") { mediaRecorder.current.stop(); } @@ -91,6 +113,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recorder.start(1000); startTime.current = Date.now(); setRecording(true); + window.electronAPI?.setRecordingState(true); } catch (error) { console.error('Failed to start recording:', error); setRecording(false); @@ -101,19 +124,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; - const stopRecording = () => { - if (mediaRecorder.current?.state === "recording") { - if (stream.current) { - stream.current.getTracks().forEach(track => track.stop()); - } - mediaRecorder.current.stop(); - setRecording(false); - window.electronAPI.stopMouseTracking(); - } - }; - const toggleRecording = () => { - recording ? stopRecording() : startRecording(); + recording ? stopRecording.current() : startRecording(); }; return { recording, toggleRecording }; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 1871327..2a84e08 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -36,5 +36,7 @@ interface Window { message?: string error?: string }> + setRecordingState: (recording: boolean) => Promise + onStopRecordingFromTray: (callback: () => void) => () => void } } \ No newline at end of file