fix: refresh overlay on Hyprland fullscreen

This commit is contained in:
2026-04-26 19:22:56 -07:00
parent 9e4ad907fe
commit ab41837d3d
19 changed files with 381 additions and 21 deletions

View File

@@ -59,6 +59,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'sub-ass-override',
'sub-use-margins',
'pause',
'fullscreen',
'duration',
'media-title',
'secondary-sub-visibility',

View File

@@ -93,6 +93,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
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,

View File

@@ -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<MpvSubtitleRenderMetrics>) => 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,

View File

@@ -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());

View File

@@ -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);
},

View File

@@ -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: {

View File

@@ -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<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderSyncInFlight = false;
@@ -1918,6 +1919,7 @@ let windowsVisibleOverlayZOrderSyncQueued = false;
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0;
let linuxMpvFullscreenOverlayRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
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();

View File

@@ -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',
]);

View File

@@ -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<string, unknown> }) => 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);
};

View File

@@ -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<string, unknown>) => 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);

View File

@@ -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'));

View File

@@ -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<string, unknown>) => 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();

View File

@@ -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<string, Array<() => 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;

View File

@@ -300,12 +300,15 @@ export function createMouseHandlers(
}
async function handleMouseEnter(
_event?: MouseEvent,
event?: MouseEvent,
showSecondaryHover = false,
source: 'direct' | 'tracked-pointer' = 'direct',
): Promise<void> {
if (source === 'direct' && suppressDirectHoverEnterSource !== null) {
return;
if (!event || !syncHoverStateFromPoint(event.clientX, event.clientY).isOverSubtitle) {
return;
}
suppressDirectHoverEnterSource = null;
}
ctx.state.isOverSubtitle = true;

View File

@@ -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> = {}): HyprlandClient {
@@ -19,6 +22,17 @@ function makeClient(overrides: Partial<HyprlandClient> = {}): HyprlandClient {
};
}
function makeMonitor(overrides: Partial<HyprlandMonitor> = {}): 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,
});
});

View File

@@ -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<typeof setInterval> | null = null;
private pollTimeouts: Array<ReturnType<typeof setTimeout>> = [];
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',