Improve startup dictionary sync UX and default playback keybindings

- Add default `f` fullscreen overlay binding and switch default AniSkip skip key to `Tab`
- Make character-dictionary auto-sync non-blocking at startup with tokenization gating for Yomitan mutations
- Add ordered startup OSD progress (checking/generating/updating/importing), refresh current subtitle on sync completion, and extend regression tests
This commit is contained in:
2026-03-09 00:50:32 -07:00
parent a0521aeeaf
commit e0f82d28f0
36 changed files with 2691 additions and 148 deletions

View File

@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseHyprctlClients,
selectHyprlandMpvWindow,
type HyprlandClient,
} from './hyprland-tracker';
@@ -9,6 +10,7 @@ function makeClient(overrides: Partial<HyprlandClient> = {}): HyprlandClient {
return {
address: '0x1',
class: 'mpv',
initialClass: 'mpv',
at: [0, 0],
size: [1280, 720],
mapped: true,
@@ -70,3 +72,37 @@ test('selectHyprlandMpvWindow prefers active visible window among socket matches
assert.equal(selected?.address, '0xsecond');
});
test('selectHyprlandMpvWindow matches mpv by initialClass when class is blank', () => {
const selected = selectHyprlandMpvWindow(
[
makeClient({
address: '0xinitial',
class: '',
initialClass: 'mpv',
}),
],
{
targetMpvSocketPath: null,
activeWindowAddress: null,
getWindowCommandLine: () => null,
},
);
assert.equal(selected?.address, '0xinitial');
});
test('parseHyprctlClients tolerates non-json prefix output', () => {
const clients = parseHyprctlClients(`ok
[{"address":"0x1","class":"mpv","initialClass":"mpv","at":[1,2],"size":[3,4]}]`);
assert.deepEqual(clients, [
{
address: '0x1',
class: 'mpv',
initialClass: 'mpv',
at: [1, 2],
size: [3, 4],
},
]);
});

View File

@@ -26,6 +26,7 @@ const log = createLogger('tracker').child('hyprland');
export interface HyprlandClient {
address?: string;
class: string;
initialClass?: string;
at: [number, number];
size: [number, number];
pid?: number;
@@ -39,6 +40,23 @@ interface SelectHyprlandMpvWindowOptions {
getWindowCommandLine: (pid: number) => string | null;
}
function extractHyprctlJsonPayload(output: string): string | null {
const trimmed = output.trim();
if (!trimmed) {
return null;
}
const arrayStart = trimmed.indexOf('[');
const objectStart = trimmed.indexOf('{');
const startCandidates = [arrayStart, objectStart].filter((index) => index >= 0);
if (startCandidates.length === 0) {
return null;
}
const startIndex = Math.min(...startCandidates);
return trimmed.slice(startIndex);
}
function matchesTargetSocket(commandLine: string, targetMpvSocketPath: string): boolean {
return (
commandLine.includes(`--input-ipc-server=${targetMpvSocketPath}`) ||
@@ -60,12 +78,23 @@ function preferActiveHyprlandWindow(
return clients[0] ?? null;
}
function isMpvClassName(value: string | undefined): boolean {
if (!value) {
return false;
}
return value.trim().toLowerCase().includes('mpv');
}
export function selectHyprlandMpvWindow(
clients: HyprlandClient[],
options: SelectHyprlandMpvWindowOptions,
): HyprlandClient | null {
const visibleMpvWindows = clients.filter(
(client) => client.class === 'mpv' && client.mapped !== false && client.hidden !== true,
(client) =>
(isMpvClassName(client.class) || isMpvClassName(client.initialClass)) &&
client.mapped !== false &&
client.hidden !== true,
);
if (!options.targetMpvSocketPath) {
@@ -89,6 +118,20 @@ export function selectHyprlandMpvWindow(
return preferActiveHyprlandWindow(matchingWindows, options.activeWindowAddress);
}
export function parseHyprctlClients(output: string): HyprlandClient[] | null {
const jsonPayload = extractHyprctlJsonPayload(output);
if (!jsonPayload) {
return null;
}
const parsed = JSON.parse(jsonPayload) as unknown;
if (!Array.isArray(parsed)) {
return null;
}
return parsed as HyprlandClient[];
}
export class HyprlandWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private eventSocket: net.Socket | null = null;
@@ -185,8 +228,12 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
private pollGeometry(): void {
try {
const output = execSync('hyprctl clients -j', { encoding: 'utf-8' });
const clients: HyprlandClient[] = JSON.parse(output);
const output = execSync('hyprctl -j clients', { encoding: 'utf-8' });
const clients = parseHyprctlClients(output);
if (!clients) {
this.updateGeometry(null);
return;
}
const mpvWindow = this.findTargetWindow(clients);
if (mpvWindow) {