mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 16:46:06 -08:00
Compare commits
3
Commits
78a9b6346f
...
ea2b3a9507
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea2b3a9507 | ||
|
|
a6d96e9966 | ||
|
|
7108e086ba |
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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: () =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user