diff --git a/src/main/index.ts b/src/main/index.ts index a823ab3..49b16fb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -695,6 +695,16 @@ function stopWindowResizeController(): void { resizeEdge = null } +function isCursorInsideWindow(window: BrowserWindow): boolean { + const cursor = screen.getCursorScreenPoint() + const bounds = window.getBounds() + + return cursor.x > bounds.x + && cursor.x < bounds.x + bounds.width + && cursor.y > bounds.y + && cursor.y < bounds.y + bounds.height +} + function getFramelessWindowChromeOptions(): Pick< BrowserWindowConstructorOptions, 'frame' | 'transparent' | 'backgroundColor' | 'roundedCorners' | 'hasShadow' | 'thickFrame' | 'backgroundMaterial' @@ -1445,6 +1455,11 @@ function setupIPC(): void { return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? false }) + ipcMain.handle('window:is-cursor-inside', (event) => { + const targetWindow = getWindowFromSender(event.sender) + return targetWindow ? isCursorInsideWindow(targetWindow) : false + }) + ipcMain.handle('app:get-build-info', () => { return getAppBuildInfo() }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 82cdae1..e93106d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -59,6 +59,7 @@ contextBridge.exposeInMainWorld('electronAPI', { 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'), + isCursorInsideWindow: () => ipcRenderer.invoke('window:is-cursor-inside') as Promise, getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as CaptureBackendSupport, getNowPlayingState: () => ipcRenderer.invoke('now-playing:get-state') as Promise, setNowPlayingConsumerActive: (active: boolean) => ipcRenderer.invoke('now-playing:set-active', active) as Promise, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 35f9ac2..fa99707 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -20,6 +20,7 @@ export default function App(): JSX.Element { const [settingsPanelHeight, setSettingsPanelHeight] = useState(0) const [bottomBarHeight, setBottomBarHeight] = useState(0) const hideTimeoutRef = useRef | null>(null) + const settingsOpenRef = useRef(false) const externalProfileOpenQueueRef = useRef(Promise.resolve()) const initializeProfiles = useSettingsStore((s) => s.initializeProfiles) @@ -175,12 +176,67 @@ export default function App(): JSX.Element { setToolbarVisible(true) }, []) + useEffect(() => { + settingsOpenRef.current = settingsOpen + if (settingsOpen) { + showToolbar() + } + }, [settingsOpen, showToolbar]) + const scheduleHide = useCallback(() => { - if (settingsOpen) return + if (settingsOpenRef.current || hideTimeoutRef.current) return + hideTimeoutRef.current = setTimeout(() => { + hideTimeoutRef.current = null + if (settingsOpenRef.current) return setToolbarVisible(false) }, 400) - }, [settingsOpen]) + }, []) + + const handleToolbarHoverLeave = useCallback((event: React.MouseEvent) => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return + } + + const bounds = event.currentTarget.getBoundingClientRect() + const isPointerInside = event.clientX > bounds.left + && event.clientX < bounds.right + && event.clientY > bounds.top + && event.clientY < bounds.bottom + + if (isPointerInside) { + showToolbar() + return + } + + scheduleHide() + }, [scheduleHide, showToolbar]) + + useEffect(() => { + if (!toolbarVisible || settingsOpen) return + + let isDisposed = false + const checkCursorInsideWindow = (): void => { + void window.electronAPI.isCursorInsideWindow() + .then((isInside) => { + if (!isDisposed && !isInside) { + scheduleHide() + } + }) + .catch(() => { + // Renderer leave events still handle auto-hide if the cursor query is unavailable. + }) + } + + checkCursorInsideWindow() + const interval = setInterval(checkCursorInsideWindow, 120) + + return () => { + isDisposed = true + clearInterval(interval) + } + }, [scheduleHide, settingsOpen, toolbarVisible]) const handleToggleSettings = useCallback(() => { toggleSettings() @@ -222,7 +278,8 @@ export default function App(): JSX.Element {
diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 2dec64e..99bbdd7 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -55,6 +55,7 @@ declare global { repositionWindow: (position: 'top' | 'bottom') => void toggleAlwaysOnTop: () => void isAlwaysOnTop: () => Promise + isCursorInsideWindow: () => Promise getCaptureBackendSupport: () => Promise getNowPlayingState: () => Promise setNowPlayingConsumerActive: (active: boolean) => Promise