Compare commits

...
3 Commits
23 changed files with 753 additions and 227 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Keyboard-only mode no longer swallows `h`, `l`, and the left/right arrows while a modal is open or a text field has focus, so typing into fields like the Jimaku or TsukiHime title works and no longer seeks mpv. Pressing `Ctrl/Cmd+Shift+Y` (or the controller toggle) inside a modal no longer turns on a hidden keyboard-only mode in the modal window.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- The open subtitle sidebar no longer re-fetches the whole subtitle list and re-queries mpv about 12 times a second. It polls only the current time and line, and reloads the list when the subtitle track, file, or parsed cues change (plus a slow ~2 s fallback), cutting idle sidebar IPC and renderer work during playback.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: overlay
- Hyprland and Sway window tracking no longer freezes the app when `hyprctl` or `swaymsg` is slow or hangs: polls now run in the background with a timeout and no longer pile up.
- The Hyprland/Sway trackers read each mpv process's command line once instead of spawning `ps` on every poll.
+5 -1
View File
@@ -63,7 +63,11 @@ the plugin artifact, persistent config, or session help.
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
identifies the media and subtitle source so renderer selections are invalidated on source changes,
including changes whose cue text and timings are identical. Native selection and clean clipboard
including changes whose cue text and timings are identical. While open, the sidebar polls the
cheap `SubtitleSidebarPlaybackState` (cached time, current line, `sourceKey`, `cuesRevision`; no
mpv round-trips) every 80 ms for active-cue tracking. It fetches the full snapshot, which resolves
the source over mpv IPC and carries every cue plus config, only on open, config reload, a
`sourceKey`/`cuesRevision` change, and a ~2 s fallback. Native selection and clean clipboard
serialization live in `src/renderer/modals/subtitle-sidebar-selection.ts`. Electron lets standard
Copy input reach the renderer, where sidebar selection takes priority over the live-subtitle binding.
The preload bridge writes selections through Electron's clipboard API so copying does not depend
+28
View File
@@ -156,6 +156,34 @@ test(
},
);
test(
'open subtitle sidebar lists the parsed cues and follows playback',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
await showCue(FIXTURE_CUES[0]);
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item').length === ${FIXTURE_CUES.length}`,
{ description: 'sidebar to list every fixture cue' },
);
for (const [index, cue] of FIXTURE_CUES.entries()) {
await session.mpv.command('seek', cue.at, 'absolute+exact');
await overlay.waitFor(
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '${index}'`,
{ description: `sidebar to highlight cue ${index}` },
);
}
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
{ description: 'sidebar to close' },
);
},
);
// Runs last: it leaves the lookup popup open over the overlay.
test(
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
+2
View File
@@ -86,7 +86,9 @@ function createControllerConfigFixture() {
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
return {
sourceKey: 'test-subtitles',
cuesRevision: 1,
cues: [],
currentTimeSec: null,
currentSubtitle: { text: '', startTime: null, endTime: null },
config: {
enabled: false,
+11
View File
@@ -12,6 +12,7 @@ import type {
RuntimeOptionId,
RuntimeOptionValue,
SubtitleMiningContext,
SubtitleSidebarPlaybackState,
SubtitleSidebarSnapshot,
SubtitlePosition,
SubsyncManualRunRequest,
@@ -79,6 +80,7 @@ export interface IpcServiceDeps {
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState;
getSubtitleSidebarOpen?: () => boolean;
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
getSubtitlePosition: () => unknown;
@@ -369,6 +371,7 @@ export interface IpcDepsRuntimeOptions {
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState;
getSubtitleSidebarOpen?: () => boolean;
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
getSubtitlePosition: () => unknown;
@@ -455,6 +458,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot,
getSubtitleSidebarPlaybackState: options.getSubtitleSidebarPlaybackState,
getSubtitleSidebarOpen: options.getSubtitleSidebarOpen ?? (() => false),
getPlaybackPaused: options.getPlaybackPaused,
getSubtitlePosition: options.getSubtitlePosition,
@@ -742,6 +746,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return await deps.getSubtitleSidebarSnapshot();
});
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarPlaybackState, () => {
if (!deps.getSubtitleSidebarPlaybackState) {
throw new Error('Subtitle sidebar playback state is unavailable.');
}
return deps.getSubtitleSidebarPlaybackState();
});
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarOpen, () => {
return deps.getSubtitleSidebarOpen?.() ?? false;
});
+44 -76
View File
@@ -92,8 +92,10 @@ import type {
SessionActionDispatchRequest,
SecondarySubMode,
SubtitleData,
SubtitleCue,
SubtitleMiningContext,
SubtitlePosition,
SubtitleSidebarPlaybackState,
WindowGeometry,
} from './types';
import { OPEN_ANKI_CARD_ACTION_ID } from './types';
@@ -2047,6 +2049,33 @@ const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({
logDebug: (message) => logger.debug(message),
});
let subtitleSidebarRequestedOpen = false;
let subtitleSidebarCuesRef: SubtitleCue[] | null = null;
let subtitleSidebarCuesRevision = 0;
/**
* Cheap sidebar state polled ~12x/s while the sidebar is open. Reads only cached state, no
* mpv round-trips. `cuesRevision` bumps whenever a new parsed cue array is swapped in, so a
* re-parse of the same source still tells the renderer to fetch the full snapshot.
*/
function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState {
if (appState.activeParsedSubtitleCues !== subtitleSidebarCuesRef) {
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
subtitleSidebarCuesRevision += 1;
}
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cuesRevision: subtitleSidebarCuesRevision,
currentTimeSec: appState.mpvClient?.currentTimePos ?? null,
currentSubtitle: {
text: appState.currentSubText,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
},
};
}
const SEEK_THRESHOLD_SECONDS = 3;
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
let explicitSeekIntentExpiresAtMs = 0;
@@ -5850,26 +5879,19 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText,
getSubtitleSidebarOpen: () => subtitleSidebarRequestedOpen,
getSubtitleSidebarPlaybackState: () => getSubtitleSidebarPlaybackState(),
// Full snapshot: resolves the active source over mpv IPC and re-parses it when it changed.
// The renderer fetches it on open, on source/cue changes, and on a slow fallback cadence.
getSubtitleSidebarSnapshot: async () => {
const currentSubtitle = {
text: appState.currentSubText,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
};
const currentTimeSec = appState.mpvClient?.currentTimePos ?? null;
const config = configService.getConfig().subtitleSidebar;
const cachedSnapshot = () => ({
...getSubtitleSidebarPlaybackState(),
cues: appState.activeParsedSubtitleCues,
config,
});
const client = appState.mpvClient;
if (!client?.connected) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
}
try {
@@ -5883,16 +5905,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
]);
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
}
if (
shouldUseCachedYoutubeParsedCues({
@@ -5901,16 +5914,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
cachedCueCount: appState.activeParsedSubtitleCues.length,
})
) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
}
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
@@ -5921,30 +5925,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
videoPath,
});
if (!resolvedSource) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
}
try {
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
}
const content = await loadSubtitleSourceText(resolvedSource.path);
@@ -5952,30 +5938,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
appState.activeParsedSubtitleCues = cues;
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
appState.activeParsedSubtitleMediaPath = videoPath || null;
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
} finally {
await resolvedSource.cleanup?.();
}
} catch {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
}
},
getPlaybackPaused: () =>
+2
View File
@@ -74,6 +74,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
getSubtitleSidebarPlaybackState?: IpcDepsRuntimeOptions['getSubtitleSidebarPlaybackState'];
getSubtitleSidebarOpen?: IpcDepsRuntimeOptions['getSubtitleSidebarOpen'];
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
@@ -276,6 +277,7 @@ export function createMainIpcRuntimeServiceDeps(
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
getSubtitleSidebarPlaybackState: params.getSubtitleSidebarPlaybackState,
getSubtitleSidebarOpen: params.getSubtitleSidebarOpen,
getPlaybackPaused: params.getPlaybackPaused,
getSubtitlePosition: params.getSubtitlePosition,
+2
View File
@@ -256,6 +256,8 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen),
getSubtitleSidebarSnapshot: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
getSubtitleSidebarPlaybackState: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarPlaybackState),
copySubtitleSidebarSelection: async (text: unknown) => {
if (typeof text !== 'string') throw new TypeError('Subtitle selection must be text.');
clipboard.writeText(text);
+75 -6
View File
@@ -315,8 +315,9 @@ function installKeyboardTestGlobals() {
shiftKey?: boolean;
repeat?: boolean;
target?: unknown;
}): void {
}): boolean {
const listeners = documentListeners.get('keydown') ?? [];
let prevented = false;
const keyboardEvent = {
key: event.key,
code: event.code,
@@ -325,12 +326,15 @@ function installKeyboardTestGlobals() {
altKey: event.altKey ?? false,
shiftKey: event.shiftKey ?? false,
repeat: event.repeat ?? false,
preventDefault: () => {},
preventDefault: () => {
prevented = true;
},
target: event.target ?? null,
};
for (const listener of listeners) {
listener(keyboardEvent);
}
return prevented;
}
function dispatchMousedown(event: {
@@ -472,6 +476,12 @@ function installKeyboardTestGlobals() {
target.closest = (selector: string) => (selector.includes('.modal') ? target : null);
return target;
},
createTextInputTarget: () => {
const target = new TestElement();
target.tagName = 'INPUT';
target.closest = (selector: string) => (selector.includes('input') ? target : null);
return target;
},
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
getSessionBindingsImpl = value;
},
@@ -501,6 +511,7 @@ function createKeyboardHandlerHarness() {
let openControllerDebugCount = 0;
let playlistBrowserKeydownCount = 0;
let changelogKeydownCount = 0;
let jimakuKeydownCount = 0;
const createWordNode = (left: number) => ({
classList: createClassList(),
@@ -536,7 +547,10 @@ function createKeyboardHandlerHarness() {
handleCharacterDictionaryKeydown: () => false,
handleSubsyncKeydown: () => false,
handleKikuKeydown: () => false,
handleJimakuKeydown: () => false,
handleJimakuKeydown: () => {
jimakuKeydownCount += 1;
return false;
},
handleTsukihimeKeydown: () => false,
handleMediaTimingReviewKeydown: () => {
mediaTimingReviewKeydownCount += 1;
@@ -577,6 +591,7 @@ function createKeyboardHandlerHarness() {
openControllerDebugCount: () => openControllerDebugCount,
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
changelogKeydownCount: () => changelogKeydownCount,
jimakuKeydownCount: () => jimakuKeydownCount,
setWordCount: (count: number) => {
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
},
@@ -1417,6 +1432,59 @@ test('keyboard mode: changelog modal handles h/l fold keys before lookup control
}
});
const KEYBOARD_MODE_LOOKUP_KEYS = [
{ key: 'h', code: 'KeyH' },
{ key: 'l', code: 'KeyL' },
{ key: 'ArrowLeft', code: 'ArrowLeft' },
{ key: 'ArrowRight', code: 'ArrowRight' },
];
test('keyboard mode: an open modal receives h/l/arrows instead of lookup controls', async () => {
const { ctx, testGlobals, handlers, jimakuKeydownCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.jimakuModalOpen = true;
ctx.state.keyboardSelectedWordIndex = 1;
const titleInput = testGlobals.createTextInputTarget();
for (const key of KEYBOARD_MODE_LOOKUP_KEYS) {
const prevented = testGlobals.dispatchKeydown({ ...key, target: titleInput });
assert.equal(prevented, false, `${key.code} must reach the focused modal input`);
}
await wait(0);
assert.equal(jimakuKeydownCount(), KEYBOARD_MODE_LOOKUP_KEYS.length);
assert.equal(ctx.state.keyboardSelectedWordIndex, 1);
assert.deepEqual(testGlobals.mpvCommands, []);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: h/l/arrows typed into a text field are not consumed by lookup controls', async () => {
const { ctx, testGlobals, handlers, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
// No words means lookup controls would seek mpv to the adjacent subtitle.
setWordCount(0);
const input = testGlobals.createTextInputTarget();
for (const key of KEYBOARD_MODE_LOOKUP_KEYS) {
const prevented = testGlobals.dispatchKeydown({ ...key, target: input });
assert.equal(prevented, false, `${key.code} must reach the text field`);
}
await wait(0);
assert.deepEqual(testGlobals.mpvCommands, []);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -1683,7 +1751,7 @@ test('keyboard mode: visible-layer Ctrl+Shift+Y should not be toggled by rendere
}
});
test('keyboard mode: modal-layer Ctrl+Shift+Y still toggles via renderer keydown', async () => {
test('keyboard mode: modal layer never enables keyboard mode', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
@@ -1691,9 +1759,10 @@ test('keyboard mode: modal-layer Ctrl+Shift+Y still toggles via renderer keydown
ctx.platform.isModalLayer = true;
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
// Controller toggle path.
handlers.handleKeyboardModeToggleRequested();
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
+10 -19
View File
@@ -403,11 +403,6 @@ export function createKeyboardHandlers(
return e.ctrlKey || e.metaKey;
}
function isKeyboardDrivenModeToggle(e: KeyboardEvent): boolean {
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
return isPrimaryModifierPressed(e) && !e.altKey && e.shiftKey && isYKey && !e.repeat;
}
function isLookupWindowToggle(e: KeyboardEvent): boolean {
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
@@ -723,7 +718,10 @@ export function createKeyboardHandlers(
return true;
}
// Keyboard mode drives the visible overlay's subtitle words. The modal layer hides
// subtitles, so enabling it there only lets arrows/H/L seek mpv from behind a modal.
function handleKeyboardModeToggleRequested(): void {
if (ctx.platform.isModalLayer) return;
toggleKeyboardDrivenMode();
}
@@ -1144,12 +1142,6 @@ export function createKeyboardHandlers(
return;
}
if (isKeyboardDrivenModeToggle(e) && ctx.platform.isModalLayer) {
e.preventDefault();
handleKeyboardModeToggleRequested();
return;
}
if (isLookupWindowToggle(e)) {
e.preventDefault();
handleLookupWindowToggleRequested();
@@ -1162,19 +1154,11 @@ export function createKeyboardHandlers(
}
}
// Ahead of the keyboard-driven lookup controls: the changelog modal binds
// arrows/H/L for folding, and those would otherwise move the subtitle word
// selection (and seek mpv) behind the open modal.
if (ctx.state.changelogModalOpen) {
options.handleChangelogKeydown(e);
return;
}
if (handleKeyboardDrivenModeLookupControls(e)) {
e.preventDefault();
return;
}
if (ctx.state.runtimeOptionsModalOpen) {
options.handleRuntimeOptionsKeydown(e);
return;
@@ -1222,6 +1206,13 @@ export function createKeyboardHandlers(
return;
}
// After open modals and text fields, which own arrows/H/L; before Yomitan popup
// keybinds so word navigation keeps working while the popup is open.
if (handleKeyboardDrivenModeLookupControls(e)) {
e.preventDefault();
return;
}
if (handlePendingNumericSelection(e)) {
return;
}
@@ -15,6 +15,7 @@ export async function setup() {
const commands: unknown[] = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'episode-1:track-1',
cuesRevision: 1,
cues: [
{ text: '最初の台詞', startTime: 0, endTime: 1 },
{ text: '同じ台詞\n二行目', startTime: 1, endTime: 2 },
@@ -39,6 +40,7 @@ export async function setup() {
Object.defineProperty(window, 'electronAPI', {
value: {
getSubtitleSidebarSnapshot: async () => snapshot,
getSubtitleSidebarPlaybackState: async () => snapshot,
getSessionBindings: async () => [
{
sourcePath: 'keybindings[0].key',
@@ -64,6 +66,7 @@ export async function setup() {
} satisfies Pick<
ElectronAPI,
| 'getSubtitleSidebarSnapshot'
| 'getSubtitleSidebarPlaybackState'
| 'getSessionBindings'
| 'getConfiguredShortcuts'
| 'getStatsToggleKey'
+123 -9
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types';
import type {
ElectronAPI,
SubtitleSidebarPlaybackState,
SubtitleSidebarSnapshot,
} from '../../types';
import { createRendererState } from '../state.js';
import {
applySidebarCssDeclarations,
@@ -68,7 +72,12 @@ function createCueRow() {
function createListStub() {
return {
innerHTML: '',
get innerHTML() {
return '';
},
set innerHTML(_html: string) {
this.children.length = 0;
},
children: [] as ReturnType<typeof createCueRow>[],
appendChild(child: ReturnType<typeof createCueRow>) {
child.offsetTop = this.children.length * child.clientHeight;
@@ -180,17 +189,20 @@ test.afterEach(() => {
/**
* Builds a sidebar modal over fake window/document/dom globals. The snapshot served to the
* modal can be changed with `setSnapshot`. Globals are restored (and polling stopped) after
* each test.
* modal (and the playback state derived from it) can be changed with `setSnapshot`; `poll`
* runs the next scheduled playback tick. Globals are restored after each test.
*/
function createSidebarHarness(options: SidebarHarnessOptions = {}) {
let snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cuesRevision: 1,
cues: options.cues ?? [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: options.currentSubtitle ?? { text: 'first', startTime: 1, endTime: 2 },
currentTimeSec: 'currentTimeSec' in options ? options.currentTimeSec : 1.1,
currentTimeSec: 'currentTimeSec' in options ? (options.currentTimeSec ?? null) : 1.1,
config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config },
};
const requests = { snapshot: 0, playback: 0 };
let nextPoll: (() => Promise<void>) | null = null;
const mpvCommands: MpvCommand[] = [];
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
@@ -208,7 +220,15 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
addEventListener: windowEvents.addEventListener,
removeEventListener: windowEvents.removeEventListener,
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
getSubtitleSidebarSnapshot: async () => {
requests.snapshot += 1;
return snapshot;
},
getSubtitleSidebarPlaybackState: async (): Promise<SubtitleSidebarPlaybackState> => {
requests.playback += 1;
const { sourceKey, cuesRevision, currentTimeSec, currentSubtitle } = snapshot;
return { sourceKey, cuesRevision, currentTimeSec, currentSubtitle };
},
getPlaybackPaused: async () => false,
sendMpvCommand: (command: MpvCommand) => {
mpvCommands.push(command);
@@ -281,11 +301,16 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
onVisibilityChanged: (visible) => {
visibilityChanges.push(visible);
},
schedule: (callback) => {
nextPoll = callback;
return () => {
if (nextPoll === callback) nextPoll = null;
};
},
...options.modalOptions,
});
harnessCleanups.push(() => {
// Closing stops the snapshot poll so it cannot fire against the next test's globals.
modal.closeSubtitleSidebarModal();
modal.disposeDomEvents();
restoreGlobals();
@@ -307,6 +332,13 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
windowEvents,
contentEvents,
modalEvents,
requests,
/** Runs the pending playback poll tick, if one is scheduled. */
async poll() {
const tick = nextPoll;
nextPoll = null;
await tick?.();
},
setSnapshot(patch: SnapshotPatch) {
snapshot = { ...snapshot, ...patch, config: { ...snapshot.config, ...patch.config } };
},
@@ -675,7 +707,7 @@ test('subtitle sidebar jumps to first resolved active cue, then resumes smooth a
currentSubtitle: { text: 'line-9', startTime: 18, endTime: 19.5 },
currentTimeSec: 18.1,
});
await h.modal.refreshSubtitleSidebarSnapshot();
await h.poll();
assert.equal(h.state.subtitleSidebarActiveCueIndex, 9);
assert.equal(h.cueList.scrollTop, 260);
@@ -685,12 +717,94 @@ test('subtitle sidebar jumps to first resolved active cue, then resumes smooth a
currentSubtitle: { text: 'line-10', startTime: 20, endTime: 21.5 },
currentTimeSec: 20.1,
});
await h.modal.refreshSubtitleSidebarSnapshot();
await h.poll();
assert.equal(h.state.subtitleSidebarActiveCueIndex, 10);
assert.deepEqual(h.cueList.scrollToCalls.at(-1), { top: 300, behavior: 'smooth' });
});
test('open subtitle sidebar follows playback from cheap polls without refetching cues or re-measuring', async () => {
let measurements = 0;
const h = createSidebarHarness({
cues: Array.from({ length: 4 }, (_, index) => ({
startTime: index * 2,
endTime: index * 2 + 1.5,
text: `line-${index}`,
})),
currentSubtitle: { text: 'line-0', startTime: 0, endTime: 1.5 },
currentTimeSec: 0.1,
config: { layout: 'embedded', maxWidth: 360 },
contentWidth: () => {
measurements += 1;
return 360;
},
});
await h.modal.openSubtitleSidebarModal();
const snapshotsAfterOpen = h.requests.snapshot;
const measurementsAfterOpen = measurements;
// Inside the lookahead window the upcoming cue activates before mpv reports its text.
h.setSnapshot({
currentSubtitle: { text: '', startTime: null, endTime: null },
currentTimeSec: 1.9,
});
await h.poll();
assert.equal(h.state.subtitleSidebarActiveCueIndex, 1);
h.setSnapshot({
currentSubtitle: { text: 'line-2', startTime: 4, endTime: 5.5 },
currentTimeSec: 4.1,
});
await h.poll();
assert.equal(h.state.subtitleSidebarActiveCueIndex, 2);
assert.equal(h.cueList.children[2]!.classList.contains('active'), true);
assert.equal(h.cueList.children[1]!.classList.contains('active'), false);
assert.equal(h.requests.playback, 2);
assert.equal(h.requests.snapshot, snapshotsAfterOpen);
assert.equal(measurements, measurementsAfterOpen);
});
for (const change of [
{ name: 'subtitle source', patch: { sourceKey: 'other-subtitles' } },
{ name: 'parsed cue revision', patch: { cuesRevision: 2 } },
]) {
test(`open subtitle sidebar refetches and re-renders cues when the ${change.name} changes`, async () => {
const h = createSidebarHarness();
await h.modal.openSubtitleSidebarModal();
const snapshotsAfterOpen = h.requests.snapshot;
h.setSnapshot({
...change.patch,
cues: [
{ startTime: 1, endTime: 2, text: 'replacement' },
{ startTime: 3, endTime: 4, text: 'second' },
],
});
await h.poll();
assert.equal(h.requests.snapshot, snapshotsAfterOpen + 1);
assert.deepEqual(
h.cueList.children.map((row) => (row.children[1] as { textContent: string }).textContent),
['replacement', 'second'],
);
});
}
test('open subtitle sidebar picks up changes main did not flag on a slow full-snapshot fallback', async () => {
const h = createSidebarHarness();
await h.modal.openSubtitleSidebarModal();
h.setSnapshot({ config: { enabled: false } });
for (let polls = 0; h.state.subtitleSidebarModalOpen && polls < 100; polls += 1) {
await h.poll();
}
assert.equal(h.state.subtitleSidebarModalOpen, false);
assert.equal(h.requests.snapshot, 2);
assert.ok(h.requests.playback > 1);
});
test('subtitle sidebar closes and resumes a hover pause', async () => {
const h = createSidebarHarness({ config: { pauseVideoOnHover: true } });
h.modal.wireDomEvents();
+46 -19
View File
@@ -19,7 +19,11 @@ import {
const MANUAL_SCROLL_HOLD_MS = 1500;
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
const SNAPSHOT_POLL_INTERVAL_MS = 80;
// While open, the sidebar polls cheap cached playback state for active-cue tracking and only
// fetches the full snapshot (mpv source resolution + every cue + config) when the source or
// parsed cues changed, plus this fallback in case main missed a change.
const PLAYBACK_POLL_INTERVAL_MS = 80;
const FULL_SNAPSHOT_FALLBACK_POLLS = 25;
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
const appliedSidebarCssKeys = new WeakMap<HTMLElement, Set<string>>();
@@ -206,9 +210,18 @@ export function createSubtitleSidebarModal(
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
onVisibilityChanged?: (visible: boolean) => void;
shouldRestoreOpenOnStartup?: () => Promise<boolean>;
/** Schedules a poll tick and returns its cancel function. Defaults to `setTimeout`. */
schedule?: (callback: () => Promise<void>, delayMs: number) => () => void;
},
) {
let snapshotPollInterval: ReturnType<typeof setTimeout> | null = null;
const schedule =
options.schedule ??
((callback: () => Promise<void>, delayMs: number) => {
const timer = setTimeout(callback, delayMs);
return () => clearTimeout(timer);
});
let cancelPlaybackPoll: (() => void) | null = null;
let pollsSinceFullSnapshot = 0;
let lastAppliedVideoMarginRatio: number | null = null;
let subtitleSidebarHoverRequestId = 0;
let disposeDomEvents: (() => void) | null = null;
@@ -218,6 +231,7 @@ export function createSubtitleSidebarModal(
let subtitleSidebarPauseHeldByYomitanPopup = false;
let lastSubtitleSidebarLookupCueIndex = -1;
let subtitleSourceKey: string | null = null;
let subtitleCuesRevision: number | null = null;
function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx);
@@ -576,6 +590,8 @@ export function createSubtitleSidebarModal(
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
pollsSinceFullSnapshot = 0;
subtitleCuesRevision = snapshot.cuesRevision;
if (snapshot.sourceKey !== subtitleSourceKey) {
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
lastSubtitleSidebarLookupCueIndex = -1;
@@ -590,8 +606,8 @@ export function createSubtitleSidebarModal(
ctx.state.subtitleSidebarModalOpen = false;
ctx.dom.subtitleSidebarModal.classList.add('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
stopSnapshotPolling();
updateActiveCue(null, snapshot.currentTimeSec ?? null);
stopPlaybackPolling();
updateActiveCue(null, snapshot.currentTimeSec);
setStatus('Subtitle sidebar disabled in config.');
syncEmbeddedSidebarLayout();
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
@@ -609,38 +625,49 @@ export function createSubtitleSidebarModal(
renderCueList();
}
}
updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec ?? null);
updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec);
syncEmbeddedSidebarLayout();
return snapshot;
}
function startSnapshotPolling(): void {
stopSnapshotPolling();
async function pollPlaybackOnce(): Promise<void> {
pollsSinceFullSnapshot += 1;
const playback = await window.electronAPI.getSubtitleSidebarPlaybackState();
if (
playback.sourceKey !== subtitleSourceKey ||
playback.cuesRevision !== subtitleCuesRevision ||
pollsSinceFullSnapshot >= FULL_SNAPSHOT_FALLBACK_POLLS
) {
await refreshSnapshot();
return;
}
updateActiveCue(playback.currentSubtitle, playback.currentTimeSec);
}
function startPlaybackPolling(): void {
stopPlaybackPolling();
const pollOnce = async (): Promise<void> => {
try {
await refreshSnapshot();
await pollPlaybackOnce();
} catch {
// Keep polling; a transient IPC failure should not stop updates.
}
if (!ctx.state.subtitleSidebarModalOpen) {
snapshotPollInterval = null;
cancelPlaybackPoll = null;
return;
}
snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS);
cancelPlaybackPoll = schedule(pollOnce, PLAYBACK_POLL_INTERVAL_MS);
};
snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS);
cancelPlaybackPoll = schedule(pollOnce, PLAYBACK_POLL_INTERVAL_MS);
}
function stopSnapshotPolling(): void {
if (!snapshotPollInterval) {
return;
}
clearTimeout(snapshotPollInterval);
snapshotPollInterval = null;
function stopPlaybackPolling(): void {
cancelPlaybackPoll?.();
cancelPlaybackPoll = null;
}
async function openSubtitleSidebarModal(): Promise<void> {
@@ -664,7 +691,7 @@ export function createSubtitleSidebarModal(
renderCueList();
syncActiveCueClasses(-1);
maybeAutoScrollActiveCue(-1, 'auto', true);
startSnapshotPolling();
startPlaybackPolling();
syncEmbeddedSidebarLayout();
restoreEmbeddedSidebarPassthrough();
window.electronAPI.notifyOverlayModalOpened?.('subtitle-sidebar');
@@ -694,7 +721,7 @@ export function createSubtitleSidebarModal(
ctx.state.subtitleSidebarModalOpen = false;
ctx.dom.subtitleSidebarModal.classList.add('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
stopSnapshotPolling();
stopPlaybackPolling();
syncEmbeddedSidebarLayout();
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
+1
View File
@@ -68,6 +68,7 @@ export const IPC_CHANNELS = {
getCurrentSubtitleRaw: 'get-current-subtitle-raw',
getCurrentSubtitleAss: 'get-current-subtitle-ass',
getSubtitleSidebarSnapshot: 'get-subtitle-sidebar-snapshot',
getSubtitleSidebarPlaybackState: 'get-subtitle-sidebar-playback-state',
getSubtitleSidebarOpen: 'get-subtitle-sidebar-open',
getPlaybackPaused: 'get-playback-paused',
getSubtitlePosition: 'get-subtitle-position',
+2
View File
@@ -55,6 +55,7 @@ import type {
SubtitleData,
SubtitleMiningContext,
SubtitlePosition,
SubtitleSidebarPlaybackState,
SubtitleSidebarSnapshot,
SubtitleRendererStyleConfig,
SubtitleStyleConfig,
@@ -477,6 +478,7 @@ export interface ElectronAPI {
getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>;
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
getSubtitleSidebarPlaybackState: () => Promise<SubtitleSidebarPlaybackState>;
copySubtitleSidebarSelection: (text: string) => Promise<void>;
getSubtitleSidebarOpen: () => Promise<boolean>;
getPlaybackPaused: () => Promise<boolean | null>;
+13 -3
View File
@@ -226,15 +226,25 @@ export interface SubtitleData {
endTime?: number | null;
}
export interface SubtitleSidebarSnapshot {
/**
* Per-tick sidebar state, served from main's cached state without mpv round-trips. When
* `sourceKey` or `cuesRevision` differs from the last full snapshot's, the renderer fetches a
* new `SubtitleSidebarSnapshot`.
*/
export interface SubtitleSidebarPlaybackState {
sourceKey: string | null;
cues: SubtitleCue[];
currentTimeSec?: number | null;
/** Bumps whenever main swaps in a different parsed cue array. */
cuesRevision: number;
currentTimeSec: number | null;
currentSubtitle: {
text: string;
startTime: number | null;
endTime: number | null;
};
}
export interface SubtitleSidebarSnapshot extends SubtitleSidebarPlaybackState {
cues: SubtitleCue[];
config: SubtitleSidebarSnapshotConfig;
}
+109 -11
View File
@@ -179,21 +179,119 @@ test('resolveHyprlandWindowGeometry uses monitor bounds for client-requested ful
});
});
test('HyprlandWindowTracker re-emits focus callback on active window events for z-order refresh', () => {
type PendingCommand = {
command: string;
args: string[];
resolve: (output: string) => void;
};
// Runner whose commands stay pending until the test resolves them.
function makeDeferredRunner() {
const pending: PendingCommand[] = [];
const runCommand = (command: string, args: string[]) =>
new Promise<string>((resolve) => {
pending.push({ command, args, resolve });
});
return { pending, runCommand };
}
function flush(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
test('HyprlandWindowTracker re-emits focus callback on active window events for z-order refresh', async () => {
const calls: string[] = [];
const tracker = new HyprlandWindowTracker();
const privateTracker = tracker as unknown as {
handleSocketEvent: (event: string) => void;
pollGeometry: () => void;
};
privateTracker.pollGeometry = () => {
calls.push('poll');
};
const tracker = new HyprlandWindowTracker(undefined, async (command, args) => {
calls.push(`${command} ${args.join(' ')}`);
return '[]';
});
tracker.onWindowFocusChange = (focused) => {
calls.push(`focus:${focused}`);
};
privateTracker.handleSocketEvent('activewindowv2>>0xmpv');
(tracker as unknown as { handleSocketEvent: (event: string) => void }).handleSocketEvent(
'activewindowv2>>0xmpv',
);
await flush();
assert.deepEqual(calls, ['poll', 'focus:false']);
assert.deepEqual(calls, ['hyprctl -j clients', 'focus:false']);
});
test('HyprlandWindowTracker coalesces polls requested while hyprctl is still running', async () => {
const { pending, runCommand } = makeDeferredRunner();
const tracker = new HyprlandWindowTracker(undefined, runCommand);
const polls = [tracker.refreshNow(), tracker.refreshNow(), tracker.refreshNow()];
await flush();
assert.equal(pending.length, 1);
pending[0]!.resolve(JSON.stringify([makeClient({ at: [10, 20] })]));
await flush();
assert.equal(pending.length, 2);
pending[1]!.resolve(JSON.stringify([makeClient({ at: [30, 40] })]));
await Promise.all(polls);
assert.equal(pending.length, 2);
assert.deepEqual(tracker.getGeometry(), { x: 30, y: 40, width: 1280, height: 720 });
});
test('HyprlandWindowTracker ignores a poll that resolves after stop', async () => {
const { pending, runCommand } = makeDeferredRunner();
const tracker = new HyprlandWindowTracker(undefined, runCommand);
let found = 0;
tracker.onWindowFound = () => {
found += 1;
};
const poll = tracker.refreshNow();
tracker.stop();
await flush();
pending[0]!.resolve(JSON.stringify([makeClient()]));
await poll;
assert.equal(found, 0);
assert.equal(tracker.getGeometry(), null);
});
test('HyprlandWindowTracker reads each mpv command line once and re-reads after the pid leaves', async () => {
let clients = [makeClient({ pid: 10 })];
const psPids: string[] = [];
const tracker = new HyprlandWindowTracker('/tmp/subminer.sock', async (command, args) => {
if (command === 'hyprctl') {
return JSON.stringify(clients);
}
psPids.push(args[1]!);
return 'mpv --input-ipc-server=/tmp/subminer.sock video.mkv\n';
});
await tracker.refreshNow();
await tracker.refreshNow();
assert.equal(tracker.isTracking(), true);
assert.deepEqual(psPids, ['10']);
clients = [];
await tracker.refreshNow();
clients = [makeClient({ pid: 10 })];
await tracker.refreshNow();
assert.deepEqual(psPids, ['10', '10']);
});
test('HyprlandWindowTracker still tracks mpv when ps fails for another pid, and retries that pid', async () => {
const psPids: string[] = [];
const tracker = new HyprlandWindowTracker('/tmp/subminer.sock', async (command, args) => {
if (command === 'hyprctl') {
return JSON.stringify([makeClient({ pid: 10 }), makeClient({ pid: 20 })]);
}
psPids.push(args[1]!);
if (args[1] === '20') {
throw new Error('ps exited 1');
}
return 'mpv --input-ipc-server=/tmp/subminer.sock video.mkv\n';
});
await tracker.refreshNow();
assert.equal(tracker.isTracking(), true);
await tracker.refreshNow();
assert.deepEqual(psPids.sort(), ['10', '20', '20']);
});
+73 -45
View File
@@ -17,8 +17,13 @@
*/
import * as net from 'net';
import { execSync } from 'child_process';
import { BaseWindowTracker } from './base-tracker';
import {
createCoalescedTask,
execFileWithTimeout,
ProcessCommandLineCache,
type CommandRunner,
} from './linux-poll';
import { createLogger } from '../logger';
import type { WindowGeometry } from '../types';
@@ -98,16 +103,20 @@ function isMpvClassName(value: string | undefined): boolean {
return value.trim().toLowerCase().includes('mpv');
}
export function selectHyprlandMpvWindow(
clients: HyprlandClient[],
options: SelectHyprlandMpvWindowOptions,
): HyprlandClient | null {
const visibleMpvWindows = clients.filter(
function filterVisibleMpvWindows(clients: HyprlandClient[]): HyprlandClient[] {
return clients.filter(
(client) =>
(isMpvClassName(client.class) || isMpvClassName(client.initialClass)) &&
client.mapped !== false &&
client.hidden !== true,
);
}
export function selectHyprlandMpvWindow(
clients: HyprlandClient[],
options: SelectHyprlandMpvWindowOptions,
): HyprlandClient | null {
const visibleMpvWindows = filterVisibleMpvWindows(clients);
if (!options.targetMpvSocketPath) {
return preferActiveHyprlandWindow(visibleMpvWindows, options.activeWindowAddress);
@@ -221,20 +230,30 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
private pollTimeouts: Array<ReturnType<typeof setTimeout>> = [];
private eventSocket: net.Socket | null = null;
private readonly targetMpvSocketPath: string | null;
private readonly runCommand: CommandRunner;
private readonly commandLines: ProcessCommandLineCache;
private activeWindowAddress: string | null = null;
private stopped = false;
// Interval ticks, focus events, and geometry bursts all funnel through here,
// so a slow hyprctl never has more than one poll in flight.
private readonly pollGeometry = createCoalescedTask(() => this.pollGeometryOnce());
constructor(targetMpvSocketPath?: string) {
constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileWithTimeout) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.runCommand = runCommand;
this.commandLines = new ProcessCommandLineCache(runCommand);
}
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
this.pollGeometry();
this.stopped = false;
this.pollInterval = setInterval(() => void this.pollGeometry(), 250);
void this.pollGeometry();
this.connectEventSocket();
}
stop(): void {
this.stopped = true;
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
@@ -249,6 +268,10 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
}
}
override refreshNow(): Promise<void> {
return this.pollGeometry();
}
private connectEventSocket(): void {
const hyprlandSig = process.env.HYPRLAND_INSTANCE_SIGNATURE;
if (!hyprlandSig) {
@@ -297,10 +320,12 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
if (name === 'activewindowv2') {
const wasFocused = this.isTargetWindowFocused();
this.activeWindowAddress = data || null;
this.pollGeometry();
if (this.isTargetWindowFocused() === wasFocused) {
this.onWindowFocusChange?.(this.isTargetWindowFocused());
}
void this.pollGeometry().then(() => {
// Re-emit unchanged focus so the overlay still refreshes its z-order.
if (!this.stopped && this.isTargetWindowFocused() === wasFocused) {
this.onWindowFocusChange?.(this.isTargetWindowFocused());
}
});
return;
}
@@ -320,7 +345,7 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
this.pollTimeouts = [0, 50, 150, 300].map((delayMs) => {
const pollTimeout = setTimeout(() => {
this.pollTimeouts = this.pollTimeouts.filter((timeout) => timeout !== pollTimeout);
this.pollGeometry();
void this.pollGeometry();
}, delayMs);
return pollTimeout;
});
@@ -329,56 +354,59 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
}
}
private pollGeometry(): void {
try {
const output = execSync('hyprctl -j clients', { encoding: 'utf-8' });
const clients = parseHyprctlClients(output);
if (!clients) {
this.updateGeometry(null);
return;
}
const mpvWindow = this.findTargetWindow(clients);
private async pollGeometryOnce(): Promise<void> {
if (this.stopped) {
return;
}
let mpvWindow: HyprlandClient | null;
let monitors: HyprlandMonitor[] | null = null;
try {
const clients = parseHyprctlClients(await this.runCommand('hyprctl', ['-j', 'clients']));
mpvWindow = clients ? await this.findTargetWindow(clients) : null;
if (mpvWindow) {
const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress;
this.updateGeometry(
resolveHyprlandWindowGeometry(mpvWindow, this.getHyprlandMonitors(mpvWindow)),
focused,
);
this.updateTargetWindowFocused(focused);
} else {
this.updateGeometry(null);
monitors = await this.getHyprlandMonitors(mpvWindow);
}
} catch (err) {
// hyprctl not available or failed - silent fail
} catch {
// hyprctl/ps not available, timed out, or failed - keep the last geometry
return;
}
if (this.stopped) {
return;
}
if (mpvWindow) {
const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress;
this.updateGeometry(resolveHyprlandWindowGeometry(mpvWindow, monitors), focused);
this.updateTargetWindowFocused(focused);
} else {
this.updateGeometry(null);
}
}
private findTargetWindow(clients: HyprlandClient[]): HyprlandClient | null {
private async findTargetWindow(clients: HyprlandClient[]): Promise<HyprlandClient | null> {
// Resolve command lines up front so window selection itself stays synchronous.
const commandLines = this.targetMpvSocketPath
? await this.commandLines.lookup(
filterVisibleMpvWindows(clients).flatMap((client) => (client.pid ? [client.pid] : [])),
)
: null;
return selectHyprlandMpvWindow(clients, {
targetMpvSocketPath: this.targetMpvSocketPath,
activeWindowAddress: this.activeWindowAddress,
getWindowCommandLine: (pid) => this.getWindowCommandLine(pid),
getWindowCommandLine: (pid) => commandLines?.get(pid) ?? null,
});
}
private getHyprlandMonitors(client: HyprlandClient): HyprlandMonitor[] | null {
private async getHyprlandMonitors(client: HyprlandClient): Promise<HyprlandMonitor[] | null> {
if (!isHyprlandFullscreenClient(client)) {
return null;
}
try {
const output = execSync('hyprctl -j monitors', { encoding: 'utf-8' });
return parseHyprctlMonitors(output);
return parseHyprctlMonitors(await this.runCommand('hyprctl', ['-j', 'monitors']));
} catch {
return null;
}
}
private getWindowCommandLine(pid: number): string | null {
const commandLine = execSync(`ps -p ${pid} -o args=`, {
encoding: 'utf-8',
}).trim();
return commandLine || null;
}
}
+113
View File
@@ -0,0 +1,113 @@
/*
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2024 sudacode
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { execFile } from 'child_process';
// Shared polling helpers for the Hyprland and Sway trackers, which poll a
// compositor CLI on a short interval from the Electron main thread.
export type CommandRunner = (command: string, args: string[]) => Promise<string>;
const COMMAND_TIMEOUT_MS = 1500;
// Runs a command off the main thread. The timeout (SIGKILL, so a wedged client
// can't ignore it) keeps a hung compositor from stalling the poll loop forever.
export function execFileWithTimeout(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
command,
args,
{ encoding: 'utf-8', timeout: COMMAND_TIMEOUT_MS, killSignal: 'SIGKILL' },
(error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout);
},
);
});
}
// Wraps an async task so overlapping calls coalesce: at most one run is in
// flight, and every call made during it shares a single follow-up run. The
// returned promise settles after a run that started no earlier than the call.
export function createCoalescedTask(task: () => Promise<void>): () => Promise<void> {
let active: Promise<void> | null = null;
let queued: Promise<void> | null = null;
const run = (): Promise<void> => {
const current: Promise<void> = task().finally(() => {
if (active === current) {
active = null;
}
});
active = current;
return current;
};
return () => {
if (queued) {
return queued;
}
if (!active) {
return run();
}
const next = (): Promise<void> => {
queued = null;
return run();
};
queued = active.then(next, next);
return queued;
};
}
// Caches process command lines by pid. A pid's argv doesn't change, so each
// pid costs one `ps` spawn. Pids missing from the latest lookup are evicted,
// which keeps the cache bounded and re-reads a pid if it gets reused.
export class ProcessCommandLineCache {
private readonly commandLines = new Map<number, string>();
constructor(private readonly runCommand: CommandRunner) {}
// A pid whose `ps` call fails (e.g. it exited mid-poll) is left out of the
// result instead of failing the lookup. Empty results aren't cached, so that
// pid is retried on the next poll.
async lookup(pids: readonly number[]): Promise<ReadonlyMap<number, string>> {
const wanted = new Set(pids);
for (const pid of this.commandLines.keys()) {
if (!wanted.has(pid)) {
this.commandLines.delete(pid);
}
}
await Promise.all(
[...wanted]
.filter((pid) => !this.commandLines.has(pid))
.map(async (pid) => {
const commandLine = (
await this.runCommand('ps', ['-p', String(pid), '-o', 'args=']).catch(() => '')
).trim();
if (commandLine) {
this.commandLines.set(pid, commandLine);
}
}),
);
return this.commandLines;
}
}
+23
View File
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { SwayWindowTracker } from './sway-tracker';
test('SwayWindowTracker tracks the mpv window bound to the target socket', async () => {
const tree = {
nodes: [
{ pid: 10, app_id: 'mpv', rect: { x: 0, y: 0, width: 800, height: 600 } },
{ pid: 20, app_id: 'mpv', rect: { x: 100, y: 50, width: 1280, height: 720 } },
],
};
const commandLines = new Map([
['10', 'mpv --input-ipc-server=/tmp/other.sock a.mkv'],
['20', 'mpv --input-ipc-server=/tmp/subminer.sock b.mkv'],
]);
const tracker = new SwayWindowTracker('/tmp/subminer.sock', async (command, args) =>
command === 'swaymsg' ? JSON.stringify(tree) : (commandLines.get(args[1]!) ?? ''),
);
await tracker.refreshNow();
assert.deepEqual(tracker.getGeometry(), { x: 100, y: 50, width: 1280, height: 720 });
});
+55 -38
View File
@@ -16,8 +16,13 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { execSync } from 'child_process';
import { BaseWindowTracker } from './base-tracker';
import {
createCoalescedTask,
execFileWithTimeout,
ProcessCommandLineCache,
type CommandRunner,
} from './linux-poll';
interface SwayRect {
x: number;
@@ -38,24 +43,36 @@ interface SwayNode {
export class SwayWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private readonly targetMpvSocketPath: string | null;
private readonly runCommand: CommandRunner;
private readonly commandLines: ProcessCommandLineCache;
private stopped = false;
private readonly pollGeometry = createCoalescedTask(() => this.pollGeometryOnce());
constructor(targetMpvSocketPath?: string) {
constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileWithTimeout) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.runCommand = runCommand;
this.commandLines = new ProcessCommandLineCache(runCommand);
}
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
this.pollGeometry();
this.stopped = false;
this.pollInterval = setInterval(() => void this.pollGeometry(), 250);
void this.pollGeometry();
}
stop(): void {
this.stopped = true;
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
override refreshNow(): Promise<void> {
return this.pollGeometry();
}
private collectMpvWindows(node: SwayNode): SwayNode[] {
const windows: SwayNode[] = [];
if (node.app_id === 'mpv' || node.window_properties?.class === 'mpv') {
@@ -77,56 +94,56 @@ export class SwayWindowTracker extends BaseWindowTracker {
return windows;
}
private findTargetSocketWindow(node: SwayNode): SwayNode | null {
private async findTargetSocketWindow(node: SwayNode): Promise<SwayNode | null> {
const windows = this.collectMpvWindows(node);
if (!this.targetMpvSocketPath) {
return windows[0] || null;
}
return windows.find((candidate) => this.isWindowForTargetSocket(candidate)) || null;
const commandLines = await this.commandLines.lookup(
windows.flatMap((window) => (window.pid ? [window.pid] : [])),
);
return (
windows.find((candidate) => {
const commandLine = candidate.pid ? commandLines.get(candidate.pid) : undefined;
return commandLine !== undefined && this.matchesTargetSocket(commandLine);
}) || null
);
}
private isWindowForTargetSocket(node: SwayNode): boolean {
if (!node.pid) {
return false;
}
const commandLine = this.getWindowCommandLine(node.pid);
if (!commandLine) {
return false;
}
private matchesTargetSocket(commandLine: string): boolean {
return (
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
);
}
private getWindowCommandLine(pid: number): string | null {
const commandLine = execSync(`ps -p ${pid} -o args=`, {
encoding: 'utf-8',
}).trim();
return commandLine || null;
}
private async pollGeometryOnce(): Promise<void> {
if (this.stopped) {
return;
}
private pollGeometry(): void {
let mpvWindow: SwayNode | null;
try {
const output = execSync('swaymsg -t get_tree', { encoding: 'utf-8' });
const tree: SwayNode = JSON.parse(output);
const mpvWindow = this.findTargetSocketWindow(tree);
const tree = JSON.parse(await this.runCommand('swaymsg', ['-t', 'get_tree'])) as SwayNode;
mpvWindow = await this.findTargetSocketWindow(tree);
} catch {
// swaymsg/ps not available, timed out, or failed - keep the last geometry
return;
}
if (this.stopped) {
return;
}
if (mpvWindow && mpvWindow.rect) {
this.updateGeometry({
x: mpvWindow.rect.x,
y: mpvWindow.rect.y,
width: mpvWindow.rect.width,
height: mpvWindow.rect.height,
});
} else {
this.updateGeometry(null);
}
} catch (err) {
// swaymsg not available or failed - silent fail
if (mpvWindow && mpvWindow.rect) {
this.updateGeometry({
x: mpvWindow.rect.x,
y: mpvWindow.rect.y,
width: mpvWindow.rect.width,
height: mpvWindow.rect.height,
});
} else {
this.updateGeometry(null);
}
}
}