From ab41837d3dd2f9f5fdc95c25bd7f2555ef9aadf5 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 26 Apr 2026 19:22:56 -0700 Subject: [PATCH] fix: refresh overlay on Hyprland fullscreen --- ...screen-overlay-geometry-and-hover-pause.md | 33 +++++ changes/306-hyprland-fullscreen-overlay.md | 4 + docs-site/troubleshooting.md | 4 + src/core/services/mpv-properties.ts | 1 + src/core/services/mpv-protocol.test.ts | 12 ++ src/core/services/mpv-protocol.ts | 3 + src/core/services/mpv.test.ts | 16 +++ src/core/services/mpv.ts | 4 + src/core/services/overlay-window.ts | 2 + src/main.ts | 59 +++++++- .../runtime/mpv-client-event-bindings.test.ts | 2 + src/main/runtime/mpv-client-event-bindings.ts | 3 + src/main/runtime/mpv-main-event-bindings.ts | 2 + .../runtime/mpv-main-event-main-deps.test.ts | 3 + src/main/runtime/mpv-main-event-main-deps.ts | 4 + src/renderer/handlers/mouse.test.ts | 68 +++++++++ src/renderer/handlers/mouse.ts | 7 +- src/window-trackers/hyprland-tracker.test.ts | 43 ++++++ src/window-trackers/hyprland-tracker.ts | 132 +++++++++++++++--- 19 files changed, 381 insertions(+), 21 deletions(-) create mode 100644 backlog/tasks/task-306 - Fix-Hyprland-fullscreen-overlay-geometry-and-hover-pause.md create mode 100644 changes/306-hyprland-fullscreen-overlay.md diff --git a/backlog/tasks/task-306 - Fix-Hyprland-fullscreen-overlay-geometry-and-hover-pause.md b/backlog/tasks/task-306 - Fix-Hyprland-fullscreen-overlay-geometry-and-hover-pause.md new file mode 100644 index 00000000..f2e6a76e --- /dev/null +++ b/backlog/tasks/task-306 - Fix-Hyprland-fullscreen-overlay-geometry-and-hover-pause.md @@ -0,0 +1,33 @@ +--- +id: TASK-306 +title: Fix Hyprland fullscreen overlay geometry and hover pause +status: Done +assignee: [] +created_date: '2026-04-27 01:44' +labels: + - linux + - hyprland + - overlay + - bug +dependencies: [] +priority: high +--- + +## Description + + + +Overlay should track mpv geometry through Hyprland fullscreen transitions, stay above fullscreen video, and keep primary subtitle hover pause working after fullscreen/toggle cycles. + +Implemented by observing mpv fullscreen property changes in addition to Hyprland geometry events, then refreshing visible overlay bounds/layering on Linux. + + + +## Acceptance Criteria + + + +- [x] #1 Hyprland tracker reacts to fullscreen/window state changes with updated geometry. +- [x] #2 Visible overlay is re-layered above mpv after Hyprland fullscreen geometry updates. +- [x] #3 Primary subtitle hover pause remains active after overlay geometry changes or visible overlay toggle cycles. + diff --git a/changes/306-hyprland-fullscreen-overlay.md b/changes/306-hyprland-fullscreen-overlay.md new file mode 100644 index 00000000..187d8603 --- /dev/null +++ b/changes/306-hyprland-fullscreen-overlay.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed Hyprland fullscreen transitions so mpv fullscreen changes refresh visible overlay geometry, reassert topmost stacking, and keep primary subtitle hover pause working after resize/toggle cycles. diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index a6f9f255..c4ac5059 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -324,6 +324,10 @@ Add a `pass` rule for each global shortcut you configure. The defaults are `Alt+ Without these rules, Hyprland intercepts the keypresses before they reach SubMiner, and the shortcuts silently do nothing. +**Overlay stays behind mpv after fullscreen** + +SubMiner watches mpv's `fullscreen` property and refreshes the overlay geometry when it changes. If the overlay still does not move or rise above fullscreen mpv, confirm that the mpv IPC socket is connected and that `hyprctl -j clients` and `hyprctl -j monitors` work from the same environment that launched SubMiner. + For more details, see the Hyprland docs on [global keybinds](https://wiki.hypr.land/Configuring/Binds/#global-keybinds) and [window rules](https://wiki.hypr.land/Configuring/Window-Rules/). ### macOS diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts index e8b81cef..8b5272f3 100644 --- a/src/core/services/mpv-properties.ts +++ b/src/core/services/mpv-properties.ts @@ -59,6 +59,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [ 'sub-ass-override', 'sub-use-margins', 'pause', + 'fullscreen', 'duration', 'media-title', 'secondary-sub-visibility', diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 5c3756b7..f3c6a39b 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -93,6 +93,7 @@ function createDeps(overrides: Partial = {}): { emitTimePosChange: () => {}, emitDurationChange: () => {}, emitPauseChange: () => {}, + emitFullscreenChange: (payload) => state.events.push(payload), autoLoadSecondarySubTrack: () => {}, setCurrentVideoPath: () => {}, emitSecondarySubtitleVisibility: (payload) => state.events.push(payload), @@ -160,6 +161,17 @@ test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay sup ]); }); +test('dispatchMpvProtocolMessage emits fullscreen changes', async () => { + const { deps, state } = createDeps(); + + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'fullscreen', data: true }, + deps, + ); + + assert.deepEqual(state.events, [{ fullscreen: true }]); +}); + test('dispatchMpvProtocolMessage skips sub-visibility suppression when overlay is hidden', async () => { const { deps, state } = createDeps({ isVisibleOverlayVisible: () => false, diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index b79dbbd9..cb668601 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -65,6 +65,7 @@ export interface MpvProtocolHandleMessageDeps { emitTimePosChange: (payload: { time: number }) => void; emitDurationChange: (payload: { duration: number }) => void; emitPauseChange: (payload: { paused: boolean }) => void; + emitFullscreenChange: (payload: { fullscreen: boolean }) => void; emitSubtitleMetricsChange: (payload: Partial) => void; setCurrentSecondarySubText: (text: string) => void; resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean; @@ -291,6 +292,8 @@ export async function dispatchMpvProtocolMessage( } } else if (msg.name === 'pause') { deps.emitPauseChange({ paused: asBoolean(msg.data, false) }); + } else if (msg.name === 'fullscreen') { + deps.emitFullscreenChange({ fullscreen: asBoolean(msg.data, false) }); } else if (msg.name === 'media-title') { deps.emitMediaTitleChange({ title: typeof msg.data === 'string' ? msg.data.trim() : null, diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index e9b5b2a5..1228f9fa 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -57,6 +57,22 @@ test('MpvIpcClient handles sub-text property change and broadcasts tokenized sub assert.equal(events[0]!.isOverlayVisible, false); }); +test('MpvIpcClient emits fullscreen property changes', async () => { + const events: Array<{ fullscreen: boolean }> = []; + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + client.on('fullscreen-change', (payload) => { + events.push(payload); + }); + + await invokeHandleMessage(client, { + event: 'property-change', + name: 'fullscreen', + data: true, + }); + + assert.deepEqual(events, [{ fullscreen: true }]); +}); + test('MpvIpcClient clears cached media title when media path changes', async () => { const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 7e5df317..99f023b4 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -119,6 +119,7 @@ export interface MpvIpcClientEventMap { 'time-pos-change': { time: number }; 'duration-change': { duration: number }; 'pause-change': { paused: boolean }; + 'fullscreen-change': { fullscreen: boolean }; 'secondary-subtitle-change': { text: string }; 'subtitle-track-change': { sid: number | null }; 'subtitle-track-list-change': { trackList: unknown[] | null }; @@ -330,6 +331,9 @@ export class MpvIpcClient implements MpvClient { this.playbackPaused = payload.paused; this.emit('pause-change', payload); }, + emitFullscreenChange: (payload) => { + this.emit('fullscreen-change', payload); + }, emitSecondarySubtitleChange: (payload) => { this.emit('secondary-subtitle-change', payload); }, diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 29406ef8..05820a5e 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -67,6 +67,8 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void { return; } window.setAlwaysOnTop(true); + window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + window.moveTop(); } export function enforceOverlayLayerOrder(options: { diff --git a/src/main.ts b/src/main.ts index a29a45f5..04f9869f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1911,6 +1911,7 @@ const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as cons const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const; const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75; const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; +const LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS = [0, 50, 150, 300, 600] as const; let windowsVisibleOverlayBlurRefreshTimeouts: Array> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array> = []; let windowsVisibleOverlayZOrderSyncInFlight = false; @@ -1918,6 +1919,7 @@ let windowsVisibleOverlayZOrderSyncQueued = false; let windowsVisibleOverlayForegroundPollInterval: ReturnType | null = null; let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; let lastWindowsVisibleOverlayBlurredAtMs = 0; +let linuxMpvFullscreenOverlayRefreshTimeouts: Array> = []; function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void { for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) { @@ -1933,6 +1935,48 @@ function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void { windowsVisibleOverlayZOrderRetryTimeouts = []; } +function clearLinuxMpvFullscreenOverlayRefreshTimeouts(): void { + for (const timeout of linuxMpvFullscreenOverlayRefreshTimeouts) { + clearTimeout(timeout); + } + linuxMpvFullscreenOverlayRefreshTimeouts = []; +} + +function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(): void { + if (process.platform !== 'linux' || !overlayManager.getVisibleOverlayVisible()) { + return; + } + + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) { + return; + } + + mainWindow.hide(); + mainWindow.show(); + ensureOverlayWindowLevel(mainWindow); +} + +function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(): void { + if (process.platform !== 'linux') { + return; + } + + clearLinuxMpvFullscreenOverlayRefreshTimeouts(); + for (const delayMs of LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS) { + const refreshTimeout = setTimeout(() => { + linuxMpvFullscreenOverlayRefreshTimeouts = linuxMpvFullscreenOverlayRefreshTimeouts.filter( + (timeout) => timeout !== refreshTimeout, + ); + refreshLinuxVisibleOverlayAfterMpvFullscreenChange(); + }, delayMs); + refreshTimeout.unref?.(); + linuxMpvFullscreenOverlayRefreshTimeouts.push(refreshTimeout); + } +} + function getWindowsNativeWindowHandle(window: BrowserWindow): string { const handle = window.getNativeWindowHandle(); return handle.length >= 8 @@ -3806,6 +3850,9 @@ const { } lastObservedTimePos = time; }, + onFullscreenChange: () => { + scheduleLinuxVisibleOverlayFullscreenRefreshBurst(); + }, onSubtitleTrackChange: (sid) => { scheduleSubtitlePrefetchRefresh(); youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid); @@ -4046,10 +4093,18 @@ const buildUpdateVisibleOverlayBoundsMainDepsHandler = createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), afterSetOverlayWindowBounds: () => { - if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) { + if (!overlayManager.getVisibleOverlayVisible()) { return; } - scheduleWindowsVisibleOverlayZOrderSyncBurst(); + if (process.platform === 'win32') { + scheduleWindowsVisibleOverlayZOrderSyncBurst(); + return; + } + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + ensureOverlayWindowLevel(mainWindow); }, }); const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); diff --git a/src/main/runtime/mpv-client-event-bindings.test.ts b/src/main/runtime/mpv-client-event-bindings.test.ts index c695c765..0a32eb74 100644 --- a/src/main/runtime/mpv-client-event-bindings.test.ts +++ b/src/main/runtime/mpv-client-event-bindings.test.ts @@ -128,6 +128,7 @@ test('mpv event bindings register all expected events', () => { onTimePosChange: () => {}, onDurationChange: () => {}, onPauseChange: () => {}, + onFullscreenChange: () => {}, onSubtitleMetricsChange: () => {}, onSecondarySubtitleVisibility: () => {}, }); @@ -151,6 +152,7 @@ test('mpv event bindings register all expected events', () => { 'time-pos-change', 'duration-change', 'pause-change', + 'fullscreen-change', 'subtitle-metrics-change', 'secondary-subtitle-visibility', ]); diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts index 3ffa4257..0036cbfd 100644 --- a/src/main/runtime/mpv-client-event-bindings.ts +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -11,6 +11,7 @@ type MpvBindingEventName = | 'time-pos-change' | 'duration-change' | 'pause-change' + | 'fullscreen-change' | 'subtitle-metrics-change' | 'secondary-subtitle-visibility'; @@ -83,6 +84,7 @@ export function createBindMpvClientEventHandlers(deps: { onTimePosChange: (payload: { time: number }) => void; onDurationChange: (payload: { duration: number }) => void; onPauseChange: (payload: { paused: boolean }) => void; + onFullscreenChange: (payload: { fullscreen: boolean }) => void; onSubtitleMetricsChange: (payload: { patch: Record }) => void; onSecondarySubtitleVisibility: (payload: { visible: boolean }) => void; }) { @@ -99,6 +101,7 @@ export function createBindMpvClientEventHandlers(deps: { mpvClient.on('time-pos-change', deps.onTimePosChange); mpvClient.on('duration-change', deps.onDurationChange); mpvClient.on('pause-change', deps.onPauseChange); + mpvClient.on('fullscreen-change', deps.onFullscreenChange); mpvClient.on('subtitle-metrics-change', deps.onSubtitleMetricsChange); mpvClient.on('secondary-subtitle-visibility', deps.onSecondarySubtitleVisibility); }; diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 941ef212..f0f4b77a 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -68,6 +68,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { recordMediaDuration: (durationSec: number) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; onTimePosUpdate?: (time: number) => void; + onFullscreenChange?: (fullscreen: boolean) => void; recordPauseState: (paused: boolean) => void; updateSubtitleRenderMetrics: (patch: Record) => void; @@ -177,6 +178,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { onTimePosChange: handleMpvTimePosChange, onDurationChange: ({ duration }) => deps.recordMediaDuration(duration), onPauseChange: handleMpvPauseChange, + onFullscreenChange: ({ fullscreen }) => deps.onFullscreenChange?.(fullscreen), onSubtitleMetricsChange: handleMpvSubtitleMetricsChange, onSecondarySubtitleVisibility: handleMpvSecondarySubtitleVisibility, })(mpvClient); diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 9e52f677..37dea175 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -57,6 +57,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as updateCurrentMediaTitle: (title) => calls.push(`title:${title}`), resetAnilistMediaGuessState: () => calls.push('reset-guess'), reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`), + onFullscreenChange: (fullscreen) => calls.push(`fullscreen:${fullscreen}`), updateSubtitleRenderMetrics: () => calls.push('metrics'), refreshDiscordPresence: () => calls.push('presence-refresh'), })(); @@ -95,6 +96,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as deps.notifyImmersionTitleUpdate('title'); deps.recordPlaybackPosition(10); deps.reportJellyfinRemoteProgress(true); + deps.onFullscreenChange?.(true); deps.recordPauseState(true); deps.updateSubtitleRenderMetrics({}); deps.setPreviousSecondarySubVisibility(true); @@ -112,6 +114,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as assert.ok(calls.includes('sync-immersion')); assert.ok(calls.includes('autoplay:/tmp/video')); assert.ok(calls.includes('metrics')); + assert.ok(calls.includes('fullscreen:true')); assert.ok(calls.includes('presence-refresh')); assert.ok(calls.includes('restore-mpv-sub')); assert.ok(calls.includes('reset-sidebar-layout')); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index f9acd77f..39b96c89 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -60,6 +60,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { resetAnilistMediaGuessState: () => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; onTimePosUpdate?: (time: number) => void; + onFullscreenChange?: (fullscreen: boolean) => void; updateSubtitleRenderMetrics: (patch: Record) => void; refreshDiscordPresence: () => void; ensureImmersionTrackerInitialized: () => void; @@ -176,6 +177,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { onTimePosUpdate: deps.onTimePosUpdate ? (time: number) => deps.onTimePosUpdate!(time) : undefined, + onFullscreenChange: deps.onFullscreenChange + ? (fullscreen: boolean) => deps.onFullscreenChange!(fullscreen) + : undefined, recordPauseState: (paused: boolean) => { deps.appState.playbackPaused = paused; deps.ensureImmersionTrackerInitialized(); diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index 03f89aea..35a2307e 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -1315,6 +1315,74 @@ test('window resize ignores synthetic subtitle enter until the pointer moves aga } }); +test('window resize allows primary hover pause from a real mouseenter over subtitles', async () => { + const ctx = createMouseTestContext(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const mpvCommands: Array<(string | number)[]> = []; + const windowListeners = new Map void>>(); + ctx.platform.shouldToggleMouseIgnore = true; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + setIgnoreMouseEvents: () => {}, + }, + addEventListener: (type: string, listener: () => void) => { + const bucket = windowListeners.get(type) ?? []; + bucket.push(listener); + windowListeners.set(type, bucket); + }, + getComputedStyle: () => ({ + visibility: 'hidden', + display: 'none', + opacity: '0', + }), + focus: () => {}, + innerHeight: 1000, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + addEventListener: () => {}, + elementFromPoint: () => ctx.dom.subtitleContainer, + querySelectorAll: () => [], + body: {}, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => true, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: (command) => { + mpvCommands.push(command); + }, + }); + + handlers.setupResizeHandler(); + for (const listener of windowListeners.get('resize') ?? []) { + listener(); + } + + await handlers.handlePrimaryMouseEnter({ clientX: 120, clientY: 240 } as MouseEvent); + assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + } +}); + test('visibility recovery keeps overlay click-through when pointer is not over subtitles', () => { const ctx = createMouseTestContext(); const originalWindow = globalThis.window; diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index 7d193be9..a7063388 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -300,12 +300,15 @@ export function createMouseHandlers( } async function handleMouseEnter( - _event?: MouseEvent, + event?: MouseEvent, showSecondaryHover = false, source: 'direct' | 'tracked-pointer' = 'direct', ): Promise { if (source === 'direct' && suppressDirectHoverEnterSource !== null) { - return; + if (!event || !syncHoverStateFromPoint(event.clientX, event.clientY).isOverSubtitle) { + return; + } + suppressDirectHoverEnterSource = null; } ctx.state.isOverSubtitle = true; diff --git a/src/window-trackers/hyprland-tracker.test.ts b/src/window-trackers/hyprland-tracker.test.ts index 66f4d869..1f8c6a60 100644 --- a/src/window-trackers/hyprland-tracker.test.ts +++ b/src/window-trackers/hyprland-tracker.test.ts @@ -1,9 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + isHyprlandGeometryEvent, parseHyprctlClients, + resolveHyprlandWindowGeometry, selectHyprlandMpvWindow, type HyprlandClient, + type HyprlandMonitor, } from './hyprland-tracker'; function makeClient(overrides: Partial = {}): HyprlandClient { @@ -19,6 +22,17 @@ function makeClient(overrides: Partial = {}): HyprlandClient { }; } +function makeMonitor(overrides: Partial = {}): HyprlandMonitor { + return { + id: 0, + x: 0, + y: 0, + width: 1920, + height: 1080, + ...overrides, + }; +} + test('selectHyprlandMpvWindow ignores hidden and unmapped mpv clients', () => { const selected = selectHyprlandMpvWindow( [ @@ -106,3 +120,32 @@ test('parseHyprctlClients tolerates non-json prefix output', () => { }, ]); }); + +test('isHyprlandGeometryEvent treats fullscreenv2 as a geometry-changing event', () => { + assert.equal(isHyprlandGeometryEvent('fullscreenv2'), true); + assert.equal(isHyprlandGeometryEvent('workspacev2'), true); + assert.equal(isHyprlandGeometryEvent('activewindowv2'), false); +}); + +test('resolveHyprlandWindowGeometry uses monitor bounds for fullscreen clients', () => { + const geometry = resolveHyprlandWindowGeometry( + makeClient({ + at: [60, 80], + size: [1280, 720], + monitor: 1, + fullscreen: 2, + fullscreenClient: 2, + }), + [ + makeMonitor({ id: 0, x: 0, y: 0, width: 1920, height: 1080 }), + makeMonitor({ id: 1, x: 1920, y: 0, width: 2560, height: 1440 }), + ], + ); + + assert.deepEqual(geometry, { + x: 1920, + y: 0, + width: 2560, + height: 1440, + }); +}); diff --git a/src/window-trackers/hyprland-tracker.ts b/src/window-trackers/hyprland-tracker.ts index 64d84397..196e49e3 100644 --- a/src/window-trackers/hyprland-tracker.ts +++ b/src/window-trackers/hyprland-tracker.ts @@ -20,6 +20,7 @@ import * as net from 'net'; import { execSync } from 'child_process'; import { BaseWindowTracker } from './base-tracker'; import { createLogger } from '../logger'; +import type { WindowGeometry } from '../types'; const log = createLogger('tracker').child('hyprland'); @@ -29,11 +30,22 @@ export interface HyprlandClient { initialClass?: string; at: [number, number]; size: [number, number]; + monitor?: number; + fullscreen?: number; + fullscreenClient?: number; pid?: number; mapped?: boolean; hidden?: boolean; } +export interface HyprlandMonitor { + id: number; + x: number; + y: number; + width: number; + height: number; +} + interface SelectHyprlandMpvWindowOptions { targetMpvSocketPath: string | null; activeWindowAddress: string | null; @@ -132,8 +144,73 @@ export function parseHyprctlClients(output: string): HyprlandClient[] | null { return parsed as HyprlandClient[]; } +export function parseHyprctlMonitors(output: string): HyprlandMonitor[] | null { + const jsonPayload = extractHyprctlJsonPayload(output); + if (!jsonPayload) { + return null; + } + + const parsed = JSON.parse(jsonPayload) as unknown; + if (!Array.isArray(parsed)) { + return null; + } + + return parsed as HyprlandMonitor[]; +} + +function isHyprlandFullscreenClient(client: HyprlandClient): boolean { + return (client.fullscreen ?? 0) > 0; +} + +export function resolveHyprlandWindowGeometry( + client: HyprlandClient, + monitors: HyprlandMonitor[] | null, +): WindowGeometry { + if (isHyprlandFullscreenClient(client) && typeof client.monitor === 'number') { + const monitor = monitors?.find((candidate) => candidate.id === client.monitor); + if (monitor) { + return { + x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height, + }; + } + } + + return { + x: client.at[0], + y: client.at[1], + width: client.size[0], + height: client.size[1], + }; +} + +export function isHyprlandGeometryEvent(name: string): boolean { + return ( + name === 'movewindow' || + name === 'movewindowv2' || + name === 'resizewindow' || + name === 'resizewindowv2' || + name === 'windowtitle' || + name === 'windowtitlev2' || + name === 'openwindow' || + name === 'closewindow' || + name === 'fullscreen' || + name === 'fullscreenv2' || + name === 'changefloatingmode' || + name === 'workspace' || + name === 'workspacev2' || + name === 'focusedmon' || + name === 'monitoradded' || + name === 'monitoraddedv2' || + name === 'monitorremoved' + ); +} + export class HyprlandWindowTracker extends BaseWindowTracker { private pollInterval: ReturnType | null = null; + private pollTimeouts: Array> = []; private eventSocket: net.Socket | null = null; private readonly targetMpvSocketPath: string | null; private activeWindowAddress: string | null = null; @@ -154,6 +231,10 @@ export class HyprlandWindowTracker extends BaseWindowTracker { clearInterval(this.pollInterval); this.pollInterval = null; } + for (const timeout of this.pollTimeouts) { + clearTimeout(timeout); + } + this.pollTimeouts = []; if (this.eventSocket) { this.eventSocket.destroy(); this.eventSocket = null; @@ -200,6 +281,9 @@ export class HyprlandWindowTracker extends BaseWindowTracker { } const [name, rawData = ''] = trimmedEvent.split('>>', 2); + if (!name) { + return; + } const data = rawData.trim(); if (name === 'activewindowv2') { @@ -212,17 +296,25 @@ export class HyprlandWindowTracker extends BaseWindowTracker { this.activeWindowAddress = null; } - if ( - name === 'movewindow' || - name === 'movewindowv2' || - name === 'windowtitle' || - name === 'windowtitlev2' || - name === 'openwindow' || - name === 'closewindow' || - name === 'fullscreen' || - name === 'changefloatingmode' - ) { - this.pollGeometry(); + if (isHyprlandGeometryEvent(name)) { + this.scheduleGeometryPollBurst(); + } + } + + private scheduleGeometryPollBurst(): void { + this.pollGeometry(); + for (const timeout of this.pollTimeouts) { + clearTimeout(timeout); + } + this.pollTimeouts = [50, 150, 300].map((delayMs) => { + const pollTimeout = setTimeout(() => { + this.pollTimeouts = this.pollTimeouts.filter((timeout) => timeout !== pollTimeout); + this.pollGeometry(); + }, delayMs); + return pollTimeout; + }); + for (const pollTimeout of this.pollTimeouts) { + pollTimeout.unref?.(); } } @@ -237,12 +329,9 @@ export class HyprlandWindowTracker extends BaseWindowTracker { const mpvWindow = this.findTargetWindow(clients); if (mpvWindow) { - this.updateGeometry({ - x: mpvWindow.at[0], - y: mpvWindow.at[1], - width: mpvWindow.size[0], - height: mpvWindow.size[1], - }); + this.updateGeometry( + resolveHyprlandWindowGeometry(mpvWindow, this.getHyprlandMonitors(mpvWindow)), + ); } else { this.updateGeometry(null); } @@ -259,6 +348,15 @@ export class HyprlandWindowTracker extends BaseWindowTracker { }); } + private getHyprlandMonitors(client: HyprlandClient): HyprlandMonitor[] | null { + if (!isHyprlandFullscreenClient(client)) { + return null; + } + + const output = execSync('hyprctl -j monitors', { encoding: 'utf-8' }); + return parseHyprctlMonitors(output); + } + private getWindowCommandLine(pid: number): string | null { const commandLine = execSync(`ps -p ${pid} -o args=`, { encoding: 'utf-8',