mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
Complete runtime service follow-ups and invisible subtitle edit mode
This commit is contained in:
@@ -1,21 +0,0 @@
|
||||
export type ActionWithType = { type: string };
|
||||
|
||||
export type ActionHandler<TAction extends ActionWithType> = (
|
||||
action: TAction,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export class ActionBus<TAction extends ActionWithType> {
|
||||
private handlers = new Map<string, ActionHandler<TAction>>();
|
||||
|
||||
register(type: TAction["type"], handler: ActionHandler<TAction>): void {
|
||||
this.handlers.set(type, handler);
|
||||
}
|
||||
|
||||
async dispatch(action: TAction): Promise<void> {
|
||||
const handler = this.handlers.get(action.type);
|
||||
if (!handler) {
|
||||
throw new Error(`No handler registered for action: ${action.type}`);
|
||||
}
|
||||
await handler(action);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export type AppAction =
|
||||
| { type: "overlay.toggleVisible" }
|
||||
| { type: "overlay.toggleInvisible" }
|
||||
| { type: "overlay.setVisible"; visible: boolean }
|
||||
| { type: "overlay.setInvisibleVisible"; visible: boolean }
|
||||
| { type: "overlay.openSettings" }
|
||||
| { type: "subtitle.copyCurrent" }
|
||||
| { type: "subtitle.copyMultiplePrompt"; timeoutMs: number }
|
||||
| { type: "anki.mineSentence" }
|
||||
| { type: "anki.mineSentenceMultiplePrompt"; timeoutMs: number }
|
||||
| { type: "anki.updateLastCardFromClipboard" }
|
||||
| { type: "anki.markAudioCard" }
|
||||
| { type: "kiku.triggerFieldGrouping" }
|
||||
| { type: "subsync.triggerFromConfig" }
|
||||
| { type: "secondarySub.toggleMode" }
|
||||
| { type: "runtimeOptions.openPalette" };
|
||||
@@ -1,45 +0,0 @@
|
||||
import {
|
||||
AnkiConnectConfig,
|
||||
JimakuApiResponse,
|
||||
JimakuDownloadQuery,
|
||||
JimakuDownloadResult,
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
JimakuFilesQuery,
|
||||
JimakuMediaInfo,
|
||||
JimakuSearchQuery,
|
||||
RuntimeOptionState,
|
||||
SubsyncManualRunRequest,
|
||||
SubsyncMode,
|
||||
SubsyncResult,
|
||||
} from "../types";
|
||||
|
||||
export interface RuntimeOptionsModuleContext {
|
||||
getAnkiConfig: () => AnkiConnectConfig;
|
||||
applyAnkiPatch: (patch: Partial<AnkiConnectConfig>) => void;
|
||||
onOptionsChanged: (options: RuntimeOptionState[]) => void;
|
||||
}
|
||||
|
||||
export interface AppContext {
|
||||
runtimeOptions?: RuntimeOptionsModuleContext;
|
||||
jimaku?: {
|
||||
getMediaInfo: () => JimakuMediaInfo;
|
||||
searchEntries: (
|
||||
query: JimakuSearchQuery,
|
||||
) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
||||
listFiles: (
|
||||
query: JimakuFilesQuery,
|
||||
) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
|
||||
downloadFile: (
|
||||
query: JimakuDownloadQuery,
|
||||
) => Promise<JimakuDownloadResult>;
|
||||
};
|
||||
subsync?: {
|
||||
getDefaultMode: () => SubsyncMode;
|
||||
openManualPicker: () => Promise<void>;
|
||||
runAuto: () => Promise<SubsyncResult>;
|
||||
runManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
showOsd: (message: string) => void;
|
||||
runWithSpinner: <T>(task: () => Promise<T>, label?: string) => Promise<T>;
|
||||
};
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { SubminerModule } from "./module";
|
||||
|
||||
export class ModuleRegistry<TContext = unknown> {
|
||||
private readonly modules: SubminerModule<TContext>[] = [];
|
||||
|
||||
register(module: SubminerModule<TContext>): void {
|
||||
if (this.modules.some((existing) => existing.id === module.id)) {
|
||||
throw new Error(`Module already registered: ${module.id}`);
|
||||
}
|
||||
this.modules.push(module);
|
||||
}
|
||||
|
||||
async initAll(context: TContext): Promise<void> {
|
||||
for (const module of this.modules) {
|
||||
if (module.init) {
|
||||
await module.init(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async startAll(): Promise<void> {
|
||||
for (const module of this.modules) {
|
||||
if (module.start) {
|
||||
await module.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stopAll(): Promise<void> {
|
||||
for (const module of [...this.modules].reverse()) {
|
||||
if (module.stop) {
|
||||
await module.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface SubminerModule<TContext = unknown> {
|
||||
id: string;
|
||||
init?: (context: TContext) => void | Promise<void>;
|
||||
start?: () => void | Promise<void>;
|
||||
stop?: () => void | Promise<void>;
|
||||
}
|
||||
228
src/core/services/anki-jimaku-service.test.ts
Normal file
228
src/core/services/anki-jimaku-service.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
AnkiJimakuIpcRuntimeOptions,
|
||||
registerAnkiJimakuIpcRuntimeService,
|
||||
} from "./anki-jimaku-service";
|
||||
|
||||
interface RuntimeHarness {
|
||||
options: AnkiJimakuIpcRuntimeOptions;
|
||||
registered: Record<string, (...args: unknown[]) => unknown>;
|
||||
state: {
|
||||
ankiIntegration: unknown;
|
||||
fieldGroupingResolver: ((choice: unknown) => void) | null;
|
||||
patches: boolean[];
|
||||
broadcasts: number;
|
||||
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
sentCommands: Array<{ command: string[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(): RuntimeHarness {
|
||||
const state = {
|
||||
ankiIntegration: null as unknown,
|
||||
fieldGroupingResolver: null as ((choice: unknown) => void) | null,
|
||||
patches: [] as boolean[],
|
||||
broadcasts: 0,
|
||||
fetchCalls: [] as Array<{ endpoint: string; query?: Record<string, unknown> }>,
|
||||
sentCommands: [] as Array<{ command: string[] }>,
|
||||
};
|
||||
|
||||
const options: AnkiJimakuIpcRuntimeOptions = {
|
||||
patchAnkiConnectEnabled: (enabled) => {
|
||||
state.patches.push(enabled);
|
||||
},
|
||||
getResolvedConfig: () => ({}),
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
send: (payload) => {
|
||||
state.sentCommands.push(payload);
|
||||
},
|
||||
}),
|
||||
getAnkiIntegration: () => state.ankiIntegration as never,
|
||||
setAnkiIntegration: (integration) => {
|
||||
state.ankiIntegration = integration;
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
broadcastRuntimeOptionsChanged: () => {
|
||||
state.broadcasts += 1;
|
||||
},
|
||||
getFieldGroupingResolver: () => state.fieldGroupingResolver as never,
|
||||
setFieldGroupingResolver: (resolver) => {
|
||||
state.fieldGroupingResolver = resolver as never;
|
||||
},
|
||||
parseMediaInfo: () => ({
|
||||
title: "video",
|
||||
confidence: "high",
|
||||
rawTitle: "video",
|
||||
filename: "video.mkv",
|
||||
season: null,
|
||||
episode: null,
|
||||
}),
|
||||
getCurrentMediaPath: () => "/tmp/video.mkv",
|
||||
jimakuFetchJson: async (endpoint, query) => {
|
||||
state.fetchCalls.push({ endpoint, query: query as Record<string, unknown> });
|
||||
return {
|
||||
ok: true,
|
||||
data: [
|
||||
{ id: 1, name: "a" },
|
||||
{ id: 2, name: "b" },
|
||||
{ id: 3, name: "c" },
|
||||
] as never,
|
||||
};
|
||||
},
|
||||
getJimakuMaxEntryResults: () => 2,
|
||||
getJimakuLanguagePreference: () => "ja",
|
||||
resolveJimakuApiKey: async () => "token",
|
||||
isRemoteMediaPath: () => false,
|
||||
downloadToFile: async (url, destPath) => ({
|
||||
ok: true,
|
||||
path: `${destPath}:${url}`,
|
||||
}),
|
||||
};
|
||||
|
||||
let registered: Record<string, (...args: unknown[]) => unknown> = {};
|
||||
registerAnkiJimakuIpcRuntimeService(
|
||||
options,
|
||||
(deps) => {
|
||||
registered = deps as unknown as Record<string, (...args: unknown[]) => unknown>;
|
||||
},
|
||||
);
|
||||
|
||||
return { options, registered, state };
|
||||
}
|
||||
|
||||
test("registerAnkiJimakuIpcRuntimeService provides full handler surface", () => {
|
||||
const { registered } = createHarness();
|
||||
const expected = [
|
||||
"setAnkiConnectEnabled",
|
||||
"clearAnkiHistory",
|
||||
"respondFieldGrouping",
|
||||
"buildKikuMergePreview",
|
||||
"getJimakuMediaInfo",
|
||||
"searchJimakuEntries",
|
||||
"listJimakuFiles",
|
||||
"resolveJimakuApiKey",
|
||||
"getCurrentMediaPath",
|
||||
"isRemoteMediaPath",
|
||||
"downloadToFile",
|
||||
"onDownloadedSubtitle",
|
||||
];
|
||||
|
||||
for (const key of expected) {
|
||||
assert.equal(typeof registered[key], "function", `missing handler: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("setAnkiConnectEnabled disables active integration and broadcasts changes", () => {
|
||||
const { registered, state } = createHarness();
|
||||
let destroyed = 0;
|
||||
state.ankiIntegration = {
|
||||
destroy: () => {
|
||||
destroyed += 1;
|
||||
},
|
||||
};
|
||||
|
||||
registered.setAnkiConnectEnabled(false);
|
||||
|
||||
assert.deepEqual(state.patches, [false]);
|
||||
assert.equal(destroyed, 1);
|
||||
assert.equal(state.ankiIntegration, null);
|
||||
assert.equal(state.broadcasts, 1);
|
||||
});
|
||||
|
||||
test("clearAnkiHistory and respondFieldGrouping execute runtime callbacks", () => {
|
||||
const { registered, state, options } = createHarness();
|
||||
let cleaned = 0;
|
||||
let resolvedChoice: unknown = null;
|
||||
state.fieldGroupingResolver = (choice) => {
|
||||
resolvedChoice = choice;
|
||||
};
|
||||
|
||||
const originalGetTracker = options.getSubtitleTimingTracker;
|
||||
options.getSubtitleTimingTracker = () =>
|
||||
({ cleanup: () => {
|
||||
cleaned += 1;
|
||||
} }) as never;
|
||||
|
||||
const choice = {
|
||||
keepNoteId: 10,
|
||||
deleteNoteId: 11,
|
||||
deleteDuplicate: true,
|
||||
cancelled: false,
|
||||
};
|
||||
registered.clearAnkiHistory();
|
||||
registered.respondFieldGrouping(choice);
|
||||
|
||||
options.getSubtitleTimingTracker = originalGetTracker;
|
||||
|
||||
assert.equal(cleaned, 1);
|
||||
assert.deepEqual(resolvedChoice, choice);
|
||||
assert.equal(state.fieldGroupingResolver, null);
|
||||
});
|
||||
|
||||
test("buildKikuMergePreview returns guard error when integration is missing", async () => {
|
||||
const { registered } = createHarness();
|
||||
|
||||
const result = await registered.buildKikuMergePreview({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
error: "AnkiConnect integration not enabled",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildKikuMergePreview delegates to integration when available", async () => {
|
||||
const { registered, state } = createHarness();
|
||||
const calls: unknown[] = [];
|
||||
state.ankiIntegration = {
|
||||
buildFieldGroupingPreview: async (
|
||||
keepNoteId: number,
|
||||
deleteNoteId: number,
|
||||
deleteDuplicate: boolean,
|
||||
) => {
|
||||
calls.push([keepNoteId, deleteNoteId, deleteDuplicate]);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await registered.buildKikuMergePreview({
|
||||
keepNoteId: 3,
|
||||
deleteNoteId: 4,
|
||||
deleteDuplicate: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [[3, 4, true]]);
|
||||
assert.deepEqual(result, { ok: true });
|
||||
});
|
||||
|
||||
test("searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv", async () => {
|
||||
const { registered, state } = createHarness();
|
||||
|
||||
const searchResult = await registered.searchJimakuEntries({ query: "test" });
|
||||
assert.deepEqual(state.fetchCalls, [
|
||||
{
|
||||
endpoint: "/api/entries/search",
|
||||
query: { anime: true, query: "test" },
|
||||
},
|
||||
]);
|
||||
assert.equal((searchResult as { ok: boolean }).ok, true);
|
||||
assert.equal((searchResult as { data: unknown[] }).data.length, 2);
|
||||
|
||||
registered.onDownloadedSubtitle("/tmp/subtitle.ass");
|
||||
assert.deepEqual(state.sentCommands, [
|
||||
{ command: ["sub-add", "/tmp/subtitle.ass", "select"] },
|
||||
]);
|
||||
});
|
||||
@@ -59,8 +59,9 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
|
||||
export function registerAnkiJimakuIpcRuntimeService(
|
||||
options: AnkiJimakuIpcRuntimeOptions,
|
||||
registerHandlers: typeof registerAnkiJimakuIpcHandlers = registerAnkiJimakuIpcHandlers,
|
||||
): void {
|
||||
registerAnkiJimakuIpcHandlers({
|
||||
registerHandlers({
|
||||
setAnkiConnectEnabled: (enabled) => {
|
||||
options.patchAnkiConnectEnabled(enabled);
|
||||
const config = options.getResolvedConfig();
|
||||
|
||||
@@ -2,7 +2,7 @@ export { TexthookerService } from "./texthooker-service";
|
||||
export { hasMpvWebsocketPlugin, SubtitleWebSocketService } from "./subtitle-ws-service";
|
||||
export { registerGlobalShortcutsService } from "./shortcut-service";
|
||||
export { createIpcDepsRuntimeService, registerIpcHandlersService } from "./ipc-service";
|
||||
export { isGlobalShortcutRegisteredSafe, shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service";
|
||||
export { shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service";
|
||||
export {
|
||||
refreshOverlayShortcutsRuntimeService,
|
||||
registerOverlayShortcutsService,
|
||||
|
||||
168
src/core/services/mining-service.test.ts
Normal file
168
src/core/services/mining-service.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
copyCurrentSubtitleService,
|
||||
handleMineSentenceDigitService,
|
||||
handleMultiCopyDigitService,
|
||||
mineSentenceCardService,
|
||||
} from "./mining-service";
|
||||
|
||||
test("copyCurrentSubtitleService reports tracker and subtitle guards", () => {
|
||||
const osd: string[] = [];
|
||||
const copied: string[] = [];
|
||||
|
||||
copyCurrentSubtitleService({
|
||||
subtitleTimingTracker: null,
|
||||
writeClipboardText: (text) => copied.push(text),
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
assert.equal(osd.at(-1), "Subtitle tracker not available");
|
||||
|
||||
copyCurrentSubtitleService({
|
||||
subtitleTimingTracker: {
|
||||
getRecentBlocks: () => [],
|
||||
getCurrentSubtitle: () => null,
|
||||
findTiming: () => null,
|
||||
},
|
||||
writeClipboardText: (text) => copied.push(text),
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
assert.equal(osd.at(-1), "No current subtitle");
|
||||
assert.deepEqual(copied, []);
|
||||
});
|
||||
|
||||
test("copyCurrentSubtitleService copies current subtitle text", () => {
|
||||
const osd: string[] = [];
|
||||
const copied: string[] = [];
|
||||
|
||||
copyCurrentSubtitleService({
|
||||
subtitleTimingTracker: {
|
||||
getRecentBlocks: () => [],
|
||||
getCurrentSubtitle: () => "hello world",
|
||||
findTiming: () => null,
|
||||
},
|
||||
writeClipboardText: (text) => copied.push(text),
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
|
||||
assert.deepEqual(copied, ["hello world"]);
|
||||
assert.equal(osd.at(-1), "Copied subtitle");
|
||||
});
|
||||
|
||||
test("mineSentenceCardService handles missing integration and disconnected mpv", async () => {
|
||||
const osd: string[] = [];
|
||||
|
||||
await mineSentenceCardService({
|
||||
ankiIntegration: null,
|
||||
mpvClient: null,
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
assert.equal(osd.at(-1), "AnkiConnect integration not enabled");
|
||||
|
||||
await mineSentenceCardService({
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async () => {},
|
||||
},
|
||||
mpvClient: {
|
||||
connected: false,
|
||||
currentSubText: "line",
|
||||
currentSubStart: 1,
|
||||
currentSubEnd: 2,
|
||||
},
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
|
||||
assert.equal(osd.at(-1), "MPV not connected");
|
||||
});
|
||||
|
||||
test("mineSentenceCardService creates sentence card from mpv subtitle state", async () => {
|
||||
const created: Array<{
|
||||
sentence: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
secondarySub?: string;
|
||||
}> = [];
|
||||
|
||||
await mineSentenceCardService({
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, startTime, endTime, secondarySub) => {
|
||||
created.push({ sentence, startTime, endTime, secondarySub });
|
||||
},
|
||||
},
|
||||
mpvClient: {
|
||||
connected: true,
|
||||
currentSubText: "subtitle line",
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSecondarySubText: "secondary line",
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(created, [
|
||||
{
|
||||
sentence: "subtitle line",
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
secondarySub: "secondary line",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("handleMultiCopyDigitService copies available history and reports truncation", () => {
|
||||
const osd: string[] = [];
|
||||
const copied: string[] = [];
|
||||
|
||||
handleMultiCopyDigitService(5, {
|
||||
subtitleTimingTracker: {
|
||||
getRecentBlocks: (count) => ["a", "b"].slice(0, count),
|
||||
getCurrentSubtitle: () => null,
|
||||
findTiming: () => null,
|
||||
},
|
||||
writeClipboardText: (text) => copied.push(text),
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
|
||||
assert.deepEqual(copied, ["a\n\nb"]);
|
||||
assert.equal(osd.at(-1), "Only 2 lines available, copied 2");
|
||||
});
|
||||
|
||||
test("handleMineSentenceDigitService reports async create failures", async () => {
|
||||
const osd: string[] = [];
|
||||
const logs: Array<{ message: string; err: unknown }> = [];
|
||||
|
||||
handleMineSentenceDigitService(2, {
|
||||
subtitleTimingTracker: {
|
||||
getRecentBlocks: () => ["one", "two"],
|
||||
getCurrentSubtitle: () => null,
|
||||
findTiming: (text) =>
|
||||
text === "one"
|
||||
? { startTime: 1, endTime: 3 }
|
||||
: { startTime: 4, endTime: 7 },
|
||||
},
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async () => {
|
||||
throw new Error("mine boom");
|
||||
},
|
||||
},
|
||||
getCurrentSecondarySubText: () => "sub2",
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
logError: (message, err) => logs.push({ message, err }),
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(logs.length, 1);
|
||||
assert.equal(logs[0]?.message, "mineSentenceMultiple failed:");
|
||||
assert.equal((logs[0]?.err as Error).message, "mine boom");
|
||||
assert.ok(osd.some((entry) => entry.includes("Mine sentence failed: mine boom")));
|
||||
});
|
||||
256
src/core/services/overlay-shortcut-handler.test.ts
Normal file
256
src/core/services/overlay-shortcut-handler.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ConfiguredShortcuts } from "../utils/shortcut-config";
|
||||
import {
|
||||
createOverlayShortcutRuntimeHandlers,
|
||||
OverlayShortcutRuntimeDeps,
|
||||
runOverlayShortcutLocalFallback,
|
||||
} from "./overlay-shortcut-handler";
|
||||
|
||||
function makeShortcuts(
|
||||
overrides: Partial<ConfiguredShortcuts> = {},
|
||||
): ConfiguredShortcuts {
|
||||
return {
|
||||
toggleVisibleOverlayGlobal: null,
|
||||
toggleInvisibleOverlayGlobal: null,
|
||||
copySubtitle: null,
|
||||
copySubtitleMultiple: null,
|
||||
updateLastCardFromClipboard: null,
|
||||
triggerFieldGrouping: null,
|
||||
triggerSubsync: null,
|
||||
mineSentence: null,
|
||||
mineSentenceMultiple: null,
|
||||
multiCopyTimeoutMs: 2500,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
const osd: string[] = [];
|
||||
const deps: OverlayShortcutRuntimeDeps = {
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
openRuntimeOptions: () => {
|
||||
calls.push("openRuntimeOptions");
|
||||
},
|
||||
openJimaku: () => {
|
||||
calls.push("openJimaku");
|
||||
},
|
||||
markAudioCard: async () => {
|
||||
calls.push("markAudioCard");
|
||||
},
|
||||
copySubtitleMultiple: (timeoutMs) => {
|
||||
calls.push(`copySubtitleMultiple:${timeoutMs}`);
|
||||
},
|
||||
copySubtitle: () => {
|
||||
calls.push("copySubtitle");
|
||||
},
|
||||
toggleSecondarySub: () => {
|
||||
calls.push("toggleSecondarySub");
|
||||
},
|
||||
updateLastCardFromClipboard: async () => {
|
||||
calls.push("updateLastCardFromClipboard");
|
||||
},
|
||||
triggerFieldGrouping: async () => {
|
||||
calls.push("triggerFieldGrouping");
|
||||
},
|
||||
triggerSubsync: async () => {
|
||||
calls.push("triggerSubsync");
|
||||
},
|
||||
mineSentence: async () => {
|
||||
calls.push("mineSentence");
|
||||
},
|
||||
mineSentenceMultiple: (timeoutMs) => {
|
||||
calls.push(`mineSentenceMultiple:${timeoutMs}`);
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { deps, calls, osd };
|
||||
}
|
||||
|
||||
test("createOverlayShortcutRuntimeHandlers dispatches sync and async handlers", async () => {
|
||||
const { deps, calls } = createDeps();
|
||||
const { overlayHandlers, fallbackHandlers } =
|
||||
createOverlayShortcutRuntimeHandlers(deps);
|
||||
|
||||
overlayHandlers.copySubtitle();
|
||||
overlayHandlers.copySubtitleMultiple(1111);
|
||||
overlayHandlers.toggleSecondarySub();
|
||||
overlayHandlers.openRuntimeOptions();
|
||||
overlayHandlers.openJimaku();
|
||||
overlayHandlers.mineSentenceMultiple(2222);
|
||||
overlayHandlers.updateLastCardFromClipboard();
|
||||
fallbackHandlers.mineSentence();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"copySubtitle",
|
||||
"copySubtitleMultiple:1111",
|
||||
"toggleSecondarySub",
|
||||
"openRuntimeOptions",
|
||||
"openJimaku",
|
||||
"mineSentenceMultiple:2222",
|
||||
"updateLastCardFromClipboard",
|
||||
"mineSentence",
|
||||
]);
|
||||
});
|
||||
|
||||
test("createOverlayShortcutRuntimeHandlers reports async failures via OSD", async () => {
|
||||
const logs: unknown[][] = [];
|
||||
const originalError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
logs.push(args);
|
||||
};
|
||||
|
||||
try {
|
||||
const { deps, osd } = createDeps({
|
||||
markAudioCard: async () => {
|
||||
throw new Error("audio boom");
|
||||
},
|
||||
});
|
||||
const { overlayHandlers } = createOverlayShortcutRuntimeHandlers(deps);
|
||||
|
||||
overlayHandlers.markAudioCard();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(logs.length, 1);
|
||||
assert.equal(logs[0]?.[0], "markLastCardAsAudioCard failed:");
|
||||
assert.ok(osd.some((entry) => entry.includes("Audio card failed: audio boom")));
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
});
|
||||
|
||||
test("runOverlayShortcutLocalFallback dispatches matching actions with timeout", () => {
|
||||
const handled: string[] = [];
|
||||
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
|
||||
const shortcuts = makeShortcuts({
|
||||
copySubtitleMultiple: "Ctrl+M",
|
||||
multiCopyTimeoutMs: 4321,
|
||||
});
|
||||
|
||||
const result = runOverlayShortcutLocalFallback(
|
||||
{} as Electron.Input,
|
||||
shortcuts,
|
||||
(_input, accelerator, allowWhenRegistered) => {
|
||||
matched.push({
|
||||
accelerator,
|
||||
allowWhenRegistered: allowWhenRegistered === true,
|
||||
});
|
||||
return accelerator === "Ctrl+M";
|
||||
},
|
||||
{
|
||||
openRuntimeOptions: () => handled.push("openRuntimeOptions"),
|
||||
openJimaku: () => handled.push("openJimaku"),
|
||||
markAudioCard: () => handled.push("markAudioCard"),
|
||||
copySubtitleMultiple: (timeoutMs) =>
|
||||
handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
copySubtitle: () => handled.push("copySubtitle"),
|
||||
toggleSecondarySub: () => handled.push("toggleSecondarySub"),
|
||||
updateLastCardFromClipboard: () =>
|
||||
handled.push("updateLastCardFromClipboard"),
|
||||
triggerFieldGrouping: () => handled.push("triggerFieldGrouping"),
|
||||
triggerSubsync: () => handled.push("triggerSubsync"),
|
||||
mineSentence: () => handled.push("mineSentence"),
|
||||
mineSentenceMultiple: (timeoutMs) =>
|
||||
handled.push(`mineSentenceMultiple:${timeoutMs}`),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(handled, ["copySubtitleMultiple:4321"]);
|
||||
assert.deepEqual(matched, [{ accelerator: "Ctrl+M", allowWhenRegistered: false }]);
|
||||
});
|
||||
|
||||
test("runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle", () => {
|
||||
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
|
||||
const shortcuts = makeShortcuts({
|
||||
toggleSecondarySub: "Ctrl+2",
|
||||
});
|
||||
|
||||
const result = runOverlayShortcutLocalFallback(
|
||||
{} as Electron.Input,
|
||||
shortcuts,
|
||||
(_input, accelerator, allowWhenRegistered) => {
|
||||
matched.push({
|
||||
accelerator,
|
||||
allowWhenRegistered: allowWhenRegistered === true,
|
||||
});
|
||||
return accelerator === "Ctrl+2";
|
||||
},
|
||||
{
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
copySubtitle: () => {},
|
||||
toggleSecondarySub: () => {},
|
||||
updateLastCardFromClipboard: () => {},
|
||||
triggerFieldGrouping: () => {},
|
||||
triggerSubsync: () => {},
|
||||
mineSentence: () => {},
|
||||
mineSentenceMultiple: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(matched, [{ accelerator: "Ctrl+2", allowWhenRegistered: true }]);
|
||||
});
|
||||
|
||||
test("runOverlayShortcutLocalFallback returns false when no action matches", () => {
|
||||
const shortcuts = makeShortcuts({
|
||||
copySubtitle: "Ctrl+C",
|
||||
});
|
||||
let called = false;
|
||||
|
||||
const result = runOverlayShortcutLocalFallback(
|
||||
{} as Electron.Input,
|
||||
shortcuts,
|
||||
() => false,
|
||||
{
|
||||
openRuntimeOptions: () => {
|
||||
called = true;
|
||||
},
|
||||
openJimaku: () => {
|
||||
called = true;
|
||||
},
|
||||
markAudioCard: () => {
|
||||
called = true;
|
||||
},
|
||||
copySubtitleMultiple: () => {
|
||||
called = true;
|
||||
},
|
||||
copySubtitle: () => {
|
||||
called = true;
|
||||
},
|
||||
toggleSecondarySub: () => {
|
||||
called = true;
|
||||
},
|
||||
updateLastCardFromClipboard: () => {
|
||||
called = true;
|
||||
},
|
||||
triggerFieldGrouping: () => {
|
||||
called = true;
|
||||
},
|
||||
triggerSubsync: () => {
|
||||
called = true;
|
||||
},
|
||||
mineSentence: () => {
|
||||
called = true;
|
||||
},
|
||||
mineSentenceMultiple: () => {
|
||||
called = true;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
@@ -80,7 +80,20 @@ export function loadSubtitlePositionService(options: {
|
||||
typeof parsed.yPercent === "number" &&
|
||||
Number.isFinite(parsed.yPercent)
|
||||
) {
|
||||
return { yPercent: parsed.yPercent };
|
||||
const position: SubtitlePosition = { yPercent: parsed.yPercent };
|
||||
if (
|
||||
typeof parsed.invisibleOffsetXPx === "number" &&
|
||||
Number.isFinite(parsed.invisibleOffsetXPx)
|
||||
) {
|
||||
position.invisibleOffsetXPx = parsed.invisibleOffsetXPx;
|
||||
}
|
||||
if (
|
||||
typeof parsed.invisibleOffsetYPx === "number" &&
|
||||
Number.isFinite(parsed.invisibleOffsetYPx)
|
||||
) {
|
||||
position.invisibleOffsetYPx = parsed.invisibleOffsetYPx;
|
||||
}
|
||||
return position;
|
||||
}
|
||||
return options.fallbackPosition;
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
export const IPC_CHANNELS = {
|
||||
rendererToMainInvoke: {
|
||||
getOverlayVisibility: "get-overlay-visibility",
|
||||
getVisibleOverlayVisibility: "get-visible-overlay-visibility",
|
||||
getInvisibleOverlayVisibility: "get-invisible-overlay-visibility",
|
||||
getCurrentSubtitle: "get-current-subtitle",
|
||||
getCurrentSubtitleAss: "get-current-subtitle-ass",
|
||||
getMpvSubtitleRenderMetrics: "get-mpv-subtitle-render-metrics",
|
||||
getSubtitlePosition: "get-subtitle-position",
|
||||
getSubtitleStyle: "get-subtitle-style",
|
||||
getMecabStatus: "get-mecab-status",
|
||||
getKeybindings: "get-keybindings",
|
||||
getSecondarySubMode: "get-secondary-sub-mode",
|
||||
getCurrentSecondarySub: "get-current-secondary-sub",
|
||||
runSubsyncManual: "subsync:run-manual",
|
||||
getAnkiConnectStatus: "get-anki-connect-status",
|
||||
runtimeOptionsGet: "runtime-options:get",
|
||||
runtimeOptionsSet: "runtime-options:set",
|
||||
runtimeOptionsCycle: "runtime-options:cycle",
|
||||
kikuBuildMergePreview: "kiku:build-merge-preview",
|
||||
jimakuGetMediaInfo: "jimaku:get-media-info",
|
||||
jimakuSearchEntries: "jimaku:search-entries",
|
||||
jimakuListFiles: "jimaku:list-files",
|
||||
jimakuDownloadFile: "jimaku:download-file",
|
||||
},
|
||||
rendererToMainSend: {
|
||||
setIgnoreMouseEvents: "set-ignore-mouse-events",
|
||||
overlayModalClosed: "overlay:modal-closed",
|
||||
openYomitanSettings: "open-yomitan-settings",
|
||||
quitApp: "quit-app",
|
||||
toggleDevTools: "toggle-dev-tools",
|
||||
toggleOverlay: "toggle-overlay",
|
||||
saveSubtitlePosition: "save-subtitle-position",
|
||||
setMecabEnabled: "set-mecab-enabled",
|
||||
mpvCommand: "mpv-command",
|
||||
setAnkiConnectEnabled: "set-anki-connect-enabled",
|
||||
clearAnkiConnectHistory: "clear-anki-connect-history",
|
||||
kikuFieldGroupingRespond: "kiku:field-grouping-respond",
|
||||
},
|
||||
mainToRendererEvent: {
|
||||
subtitleSet: "subtitle:set",
|
||||
mpvSubVisibility: "mpv:subVisibility",
|
||||
subtitlePositionSet: "subtitle-position:set",
|
||||
mpvSubtitleRenderMetricsSet: "mpv-subtitle-render-metrics:set",
|
||||
subtitleAssSet: "subtitle-ass:set",
|
||||
overlayDebugVisualizationSet: "overlay-debug-visualization:set",
|
||||
secondarySubtitleSet: "secondary-subtitle:set",
|
||||
secondarySubtitleMode: "secondary-subtitle:mode",
|
||||
subsyncOpenManual: "subsync:open-manual",
|
||||
kikuFieldGroupingRequest: "kiku:field-grouping-request",
|
||||
runtimeOptionsChanged: "runtime-options:changed",
|
||||
runtimeOptionsOpen: "runtime-options:open",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type RendererToMainInvokeChannel =
|
||||
(typeof IPC_CHANNELS.rendererToMainInvoke)[keyof typeof IPC_CHANNELS.rendererToMainInvoke];
|
||||
export type RendererToMainSendChannel =
|
||||
(typeof IPC_CHANNELS.rendererToMainSend)[keyof typeof IPC_CHANNELS.rendererToMainSend];
|
||||
export type MainToRendererEventChannel =
|
||||
(typeof IPC_CHANNELS.mainToRendererEvent)[keyof typeof IPC_CHANNELS.mainToRendererEvent];
|
||||
@@ -1,19 +0,0 @@
|
||||
import { ipcMain, IpcMainEvent } from "electron";
|
||||
import {
|
||||
RendererToMainInvokeChannel,
|
||||
RendererToMainSendChannel,
|
||||
} from "./contract";
|
||||
|
||||
export function onRendererSend(
|
||||
channel: RendererToMainSendChannel,
|
||||
listener: (event: IpcMainEvent, ...args: any[]) => void,
|
||||
): void {
|
||||
ipcMain.on(channel, listener);
|
||||
}
|
||||
|
||||
export function handleRendererInvoke(
|
||||
channel: RendererToMainInvokeChannel,
|
||||
handler: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => unknown,
|
||||
): void {
|
||||
ipcMain.handle(channel, handler);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ipcRenderer, IpcRendererEvent } from "electron";
|
||||
import {
|
||||
MainToRendererEventChannel,
|
||||
RendererToMainInvokeChannel,
|
||||
RendererToMainSendChannel,
|
||||
} from "./contract";
|
||||
|
||||
export function invokeFromRenderer<T>(
|
||||
channel: RendererToMainInvokeChannel,
|
||||
...args: unknown[]
|
||||
): Promise<T> {
|
||||
return ipcRenderer.invoke(channel, ...args) as Promise<T>;
|
||||
}
|
||||
|
||||
export function sendFromRenderer(
|
||||
channel: RendererToMainSendChannel,
|
||||
...args: unknown[]
|
||||
): void {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
}
|
||||
|
||||
export function onMainEvent(
|
||||
channel: MainToRendererEventChannel,
|
||||
listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
|
||||
): void {
|
||||
ipcRenderer.on(channel, listener);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { AppContext } from "../../core/app-context";
|
||||
import { SubminerModule } from "../../core/module";
|
||||
import {
|
||||
JimakuApiResponse,
|
||||
JimakuDownloadQuery,
|
||||
JimakuDownloadResult,
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
JimakuFilesQuery,
|
||||
JimakuMediaInfo,
|
||||
JimakuSearchQuery,
|
||||
} from "../../types";
|
||||
|
||||
export class JimakuModule implements SubminerModule<AppContext> {
|
||||
readonly id = "jimaku";
|
||||
private context: AppContext["jimaku"] | undefined;
|
||||
|
||||
init(context: AppContext): void {
|
||||
if (!context.jimaku) {
|
||||
throw new Error("Jimaku context is missing");
|
||||
}
|
||||
this.context = context.jimaku;
|
||||
}
|
||||
|
||||
getMediaInfo(): JimakuMediaInfo {
|
||||
if (!this.context) {
|
||||
return {
|
||||
title: "",
|
||||
season: null,
|
||||
episode: null,
|
||||
confidence: "low",
|
||||
filename: "",
|
||||
rawTitle: "",
|
||||
};
|
||||
}
|
||||
return this.context.getMediaInfo();
|
||||
}
|
||||
|
||||
searchEntries(
|
||||
query: JimakuSearchQuery,
|
||||
): Promise<JimakuApiResponse<JimakuEntry[]>> {
|
||||
if (!this.context) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: { error: "Jimaku module not initialized" },
|
||||
});
|
||||
}
|
||||
return this.context.searchEntries(query);
|
||||
}
|
||||
|
||||
listFiles(
|
||||
query: JimakuFilesQuery,
|
||||
): Promise<JimakuApiResponse<JimakuFileEntry[]>> {
|
||||
if (!this.context) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: { error: "Jimaku module not initialized" },
|
||||
});
|
||||
}
|
||||
return this.context.listFiles(query);
|
||||
}
|
||||
|
||||
downloadFile(query: JimakuDownloadQuery): Promise<JimakuDownloadResult> {
|
||||
if (!this.context) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: { error: "Jimaku module not initialized" },
|
||||
});
|
||||
}
|
||||
return this.context.downloadFile(query);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { AppContext } from "../../core/app-context";
|
||||
import { SubminerModule } from "../../core/module";
|
||||
import { RuntimeOptionsManager } from "../../runtime-options";
|
||||
import {
|
||||
AnkiConnectConfig,
|
||||
RuntimeOptionApplyResult,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
} from "../../types";
|
||||
|
||||
export class RuntimeOptionsModule implements SubminerModule<AppContext> {
|
||||
readonly id = "runtime-options";
|
||||
private manager: RuntimeOptionsManager | null = null;
|
||||
|
||||
init(context: AppContext): void {
|
||||
if (!context.runtimeOptions) {
|
||||
throw new Error("Runtime options context is missing");
|
||||
}
|
||||
|
||||
this.manager = new RuntimeOptionsManager(
|
||||
context.runtimeOptions.getAnkiConfig,
|
||||
{
|
||||
applyAnkiPatch: context.runtimeOptions.applyAnkiPatch,
|
||||
onOptionsChanged: context.runtimeOptions.onOptionsChanged,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
listOptions(): RuntimeOptionState[] {
|
||||
return this.manager ? this.manager.listOptions() : [];
|
||||
}
|
||||
|
||||
getOptionValue(id: RuntimeOptionId): RuntimeOptionValue | undefined {
|
||||
return this.manager?.getOptionValue(id);
|
||||
}
|
||||
|
||||
setOptionValue(
|
||||
id: RuntimeOptionId,
|
||||
value: RuntimeOptionValue,
|
||||
): RuntimeOptionApplyResult {
|
||||
if (!this.manager) {
|
||||
return { ok: false, error: "Runtime options manager unavailable" };
|
||||
}
|
||||
return this.manager.setOptionValue(id, value);
|
||||
}
|
||||
|
||||
cycleOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult {
|
||||
if (!this.manager) {
|
||||
return { ok: false, error: "Runtime options manager unavailable" };
|
||||
}
|
||||
return this.manager.cycleOption(id, direction);
|
||||
}
|
||||
|
||||
getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig {
|
||||
if (!this.manager) {
|
||||
return baseConfig ? JSON.parse(JSON.stringify(baseConfig)) : {};
|
||||
}
|
||||
return this.manager.getEffectiveAnkiConnectConfig(baseConfig);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { AppContext } from "../../core/app-context";
|
||||
import { SubminerModule } from "../../core/module";
|
||||
import { SubsyncManualRunRequest, SubsyncResult } from "../../types";
|
||||
|
||||
export class SubsyncModule implements SubminerModule<AppContext> {
|
||||
readonly id = "subsync";
|
||||
private inProgress = false;
|
||||
private context: AppContext["subsync"] | undefined;
|
||||
|
||||
init(context: AppContext): void {
|
||||
if (!context.subsync) {
|
||||
throw new Error("Subsync context is missing");
|
||||
}
|
||||
this.context = context.subsync;
|
||||
}
|
||||
|
||||
isInProgress(): boolean {
|
||||
return this.inProgress;
|
||||
}
|
||||
|
||||
async triggerFromConfig(): Promise<void> {
|
||||
if (!this.context) {
|
||||
throw new Error("Subsync module not initialized");
|
||||
}
|
||||
|
||||
if (this.inProgress) {
|
||||
this.context.showOsd("Subsync already running");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.context.getDefaultMode() === "manual") {
|
||||
await this.context.openManualPicker();
|
||||
this.context.showOsd("Subsync: choose engine and source");
|
||||
return;
|
||||
}
|
||||
|
||||
this.inProgress = true;
|
||||
const result = await this.context.runWithSpinner(
|
||||
() => this.context!.runAuto(),
|
||||
"Subsync: syncing",
|
||||
);
|
||||
this.context.showOsd(result.message);
|
||||
} catch (error) {
|
||||
this.context.showOsd(`Subsync failed: ${(error as Error).message}`);
|
||||
} finally {
|
||||
this.inProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async runManual(request: SubsyncManualRunRequest): Promise<SubsyncResult> {
|
||||
if (!this.context) {
|
||||
return { ok: false, message: "Subsync module not initialized" };
|
||||
}
|
||||
|
||||
if (this.inProgress) {
|
||||
const busy = "Subsync already running";
|
||||
this.context.showOsd(busy);
|
||||
return { ok: false, message: busy };
|
||||
}
|
||||
|
||||
try {
|
||||
this.inProgress = true;
|
||||
const result = await this.context.runWithSpinner(
|
||||
() => this.context!.runManual(request),
|
||||
"Subsync: syncing",
|
||||
);
|
||||
this.context.showOsd(result.message);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = `Subsync failed: ${(error as Error).message}`;
|
||||
this.context.showOsd(message);
|
||||
return { ok: false, message };
|
||||
} finally {
|
||||
this.inProgress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,8 @@ interface Keybinding {
|
||||
|
||||
interface SubtitlePosition {
|
||||
yPercent: number;
|
||||
invisibleOffsetXPx?: number;
|
||||
invisibleOffsetYPx?: number;
|
||||
}
|
||||
|
||||
type SecondarySubMode = "hidden" | "visible" | "hover";
|
||||
@@ -342,12 +344,16 @@ const isMacOSPlatform =
|
||||
// Linux passthrough forwarding is not reliable for this overlay; keep pointer
|
||||
// routing local so hover lookup, drag-reposition, and key handling remain usable.
|
||||
const shouldToggleMouseIgnore = !isLinuxPlatform;
|
||||
const INVISIBLE_POSITION_EDIT_TOGGLE_CODE = "KeyP";
|
||||
const INVISIBLE_POSITION_STEP_PX = 1;
|
||||
const INVISIBLE_POSITION_STEP_FAST_PX = 4;
|
||||
|
||||
let isOverSubtitle = false;
|
||||
let isDragging = false;
|
||||
let dragStartY = 0;
|
||||
let startYPercent = 0;
|
||||
let currentYPercent: number | null = null;
|
||||
let persistedSubtitlePosition: SubtitlePosition = { yPercent: 10 };
|
||||
let jimakuModalOpen = false;
|
||||
let jimakuEntries: JimakuEntry[] = [];
|
||||
let jimakuFiles: JimakuFileEntry[] = [];
|
||||
@@ -393,6 +399,15 @@ const DEFAULT_MPV_SUBTITLE_RENDER_METRICS: MpvSubtitleRenderMetrics = {
|
||||
let mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = {
|
||||
...DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
|
||||
};
|
||||
let invisiblePositionEditMode = false;
|
||||
let invisiblePositionEditStartX = 0;
|
||||
let invisiblePositionEditStartY = 0;
|
||||
let invisibleSubtitleOffsetXPx = 0;
|
||||
let invisibleSubtitleOffsetYPx = 0;
|
||||
let invisibleLayoutBaseLeftPx = 0;
|
||||
let invisibleLayoutBaseBottomPx: number | null = null;
|
||||
let invisibleLayoutBaseTopPx: number | null = null;
|
||||
let invisiblePositionEditHud: HTMLDivElement | null = null;
|
||||
let currentInvisibleSubtitleLineCount = 1;
|
||||
let lastHoverSelectionKey = "";
|
||||
let lastHoverSelectionNode: Text | null = null;
|
||||
@@ -554,7 +569,8 @@ function handleMouseLeave(): void {
|
||||
!jimakuModalOpen &&
|
||||
!kikuModalOpen &&
|
||||
!runtimeOptionsModalOpen &&
|
||||
!subsyncModalOpen
|
||||
!subsyncModalOpen &&
|
||||
!invisiblePositionEditMode
|
||||
) {
|
||||
overlay.classList.remove("interactive");
|
||||
if (shouldToggleMouseIgnore) {
|
||||
@@ -591,10 +607,52 @@ function applyYPercent(yPercent: number): void {
|
||||
subtitleContainer.style.marginBottom = `${marginBottom}px`;
|
||||
}
|
||||
|
||||
function updatePersistedSubtitlePosition(position: SubtitlePosition | null): void {
|
||||
const nextYPercent =
|
||||
position && typeof position.yPercent === "number" && Number.isFinite(position.yPercent)
|
||||
? position.yPercent
|
||||
: persistedSubtitlePosition.yPercent;
|
||||
const nextXOffset =
|
||||
position && typeof position.invisibleOffsetXPx === "number" && Number.isFinite(position.invisibleOffsetXPx)
|
||||
? position.invisibleOffsetXPx
|
||||
: 0;
|
||||
const nextYOffset =
|
||||
position && typeof position.invisibleOffsetYPx === "number" && Number.isFinite(position.invisibleOffsetYPx)
|
||||
? position.invisibleOffsetYPx
|
||||
: 0;
|
||||
persistedSubtitlePosition = {
|
||||
yPercent: nextYPercent,
|
||||
invisibleOffsetXPx: nextXOffset,
|
||||
invisibleOffsetYPx: nextYOffset,
|
||||
};
|
||||
}
|
||||
|
||||
function persistSubtitlePositionPatch(patch: Partial<SubtitlePosition>): void {
|
||||
const nextPosition: SubtitlePosition = {
|
||||
yPercent:
|
||||
typeof patch.yPercent === "number" && Number.isFinite(patch.yPercent)
|
||||
? patch.yPercent
|
||||
: persistedSubtitlePosition.yPercent,
|
||||
invisibleOffsetXPx:
|
||||
typeof patch.invisibleOffsetXPx === "number" &&
|
||||
Number.isFinite(patch.invisibleOffsetXPx)
|
||||
? patch.invisibleOffsetXPx
|
||||
: persistedSubtitlePosition.invisibleOffsetXPx ?? 0,
|
||||
invisibleOffsetYPx:
|
||||
typeof patch.invisibleOffsetYPx === "number" &&
|
||||
Number.isFinite(patch.invisibleOffsetYPx)
|
||||
? patch.invisibleOffsetYPx
|
||||
: persistedSubtitlePosition.invisibleOffsetYPx ?? 0,
|
||||
};
|
||||
persistedSubtitlePosition = nextPosition;
|
||||
window.electronAPI.saveSubtitlePosition(nextPosition);
|
||||
}
|
||||
|
||||
function applyStoredSubtitlePosition(
|
||||
position: SubtitlePosition | null,
|
||||
source: string,
|
||||
): void {
|
||||
updatePersistedSubtitlePosition(position);
|
||||
if (position && position.yPercent !== undefined) {
|
||||
applyYPercent(position.yPercent);
|
||||
console.log(
|
||||
@@ -612,6 +670,66 @@ function applyStoredSubtitlePosition(
|
||||
}
|
||||
}
|
||||
|
||||
function applyInvisibleSubtitleOffsetPosition(): void {
|
||||
const nextLeft = invisibleLayoutBaseLeftPx + invisibleSubtitleOffsetXPx;
|
||||
subtitleContainer.style.left = `${nextLeft}px`;
|
||||
|
||||
if (invisibleLayoutBaseBottomPx !== null) {
|
||||
subtitleContainer.style.bottom = `${Math.max(0, invisibleLayoutBaseBottomPx + invisibleSubtitleOffsetYPx)}px`;
|
||||
subtitleContainer.style.top = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (invisibleLayoutBaseTopPx !== null) {
|
||||
subtitleContainer.style.top = `${Math.max(0, invisibleLayoutBaseTopPx - invisibleSubtitleOffsetYPx)}px`;
|
||||
subtitleContainer.style.bottom = "";
|
||||
}
|
||||
}
|
||||
|
||||
function updateInvisiblePositionEditHud(): void {
|
||||
if (!invisiblePositionEditHud) return;
|
||||
invisiblePositionEditHud.textContent =
|
||||
`Position Edit Ctrl/Cmd+Shift+P toggle Arrow keys move Enter/Ctrl+S save Esc cancel x:${Math.round(invisibleSubtitleOffsetXPx)} y:${Math.round(invisibleSubtitleOffsetYPx)}`;
|
||||
}
|
||||
|
||||
function setInvisiblePositionEditMode(enabled: boolean): void {
|
||||
if (!isInvisibleLayer) return;
|
||||
if (invisiblePositionEditMode === enabled) return;
|
||||
invisiblePositionEditMode = enabled;
|
||||
document.body.classList.toggle("invisible-position-edit", enabled);
|
||||
if (enabled) {
|
||||
invisiblePositionEditStartX = invisibleSubtitleOffsetXPx;
|
||||
invisiblePositionEditStartY = invisibleSubtitleOffsetYPx;
|
||||
overlay.classList.add("interactive");
|
||||
if (shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
} else if (!isOverSubtitle && !isAnySettingsModalOpen()) {
|
||||
overlay.classList.remove("interactive");
|
||||
if (shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
updateInvisiblePositionEditHud();
|
||||
}
|
||||
|
||||
function applyInvisibleStoredSubtitlePosition(
|
||||
position: SubtitlePosition | null,
|
||||
source: string,
|
||||
): void {
|
||||
updatePersistedSubtitlePosition(position);
|
||||
invisibleSubtitleOffsetXPx = persistedSubtitlePosition.invisibleOffsetXPx ?? 0;
|
||||
invisibleSubtitleOffsetYPx = persistedSubtitlePosition.invisibleOffsetYPx ?? 0;
|
||||
applyInvisibleSubtitleOffsetPosition();
|
||||
console.log(
|
||||
"[invisible-overlay] Applied subtitle offset from",
|
||||
source,
|
||||
`${invisibleSubtitleOffsetXPx}px`,
|
||||
`${invisibleSubtitleOffsetYPx}px`,
|
||||
);
|
||||
updateInvisiblePositionEditHud();
|
||||
}
|
||||
|
||||
function applySubtitleFontSize(fontSize: number): void {
|
||||
const clampedSize = Math.max(10, fontSize);
|
||||
subtitleRoot.style.fontSize = `${clampedSize}px`;
|
||||
@@ -944,6 +1062,15 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics(
|
||||
}
|
||||
}
|
||||
}
|
||||
invisibleLayoutBaseLeftPx = parseFloat(subtitleContainer.style.left) || 0;
|
||||
invisibleLayoutBaseBottomPx = Number.isFinite(parseFloat(subtitleContainer.style.bottom))
|
||||
? parseFloat(subtitleContainer.style.bottom)
|
||||
: null;
|
||||
invisibleLayoutBaseTopPx = Number.isFinite(parseFloat(subtitleContainer.style.top))
|
||||
? parseFloat(subtitleContainer.style.top)
|
||||
: null;
|
||||
applyInvisibleSubtitleOffsetPosition();
|
||||
updateInvisiblePositionEditHud();
|
||||
console.log("[invisible-overlay] Applied mpv subtitle render metrics from", source);
|
||||
}
|
||||
|
||||
@@ -1860,7 +1987,7 @@ function setupDragging(): void {
|
||||
subtitleContainer.style.cursor = "";
|
||||
|
||||
const yPercent = getCurrentYPercent();
|
||||
window.electronAPI.saveSubtitlePosition({ yPercent });
|
||||
persistSubtitlePositionPatch({ yPercent });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2017,6 +2144,91 @@ function keyEventToString(e: KeyboardEvent): string {
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
function isInvisiblePositionToggleShortcut(e: KeyboardEvent): boolean {
|
||||
return (
|
||||
e.code === INVISIBLE_POSITION_EDIT_TOGGLE_CODE &&
|
||||
!e.altKey &&
|
||||
e.shiftKey &&
|
||||
(e.ctrlKey || e.metaKey)
|
||||
);
|
||||
}
|
||||
|
||||
function saveInvisiblePositionEdit(): void {
|
||||
persistSubtitlePositionPatch({
|
||||
invisibleOffsetXPx: invisibleSubtitleOffsetXPx,
|
||||
invisibleOffsetYPx: invisibleSubtitleOffsetYPx,
|
||||
});
|
||||
setInvisiblePositionEditMode(false);
|
||||
}
|
||||
|
||||
function cancelInvisiblePositionEdit(): void {
|
||||
invisibleSubtitleOffsetXPx = invisiblePositionEditStartX;
|
||||
invisibleSubtitleOffsetYPx = invisiblePositionEditStartY;
|
||||
applyInvisibleSubtitleOffsetPosition();
|
||||
setInvisiblePositionEditMode(false);
|
||||
}
|
||||
|
||||
function handleInvisiblePositionEditKeydown(e: KeyboardEvent): boolean {
|
||||
if (!isInvisibleLayer) return false;
|
||||
|
||||
if (isInvisiblePositionToggleShortcut(e)) {
|
||||
e.preventDefault();
|
||||
if (invisiblePositionEditMode) {
|
||||
cancelInvisiblePositionEdit();
|
||||
} else {
|
||||
setInvisiblePositionEditMode(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!invisiblePositionEditMode) return false;
|
||||
|
||||
const step = e.shiftKey ? INVISIBLE_POSITION_STEP_FAST_PX : INVISIBLE_POSITION_STEP_PX;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelInvisiblePositionEdit();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "Enter" || ((e.ctrlKey || e.metaKey) && e.code === "KeyS")) {
|
||||
e.preventDefault();
|
||||
saveInvisiblePositionEdit();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
e.key === "ArrowUp" ||
|
||||
e.key === "ArrowDown" ||
|
||||
e.key === "ArrowLeft" ||
|
||||
e.key === "ArrowRight" ||
|
||||
e.key === "h" ||
|
||||
e.key === "j" ||
|
||||
e.key === "k" ||
|
||||
e.key === "l" ||
|
||||
e.key === "H" ||
|
||||
e.key === "J" ||
|
||||
e.key === "K" ||
|
||||
e.key === "L"
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (e.key === "ArrowUp" || e.key === "k" || e.key === "K") {
|
||||
invisibleSubtitleOffsetYPx += step;
|
||||
} else if (e.key === "ArrowDown" || e.key === "j" || e.key === "J") {
|
||||
invisibleSubtitleOffsetYPx -= step;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "h" || e.key === "H") {
|
||||
invisibleSubtitleOffsetXPx -= step;
|
||||
} else if (e.key === "ArrowRight" || e.key === "l" || e.key === "L") {
|
||||
invisibleSubtitleOffsetXPx += step;
|
||||
}
|
||||
applyInvisibleSubtitleOffsetPosition();
|
||||
updateInvisiblePositionEditHud();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
let keybindingsMap = new Map<string, (string | number)[]>();
|
||||
|
||||
type ChordAction =
|
||||
@@ -2073,6 +2285,7 @@ async function setupMpvInputForwarding(): Promise<void> {
|
||||
document.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
|
||||
if (yomitanPopup) return;
|
||||
if (handleInvisiblePositionEditKeydown(e)) return;
|
||||
|
||||
if (runtimeOptionsModalOpen) {
|
||||
handleRuntimeOptionsKeydown(e);
|
||||
@@ -2202,6 +2415,16 @@ function setupSelectionObserver(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function setupInvisiblePositionEditHud(): void {
|
||||
if (!isInvisibleLayer) return;
|
||||
const hud = document.createElement("div");
|
||||
hud.id = "invisiblePositionEditHud";
|
||||
hud.className = "invisible-position-edit-hud";
|
||||
overlay.appendChild(hud);
|
||||
invisiblePositionEditHud = hud;
|
||||
updateInvisiblePositionEditHud();
|
||||
}
|
||||
|
||||
function setupYomitanObserver(): void {
|
||||
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
||||
for (const mutation of mutations) {
|
||||
@@ -2340,13 +2563,15 @@ async function init(): Promise<void> {
|
||||
renderSubtitle(data);
|
||||
});
|
||||
|
||||
if (!isInvisibleLayer) {
|
||||
window.electronAPI.onSubtitlePosition(
|
||||
(position: SubtitlePosition | null) => {
|
||||
window.electronAPI.onSubtitlePosition(
|
||||
(position: SubtitlePosition | null) => {
|
||||
if (isInvisibleLayer) {
|
||||
applyInvisibleStoredSubtitlePosition(position, "media-change");
|
||||
} else {
|
||||
applyStoredSubtitlePosition(position, "media-change");
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (isInvisibleLayer) {
|
||||
window.electronAPI.onMpvSubtitleRenderMetrics(
|
||||
@@ -2380,6 +2605,7 @@ async function init(): Promise<void> {
|
||||
hoverTarget.addEventListener("mouseenter", handleMouseEnter);
|
||||
hoverTarget.addEventListener("mouseleave", handleMouseLeave);
|
||||
setupInvisibleHoverSelection();
|
||||
setupInvisiblePositionEditHud();
|
||||
|
||||
secondarySubContainer.addEventListener("mouseenter", handleMouseEnter);
|
||||
secondarySubContainer.addEventListener("mouseleave", handleMouseLeave);
|
||||
@@ -2503,6 +2729,8 @@ async function init(): Promise<void> {
|
||||
setupResizeHandler();
|
||||
|
||||
if (isInvisibleLayer) {
|
||||
const position = await window.electronAPI.getSubtitlePosition();
|
||||
applyInvisibleStoredSubtitlePosition(position, "startup");
|
||||
const metrics = await window.electronAPI.getMpvSubtitleRenderMetrics();
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, "startup");
|
||||
} else {
|
||||
|
||||
@@ -350,6 +350,39 @@ body.layer-invisible.debug-invisible-visualization #subtitleRoot .c {
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
.invisible-position-edit-hud {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 30;
|
||||
max-width: min(90vw, 1100px);
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
background: rgba(22, 24, 36, 0.88);
|
||||
border: 1px solid rgba(130, 150, 255, 0.55);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
body.layer-invisible.invisible-position-edit .invisible-position-edit-hud {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body.layer-invisible.invisible-position-edit #subtitleRoot,
|
||||
body.layer-invisible.invisible-position-edit #subtitleRoot .word,
|
||||
body.layer-invisible.invisible-position-edit #subtitleRoot .c {
|
||||
color: #ed8796 !important;
|
||||
-webkit-text-fill-color: #ed8796 !important;
|
||||
-webkit-text-stroke: calc(var(--sub-border-size, 2px) * 2) rgba(0, 0, 0, 0.85) !important;
|
||||
paint-order: stroke fill !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
#secondarySubContainer {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface WindowGeometry {
|
||||
|
||||
export interface SubtitlePosition {
|
||||
yPercent: number;
|
||||
invisibleOffsetXPx?: number;
|
||||
invisibleOffsetYPx?: number;
|
||||
}
|
||||
|
||||
export interface SubtitleStyle {
|
||||
|
||||
Reference in New Issue
Block a user