mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
initial commit
This commit is contained in:
228
src/anki-connect.ts
Normal file
228
src/anki-connect.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* SubMiner - Subtitle mining overlay for mpv
|
||||
* 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 axios, { AxiosInstance } from "axios";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("anki");
|
||||
|
||||
interface AnkiConnectRequest {
|
||||
action: string;
|
||||
version: number;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AnkiConnectResponse {
|
||||
result: unknown;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export class AnkiConnectClient {
|
||||
private client: AxiosInstance;
|
||||
private url: string;
|
||||
private backoffMs = 200;
|
||||
private maxBackoffMs = 5000;
|
||||
private consecutiveFailures = 0;
|
||||
private maxConsecutiveFailures = 5;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
|
||||
const httpAgent = new http.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 1000,
|
||||
maxSockets: 5,
|
||||
maxFreeSockets: 2,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const httpsAgent = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 1000,
|
||||
maxSockets: 5,
|
||||
maxFreeSockets: 2,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: url,
|
||||
timeout: 10000,
|
||||
httpAgent,
|
||||
httpsAgent,
|
||||
});
|
||||
}
|
||||
|
||||
private async sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private isRetryableError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
|
||||
const code = (error as Record<string, unknown>).code;
|
||||
const message =
|
||||
typeof (error as Record<string, unknown>).message === "string"
|
||||
? ((error as Record<string, unknown>).message as string).toLowerCase()
|
||||
: "";
|
||||
|
||||
return (
|
||||
code === "ECONNRESET" ||
|
||||
code === "ETIMEDOUT" ||
|
||||
code === "ENOTFOUND" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
code === "EPIPE" ||
|
||||
message.includes("socket hang up") ||
|
||||
message.includes("network error") ||
|
||||
message.includes("timeout")
|
||||
);
|
||||
}
|
||||
|
||||
async invoke(
|
||||
action: string,
|
||||
params: Record<string, unknown> = {},
|
||||
options: { timeout?: number; maxRetries?: number } = {},
|
||||
): Promise<unknown> {
|
||||
const maxRetries = options.maxRetries ?? 3;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
const isMediaUpload = action === "storeMediaFile";
|
||||
const requestTimeout = options.timeout || (isMediaUpload ? 30000 : 10000);
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
const delay = Math.min(
|
||||
this.backoffMs * Math.pow(2, attempt - 1),
|
||||
this.maxBackoffMs,
|
||||
);
|
||||
log.info(
|
||||
`AnkiConnect retry ${attempt}/${maxRetries} after ${delay}ms delay`,
|
||||
);
|
||||
await this.sleep(delay);
|
||||
}
|
||||
|
||||
const response = await this.client.post<AnkiConnectResponse>(
|
||||
"",
|
||||
{
|
||||
action,
|
||||
version: 6,
|
||||
params,
|
||||
} as AnkiConnectRequest,
|
||||
{
|
||||
timeout: requestTimeout,
|
||||
},
|
||||
);
|
||||
|
||||
this.consecutiveFailures = 0;
|
||||
this.backoffMs = 200;
|
||||
|
||||
if (response.data.error) {
|
||||
throw new Error(response.data.error);
|
||||
}
|
||||
|
||||
return response.data.result;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
this.consecutiveFailures++;
|
||||
|
||||
if (!this.isRetryableError(error) || attempt === maxRetries) {
|
||||
if (this.consecutiveFailures < this.maxConsecutiveFailures) {
|
||||
log.error(
|
||||
`AnkiConnect error (attempt ${this.consecutiveFailures}/${this.maxConsecutiveFailures}):`,
|
||||
lastError.message,
|
||||
);
|
||||
} else if (this.consecutiveFailures === this.maxConsecutiveFailures) {
|
||||
log.error(
|
||||
"AnkiConnect: Too many consecutive failures, suppressing further error logs",
|
||||
);
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Unknown error");
|
||||
}
|
||||
|
||||
async findNotes(
|
||||
query: string,
|
||||
options?: { maxRetries?: number },
|
||||
): Promise<number[]> {
|
||||
const result = await this.invoke("findNotes", { query }, options);
|
||||
return (result as number[]) || [];
|
||||
}
|
||||
|
||||
async notesInfo(noteIds: number[]): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.invoke("notesInfo", { notes: noteIds });
|
||||
return (result as Record<string, unknown>[]) || [];
|
||||
}
|
||||
|
||||
async updateNoteFields(
|
||||
noteId: number,
|
||||
fields: Record<string, string>,
|
||||
): Promise<void> {
|
||||
await this.invoke("updateNoteFields", {
|
||||
note: {
|
||||
id: noteId,
|
||||
fields,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async storeMediaFile(filename: string, data: Buffer): Promise<void> {
|
||||
const base64Data = data.toString("base64");
|
||||
const sizeKB = Math.round(base64Data.length / 1024);
|
||||
log.info(`Uploading media file: ${filename} (${sizeKB}KB)`);
|
||||
|
||||
await this.invoke(
|
||||
"storeMediaFile",
|
||||
{
|
||||
filename,
|
||||
data: base64Data,
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
}
|
||||
|
||||
async addNote(
|
||||
deckName: string,
|
||||
modelName: string,
|
||||
fields: Record<string, string>,
|
||||
): Promise<number> {
|
||||
const result = await this.invoke("addNote", {
|
||||
note: { deckName, modelName, fields },
|
||||
});
|
||||
return result as number;
|
||||
}
|
||||
|
||||
async deleteNotes(noteIds: number[]): Promise<void> {
|
||||
await this.invoke("deleteNotes", { notes: noteIds });
|
||||
}
|
||||
|
||||
async retrieveMediaFile(filename: string): Promise<string> {
|
||||
const result = await this.invoke("retrieveMediaFile", { filename });
|
||||
return (result as string) || "";
|
||||
}
|
||||
|
||||
resetBackoff(): void {
|
||||
this.backoffMs = 200;
|
||||
this.consecutiveFailures = 0;
|
||||
}
|
||||
}
|
||||
2679
src/anki-integration.ts
Normal file
2679
src/anki-integration.ts
Normal file
File diff suppressed because it is too large
Load Diff
79
src/config/config.test.ts
Normal file
79
src/config/config.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { ConfigService } from "./service";
|
||||
import { DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY } from "./definitions";
|
||||
import { generateConfigTemplate } from "./template";
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "subminer-config-test-"));
|
||||
}
|
||||
|
||||
test("loads defaults when config is missing", () => {
|
||||
const dir = makeTempDir();
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
|
||||
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
|
||||
});
|
||||
|
||||
test("parses jsonc and warns/falls back on invalid value", () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "config.jsonc"),
|
||||
`{
|
||||
// invalid websocket port
|
||||
"websocket": { "port": "bad" }
|
||||
}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
|
||||
assert.ok(service.getWarnings().some((w) => w.path === "websocket.port"));
|
||||
});
|
||||
|
||||
test("parses invisible overlay config and new global shortcuts", () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "config.jsonc"),
|
||||
`{
|
||||
"shortcuts": {
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+U",
|
||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I"
|
||||
},
|
||||
"invisibleOverlay": {
|
||||
"startupVisibility": "hidden"
|
||||
},
|
||||
"bind_visible_overlay_to_mpv_sub_visibility": false,
|
||||
"youtubeSubgen": {
|
||||
"primarySubLanguages": ["ja", "jpn", "jp"]
|
||||
}
|
||||
}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, "Alt+Shift+U");
|
||||
assert.equal(config.shortcuts.toggleInvisibleOverlayGlobal, "Alt+Shift+I");
|
||||
assert.equal(config.invisibleOverlay.startupVisibility, "hidden");
|
||||
assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false);
|
||||
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ["ja", "jpn", "jp"]);
|
||||
});
|
||||
|
||||
test("runtime options registry is centralized", () => {
|
||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||
assert.deepEqual(ids, ["anki.autoUpdateNewCards", "anki.kikuFieldGrouping"]);
|
||||
});
|
||||
|
||||
test("template generator includes known keys", () => {
|
||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
assert.match(output, /"ankiConnect":/);
|
||||
assert.match(output, /"websocket":/);
|
||||
assert.match(output, /"youtubeSubgen":/);
|
||||
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
|
||||
});
|
||||
474
src/config/definitions.ts
Normal file
474
src/config/definitions.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
import {
|
||||
AnkiConnectConfig,
|
||||
Config,
|
||||
RawConfig,
|
||||
ResolvedConfig,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionScope,
|
||||
RuntimeOptionValue,
|
||||
RuntimeOptionValueType,
|
||||
} from "../types";
|
||||
|
||||
export type ConfigValueKind =
|
||||
| "boolean"
|
||||
| "number"
|
||||
| "string"
|
||||
| "enum"
|
||||
| "array"
|
||||
| "object";
|
||||
|
||||
export interface RuntimeOptionRegistryEntry {
|
||||
id: RuntimeOptionId;
|
||||
path: string;
|
||||
label: string;
|
||||
scope: RuntimeOptionScope;
|
||||
valueType: RuntimeOptionValueType;
|
||||
allowedValues: RuntimeOptionValue[];
|
||||
defaultValue: RuntimeOptionValue;
|
||||
requiresRestart: boolean;
|
||||
formatValueForOsd: (value: RuntimeOptionValue) => string;
|
||||
toAnkiPatch: (value: RuntimeOptionValue) => Partial<AnkiConnectConfig>;
|
||||
}
|
||||
|
||||
export interface ConfigOptionRegistryEntry {
|
||||
path: string;
|
||||
kind: ConfigValueKind;
|
||||
defaultValue: unknown;
|
||||
description: string;
|
||||
enumValues?: readonly string[];
|
||||
runtime?: RuntimeOptionRegistryEntry;
|
||||
}
|
||||
|
||||
export interface ConfigTemplateSection {
|
||||
title: string;
|
||||
description: string[];
|
||||
key: keyof ResolvedConfig;
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export const SPECIAL_COMMANDS = {
|
||||
SUBSYNC_TRIGGER: "__subsync-trigger",
|
||||
RUNTIME_OPTIONS_OPEN: "__runtime-options-open",
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: "__runtime-option-cycle:",
|
||||
REPLAY_SUBTITLE: "__replay-subtitle",
|
||||
PLAY_NEXT_SUBTITLE: "__play-next-subtitle",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig["keybindings"]> = [
|
||||
{ key: "Space", command: ["cycle", "pause"] },
|
||||
{ key: "ArrowRight", command: ["seek", 5] },
|
||||
{ key: "ArrowLeft", command: ["seek", -5] },
|
||||
{ key: "ArrowUp", command: ["seek", 60] },
|
||||
{ key: "ArrowDown", command: ["seek", -60] },
|
||||
{ key: "Shift+KeyH", command: ["sub-seek", -1] },
|
||||
{ key: "Shift+KeyL", command: ["sub-seek", 1] },
|
||||
{ key: "Ctrl+Shift+KeyH", command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
|
||||
{ key: "Ctrl+Shift+KeyL", command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
|
||||
{ key: "KeyQ", command: ["quit"] },
|
||||
{ key: "Ctrl+KeyW", command: ["quit"] },
|
||||
];
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
keybindings: [],
|
||||
websocket: {
|
||||
enabled: "auto",
|
||||
port: 6677,
|
||||
},
|
||||
texthooker: {
|
||||
openBrowser: true,
|
||||
},
|
||||
ankiConnect: {
|
||||
enabled: false,
|
||||
url: "http://127.0.0.1:8765",
|
||||
pollingRate: 3000,
|
||||
fields: {
|
||||
audio: "ExpressionAudio",
|
||||
image: "Picture",
|
||||
sentence: "Sentence",
|
||||
miscInfo: "MiscInfo",
|
||||
translation: "SelectionText",
|
||||
},
|
||||
ai: {
|
||||
enabled: false,
|
||||
alwaysUseAiTranslation: false,
|
||||
apiKey: "",
|
||||
model: "openai/gpt-4o-mini",
|
||||
baseUrl: "https://openrouter.ai/api",
|
||||
targetLanguage: "English",
|
||||
systemPrompt:
|
||||
"You are a translation engine. Return only the translated text with no explanations.",
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
imageType: "static",
|
||||
imageFormat: "jpg",
|
||||
imageQuality: 92,
|
||||
imageMaxWidth: undefined,
|
||||
imageMaxHeight: undefined,
|
||||
animatedFps: 10,
|
||||
animatedMaxWidth: 640,
|
||||
animatedMaxHeight: undefined,
|
||||
animatedCrf: 35,
|
||||
audioPadding: 0.5,
|
||||
fallbackDuration: 3.0,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {
|
||||
overwriteAudio: true,
|
||||
overwriteImage: true,
|
||||
mediaInsertMode: "append",
|
||||
highlightWord: true,
|
||||
notificationType: "osd",
|
||||
autoUpdateNewCards: true,
|
||||
},
|
||||
metadata: {
|
||||
pattern: "[SubMiner] %f (%t)",
|
||||
},
|
||||
isLapis: {
|
||||
enabled: false,
|
||||
sentenceCardModel: "Japanese sentences",
|
||||
sentenceCardSentenceField: "Sentence",
|
||||
sentenceCardAudioField: "SentenceAudio",
|
||||
},
|
||||
isKiku: {
|
||||
enabled: false,
|
||||
fieldGrouping: "disabled",
|
||||
deleteDuplicateInAuto: true,
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
toggleVisibleOverlayGlobal: "Alt+Shift+O",
|
||||
toggleInvisibleOverlayGlobal: "Alt+Shift+I",
|
||||
copySubtitle: "CommandOrControl+C",
|
||||
copySubtitleMultiple: "CommandOrControl+Shift+C",
|
||||
updateLastCardFromClipboard: "CommandOrControl+V",
|
||||
triggerFieldGrouping: "CommandOrControl+G",
|
||||
triggerSubsync: "Ctrl+Alt+S",
|
||||
mineSentence: "CommandOrControl+S",
|
||||
mineSentenceMultiple: "CommandOrControl+Shift+S",
|
||||
multiCopyTimeoutMs: 3000,
|
||||
toggleSecondarySub: "CommandOrControl+Shift+V",
|
||||
markAudioCard: "CommandOrControl+Shift+A",
|
||||
openRuntimeOptions: "CommandOrControl+Shift+O",
|
||||
},
|
||||
secondarySub: {
|
||||
secondarySubLanguages: [],
|
||||
autoLoadSecondarySub: false,
|
||||
defaultMode: "hover",
|
||||
},
|
||||
subsync: {
|
||||
defaultMode: "auto",
|
||||
alass_path: "",
|
||||
ffsubsync_path: "",
|
||||
ffmpeg_path: "",
|
||||
},
|
||||
subtitleStyle: {
|
||||
fontFamily:
|
||||
"Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
|
||||
fontSize: 35,
|
||||
fontColor: "#cad3f5",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
backgroundColor: "rgba(54, 58, 79, 0.5)",
|
||||
secondary: {
|
||||
fontSize: 24,
|
||||
fontColor: "#ffffff",
|
||||
backgroundColor: "transparent",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
fontFamily:
|
||||
"Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
|
||||
},
|
||||
},
|
||||
auto_start_overlay: false,
|
||||
bind_visible_overlay_to_mpv_sub_visibility: true,
|
||||
jimaku: {
|
||||
apiBaseUrl: "https://jimaku.cc",
|
||||
languagePreference: "ja",
|
||||
maxEntryResults: 10,
|
||||
},
|
||||
youtubeSubgen: {
|
||||
mode: "automatic",
|
||||
whisperBin: "",
|
||||
whisperModel: "",
|
||||
primarySubLanguages: ["ja", "jpn"],
|
||||
},
|
||||
invisibleOverlay: {
|
||||
startupVisibility: "platform-default",
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect;
|
||||
|
||||
export const RUNTIME_OPTION_REGISTRY: RuntimeOptionRegistryEntry[] = [
|
||||
{
|
||||
id: "anki.autoUpdateNewCards",
|
||||
path: "ankiConnect.behavior.autoUpdateNewCards",
|
||||
label: "Auto Update New Cards",
|
||||
scope: "ankiConnect",
|
||||
valueType: "boolean",
|
||||
allowedValues: [true, false],
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.behavior.autoUpdateNewCards,
|
||||
requiresRestart: false,
|
||||
formatValueForOsd: (value) => (value === true ? "On" : "Off"),
|
||||
toAnkiPatch: (value) => ({
|
||||
behavior: { autoUpdateNewCards: value === true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "anki.kikuFieldGrouping",
|
||||
path: "ankiConnect.isKiku.fieldGrouping",
|
||||
label: "Kiku Field Grouping",
|
||||
scope: "ankiConnect",
|
||||
valueType: "enum",
|
||||
allowedValues: ["auto", "manual", "disabled"],
|
||||
defaultValue: "disabled",
|
||||
requiresRestart: false,
|
||||
formatValueForOsd: (value) => String(value),
|
||||
toAnkiPatch: (value) => ({
|
||||
isKiku: {
|
||||
fieldGrouping:
|
||||
value === "auto" || value === "manual" || value === "disabled"
|
||||
? value
|
||||
: "disabled",
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
|
||||
{
|
||||
path: "websocket.enabled",
|
||||
kind: "enum",
|
||||
enumValues: ["auto", "true", "false"],
|
||||
defaultValue: DEFAULT_CONFIG.websocket.enabled,
|
||||
description: "Built-in subtitle websocket server mode.",
|
||||
},
|
||||
{
|
||||
path: "websocket.port",
|
||||
kind: "number",
|
||||
defaultValue: DEFAULT_CONFIG.websocket.port,
|
||||
description: "Built-in subtitle websocket server port.",
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.enabled",
|
||||
kind: "boolean",
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.enabled,
|
||||
description: "Enable AnkiConnect integration.",
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.pollingRate",
|
||||
kind: "number",
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.pollingRate,
|
||||
description: "Polling interval in milliseconds.",
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.behavior.autoUpdateNewCards",
|
||||
kind: "boolean",
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.behavior.autoUpdateNewCards,
|
||||
description: "Automatically update newly added cards.",
|
||||
runtime: RUNTIME_OPTION_REGISTRY[0],
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.isKiku.fieldGrouping",
|
||||
kind: "enum",
|
||||
enumValues: ["auto", "manual", "disabled"],
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
|
||||
description: "Kiku duplicate-card field grouping mode.",
|
||||
runtime: RUNTIME_OPTION_REGISTRY[1],
|
||||
},
|
||||
{
|
||||
path: "subsync.defaultMode",
|
||||
kind: "enum",
|
||||
enumValues: ["auto", "manual"],
|
||||
defaultValue: DEFAULT_CONFIG.subsync.defaultMode,
|
||||
description: "Subsync default mode.",
|
||||
},
|
||||
{
|
||||
path: "shortcuts.multiCopyTimeoutMs",
|
||||
kind: "number",
|
||||
defaultValue: DEFAULT_CONFIG.shortcuts.multiCopyTimeoutMs,
|
||||
description: "Timeout for multi-copy/mine modes.",
|
||||
},
|
||||
{
|
||||
path: "bind_visible_overlay_to_mpv_sub_visibility",
|
||||
kind: "boolean",
|
||||
defaultValue: DEFAULT_CONFIG.bind_visible_overlay_to_mpv_sub_visibility,
|
||||
description:
|
||||
"Link visible overlay toggles to MPV subtitle visibility (primary and secondary).",
|
||||
},
|
||||
{
|
||||
path: "jimaku.languagePreference",
|
||||
kind: "enum",
|
||||
enumValues: ["ja", "en", "none"],
|
||||
defaultValue: DEFAULT_CONFIG.jimaku.languagePreference,
|
||||
description: "Preferred language used in Jimaku search.",
|
||||
},
|
||||
{
|
||||
path: "jimaku.maxEntryResults",
|
||||
kind: "number",
|
||||
defaultValue: DEFAULT_CONFIG.jimaku.maxEntryResults,
|
||||
description: "Maximum Jimaku search results returned.",
|
||||
},
|
||||
{
|
||||
path: "youtubeSubgen.mode",
|
||||
kind: "enum",
|
||||
enumValues: ["automatic", "preprocess", "off"],
|
||||
defaultValue: DEFAULT_CONFIG.youtubeSubgen.mode,
|
||||
description: "YouTube subtitle generation mode for the launcher script.",
|
||||
},
|
||||
{
|
||||
path: "youtubeSubgen.whisperBin",
|
||||
kind: "string",
|
||||
defaultValue: DEFAULT_CONFIG.youtubeSubgen.whisperBin,
|
||||
description: "Path to whisper.cpp CLI used as fallback transcription engine.",
|
||||
},
|
||||
{
|
||||
path: "youtubeSubgen.whisperModel",
|
||||
kind: "string",
|
||||
defaultValue: DEFAULT_CONFIG.youtubeSubgen.whisperModel,
|
||||
description: "Path to whisper model used for fallback transcription.",
|
||||
},
|
||||
{
|
||||
path: "youtubeSubgen.primarySubLanguages",
|
||||
kind: "string",
|
||||
defaultValue: DEFAULT_CONFIG.youtubeSubgen.primarySubLanguages.join(","),
|
||||
description:
|
||||
"Comma-separated primary subtitle language priority used by the launcher.",
|
||||
},
|
||||
];
|
||||
|
||||
export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: "Overlay Auto-Start",
|
||||
description: [
|
||||
"When overlay connects to mpv, automatically show overlay and hide mpv subtitles.",
|
||||
],
|
||||
key: "auto_start_overlay",
|
||||
},
|
||||
{
|
||||
title: "Visible Overlay Subtitle Binding",
|
||||
description: [
|
||||
"Control whether visible overlay toggles also toggle MPV subtitle visibility.",
|
||||
"When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.",
|
||||
],
|
||||
key: "bind_visible_overlay_to_mpv_sub_visibility",
|
||||
},
|
||||
{
|
||||
title: "Texthooker Server",
|
||||
description: [
|
||||
"Control whether browser opens automatically for texthooker.",
|
||||
],
|
||||
key: "texthooker",
|
||||
},
|
||||
{
|
||||
title: "WebSocket Server",
|
||||
description: [
|
||||
"Built-in WebSocket server broadcasts subtitle text to connected clients.",
|
||||
"Auto mode disables built-in server if mpv_websocket is detected.",
|
||||
],
|
||||
key: "websocket",
|
||||
},
|
||||
{
|
||||
title: "AnkiConnect Integration",
|
||||
description: ["Automatic Anki updates and media generation options."],
|
||||
key: "ankiConnect",
|
||||
},
|
||||
{
|
||||
title: "Keyboard Shortcuts",
|
||||
description: [
|
||||
"Overlay keyboard shortcuts. Set a shortcut to null to disable.",
|
||||
],
|
||||
key: "shortcuts",
|
||||
},
|
||||
{
|
||||
title: "Invisible Overlay",
|
||||
description: [
|
||||
"Startup behavior for the invisible interactive subtitle mining layer.",
|
||||
],
|
||||
key: "invisibleOverlay",
|
||||
},
|
||||
{
|
||||
title: "Keybindings (MPV Commands)",
|
||||
description: [
|
||||
"Extra keybindings that are merged with built-in defaults.",
|
||||
"Set command to null to disable a default keybinding.",
|
||||
],
|
||||
key: "keybindings",
|
||||
},
|
||||
{
|
||||
title: "Subtitle Appearance",
|
||||
description: ["Primary and secondary subtitle styling."],
|
||||
key: "subtitleStyle",
|
||||
},
|
||||
{
|
||||
title: "Secondary Subtitles",
|
||||
description: [
|
||||
"Dual subtitle track options.",
|
||||
"Used by subminer YouTube subtitle generation as secondary language preferences.",
|
||||
],
|
||||
key: "secondarySub",
|
||||
},
|
||||
{
|
||||
title: "Auto Subtitle Sync",
|
||||
description: ["Subsync engine and executable paths."],
|
||||
key: "subsync",
|
||||
},
|
||||
{
|
||||
title: "Subtitle Position",
|
||||
description: ["Initial vertical subtitle position from the bottom."],
|
||||
key: "subtitlePosition",
|
||||
},
|
||||
{
|
||||
title: "Jimaku",
|
||||
description: ["Jimaku API configuration and defaults."],
|
||||
key: "jimaku",
|
||||
},
|
||||
{
|
||||
title: "YouTube Subtitle Generation",
|
||||
description: [
|
||||
"Defaults for subminer YouTube subtitle extraction/transcription mode.",
|
||||
],
|
||||
key: "youtubeSubgen",
|
||||
},
|
||||
];
|
||||
|
||||
export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig {
|
||||
return JSON.parse(JSON.stringify(config)) as ResolvedConfig;
|
||||
}
|
||||
|
||||
export function deepMergeRawConfig(
|
||||
base: RawConfig,
|
||||
patch: RawConfig,
|
||||
): RawConfig {
|
||||
const clone = JSON.parse(JSON.stringify(base)) as Record<string, unknown>;
|
||||
const patchObject = patch as Record<string, unknown>;
|
||||
|
||||
const mergeInto = (
|
||||
target: Record<string, unknown>,
|
||||
source: Record<string, unknown>,
|
||||
): void => {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
typeof target[key] === "object" &&
|
||||
target[key] !== null &&
|
||||
!Array.isArray(target[key])
|
||||
) {
|
||||
mergeInto(
|
||||
target[key] as Record<string, unknown>,
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
} else {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mergeInto(clone, patchObject);
|
||||
return clone as RawConfig;
|
||||
}
|
||||
3
src/config/index.ts
Normal file
3
src/config/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./definitions";
|
||||
export * from "./service";
|
||||
export * from "./template";
|
||||
600
src/config/service.ts
Normal file
600
src/config/service.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { parse as parseJsonc } from "jsonc-parser";
|
||||
import {
|
||||
Config,
|
||||
ConfigValidationWarning,
|
||||
RawConfig,
|
||||
ResolvedConfig,
|
||||
} from "../types";
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
deepCloneConfig,
|
||||
deepMergeRawConfig,
|
||||
} from "./definitions";
|
||||
|
||||
interface LoadResult {
|
||||
config: RawConfig;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
private readonly configDir: string;
|
||||
private readonly configFileJsonc: string;
|
||||
private readonly configFileJson: string;
|
||||
private rawConfig: RawConfig = {};
|
||||
private resolvedConfig: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG);
|
||||
private warnings: ConfigValidationWarning[] = [];
|
||||
private configPathInUse: string;
|
||||
|
||||
constructor(configDir: string) {
|
||||
this.configDir = configDir;
|
||||
this.configFileJsonc = path.join(configDir, "config.jsonc");
|
||||
this.configFileJson = path.join(configDir, "config.json");
|
||||
this.configPathInUse = this.configFileJsonc;
|
||||
this.reloadConfig();
|
||||
}
|
||||
|
||||
getConfigPath(): string {
|
||||
return this.configPathInUse;
|
||||
}
|
||||
|
||||
getConfig(): ResolvedConfig {
|
||||
return deepCloneConfig(this.resolvedConfig);
|
||||
}
|
||||
|
||||
getRawConfig(): RawConfig {
|
||||
return JSON.parse(JSON.stringify(this.rawConfig)) as RawConfig;
|
||||
}
|
||||
|
||||
getWarnings(): ConfigValidationWarning[] {
|
||||
return [...this.warnings];
|
||||
}
|
||||
|
||||
reloadConfig(): ResolvedConfig {
|
||||
const { config, path: configPath } = this.loadRawConfig();
|
||||
this.rawConfig = config;
|
||||
this.configPathInUse = configPath;
|
||||
const { resolved, warnings } = this.resolveConfig(config);
|
||||
this.resolvedConfig = resolved;
|
||||
this.warnings = warnings;
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
saveRawConfig(config: RawConfig): void {
|
||||
if (!fs.existsSync(this.configDir)) {
|
||||
fs.mkdirSync(this.configDir, { recursive: true });
|
||||
}
|
||||
const targetPath = this.configPathInUse.endsWith(".json")
|
||||
? this.configPathInUse
|
||||
: this.configFileJsonc;
|
||||
fs.writeFileSync(targetPath, JSON.stringify(config, null, 2));
|
||||
this.rawConfig = config;
|
||||
this.configPathInUse = targetPath;
|
||||
const { resolved, warnings } = this.resolveConfig(config);
|
||||
this.resolvedConfig = resolved;
|
||||
this.warnings = warnings;
|
||||
}
|
||||
|
||||
patchRawConfig(patch: RawConfig): void {
|
||||
const merged = deepMergeRawConfig(this.getRawConfig(), patch);
|
||||
this.saveRawConfig(merged);
|
||||
}
|
||||
|
||||
private loadRawConfig(): LoadResult {
|
||||
const configPath = fs.existsSync(this.configFileJsonc)
|
||||
? this.configFileJsonc
|
||||
: fs.existsSync(this.configFileJson)
|
||||
? this.configFileJson
|
||||
: this.configFileJsonc;
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return { config: {}, path: configPath };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(configPath, "utf-8");
|
||||
const parsed = configPath.endsWith(".jsonc")
|
||||
? parseJsonc(data)
|
||||
: JSON.parse(data);
|
||||
return {
|
||||
config: isObject(parsed) ? (parsed as Config) : {},
|
||||
path: configPath,
|
||||
};
|
||||
} catch {
|
||||
return { config: {}, path: configPath };
|
||||
}
|
||||
}
|
||||
|
||||
private resolveConfig(raw: RawConfig): {
|
||||
resolved: ResolvedConfig;
|
||||
warnings: ConfigValidationWarning[];
|
||||
} {
|
||||
const warnings: ConfigValidationWarning[] = [];
|
||||
const resolved = deepCloneConfig(DEFAULT_CONFIG);
|
||||
|
||||
const warn = (
|
||||
path: string,
|
||||
value: unknown,
|
||||
fallback: unknown,
|
||||
message: string,
|
||||
): void => {
|
||||
warnings.push({
|
||||
path,
|
||||
value,
|
||||
fallback,
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
const src = isObject(raw) ? raw : {};
|
||||
|
||||
if (isObject(src.texthooker)) {
|
||||
const openBrowser = asBoolean(src.texthooker.openBrowser);
|
||||
if (openBrowser !== undefined) {
|
||||
resolved.texthooker.openBrowser = openBrowser;
|
||||
} else if (src.texthooker.openBrowser !== undefined) {
|
||||
warn(
|
||||
"texthooker.openBrowser",
|
||||
src.texthooker.openBrowser,
|
||||
resolved.texthooker.openBrowser,
|
||||
"Expected boolean.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.websocket)) {
|
||||
const enabled = src.websocket.enabled;
|
||||
if (enabled === "auto" || enabled === true || enabled === false) {
|
||||
resolved.websocket.enabled = enabled;
|
||||
} else if (enabled !== undefined) {
|
||||
warn(
|
||||
"websocket.enabled",
|
||||
enabled,
|
||||
resolved.websocket.enabled,
|
||||
"Expected true, false, or 'auto'.",
|
||||
);
|
||||
}
|
||||
|
||||
const port = asNumber(src.websocket.port);
|
||||
if (port !== undefined && port > 0 && port <= 65535) {
|
||||
resolved.websocket.port = Math.floor(port);
|
||||
} else if (src.websocket.port !== undefined) {
|
||||
warn(
|
||||
"websocket.port",
|
||||
src.websocket.port,
|
||||
resolved.websocket.port,
|
||||
"Expected integer between 1 and 65535.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(src.keybindings)) {
|
||||
resolved.keybindings = src.keybindings.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is { key: string; command: (string | number)[] | null } => {
|
||||
if (!isObject(entry)) return false;
|
||||
if (typeof entry.key !== "string") return false;
|
||||
if (entry.command === null) return true;
|
||||
return Array.isArray(entry.command);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(src.shortcuts)) {
|
||||
const shortcutKeys = [
|
||||
"toggleVisibleOverlayGlobal",
|
||||
"toggleInvisibleOverlayGlobal",
|
||||
"copySubtitle",
|
||||
"copySubtitleMultiple",
|
||||
"updateLastCardFromClipboard",
|
||||
"triggerFieldGrouping",
|
||||
"triggerSubsync",
|
||||
"mineSentence",
|
||||
"mineSentenceMultiple",
|
||||
"toggleSecondarySub",
|
||||
"markAudioCard",
|
||||
"openRuntimeOptions",
|
||||
] as const;
|
||||
|
||||
for (const key of shortcutKeys) {
|
||||
const value = src.shortcuts[key];
|
||||
if (typeof value === "string" || value === null) {
|
||||
resolved.shortcuts[key] =
|
||||
value as (typeof resolved.shortcuts)[typeof key];
|
||||
} else if (value !== undefined) {
|
||||
warn(
|
||||
`shortcuts.${key}`,
|
||||
value,
|
||||
resolved.shortcuts[key],
|
||||
"Expected string or null.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = asNumber(src.shortcuts.multiCopyTimeoutMs);
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
resolved.shortcuts.multiCopyTimeoutMs = Math.floor(timeout);
|
||||
} else if (src.shortcuts.multiCopyTimeoutMs !== undefined) {
|
||||
warn(
|
||||
"shortcuts.multiCopyTimeoutMs",
|
||||
src.shortcuts.multiCopyTimeoutMs,
|
||||
resolved.shortcuts.multiCopyTimeoutMs,
|
||||
"Expected positive number.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.invisibleOverlay)) {
|
||||
const startupVisibility = src.invisibleOverlay.startupVisibility;
|
||||
if (
|
||||
startupVisibility === "platform-default" ||
|
||||
startupVisibility === "visible" ||
|
||||
startupVisibility === "hidden"
|
||||
) {
|
||||
resolved.invisibleOverlay.startupVisibility = startupVisibility;
|
||||
} else if (startupVisibility !== undefined) {
|
||||
warn(
|
||||
"invisibleOverlay.startupVisibility",
|
||||
startupVisibility,
|
||||
resolved.invisibleOverlay.startupVisibility,
|
||||
"Expected platform-default, visible, or hidden.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.secondarySub)) {
|
||||
if (Array.isArray(src.secondarySub.secondarySubLanguages)) {
|
||||
resolved.secondarySub.secondarySubLanguages =
|
||||
src.secondarySub.secondarySubLanguages.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
}
|
||||
const autoLoad = asBoolean(src.secondarySub.autoLoadSecondarySub);
|
||||
if (autoLoad !== undefined) {
|
||||
resolved.secondarySub.autoLoadSecondarySub = autoLoad;
|
||||
}
|
||||
const defaultMode = src.secondarySub.defaultMode;
|
||||
if (
|
||||
defaultMode === "hidden" ||
|
||||
defaultMode === "visible" ||
|
||||
defaultMode === "hover"
|
||||
) {
|
||||
resolved.secondarySub.defaultMode = defaultMode;
|
||||
} else if (defaultMode !== undefined) {
|
||||
warn(
|
||||
"secondarySub.defaultMode",
|
||||
defaultMode,
|
||||
resolved.secondarySub.defaultMode,
|
||||
"Expected hidden, visible, or hover.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.subsync)) {
|
||||
const mode = src.subsync.defaultMode;
|
||||
if (mode === "auto" || mode === "manual") {
|
||||
resolved.subsync.defaultMode = mode;
|
||||
} else if (mode !== undefined) {
|
||||
warn(
|
||||
"subsync.defaultMode",
|
||||
mode,
|
||||
resolved.subsync.defaultMode,
|
||||
"Expected auto or manual.",
|
||||
);
|
||||
}
|
||||
|
||||
const alass = asString(src.subsync.alass_path);
|
||||
if (alass !== undefined) resolved.subsync.alass_path = alass;
|
||||
const ffsubsync = asString(src.subsync.ffsubsync_path);
|
||||
if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync;
|
||||
const ffmpeg = asString(src.subsync.ffmpeg_path);
|
||||
if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg;
|
||||
}
|
||||
|
||||
if (isObject(src.subtitlePosition)) {
|
||||
const y = asNumber(src.subtitlePosition.yPercent);
|
||||
if (y !== undefined) {
|
||||
resolved.subtitlePosition.yPercent = y;
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.jimaku)) {
|
||||
const apiKey = asString(src.jimaku.apiKey);
|
||||
if (apiKey !== undefined) resolved.jimaku.apiKey = apiKey;
|
||||
const apiKeyCommand = asString(src.jimaku.apiKeyCommand);
|
||||
if (apiKeyCommand !== undefined)
|
||||
resolved.jimaku.apiKeyCommand = apiKeyCommand;
|
||||
const apiBaseUrl = asString(src.jimaku.apiBaseUrl);
|
||||
if (apiBaseUrl !== undefined) resolved.jimaku.apiBaseUrl = apiBaseUrl;
|
||||
|
||||
const lang = src.jimaku.languagePreference;
|
||||
if (lang === "ja" || lang === "en" || lang === "none") {
|
||||
resolved.jimaku.languagePreference = lang;
|
||||
} else if (lang !== undefined) {
|
||||
warn(
|
||||
"jimaku.languagePreference",
|
||||
lang,
|
||||
resolved.jimaku.languagePreference,
|
||||
"Expected ja, en, or none.",
|
||||
);
|
||||
}
|
||||
|
||||
const maxEntryResults = asNumber(src.jimaku.maxEntryResults);
|
||||
if (maxEntryResults !== undefined && maxEntryResults > 0) {
|
||||
resolved.jimaku.maxEntryResults = Math.floor(maxEntryResults);
|
||||
} else if (src.jimaku.maxEntryResults !== undefined) {
|
||||
warn(
|
||||
"jimaku.maxEntryResults",
|
||||
src.jimaku.maxEntryResults,
|
||||
resolved.jimaku.maxEntryResults,
|
||||
"Expected positive number.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.youtubeSubgen)) {
|
||||
const mode = src.youtubeSubgen.mode;
|
||||
if (mode === "automatic" || mode === "preprocess" || mode === "off") {
|
||||
resolved.youtubeSubgen.mode = mode;
|
||||
} else if (mode !== undefined) {
|
||||
warn(
|
||||
"youtubeSubgen.mode",
|
||||
mode,
|
||||
resolved.youtubeSubgen.mode,
|
||||
"Expected automatic, preprocess, or off.",
|
||||
);
|
||||
}
|
||||
|
||||
const whisperBin = asString(src.youtubeSubgen.whisperBin);
|
||||
if (whisperBin !== undefined) {
|
||||
resolved.youtubeSubgen.whisperBin = whisperBin;
|
||||
} else if (src.youtubeSubgen.whisperBin !== undefined) {
|
||||
warn(
|
||||
"youtubeSubgen.whisperBin",
|
||||
src.youtubeSubgen.whisperBin,
|
||||
resolved.youtubeSubgen.whisperBin,
|
||||
"Expected string.",
|
||||
);
|
||||
}
|
||||
|
||||
const whisperModel = asString(src.youtubeSubgen.whisperModel);
|
||||
if (whisperModel !== undefined) {
|
||||
resolved.youtubeSubgen.whisperModel = whisperModel;
|
||||
} else if (src.youtubeSubgen.whisperModel !== undefined) {
|
||||
warn(
|
||||
"youtubeSubgen.whisperModel",
|
||||
src.youtubeSubgen.whisperModel,
|
||||
resolved.youtubeSubgen.whisperModel,
|
||||
"Expected string.",
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(src.youtubeSubgen.primarySubLanguages)) {
|
||||
resolved.youtubeSubgen.primarySubLanguages =
|
||||
src.youtubeSubgen.primarySubLanguages.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
} else if (src.youtubeSubgen.primarySubLanguages !== undefined) {
|
||||
warn(
|
||||
"youtubeSubgen.primarySubLanguages",
|
||||
src.youtubeSubgen.primarySubLanguages,
|
||||
resolved.youtubeSubgen.primarySubLanguages,
|
||||
"Expected string array.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (asBoolean(src.auto_start_overlay) !== undefined) {
|
||||
resolved.auto_start_overlay = src.auto_start_overlay as boolean;
|
||||
}
|
||||
|
||||
if (asBoolean(src.bind_visible_overlay_to_mpv_sub_visibility) !== undefined) {
|
||||
resolved.bind_visible_overlay_to_mpv_sub_visibility =
|
||||
src.bind_visible_overlay_to_mpv_sub_visibility as boolean;
|
||||
} else if (src.bind_visible_overlay_to_mpv_sub_visibility !== undefined) {
|
||||
warn(
|
||||
"bind_visible_overlay_to_mpv_sub_visibility",
|
||||
src.bind_visible_overlay_to_mpv_sub_visibility,
|
||||
resolved.bind_visible_overlay_to_mpv_sub_visibility,
|
||||
"Expected boolean.",
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(src.subtitleStyle)) {
|
||||
resolved.subtitleStyle = {
|
||||
...resolved.subtitleStyle,
|
||||
...(src.subtitleStyle as ResolvedConfig["subtitleStyle"]),
|
||||
secondary: {
|
||||
...resolved.subtitleStyle.secondary,
|
||||
...(isObject(src.subtitleStyle.secondary)
|
||||
? (src.subtitleStyle
|
||||
.secondary as ResolvedConfig["subtitleStyle"]["secondary"])
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isObject(src.ankiConnect)) {
|
||||
const ac = src.ankiConnect;
|
||||
const aiSource = isObject(ac.ai)
|
||||
? ac.ai
|
||||
: isObject(ac.openRouter)
|
||||
? ac.openRouter
|
||||
: {};
|
||||
resolved.ankiConnect = {
|
||||
...resolved.ankiConnect,
|
||||
...(isObject(ac) ? (ac as Partial<ResolvedConfig["ankiConnect"]>) : {}),
|
||||
fields: {
|
||||
...resolved.ankiConnect.fields,
|
||||
...(isObject(ac.fields)
|
||||
? (ac.fields as ResolvedConfig["ankiConnect"]["fields"])
|
||||
: {}),
|
||||
},
|
||||
ai: {
|
||||
...resolved.ankiConnect.ai,
|
||||
...(aiSource as ResolvedConfig["ankiConnect"]["ai"]),
|
||||
},
|
||||
media: {
|
||||
...resolved.ankiConnect.media,
|
||||
...(isObject(ac.media)
|
||||
? (ac.media as ResolvedConfig["ankiConnect"]["media"])
|
||||
: {}),
|
||||
},
|
||||
behavior: {
|
||||
...resolved.ankiConnect.behavior,
|
||||
...(isObject(ac.behavior)
|
||||
? (ac.behavior as ResolvedConfig["ankiConnect"]["behavior"])
|
||||
: {}),
|
||||
},
|
||||
metadata: {
|
||||
...resolved.ankiConnect.metadata,
|
||||
...(isObject(ac.metadata)
|
||||
? (ac.metadata as ResolvedConfig["ankiConnect"]["metadata"])
|
||||
: {}),
|
||||
},
|
||||
isLapis: {
|
||||
...resolved.ankiConnect.isLapis,
|
||||
...(isObject(ac.isLapis)
|
||||
? (ac.isLapis as ResolvedConfig["ankiConnect"]["isLapis"])
|
||||
: {}),
|
||||
},
|
||||
isKiku: {
|
||||
...resolved.ankiConnect.isKiku,
|
||||
...(isObject(ac.isKiku)
|
||||
? (ac.isKiku as ResolvedConfig["ankiConnect"]["isKiku"])
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
const legacy = ac as Record<string, unknown>;
|
||||
const mapLegacy = (
|
||||
key: string,
|
||||
apply: (value: unknown) => void,
|
||||
): void => {
|
||||
if (legacy[key] !== undefined) apply(legacy[key]);
|
||||
};
|
||||
|
||||
mapLegacy("audioField", (value) => {
|
||||
resolved.ankiConnect.fields.audio = value as string;
|
||||
});
|
||||
mapLegacy("imageField", (value) => {
|
||||
resolved.ankiConnect.fields.image = value as string;
|
||||
});
|
||||
mapLegacy("sentenceField", (value) => {
|
||||
resolved.ankiConnect.fields.sentence = value as string;
|
||||
});
|
||||
mapLegacy("miscInfoField", (value) => {
|
||||
resolved.ankiConnect.fields.miscInfo = value as string;
|
||||
});
|
||||
mapLegacy("miscInfoPattern", (value) => {
|
||||
resolved.ankiConnect.metadata.pattern = value as string;
|
||||
});
|
||||
mapLegacy("generateAudio", (value) => {
|
||||
resolved.ankiConnect.media.generateAudio = value as boolean;
|
||||
});
|
||||
mapLegacy("generateImage", (value) => {
|
||||
resolved.ankiConnect.media.generateImage = value as boolean;
|
||||
});
|
||||
mapLegacy("imageType", (value) => {
|
||||
resolved.ankiConnect.media.imageType = value as "static" | "avif";
|
||||
});
|
||||
mapLegacy("imageFormat", (value) => {
|
||||
resolved.ankiConnect.media.imageFormat = value as
|
||||
| "jpg"
|
||||
| "png"
|
||||
| "webp";
|
||||
});
|
||||
mapLegacy("imageQuality", (value) => {
|
||||
resolved.ankiConnect.media.imageQuality = value as number;
|
||||
});
|
||||
mapLegacy("imageMaxWidth", (value) => {
|
||||
resolved.ankiConnect.media.imageMaxWidth = value as number;
|
||||
});
|
||||
mapLegacy("imageMaxHeight", (value) => {
|
||||
resolved.ankiConnect.media.imageMaxHeight = value as number;
|
||||
});
|
||||
mapLegacy("animatedFps", (value) => {
|
||||
resolved.ankiConnect.media.animatedFps = value as number;
|
||||
});
|
||||
mapLegacy("animatedMaxWidth", (value) => {
|
||||
resolved.ankiConnect.media.animatedMaxWidth = value as number;
|
||||
});
|
||||
mapLegacy("animatedMaxHeight", (value) => {
|
||||
resolved.ankiConnect.media.animatedMaxHeight = value as number;
|
||||
});
|
||||
mapLegacy("animatedCrf", (value) => {
|
||||
resolved.ankiConnect.media.animatedCrf = value as number;
|
||||
});
|
||||
mapLegacy("audioPadding", (value) => {
|
||||
resolved.ankiConnect.media.audioPadding = value as number;
|
||||
});
|
||||
mapLegacy("fallbackDuration", (value) => {
|
||||
resolved.ankiConnect.media.fallbackDuration = value as number;
|
||||
});
|
||||
mapLegacy("maxMediaDuration", (value) => {
|
||||
resolved.ankiConnect.media.maxMediaDuration = value as number;
|
||||
});
|
||||
mapLegacy("overwriteAudio", (value) => {
|
||||
resolved.ankiConnect.behavior.overwriteAudio = value as boolean;
|
||||
});
|
||||
mapLegacy("overwriteImage", (value) => {
|
||||
resolved.ankiConnect.behavior.overwriteImage = value as boolean;
|
||||
});
|
||||
mapLegacy("mediaInsertMode", (value) => {
|
||||
resolved.ankiConnect.behavior.mediaInsertMode = value as
|
||||
| "append"
|
||||
| "prepend";
|
||||
});
|
||||
mapLegacy("highlightWord", (value) => {
|
||||
resolved.ankiConnect.behavior.highlightWord = value as boolean;
|
||||
});
|
||||
mapLegacy("notificationType", (value) => {
|
||||
resolved.ankiConnect.behavior.notificationType = value as
|
||||
| "osd"
|
||||
| "system"
|
||||
| "both"
|
||||
| "none";
|
||||
});
|
||||
mapLegacy("autoUpdateNewCards", (value) => {
|
||||
resolved.ankiConnect.behavior.autoUpdateNewCards = value as boolean;
|
||||
});
|
||||
|
||||
if (
|
||||
resolved.ankiConnect.isKiku.fieldGrouping !== "auto" &&
|
||||
resolved.ankiConnect.isKiku.fieldGrouping !== "manual" &&
|
||||
resolved.ankiConnect.isKiku.fieldGrouping !== "disabled"
|
||||
) {
|
||||
warn(
|
||||
"ankiConnect.isKiku.fieldGrouping",
|
||||
resolved.ankiConnect.isKiku.fieldGrouping,
|
||||
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
|
||||
"Expected auto, manual, or disabled.",
|
||||
);
|
||||
resolved.ankiConnect.isKiku.fieldGrouping =
|
||||
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping;
|
||||
}
|
||||
}
|
||||
|
||||
return { resolved, warnings };
|
||||
}
|
||||
}
|
||||
78
src/config/template.ts
Normal file
78
src/config/template.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ResolvedConfig } from "../types";
|
||||
import {
|
||||
CONFIG_TEMPLATE_SECTIONS,
|
||||
DEFAULT_CONFIG,
|
||||
deepCloneConfig,
|
||||
} from "./definitions";
|
||||
|
||||
function renderValue(value: unknown, indent = 0): string {
|
||||
const pad = " ".repeat(indent);
|
||||
const nextPad = " ".repeat(indent + 2);
|
||||
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return "[]";
|
||||
const items = value.map((item) => `${nextPad}${renderValue(item, indent + 2)}`);
|
||||
return `\n${items.join(",\n")}\n${pad}`.replace(/^/, "[").concat("]");
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>).filter(
|
||||
([, child]) => child !== undefined,
|
||||
);
|
||||
if (entries.length === 0) return "{}";
|
||||
const lines = entries.map(
|
||||
([key, child]) => `${nextPad}${JSON.stringify(key)}: ${renderValue(child, indent + 2)}`,
|
||||
);
|
||||
return `\n${lines.join(",\n")}\n${pad}`.replace(/^/, "{").concat("}");
|
||||
}
|
||||
|
||||
return "null";
|
||||
}
|
||||
|
||||
function renderSection(
|
||||
key: keyof ResolvedConfig,
|
||||
value: unknown,
|
||||
isLast: boolean,
|
||||
comments: string[],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(" // ==========================================");
|
||||
for (const comment of comments) {
|
||||
lines.push(` // ${comment}`);
|
||||
}
|
||||
lines.push(" // ==========================================");
|
||||
lines.push(` ${JSON.stringify(key)}: ${renderValue(value, 2)}${isLast ? "" : ","}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function generateConfigTemplate(config: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG)): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("/**");
|
||||
lines.push(" * SubMiner Example Configuration File");
|
||||
lines.push(" *");
|
||||
lines.push(" * This file is auto-generated from src/config/definitions.ts.");
|
||||
lines.push(" * Copy to ~/.config/SubMiner/config.jsonc and edit as needed.");
|
||||
lines.push(" */");
|
||||
lines.push("{");
|
||||
|
||||
CONFIG_TEMPLATE_SECTIONS.forEach((section, index) => {
|
||||
lines.push("");
|
||||
const comments = [section.title, ...section.description, ...(section.notes ?? [])];
|
||||
lines.push(
|
||||
renderSection(
|
||||
section.key,
|
||||
config[section.key],
|
||||
index === CONFIG_TEMPLATE_SECTIONS.length - 1,
|
||||
comments,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
lines.push("}");
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
12
src/generate-config-example.ts
Normal file
12
src/generate-config-example.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { DEFAULT_CONFIG, generateConfigTemplate } from "./config";
|
||||
|
||||
function main(): void {
|
||||
const outputPath = path.join(process.cwd(), "config.example.jsonc");
|
||||
const template = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
fs.writeFileSync(outputPath, template, "utf-8");
|
||||
console.log(`Generated ${outputPath}`);
|
||||
}
|
||||
|
||||
main();
|
||||
150
src/logger.ts
Normal file
150
src/logger.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
type LogMethod = (message: string, ...meta: unknown[]) => void;
|
||||
|
||||
type Logger = {
|
||||
debug: LogMethod;
|
||||
info: LogMethod;
|
||||
warn: LogMethod;
|
||||
error: LogMethod;
|
||||
child: (childScope: string) => Logger;
|
||||
};
|
||||
|
||||
const LOG_LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
|
||||
const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = pad(date.getMonth() + 1);
|
||||
const day = pad(date.getDate());
|
||||
const hour = pad(date.getHours());
|
||||
const minute = pad(date.getMinutes());
|
||||
const second = pad(date.getSeconds());
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
function resolveMinLevel(): LogLevel {
|
||||
const raw =
|
||||
typeof process !== "undefined" && process?.env
|
||||
? process.env.SUBMINER_LOG_LEVEL
|
||||
: undefined;
|
||||
const normalized = (raw || "").toLowerCase() as LogLevel;
|
||||
if (LOG_LEVELS.includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
function normalizeError(error: Error): { message: string; stack?: string } {
|
||||
return {
|
||||
message: error.message,
|
||||
...(error.stack ? { stack: error.stack } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeMeta(value: unknown): unknown {
|
||||
if (value instanceof Error) {
|
||||
return normalizeError(value);
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "undefined" ||
|
||||
value === null
|
||||
) {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function emit(
|
||||
level: LogLevel,
|
||||
scope: string,
|
||||
message: string,
|
||||
meta: unknown[],
|
||||
): void {
|
||||
const minLevel = resolveMinLevel();
|
||||
if (LEVEL_PRIORITY[level] < LEVEL_PRIORITY[minLevel]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = formatTimestamp(new Date());
|
||||
const prefix = `[subminer] - ${timestamp} - ${level.toUpperCase()} - [${scope}] ${message}`;
|
||||
const normalizedMeta = meta.map(sanitizeMeta);
|
||||
|
||||
if (normalizedMeta.length === 0) {
|
||||
if (level === "error") {
|
||||
console.error(prefix);
|
||||
} else if (level === "warn") {
|
||||
console.warn(prefix);
|
||||
} else if (level === "debug") {
|
||||
console.debug(prefix);
|
||||
} else {
|
||||
console.info(prefix);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = normalizedMeta.map(safeStringify).join(" ");
|
||||
const finalMessage = `${prefix} ${serialized}`;
|
||||
|
||||
if (level === "error") {
|
||||
console.error(finalMessage);
|
||||
} else if (level === "warn") {
|
||||
console.warn(finalMessage);
|
||||
} else if (level === "debug") {
|
||||
console.debug(finalMessage);
|
||||
} else {
|
||||
console.info(finalMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogger(scope: string): Logger {
|
||||
const baseScope = scope.trim();
|
||||
if (!baseScope) {
|
||||
throw new Error("Logger scope is required");
|
||||
}
|
||||
|
||||
const logAt = (level: LogLevel): LogMethod => {
|
||||
return (message: string, ...meta: unknown[]) => {
|
||||
emit(level, baseScope, message, meta);
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
debug: logAt("debug"),
|
||||
info: logAt("info"),
|
||||
warn: logAt("warn"),
|
||||
error: logAt("error"),
|
||||
child: (childScope: string): Logger => {
|
||||
const normalizedChild = childScope.trim();
|
||||
if (!normalizedChild) {
|
||||
throw new Error("Child logger scope is required");
|
||||
}
|
||||
return createLogger(`${baseScope}:${normalizedChild}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
5003
src/main.ts
Normal file
5003
src/main.ts
Normal file
File diff suppressed because it is too large
Load Diff
178
src/mecab-tokenizer.ts
Normal file
178
src/mecab-tokenizer.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* 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 { spawn, execSync } from "child_process";
|
||||
import { PartOfSpeech, Token, MecabStatus } from "./types";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
export { PartOfSpeech };
|
||||
|
||||
const log = createLogger("mecab");
|
||||
|
||||
function mapPartOfSpeech(pos1: string): PartOfSpeech {
|
||||
switch (pos1) {
|
||||
case "名詞":
|
||||
return PartOfSpeech.noun;
|
||||
case "動詞":
|
||||
return PartOfSpeech.verb;
|
||||
case "形容詞":
|
||||
return PartOfSpeech.i_adjective;
|
||||
case "形状詞":
|
||||
case "形容動詞":
|
||||
return PartOfSpeech.na_adjective;
|
||||
case "助詞":
|
||||
return PartOfSpeech.particle;
|
||||
case "助動詞":
|
||||
return PartOfSpeech.bound_auxiliary;
|
||||
case "記号":
|
||||
case "補助記号":
|
||||
return PartOfSpeech.symbol;
|
||||
default:
|
||||
return PartOfSpeech.other;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseMecabLine(line: string): Token | null {
|
||||
if (!line || line === "EOS" || line.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabIndex = line.indexOf("\t");
|
||||
if (tabIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const surface = line.substring(0, tabIndex);
|
||||
const featureString = line.substring(tabIndex + 1);
|
||||
const features = featureString.split(",");
|
||||
|
||||
const pos1 = features[0] || "";
|
||||
const pos2 = features[1] || "";
|
||||
const pos3 = features[2] || "";
|
||||
const pos4 = features[3] || "";
|
||||
const inflectionType = features[4] || "";
|
||||
const inflectionForm = features[5] || "";
|
||||
const lemma = features[6] || surface;
|
||||
const reading = features[7] || "";
|
||||
const pronunciation = features[8] || "";
|
||||
|
||||
return {
|
||||
word: surface,
|
||||
partOfSpeech: mapPartOfSpeech(pos1),
|
||||
pos1,
|
||||
pos2,
|
||||
pos3,
|
||||
pos4,
|
||||
inflectionType,
|
||||
inflectionForm,
|
||||
headword: lemma !== "*" ? lemma : surface,
|
||||
katakanaReading: reading !== "*" ? reading : "",
|
||||
pronunciation: pronunciation !== "*" ? pronunciation : "",
|
||||
};
|
||||
}
|
||||
|
||||
export class MecabTokenizer {
|
||||
private mecabPath: string | null = null;
|
||||
private available: boolean = false;
|
||||
private enabled: boolean = true;
|
||||
|
||||
async checkAvailability(): Promise<boolean> {
|
||||
try {
|
||||
const result = execSync("which mecab", { encoding: "utf-8" }).trim();
|
||||
if (result) {
|
||||
this.mecabPath = result;
|
||||
this.available = true;
|
||||
log.info("MeCab found at:", this.mecabPath);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
log.info("MeCab not found on system");
|
||||
}
|
||||
|
||||
this.available = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
async tokenize(text: string): Promise<Token[] | null> {
|
||||
if (!this.available || !this.enabled || !text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const mecab = spawn("mecab", [], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
mecab.stdout.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
mecab.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
mecab.on("close", (code: number | null) => {
|
||||
if (code !== 0) {
|
||||
log.error("MeCab process exited with code:", code);
|
||||
if (stderr) {
|
||||
log.error("MeCab stderr:", stderr);
|
||||
}
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = stdout.split("\n");
|
||||
const tokens: Token[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const token = parseMecabLine(line);
|
||||
if (token) {
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
resolve(tokens);
|
||||
});
|
||||
|
||||
mecab.on("error", (err: Error) => {
|
||||
log.error("Failed to spawn MeCab:", err.message);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
mecab.stdin.write(text);
|
||||
mecab.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
getStatus(): MecabStatus {
|
||||
return {
|
||||
available: this.available,
|
||||
enabled: this.enabled,
|
||||
path: this.mecabPath,
|
||||
};
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
export { mapPartOfSpeech };
|
||||
431
src/media-generator.ts
Normal file
431
src/media-generator.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/*
|
||||
* SubMiner - Subtitle mining overlay for mpv
|
||||
* 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 { ExecFileException, execFile } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("media");
|
||||
|
||||
export class MediaGenerator {
|
||||
private tempDir: string;
|
||||
private notifyIconDir: string;
|
||||
private av1EncoderPromise: Promise<string | null> | null = null;
|
||||
|
||||
constructor(tempDir?: string) {
|
||||
this.tempDir = tempDir || path.join(os.tmpdir(), "subminer-media");
|
||||
this.notifyIconDir = path.join(os.tmpdir(), "subminer-notify");
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(this.notifyIconDir)) {
|
||||
fs.mkdirSync(this.notifyIconDir, { recursive: true });
|
||||
}
|
||||
// Clean up old notification icons on startup (older than 1 hour)
|
||||
this.cleanupOldNotificationIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up notification icons older than 1 hour.
|
||||
* Called on startup to prevent accumulation of temp files.
|
||||
*/
|
||||
private cleanupOldNotificationIcons(): void {
|
||||
try {
|
||||
if (!fs.existsSync(this.notifyIconDir)) return;
|
||||
|
||||
const files = fs.readdirSync(this.notifyIconDir);
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".png")) continue;
|
||||
const filePath = path.join(this.notifyIconDir, file);
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.mtimeMs < oneHourAgo) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug(
|
||||
`Failed to clean up ${filePath}:`,
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Failed to cleanup old notification icons:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a notification icon buffer to a temp file and return the file path.
|
||||
* The file path can be passed directly to Electron Notification for better
|
||||
* compatibility with Linux/Wayland notification daemons.
|
||||
*/
|
||||
writeNotificationIconToFile(iconBuffer: Buffer, noteId: number): string {
|
||||
const filename = `icon_${noteId}_${Date.now()}.png`;
|
||||
const filePath = path.join(this.notifyIconDir, filename);
|
||||
fs.writeFileSync(filePath, iconBuffer);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
scheduleNotificationIconCleanup(filePath: string, delayMs = 10000): void {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private ffmpegError(label: string, error: ExecFileException): Error {
|
||||
if (error.code === "ENOENT") {
|
||||
return new Error(
|
||||
"FFmpeg not found. Install FFmpeg to enable media generation.",
|
||||
);
|
||||
}
|
||||
return new Error(`FFmpeg ${label} failed: ${error.message}`);
|
||||
}
|
||||
|
||||
private detectAv1Encoder(): Promise<string | null> {
|
||||
if (this.av1EncoderPromise) return this.av1EncoderPromise;
|
||||
|
||||
this.av1EncoderPromise = new Promise((resolve) => {
|
||||
execFile(
|
||||
"ffmpeg",
|
||||
["-hide_banner", "-encoders"],
|
||||
{ timeout: 10000 },
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const output = `${stdout || ""}\n${stderr || ""}`;
|
||||
const candidates = ["libaom-av1", "libsvtav1", "librav1e"];
|
||||
for (const encoder of candidates) {
|
||||
if (output.includes(encoder)) {
|
||||
resolve(encoder);
|
||||
return;
|
||||
}
|
||||
}
|
||||
resolve(null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return this.av1EncoderPromise;
|
||||
}
|
||||
|
||||
async generateAudio(
|
||||
videoPath: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
padding: number = 0.5,
|
||||
audioStreamIndex: number | null = null,
|
||||
): Promise<Buffer> {
|
||||
const start = Math.max(0, startTime - padding);
|
||||
const duration = endTime - startTime + 2 * padding;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = path.join(this.tempDir, `audio_${Date.now()}.mp3`);
|
||||
const args: string[] = [
|
||||
"-ss",
|
||||
start.toString(),
|
||||
"-t",
|
||||
duration.toString(),
|
||||
"-i",
|
||||
videoPath,
|
||||
];
|
||||
|
||||
if (
|
||||
typeof audioStreamIndex === "number" &&
|
||||
Number.isInteger(audioStreamIndex) &&
|
||||
audioStreamIndex >= 0
|
||||
) {
|
||||
args.push("-map", `0:${audioStreamIndex}`);
|
||||
}
|
||||
|
||||
args.push(
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"libmp3lame",
|
||||
"-q:a",
|
||||
"2",
|
||||
"-ar",
|
||||
"44100",
|
||||
"-y",
|
||||
outputPath,
|
||||
);
|
||||
|
||||
execFile("ffmpeg", args, { timeout: 30000 }, (error) => {
|
||||
if (error) {
|
||||
reject(this.ffmpegError("audio generation", error));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(outputPath);
|
||||
fs.unlinkSync(outputPath);
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async generateScreenshot(
|
||||
videoPath: string,
|
||||
timestamp: number,
|
||||
options: {
|
||||
format: "jpg" | "png" | "webp";
|
||||
quality?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
},
|
||||
): Promise<Buffer> {
|
||||
const { format, quality = 92, maxWidth, maxHeight } = options;
|
||||
const ext = format === "webp" ? "webp" : format === "png" ? "png" : "jpg";
|
||||
const codecMap: Record<string, string> = {
|
||||
jpg: "mjpeg",
|
||||
png: "png",
|
||||
webp: "webp",
|
||||
};
|
||||
|
||||
const args: string[] = [
|
||||
"-ss",
|
||||
timestamp.toString(),
|
||||
"-i",
|
||||
videoPath,
|
||||
"-vframes",
|
||||
"1",
|
||||
];
|
||||
|
||||
const vfParts: string[] = [];
|
||||
if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) {
|
||||
vfParts.push(
|
||||
`scale=w=${maxWidth}:h=${maxHeight}:force_original_aspect_ratio=decrease`,
|
||||
);
|
||||
} else if (maxWidth && maxWidth > 0) {
|
||||
vfParts.push(`scale=w=${maxWidth}:h=-2`);
|
||||
} else if (maxHeight && maxHeight > 0) {
|
||||
vfParts.push(`scale=w=-2:h=${maxHeight}`);
|
||||
}
|
||||
if (vfParts.length > 0) {
|
||||
args.push("-vf", vfParts.join(","));
|
||||
}
|
||||
|
||||
args.push("-c:v", codecMap[format]);
|
||||
|
||||
if (format !== "png") {
|
||||
const clampedQuality = Math.max(1, Math.min(100, quality));
|
||||
if (format === "jpg") {
|
||||
const qv = Math.round(2 + (100 - clampedQuality) * (29 / 99));
|
||||
args.push("-q:v", qv.toString());
|
||||
} else {
|
||||
args.push("-q:v", clampedQuality.toString());
|
||||
}
|
||||
}
|
||||
|
||||
args.push("-y");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = path.join(
|
||||
this.tempDir,
|
||||
`screenshot_${Date.now()}.${ext}`,
|
||||
);
|
||||
args.push(outputPath);
|
||||
|
||||
execFile("ffmpeg", args, { timeout: 30000 }, (error) => {
|
||||
if (error) {
|
||||
reject(this.ffmpegError("screenshot generation", error));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(outputPath);
|
||||
fs.unlinkSync(outputPath);
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a small PNG icon suitable for desktop notifications.
|
||||
* Always outputs PNG format (known-good for Electron + Linux notification daemons).
|
||||
* Scaled to 256px width for fast encoding and small file size.
|
||||
*/
|
||||
async generateNotificationIcon(
|
||||
videoPath: string,
|
||||
timestamp: number,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = path.join(
|
||||
this.tempDir,
|
||||
`notify_icon_${Date.now()}.png`,
|
||||
);
|
||||
|
||||
execFile(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-ss",
|
||||
timestamp.toString(),
|
||||
"-i",
|
||||
videoPath,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
"scale=256:256:force_original_aspect_ratio=decrease,pad=256:256:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v",
|
||||
"png",
|
||||
"-y",
|
||||
outputPath,
|
||||
],
|
||||
{ timeout: 30000 },
|
||||
(error) => {
|
||||
if (error) {
|
||||
reject(this.ffmpegError("notification icon generation", error));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(outputPath);
|
||||
fs.unlinkSync(outputPath);
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async generateAnimatedImage(
|
||||
videoPath: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
padding: number = 0.5,
|
||||
options: {
|
||||
fps?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
crf?: number;
|
||||
} = {},
|
||||
): Promise<Buffer> {
|
||||
const start = Math.max(0, startTime - padding);
|
||||
const duration = endTime - startTime + 2 * padding;
|
||||
const { fps = 10, maxWidth = 640, maxHeight, crf = 35 } = options;
|
||||
|
||||
const clampedFps = Math.max(1, Math.min(60, fps));
|
||||
const clampedCrf = Math.max(0, Math.min(63, crf));
|
||||
|
||||
const vfParts: string[] = [];
|
||||
vfParts.push(`fps=${clampedFps}`);
|
||||
if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) {
|
||||
vfParts.push(
|
||||
`scale=w=${maxWidth}:h=${maxHeight}:force_original_aspect_ratio=decrease`,
|
||||
);
|
||||
} else if (maxWidth && maxWidth > 0) {
|
||||
vfParts.push(`scale=w=${maxWidth}:h=-2`);
|
||||
} else if (maxHeight && maxHeight > 0) {
|
||||
vfParts.push(`scale=w=-2:h=${maxHeight}`);
|
||||
}
|
||||
|
||||
const av1Encoder = await this.detectAv1Encoder();
|
||||
if (!av1Encoder) {
|
||||
throw new Error(
|
||||
"No supported AV1 encoder found for animated AVIF (tried libaom-av1, libsvtav1, librav1e).",
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = path.join(
|
||||
this.tempDir,
|
||||
`animation_${Date.now()}.avif`,
|
||||
);
|
||||
|
||||
const encoderArgs: string[] = ["-c:v", av1Encoder];
|
||||
if (av1Encoder === "libaom-av1") {
|
||||
encoderArgs.push(
|
||||
"-crf",
|
||||
clampedCrf.toString(),
|
||||
"-b:v",
|
||||
"0",
|
||||
"-cpu-used",
|
||||
"8",
|
||||
);
|
||||
} else if (av1Encoder === "libsvtav1") {
|
||||
encoderArgs.push(
|
||||
"-crf",
|
||||
clampedCrf.toString(),
|
||||
"-preset",
|
||||
"8",
|
||||
);
|
||||
} else {
|
||||
// librav1e
|
||||
encoderArgs.push("-qp", clampedCrf.toString(), "-speed", "8");
|
||||
}
|
||||
|
||||
execFile(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-ss",
|
||||
start.toString(),
|
||||
"-t",
|
||||
duration.toString(),
|
||||
"-i",
|
||||
videoPath,
|
||||
"-vf",
|
||||
vfParts.join(","),
|
||||
...encoderArgs,
|
||||
"-y",
|
||||
outputPath,
|
||||
],
|
||||
{ timeout: 60000 },
|
||||
(error) => {
|
||||
if (error) {
|
||||
reject(this.ffmpegError("animation generation", error));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(outputPath);
|
||||
fs.unlinkSync(outputPath);
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
try {
|
||||
if (fs.existsSync(this.tempDir)) {
|
||||
fs.rmSync(this.tempDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Failed to cleanup media generator temp directory:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
267
src/preload.ts
Normal file
267
src/preload.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* 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 { contextBridge, ipcRenderer, IpcRendererEvent } from "electron";
|
||||
import type {
|
||||
SubtitleData,
|
||||
SubtitlePosition,
|
||||
MecabStatus,
|
||||
Keybinding,
|
||||
ElectronAPI,
|
||||
SecondarySubMode,
|
||||
SubtitleStyleConfig,
|
||||
JimakuMediaInfo,
|
||||
JimakuSearchQuery,
|
||||
JimakuFilesQuery,
|
||||
JimakuDownloadQuery,
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
JimakuApiResponse,
|
||||
JimakuDownloadResult,
|
||||
SubsyncManualPayload,
|
||||
SubsyncManualRunRequest,
|
||||
SubsyncResult,
|
||||
KikuFieldGroupingRequestData,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuMergePreviewRequest,
|
||||
KikuMergePreviewResponse,
|
||||
RuntimeOptionApplyResult,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
MpvSubtitleRenderMetrics,
|
||||
} from "./types";
|
||||
|
||||
const overlayLayerArg = process.argv.find((arg) =>
|
||||
arg.startsWith("--overlay-layer="),
|
||||
);
|
||||
const overlayLayerFromArg = overlayLayerArg?.slice("--overlay-layer=".length);
|
||||
const overlayLayer =
|
||||
overlayLayerFromArg === "visible" || overlayLayerFromArg === "invisible"
|
||||
? overlayLayerFromArg
|
||||
: null;
|
||||
|
||||
const electronAPI: ElectronAPI = {
|
||||
getOverlayLayer: () => overlayLayer,
|
||||
onSubtitle: (callback: (data: SubtitleData) => void) => {
|
||||
ipcRenderer.on(
|
||||
"subtitle:set",
|
||||
(_event: IpcRendererEvent, data: SubtitleData) => callback(data),
|
||||
);
|
||||
},
|
||||
|
||||
onVisibility: (callback: (visible: boolean) => void) => {
|
||||
ipcRenderer.on(
|
||||
"mpv:subVisibility",
|
||||
(_event: IpcRendererEvent, visible: boolean) => callback(visible),
|
||||
);
|
||||
},
|
||||
|
||||
onSubtitlePosition: (
|
||||
callback: (position: SubtitlePosition | null) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
"subtitle-position:set",
|
||||
(_event: IpcRendererEvent, position: SubtitlePosition | null) => {
|
||||
callback(position);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
getOverlayVisibility: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("get-overlay-visibility"),
|
||||
getCurrentSubtitle: (): Promise<SubtitleData> =>
|
||||
ipcRenderer.invoke("get-current-subtitle"),
|
||||
getCurrentSubtitleAss: (): Promise<string> =>
|
||||
ipcRenderer.invoke("get-current-subtitle-ass"),
|
||||
getMpvSubtitleRenderMetrics: () =>
|
||||
ipcRenderer.invoke("get-mpv-subtitle-render-metrics"),
|
||||
onMpvSubtitleRenderMetrics: (
|
||||
callback: (metrics: MpvSubtitleRenderMetrics) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
"mpv-subtitle-render-metrics:set",
|
||||
(_event: IpcRendererEvent, metrics: MpvSubtitleRenderMetrics) => {
|
||||
callback(metrics);
|
||||
},
|
||||
);
|
||||
},
|
||||
onSubtitleAss: (callback: (assText: string) => void) => {
|
||||
ipcRenderer.on(
|
||||
"subtitle-ass:set",
|
||||
(_event: IpcRendererEvent, assText: string) => {
|
||||
callback(assText);
|
||||
},
|
||||
);
|
||||
},
|
||||
onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => {
|
||||
ipcRenderer.on(
|
||||
"overlay-debug-visualization:set",
|
||||
(_event: IpcRendererEvent, enabled: boolean) => {
|
||||
callback(enabled);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ipcRenderer.send("set-ignore-mouse-events", ignore, options);
|
||||
},
|
||||
|
||||
openYomitanSettings: () => {
|
||||
ipcRenderer.send("open-yomitan-settings");
|
||||
},
|
||||
|
||||
getSubtitlePosition: (): Promise<SubtitlePosition | null> =>
|
||||
ipcRenderer.invoke("get-subtitle-position"),
|
||||
saveSubtitlePosition: (position: SubtitlePosition) => {
|
||||
ipcRenderer.send("save-subtitle-position", position);
|
||||
},
|
||||
|
||||
getMecabStatus: (): Promise<MecabStatus> =>
|
||||
ipcRenderer.invoke("get-mecab-status"),
|
||||
setMecabEnabled: (enabled: boolean) => {
|
||||
ipcRenderer.send("set-mecab-enabled", enabled);
|
||||
},
|
||||
|
||||
sendMpvCommand: (command: (string | number)[]) => {
|
||||
ipcRenderer.send("mpv-command", command);
|
||||
},
|
||||
|
||||
getKeybindings: (): Promise<Keybinding[]> =>
|
||||
ipcRenderer.invoke("get-keybindings"),
|
||||
|
||||
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
|
||||
ipcRenderer.invoke("jimaku:get-media-info"),
|
||||
jimakuSearchEntries: (
|
||||
query: JimakuSearchQuery,
|
||||
): Promise<JimakuApiResponse<JimakuEntry[]>> =>
|
||||
ipcRenderer.invoke("jimaku:search-entries", query),
|
||||
jimakuListFiles: (
|
||||
query: JimakuFilesQuery,
|
||||
): Promise<JimakuApiResponse<JimakuFileEntry[]>> =>
|
||||
ipcRenderer.invoke("jimaku:list-files", query),
|
||||
jimakuDownloadFile: (
|
||||
query: JimakuDownloadQuery,
|
||||
): Promise<JimakuDownloadResult> =>
|
||||
ipcRenderer.invoke("jimaku:download-file", query),
|
||||
|
||||
quitApp: () => {
|
||||
ipcRenderer.send("quit-app");
|
||||
},
|
||||
|
||||
toggleDevTools: () => {
|
||||
ipcRenderer.send("toggle-dev-tools");
|
||||
},
|
||||
|
||||
toggleOverlay: () => {
|
||||
ipcRenderer.send("toggle-overlay");
|
||||
},
|
||||
|
||||
getAnkiConnectStatus: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("get-anki-connect-status"),
|
||||
setAnkiConnectEnabled: (enabled: boolean) => {
|
||||
ipcRenderer.send("set-anki-connect-enabled", enabled);
|
||||
},
|
||||
clearAnkiConnectHistory: () => {
|
||||
ipcRenderer.send("clear-anki-connect-history");
|
||||
},
|
||||
|
||||
onSecondarySub: (callback: (text: string) => void) => {
|
||||
ipcRenderer.on(
|
||||
"secondary-subtitle:set",
|
||||
(_event: IpcRendererEvent, text: string) => callback(text),
|
||||
);
|
||||
},
|
||||
|
||||
onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => {
|
||||
ipcRenderer.on(
|
||||
"secondary-subtitle:mode",
|
||||
(_event: IpcRendererEvent, mode: SecondarySubMode) => callback(mode),
|
||||
);
|
||||
},
|
||||
|
||||
getSecondarySubMode: (): Promise<SecondarySubMode> =>
|
||||
ipcRenderer.invoke("get-secondary-sub-mode"),
|
||||
getCurrentSecondarySub: (): Promise<string> =>
|
||||
ipcRenderer.invoke("get-current-secondary-sub"),
|
||||
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
|
||||
ipcRenderer.invoke("get-subtitle-style"),
|
||||
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => {
|
||||
ipcRenderer.on(
|
||||
"subsync:open-manual",
|
||||
(_event: IpcRendererEvent, payload: SubsyncManualPayload) => {
|
||||
callback(payload);
|
||||
},
|
||||
);
|
||||
},
|
||||
runSubsyncManual: (
|
||||
request: SubsyncManualRunRequest,
|
||||
): Promise<SubsyncResult> =>
|
||||
ipcRenderer.invoke("subsync:run-manual", request),
|
||||
|
||||
onKikuFieldGroupingRequest: (
|
||||
callback: (data: KikuFieldGroupingRequestData) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
"kiku:field-grouping-request",
|
||||
(_event: IpcRendererEvent, data: KikuFieldGroupingRequestData) =>
|
||||
callback(data),
|
||||
);
|
||||
},
|
||||
kikuBuildMergePreview: (
|
||||
request: KikuMergePreviewRequest,
|
||||
): Promise<KikuMergePreviewResponse> =>
|
||||
ipcRenderer.invoke("kiku:build-merge-preview", request),
|
||||
|
||||
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => {
|
||||
ipcRenderer.send("kiku:field-grouping-respond", choice);
|
||||
},
|
||||
|
||||
getRuntimeOptions: (): Promise<RuntimeOptionState[]> =>
|
||||
ipcRenderer.invoke("runtime-options:get"),
|
||||
setRuntimeOptionValue: (
|
||||
id: RuntimeOptionId,
|
||||
value: RuntimeOptionValue,
|
||||
): Promise<RuntimeOptionApplyResult> =>
|
||||
ipcRenderer.invoke("runtime-options:set", id, value),
|
||||
cycleRuntimeOption: (
|
||||
id: RuntimeOptionId,
|
||||
direction: 1 | -1,
|
||||
): Promise<RuntimeOptionApplyResult> =>
|
||||
ipcRenderer.invoke("runtime-options:cycle", id, direction),
|
||||
onRuntimeOptionsChanged: (
|
||||
callback: (options: RuntimeOptionState[]) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
"runtime-options:changed",
|
||||
(_event: IpcRendererEvent, options: RuntimeOptionState[]) => {
|
||||
callback(options);
|
||||
},
|
||||
);
|
||||
},
|
||||
onOpenRuntimeOptions: (callback: () => void) => {
|
||||
ipcRenderer.on("runtime-options:open", () => {
|
||||
callback();
|
||||
});
|
||||
},
|
||||
notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => {
|
||||
ipcRenderer.send("overlay:modal-closed", modal);
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI);
|
||||
265
src/renderer/index.html
Normal file
265
src/renderer/index.html
Normal file
@@ -0,0 +1,265 @@
|
||||
<!--
|
||||
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/>.
|
||||
-->
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:;"
|
||||
/>
|
||||
<title>SubMiner</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="overlay">
|
||||
<div id="secondarySubContainer" class="secondary-sub-hidden">
|
||||
<div id="secondarySubRoot"></div>
|
||||
</div>
|
||||
<div id="subtitleContainer">
|
||||
<div id="subtitleRoot"></div>
|
||||
</div>
|
||||
<div id="jimakuModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Jimaku Subtitles</div>
|
||||
<button id="jimakuClose" class="modal-close" type="button">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="jimaku-form">
|
||||
<label class="jimaku-field">
|
||||
<span>Title</span>
|
||||
<input id="jimakuTitle" type="text" placeholder="Anime title" />
|
||||
</label>
|
||||
<label class="jimaku-field">
|
||||
<span>Season</span>
|
||||
<input
|
||||
id="jimakuSeason"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
/>
|
||||
</label>
|
||||
<label class="jimaku-field">
|
||||
<span>Episode</span>
|
||||
<input
|
||||
id="jimakuEpisode"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
/>
|
||||
</label>
|
||||
<button id="jimakuSearch" class="jimaku-button" type="button">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
<div id="jimakuStatus" class="jimaku-status"></div>
|
||||
<div id="jimakuEntriesSection" class="jimaku-section hidden">
|
||||
<div class="jimaku-section-title">Entries</div>
|
||||
<ul id="jimakuEntries" class="jimaku-list"></ul>
|
||||
</div>
|
||||
<div id="jimakuFilesSection" class="jimaku-section hidden">
|
||||
<div class="jimaku-section-title">Files</div>
|
||||
<ul id="jimakuFiles" class="jimaku-list"></ul>
|
||||
<button
|
||||
id="jimakuBroaden"
|
||||
class="jimaku-link hidden"
|
||||
type="button"
|
||||
>
|
||||
Broaden search (all files)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="kikuFieldGroupingModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Duplicate Card Detected</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="kikuSelectionStep">
|
||||
<div class="kiku-info-text">
|
||||
A card with the same expression already exists. Select which
|
||||
card to keep. The other card's content will be merged using Kiku
|
||||
field grouping. You can choose whether to delete the duplicate.
|
||||
</div>
|
||||
<div class="kiku-cards-container">
|
||||
<div id="kikuCard1" class="kiku-card active" tabindex="0">
|
||||
<div class="kiku-card-label">1 — Original Card</div>
|
||||
<div
|
||||
class="kiku-card-expression"
|
||||
id="kikuCard1Expression"
|
||||
></div>
|
||||
<div class="kiku-card-sentence" id="kikuCard1Sentence"></div>
|
||||
<div class="kiku-card-meta" id="kikuCard1Meta"></div>
|
||||
</div>
|
||||
<div id="kikuCard2" class="kiku-card" tabindex="0">
|
||||
<div class="kiku-card-label">2 — New Card</div>
|
||||
<div
|
||||
class="kiku-card-expression"
|
||||
id="kikuCard2Expression"
|
||||
></div>
|
||||
<div class="kiku-card-sentence" id="kikuCard2Sentence"></div>
|
||||
<div class="kiku-card-meta" id="kikuCard2Meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kiku-footer">
|
||||
<label class="kiku-delete-toggle">
|
||||
<input id="kikuDeleteDuplicate" type="checkbox" checked />
|
||||
Delete duplicate card after merge
|
||||
</label>
|
||||
<button
|
||||
id="kikuConfirmButton"
|
||||
class="kiku-confirm-button"
|
||||
type="button"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
id="kikuCancelButton"
|
||||
class="kiku-cancel-button"
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="kikuPreviewStep" class="hidden">
|
||||
<div class="kiku-preview-header">
|
||||
<div class="kiku-preview-title">Final Merge Preview</div>
|
||||
<div class="kiku-preview-toggle">
|
||||
<button id="kikuPreviewCompact" type="button">Compact</button>
|
||||
<button id="kikuPreviewFull" type="button">Full</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="kikuPreviewError"
|
||||
class="kiku-preview-error hidden"
|
||||
></div>
|
||||
<pre id="kikuPreviewJson" class="kiku-preview-json"></pre>
|
||||
<div class="kiku-footer">
|
||||
<button
|
||||
id="kikuBackButton"
|
||||
class="kiku-cancel-button"
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
id="kikuFinalConfirmButton"
|
||||
class="kiku-confirm-button"
|
||||
type="button"
|
||||
>
|
||||
Confirm Merge
|
||||
</button>
|
||||
<button
|
||||
id="kikuFinalCancelButton"
|
||||
class="kiku-cancel-button"
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="kikuHint" class="kiku-hint">
|
||||
Press 1 or 2 to select · Enter to confirm · Esc to
|
||||
cancel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="runtimeOptionsModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content runtime-modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Runtime Options</div>
|
||||
<button
|
||||
id="runtimeOptionsClose"
|
||||
class="modal-close"
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="runtimeOptionsHint" class="runtime-options-hint">
|
||||
Arrow keys: select/change · Enter or double-click: apply · Esc:
|
||||
close
|
||||
</div>
|
||||
<ul id="runtimeOptionsList" class="runtime-options-list"></ul>
|
||||
<div id="runtimeOptionsStatus" class="runtime-options-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content subsync-modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Auto Subtitle Sync</div>
|
||||
<button id="subsyncClose" class="modal-close" type="button">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="subsync-form">
|
||||
<div class="subsync-field">
|
||||
<span>Engine</span>
|
||||
<label class="subsync-radio">
|
||||
<input
|
||||
id="subsyncEngineAlass"
|
||||
type="radio"
|
||||
name="subsyncEngine"
|
||||
checked
|
||||
/>
|
||||
alass
|
||||
</label>
|
||||
<label class="subsync-radio">
|
||||
<input
|
||||
id="subsyncEngineFfsubsync"
|
||||
type="radio"
|
||||
name="subsyncEngine"
|
||||
/>
|
||||
ffsubsync
|
||||
</label>
|
||||
</div>
|
||||
<label id="subsyncSourceLabel" class="subsync-field">
|
||||
<span>Source Subtitle (for alass)</span>
|
||||
<select id="subsyncSourceSelect"></select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="subsyncStatus" class="runtime-options-status"></div>
|
||||
<div class="subsync-footer">
|
||||
<button
|
||||
id="subsyncRun"
|
||||
class="kiku-confirm-button"
|
||||
type="button"
|
||||
>
|
||||
Run Sync
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2443
src/renderer/renderer.ts
Normal file
2443
src/renderer/renderer.ts
Normal file
File diff suppressed because it is too large
Load Diff
703
src/renderer/style.css
Normal file
703
src/renderer/style.css
Normal file
@@ -0,0 +1,703 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
font-family:
|
||||
"Noto Sans CJK JP Regular", "Noto Sans CJK JP", "Arial Unicode MS", Arial,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
#overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#overlay.interactive {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
pointer-events: auto;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: min(720px, 92%);
|
||||
max-height: 80%;
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jimaku-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 120px 120px auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.jimaku-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.jimaku-field input {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.jimaku-button {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jimaku-button:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.jimaku-status {
|
||||
min-height: 20px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.jimaku-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 220px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jimaku-section.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jimaku-section-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.jimaku-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.jimaku-list li {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.jimaku-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.jimaku-list li.active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.jimaku-list .jimaku-subtext {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.jimaku-link {
|
||||
align-self: flex-start;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.jimaku-link.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.jimaku-form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.jimaku-button {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
#subtitleContainer {
|
||||
max-width: 80%;
|
||||
margin-bottom: 60px;
|
||||
padding: 12px 20px;
|
||||
background: rgba(54, 58, 79, 0.5);
|
||||
border-radius: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#subtitleRoot {
|
||||
text-align: center;
|
||||
font-size: 35px;
|
||||
line-height: 1.5;
|
||||
color: #cad3f5;
|
||||
text-shadow:
|
||||
2px 2px 4px rgba(0, 0, 0, 0.8),
|
||||
-1px -1px 2px rgba(0, 0, 0, 0.5);
|
||||
/* Enable text selection for Yomitan */
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
#subtitleRoot:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#subtitleContainer:has(#subtitleRoot:empty) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#subtitleRoot .c {
|
||||
display: inline;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#subtitleRoot .c:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
#subtitleRoot .word {
|
||||
display: inline;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#subtitleRoot .word:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#subtitleRoot br {
|
||||
display: block;
|
||||
content: "";
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
#subtitleRoot.has-selection .word:hover,
|
||||
#subtitleRoot.has-selection .c:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body.layer-invisible #subtitleContainer {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
body.layer-invisible #subtitleRoot,
|
||||
body.layer-invisible #subtitleRoot .word,
|
||||
body.layer-invisible #subtitleRoot .c {
|
||||
color: transparent !important;
|
||||
text-shadow: none !important;
|
||||
-webkit-text-stroke: 0 !important;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
background: transparent !important;
|
||||
caret-color: transparent !important;
|
||||
line-height: normal !important;
|
||||
font-kerning: auto;
|
||||
letter-spacing: normal;
|
||||
font-variant-ligatures: normal;
|
||||
font-feature-settings: normal;
|
||||
text-rendering: auto;
|
||||
}
|
||||
|
||||
body.layer-invisible #subtitleRoot br {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
body.layer-invisible #subtitleRoot .word:hover,
|
||||
body.layer-invisible #subtitleRoot .c:hover,
|
||||
body.layer-invisible #subtitleRoot.has-selection .word:hover,
|
||||
body.layer-invisible #subtitleRoot.has-selection .c:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
body.layer-invisible #subtitleRoot::selection,
|
||||
body.layer-invisible #subtitleRoot .word::selection,
|
||||
body.layer-invisible #subtitleRoot .c::selection {
|
||||
background: transparent !important;
|
||||
color: transparent !important;
|
||||
}
|
||||
|
||||
body.layer-invisible.debug-invisible-visualization #subtitleRoot,
|
||||
body.layer-invisible.debug-invisible-visualization #subtitleRoot .word,
|
||||
body.layer-invisible.debug-invisible-visualization #subtitleRoot .c {
|
||||
color: #ed8796 !important;
|
||||
-webkit-text-fill-color: #ed8796 !important;
|
||||
-webkit-text-stroke: 0.55px rgba(0, 0, 0, 0.95) !important;
|
||||
text-shadow:
|
||||
0 0 8px rgba(237, 135, 150, 0.9),
|
||||
0 2px 6px rgba(0, 0, 0, 0.95) !important;
|
||||
}
|
||||
|
||||
#secondarySubContainer {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 80%;
|
||||
padding: 10px 18px;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#secondarySubRoot {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
line-height: 1.5;
|
||||
color: #ffffff;
|
||||
-webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7);
|
||||
paint-order: stroke fill;
|
||||
text-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.95),
|
||||
0 0 8px rgba(0, 0, 0, 0.8),
|
||||
0 0 16px rgba(0, 0, 0, 0.55);
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
#secondarySubRoot:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#secondarySubContainer:has(#secondarySubRoot:empty) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.secondary-sub-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#secondarySubContainer.secondary-sub-hover {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: auto;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: none;
|
||||
max-width: 100%;
|
||||
background: transparent;
|
||||
padding: 40px 0 0 0;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#secondarySubContainer.secondary-sub-hover #secondarySubRoot {
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 10px 18px;
|
||||
}
|
||||
|
||||
#secondarySubContainer.secondary-sub-hover:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
iframe[id^="yomitan-popup"] {
|
||||
pointer-events: auto !important;
|
||||
z-index: 2147483647 !important;
|
||||
}
|
||||
|
||||
.kiku-info-text {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.kiku-cards-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kiku-card {
|
||||
background: rgba(40, 40, 40, 0.8);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.kiku-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.kiku-card.active {
|
||||
border-color: rgba(100, 180, 255, 0.8);
|
||||
background: rgba(40, 60, 90, 0.5);
|
||||
}
|
||||
|
||||
.kiku-card-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kiku-card.active .kiku-card-label {
|
||||
color: rgba(100, 180, 255, 0.9);
|
||||
}
|
||||
|
||||
.kiku-card-expression {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kiku-card-sentence {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
max-height: 52px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.kiku-card-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.kiku-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.kiku-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.kiku-preview-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.kiku-preview-toggle {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kiku-preview-toggle button {
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kiku-preview-toggle button.active {
|
||||
border-color: rgba(100, 180, 255, 0.45);
|
||||
background: rgba(100, 180, 255, 0.16);
|
||||
color: rgba(100, 180, 255, 0.95);
|
||||
}
|
||||
|
||||
.kiku-preview-json {
|
||||
margin: 0;
|
||||
min-height: 220px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.34);
|
||||
padding: 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.kiku-preview-error {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: #ff8f8f;
|
||||
}
|
||||
|
||||
.kiku-delete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.kiku-delete-toggle input {
|
||||
accent-color: rgba(100, 180, 255, 0.9);
|
||||
}
|
||||
|
||||
.kiku-confirm-button {
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(100, 180, 255, 0.4);
|
||||
background: rgba(100, 180, 255, 0.15);
|
||||
color: rgba(100, 180, 255, 0.95);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.kiku-confirm-button:hover {
|
||||
background: rgba(100, 180, 255, 0.25);
|
||||
}
|
||||
|
||||
.subsync-modal-content {
|
||||
width: min(560px, 92%);
|
||||
}
|
||||
|
||||
.subsync-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.subsync-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.subsync-radio {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-right: 14px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.subsync-field select {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.subsync-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.kiku-cancel-button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.kiku-cancel-button:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kiku-hint {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.runtime-modal-content {
|
||||
width: min(560px, 92%);
|
||||
}
|
||||
|
||||
.runtime-options-hint {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.runtime-options-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.runtime-options-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-options-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.runtime-options-item.active {
|
||||
background: rgba(100, 180, 255, 0.15);
|
||||
}
|
||||
|
||||
.runtime-options-label {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.runtime-options-value {
|
||||
font-size: 13px;
|
||||
color: rgba(100, 180, 255, 0.9);
|
||||
}
|
||||
|
||||
.runtime-options-allowed {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.runtime-options-status {
|
||||
min-height: 18px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.runtime-options-status.error {
|
||||
color: #ff8f8f;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.kiku-cards-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
208
src/runtime-options.ts
Normal file
208
src/runtime-options.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* 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 {
|
||||
AnkiConnectConfig,
|
||||
RuntimeOptionApplyResult,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
} from "./types";
|
||||
import { RUNTIME_OPTION_REGISTRY, RuntimeOptionRegistryEntry } from "./config";
|
||||
|
||||
type RuntimeOverrides = Record<string, unknown>;
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function getPathValue(source: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split(".");
|
||||
let current: unknown = source;
|
||||
for (const part of parts) {
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function setPathValue(target: Record<string, unknown>, path: string, value: unknown): void {
|
||||
const parts = path.split(".");
|
||||
let current = target;
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const part = parts[i];
|
||||
const isLeaf = i === parts.length - 1;
|
||||
if (isLeaf) {
|
||||
current[part] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
const next = current[part];
|
||||
if (!next || typeof next !== "object" || Array.isArray(next)) {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
function allowedValues(definition: RuntimeOptionRegistryEntry): RuntimeOptionValue[] {
|
||||
return [...definition.allowedValues];
|
||||
}
|
||||
|
||||
function isAllowedValue(
|
||||
definition: RuntimeOptionRegistryEntry,
|
||||
value: RuntimeOptionValue,
|
||||
): boolean {
|
||||
if (definition.valueType === "boolean") {
|
||||
return typeof value === "boolean";
|
||||
}
|
||||
return typeof value === "string" && definition.allowedValues.includes(value);
|
||||
}
|
||||
|
||||
export class RuntimeOptionsManager {
|
||||
private readonly getAnkiConfig: () => AnkiConnectConfig;
|
||||
private readonly applyAnkiPatch: (patch: Partial<AnkiConnectConfig>) => void;
|
||||
private readonly onOptionsChanged: (options: RuntimeOptionState[]) => void;
|
||||
private runtimeOverrides: RuntimeOverrides = {};
|
||||
private readonly definitions = new Map<RuntimeOptionId, RuntimeOptionRegistryEntry>();
|
||||
|
||||
constructor(
|
||||
getAnkiConfig: () => AnkiConnectConfig,
|
||||
callbacks: {
|
||||
applyAnkiPatch: (patch: Partial<AnkiConnectConfig>) => void;
|
||||
onOptionsChanged: (options: RuntimeOptionState[]) => void;
|
||||
},
|
||||
) {
|
||||
this.getAnkiConfig = getAnkiConfig;
|
||||
this.applyAnkiPatch = callbacks.applyAnkiPatch;
|
||||
this.onOptionsChanged = callbacks.onOptionsChanged;
|
||||
for (const definition of RUNTIME_OPTION_REGISTRY) {
|
||||
this.definitions.set(definition.id, definition);
|
||||
}
|
||||
}
|
||||
|
||||
private getEffectiveValue(definition: RuntimeOptionRegistryEntry): RuntimeOptionValue {
|
||||
const override = getPathValue(this.runtimeOverrides, definition.path);
|
||||
if (override !== undefined) return override as RuntimeOptionValue;
|
||||
|
||||
const source = {
|
||||
ankiConnect: this.getAnkiConfig(),
|
||||
} as Record<string, unknown>;
|
||||
|
||||
const raw = getPathValue(source, definition.path);
|
||||
if (raw === undefined || raw === null) {
|
||||
return definition.defaultValue;
|
||||
}
|
||||
return raw as RuntimeOptionValue;
|
||||
}
|
||||
|
||||
listOptions(): RuntimeOptionState[] {
|
||||
const options: RuntimeOptionState[] = [];
|
||||
for (const definition of RUNTIME_OPTION_REGISTRY) {
|
||||
options.push({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
scope: definition.scope,
|
||||
valueType: definition.valueType,
|
||||
value: this.getEffectiveValue(definition),
|
||||
allowedValues: allowedValues(definition),
|
||||
requiresRestart: definition.requiresRestart,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
getOptionValue(id: RuntimeOptionId): RuntimeOptionValue | undefined {
|
||||
const definition = this.definitions.get(id);
|
||||
if (!definition) return undefined;
|
||||
return this.getEffectiveValue(definition);
|
||||
}
|
||||
|
||||
setOptionValue(id: RuntimeOptionId, value: RuntimeOptionValue): RuntimeOptionApplyResult {
|
||||
const definition = this.definitions.get(id);
|
||||
if (!definition) {
|
||||
return { ok: false, error: `Unknown runtime option: ${id}` };
|
||||
}
|
||||
|
||||
if (!isAllowedValue(definition, value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid value for ${id}: ${String(value)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const next = deepClone(this.runtimeOverrides);
|
||||
setPathValue(next, definition.path, value);
|
||||
this.runtimeOverrides = next;
|
||||
|
||||
const ankiPatch = definition.toAnkiPatch(value);
|
||||
this.applyAnkiPatch(ankiPatch);
|
||||
|
||||
const option = this.listOptions().find((item) => item.id === id);
|
||||
if (!option) {
|
||||
return { ok: false, error: `Failed to apply option: ${id}` };
|
||||
}
|
||||
|
||||
const osdMessage = `Runtime option: ${definition.label} -> ${definition.formatValueForOsd(option.value)}`;
|
||||
this.onOptionsChanged(this.listOptions());
|
||||
return {
|
||||
ok: true,
|
||||
option,
|
||||
osdMessage,
|
||||
requiresRestart: definition.requiresRestart,
|
||||
};
|
||||
}
|
||||
|
||||
cycleOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult {
|
||||
const definition = this.definitions.get(id);
|
||||
if (!definition) {
|
||||
return { ok: false, error: `Unknown runtime option: ${id}` };
|
||||
}
|
||||
|
||||
const values = allowedValues(definition);
|
||||
if (values.length === 0) {
|
||||
return { ok: false, error: `Option ${id} has no allowed values` };
|
||||
}
|
||||
|
||||
const currentValue = this.getEffectiveValue(definition);
|
||||
const currentIndex = values.findIndex((value) => value === currentValue);
|
||||
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||
const nextIndex =
|
||||
direction === 1
|
||||
? (safeIndex + 1) % values.length
|
||||
: (safeIndex - 1 + values.length) % values.length;
|
||||
return this.setOptionValue(id, values[nextIndex]);
|
||||
}
|
||||
|
||||
getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig {
|
||||
const source = baseConfig ?? this.getAnkiConfig();
|
||||
const effective: AnkiConnectConfig = deepClone(source);
|
||||
|
||||
for (const definition of RUNTIME_OPTION_REGISTRY) {
|
||||
const override = getPathValue(this.runtimeOverrides, definition.path);
|
||||
if (override === undefined) continue;
|
||||
|
||||
const subPath = definition.path.replace(/^ankiConnect\./, "");
|
||||
setPathValue(effective as unknown as Record<string, unknown>, subPath, override);
|
||||
}
|
||||
|
||||
return effective;
|
||||
}
|
||||
}
|
||||
215
src/subtitle-timing-tracker.ts
Normal file
215
src/subtitle-timing-tracker.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* SubMiner - Subtitle mining overlay for mpv
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
interface TimingEntry {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
displayText: string;
|
||||
timingKey: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class SubtitleTimingTracker {
|
||||
private timings = new Map<string, TimingEntry>();
|
||||
private history: HistoryEntry[] = [];
|
||||
private readonly maxHistory = 200;
|
||||
private readonly ttlMs = 5 * 60 * 1000;
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.startCleanup();
|
||||
}
|
||||
|
||||
recordSubtitle(text: string, startTime: number, endTime: number): void {
|
||||
const normalizedText = this.normalizeText(text);
|
||||
if (!normalizedText) return;
|
||||
|
||||
const displayText = this.prepareDisplayText(text);
|
||||
const timingKey = normalizedText;
|
||||
|
||||
this.timings.set(timingKey, {
|
||||
startTime,
|
||||
endTime,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Check for duplicate of most recent entry (deduplicate adjacent repeats)
|
||||
const lastEntry = this.history[this.history.length - 1];
|
||||
if (lastEntry && lastEntry.timingKey === timingKey) {
|
||||
// Update timing to most recent occurrence
|
||||
lastEntry.startTime = startTime;
|
||||
lastEntry.endTime = endTime;
|
||||
lastEntry.timestamp = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
this.history.push({
|
||||
displayText,
|
||||
timingKey,
|
||||
startTime,
|
||||
endTime,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Prune history if too large
|
||||
if (this.history.length > this.maxHistory) {
|
||||
this.history = this.history.slice(-this.maxHistory);
|
||||
}
|
||||
}
|
||||
|
||||
findTiming(text: string): { startTime: number; endTime: number } | null {
|
||||
const normalizedText = this.normalizeText(text);
|
||||
if (!normalizedText) return null;
|
||||
|
||||
const entry = this.timings.get(normalizedText);
|
||||
if (!entry) {
|
||||
return this.findFuzzyMatch(normalizedText);
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent subtitle blocks in chronological order.
|
||||
* Returns the last `count` subtitle events (oldest → newest).
|
||||
* Blocks preserve internal line breaks and are joined with blank lines.
|
||||
*/
|
||||
getRecentBlocks(count: number): string[] {
|
||||
if (count <= 0) return [];
|
||||
if (count > this.history.length) {
|
||||
count = this.history.length;
|
||||
}
|
||||
return this.history.slice(-count).map((entry) => entry.displayText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display text for the most recent subtitle.
|
||||
*/
|
||||
getCurrentSubtitle(): string | null {
|
||||
const lastEntry = this.history[this.history.length - 1];
|
||||
return lastEntry ? lastEntry.displayText : null;
|
||||
}
|
||||
|
||||
private findFuzzyMatch(
|
||||
text: string,
|
||||
): { startTime: number; endTime: number } | null {
|
||||
let bestMatch: TimingEntry | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const [key, entry] of this.timings.entries()) {
|
||||
const score = this.calculateSimilarity(text, key);
|
||||
if (score > bestScore && score > 0.7) {
|
||||
bestScore = score;
|
||||
bestMatch = entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch) {
|
||||
return {
|
||||
startTime: bestMatch.startTime,
|
||||
endTime: bestMatch.endTime,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private calculateSimilarity(a: string, b: string): number {
|
||||
const longer = a.length > b.length ? a : b;
|
||||
const shorter = a.length > b.length ? b : a;
|
||||
|
||||
if (longer.length === 0) return 1;
|
||||
|
||||
const editDistance = this.getEditDistance(longer, shorter);
|
||||
return (longer.length - editDistance) / longer.length;
|
||||
}
|
||||
|
||||
private getEditDistance(longer: string, shorter: string): number {
|
||||
const costs: number[] = [];
|
||||
for (let i = 0; i <= shorter.length; i++) {
|
||||
let lastValue = i;
|
||||
for (let j = 1; j <= longer.length; j++) {
|
||||
let newValue = costs[j - 1] || 0;
|
||||
if (longer.charAt(j - 1) !== shorter.charAt(i - 1)) {
|
||||
newValue = Math.min(Math.min(newValue, lastValue), costs[j] || 0) + 1;
|
||||
}
|
||||
costs[j - 1] = lastValue;
|
||||
lastValue = newValue;
|
||||
}
|
||||
costs[shorter.length] = lastValue;
|
||||
}
|
||||
return costs[shorter.length] || 0;
|
||||
}
|
||||
|
||||
private normalizeText(text: string): string {
|
||||
return text
|
||||
.replace(/\\N/g, " ")
|
||||
.replace(/\\n/g, " ")
|
||||
.replace(/\n/g, " ")
|
||||
.replace(/{[^}]*}/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
private prepareDisplayText(text: string): string {
|
||||
// Convert ASS/SSA newlines to real newlines, strip tags
|
||||
return text
|
||||
.replace(/\\N/g, "\n")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/{[^}]*}/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
private startCleanup(): void {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanup();
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
// Clean up old timing entries
|
||||
for (const [key, entry] of this.timings.entries()) {
|
||||
if (now - entry.timestamp > this.ttlMs) {
|
||||
this.timings.delete(key);
|
||||
}
|
||||
}
|
||||
// Clean up old history entries
|
||||
this.history = this.history.filter(
|
||||
(entry) => now - entry.timestamp <= this.ttlMs,
|
||||
);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
this.timings.clear();
|
||||
this.history = [];
|
||||
}
|
||||
}
|
||||
233
src/token-merger.ts
Normal file
233
src/token-merger.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* 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 { PartOfSpeech, Token, MergedToken } from "./types";
|
||||
|
||||
export function isNoun(tok: Token): boolean {
|
||||
return tok.partOfSpeech === PartOfSpeech.noun;
|
||||
}
|
||||
|
||||
export function isProperNoun(tok: Token): boolean {
|
||||
return tok.partOfSpeech === PartOfSpeech.noun && tok.pos2 === "固有名詞";
|
||||
}
|
||||
|
||||
export function ignoreReading(tok: Token): boolean {
|
||||
return tok.partOfSpeech === PartOfSpeech.symbol && tok.pos2 === "文字";
|
||||
}
|
||||
|
||||
export function isCopula(tok: Token): boolean {
|
||||
const raw = tok.inflectionType;
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
return ["特殊・ダ", "特殊・デス", "特殊|だ", "特殊|デス"].includes(raw);
|
||||
}
|
||||
|
||||
export function isAuxVerb(tok: Token): boolean {
|
||||
return tok.partOfSpeech === PartOfSpeech.bound_auxiliary && !isCopula(tok);
|
||||
}
|
||||
|
||||
export function isContinuativeForm(tok: Token): boolean {
|
||||
if (!tok.inflectionForm) {
|
||||
return false;
|
||||
}
|
||||
const inflectionForm = tok.inflectionForm;
|
||||
const isContinuative =
|
||||
inflectionForm === "連用デ接続" ||
|
||||
inflectionForm === "連用タ接続" ||
|
||||
inflectionForm.startsWith("連用形");
|
||||
|
||||
if (!isContinuative) {
|
||||
return false;
|
||||
}
|
||||
return tok.headword !== "ない";
|
||||
}
|
||||
|
||||
export function isVerbSuffix(tok: Token): boolean {
|
||||
return (
|
||||
tok.partOfSpeech === PartOfSpeech.verb &&
|
||||
(tok.pos2 === "非自立" || tok.pos2 === "接尾")
|
||||
);
|
||||
}
|
||||
|
||||
export function isTatteParticle(tok: Token): boolean {
|
||||
return (
|
||||
tok.partOfSpeech === PartOfSpeech.particle &&
|
||||
tok.pos2 === "接続助詞" &&
|
||||
tok.headword === "たって"
|
||||
);
|
||||
}
|
||||
|
||||
export function isBaParticle(tok: Token): boolean {
|
||||
return (
|
||||
tok.partOfSpeech === PartOfSpeech.particle &&
|
||||
tok.pos2 === "接続助詞" &&
|
||||
tok.word === "ば"
|
||||
);
|
||||
}
|
||||
|
||||
export function isTeDeParticle(tok: Token): boolean {
|
||||
return (
|
||||
tok.partOfSpeech === PartOfSpeech.particle &&
|
||||
tok.pos2 === "接続助詞" &&
|
||||
["て", "で", "ちゃ"].includes(tok.word)
|
||||
);
|
||||
}
|
||||
|
||||
export function isTaDaParticle(tok: Token): boolean {
|
||||
return isAuxVerb(tok) && ["た", "だ"].includes(tok.word);
|
||||
}
|
||||
|
||||
export function isVerb(tok: Token): boolean {
|
||||
return [PartOfSpeech.verb, PartOfSpeech.bound_auxiliary].includes(
|
||||
tok.partOfSpeech,
|
||||
);
|
||||
}
|
||||
|
||||
export function isVerbNonIndependent(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function canReceiveAuxiliary(tok: Token): boolean {
|
||||
return [
|
||||
PartOfSpeech.verb,
|
||||
PartOfSpeech.bound_auxiliary,
|
||||
PartOfSpeech.i_adjective,
|
||||
].includes(tok.partOfSpeech);
|
||||
}
|
||||
|
||||
export function isNounSuffix(tok: Token): boolean {
|
||||
return tok.partOfSpeech === PartOfSpeech.verb && tok.pos2 === "接尾";
|
||||
}
|
||||
|
||||
export function isCounter(tok: Token): boolean {
|
||||
return (
|
||||
tok.partOfSpeech === PartOfSpeech.noun &&
|
||||
tok.pos3 !== undefined &&
|
||||
tok.pos3.startsWith("助数詞")
|
||||
);
|
||||
}
|
||||
|
||||
export function isNumeral(tok: Token): boolean {
|
||||
return (
|
||||
tok.partOfSpeech === PartOfSpeech.noun &&
|
||||
tok.pos2 !== undefined &&
|
||||
tok.pos2.startsWith("数")
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldMerge(lastStandaloneToken: Token, token: Token): boolean {
|
||||
if (isVerb(lastStandaloneToken)) {
|
||||
if (isAuxVerb(token)) {
|
||||
return true;
|
||||
}
|
||||
if (isContinuativeForm(lastStandaloneToken) && isVerbSuffix(token)) {
|
||||
return true;
|
||||
}
|
||||
if (isVerbSuffix(token) && isVerbNonIndependent()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isNoun(lastStandaloneToken) &&
|
||||
!isProperNoun(lastStandaloneToken) &&
|
||||
isNounSuffix(token)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isCounter(token) && isNumeral(lastStandaloneToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isBaParticle(token) && canReceiveAuxiliary(lastStandaloneToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTatteParticle(token) && canReceiveAuxiliary(lastStandaloneToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTeDeParticle(token) && isContinuativeForm(lastStandaloneToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTaDaParticle(token) && canReceiveAuxiliary(lastStandaloneToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTeDeParticle(lastStandaloneToken) && isVerbSuffix(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function mergeTokens(tokens: Token[]): MergedToken[] {
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: MergedToken[] = [];
|
||||
let charOffset = 0;
|
||||
let lastStandaloneToken: Token | null = null;
|
||||
|
||||
for (const token of tokens) {
|
||||
const start = charOffset;
|
||||
const end = charOffset + token.word.length;
|
||||
charOffset = end;
|
||||
|
||||
let shouldMergeToken = false;
|
||||
|
||||
if (result.length > 0 && lastStandaloneToken !== null) {
|
||||
shouldMergeToken = shouldMerge(lastStandaloneToken, token);
|
||||
}
|
||||
|
||||
const tokenReading = ignoreReading(token)
|
||||
? ""
|
||||
: token.katakanaReading || token.word;
|
||||
|
||||
if (shouldMergeToken && result.length > 0) {
|
||||
const prev = result.pop()!;
|
||||
result.push({
|
||||
surface: prev.surface + token.word,
|
||||
reading: prev.reading + tokenReading,
|
||||
headword: prev.headword,
|
||||
startPos: prev.startPos,
|
||||
endPos: end,
|
||||
partOfSpeech: prev.partOfSpeech,
|
||||
isMerged: true,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
surface: token.word,
|
||||
reading: tokenReading,
|
||||
headword: token.headword,
|
||||
startPos: start,
|
||||
endPos: end,
|
||||
partOfSpeech: token.partOfSpeech,
|
||||
isMerged: false,
|
||||
});
|
||||
}
|
||||
|
||||
lastStandaloneToken = token;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
616
src/types.ts
Normal file
616
src/types.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export enum PartOfSpeech {
|
||||
noun = "noun",
|
||||
verb = "verb",
|
||||
i_adjective = "i_adjective",
|
||||
na_adjective = "na_adjective",
|
||||
particle = "particle",
|
||||
bound_auxiliary = "bound_auxiliary",
|
||||
symbol = "symbol",
|
||||
other = "other",
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
word: string;
|
||||
partOfSpeech: PartOfSpeech;
|
||||
pos1: string;
|
||||
pos2: string;
|
||||
pos3: string;
|
||||
pos4: string;
|
||||
inflectionType: string;
|
||||
inflectionForm: string;
|
||||
headword: string;
|
||||
katakanaReading: string;
|
||||
pronunciation: string;
|
||||
}
|
||||
|
||||
export interface MergedToken {
|
||||
surface: string;
|
||||
reading: string;
|
||||
headword: string;
|
||||
startPos: number;
|
||||
endPos: number;
|
||||
partOfSpeech: PartOfSpeech;
|
||||
isMerged: boolean;
|
||||
}
|
||||
|
||||
export interface WindowGeometry {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface SubtitlePosition {
|
||||
yPercent: number;
|
||||
}
|
||||
|
||||
export interface SubtitleStyle {
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
export interface Keybinding {
|
||||
key: string;
|
||||
command: (string | number)[] | null;
|
||||
}
|
||||
|
||||
export type SecondarySubMode = "hidden" | "visible" | "hover";
|
||||
|
||||
export interface SecondarySubConfig {
|
||||
secondarySubLanguages?: string[];
|
||||
autoLoadSecondarySub?: boolean;
|
||||
defaultMode?: SecondarySubMode;
|
||||
}
|
||||
|
||||
export type SubsyncMode = "auto" | "manual";
|
||||
|
||||
export interface SubsyncConfig {
|
||||
defaultMode?: SubsyncMode;
|
||||
alass_path?: string;
|
||||
ffsubsync_path?: string;
|
||||
ffmpeg_path?: string;
|
||||
}
|
||||
|
||||
export interface WebSocketConfig {
|
||||
enabled?: boolean | "auto";
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface TexthookerConfig {
|
||||
openBrowser?: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationOptions {
|
||||
body?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface MpvClient {
|
||||
currentSubText: string;
|
||||
currentVideoPath: string;
|
||||
currentTimePos: number;
|
||||
currentSubStart: number;
|
||||
currentSubEnd: number;
|
||||
currentAudioStreamIndex: number | null;
|
||||
send(command: { command: unknown[]; request_id?: number }): boolean;
|
||||
}
|
||||
|
||||
export interface KikuDuplicateCardInfo {
|
||||
noteId: number;
|
||||
expression: string;
|
||||
sentencePreview: string;
|
||||
hasAudio: boolean;
|
||||
hasImage: boolean;
|
||||
isOriginal: boolean;
|
||||
}
|
||||
|
||||
export interface KikuFieldGroupingRequestData {
|
||||
original: KikuDuplicateCardInfo;
|
||||
duplicate: KikuDuplicateCardInfo;
|
||||
}
|
||||
|
||||
export interface KikuFieldGroupingChoice {
|
||||
keepNoteId: number;
|
||||
deleteNoteId: number;
|
||||
deleteDuplicate: boolean;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export interface KikuMergePreviewRequest {
|
||||
keepNoteId: number;
|
||||
deleteNoteId: number;
|
||||
deleteDuplicate: boolean;
|
||||
}
|
||||
|
||||
export interface KikuMergePreviewResponse {
|
||||
ok: boolean;
|
||||
compact?: Record<string, unknown>;
|
||||
full?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type RuntimeOptionId =
|
||||
| "anki.autoUpdateNewCards"
|
||||
| "anki.kikuFieldGrouping";
|
||||
|
||||
export type RuntimeOptionScope = "ankiConnect";
|
||||
|
||||
export type RuntimeOptionValueType = "boolean" | "enum";
|
||||
|
||||
export type RuntimeOptionValue = boolean | string;
|
||||
|
||||
export interface RuntimeOptionState {
|
||||
id: RuntimeOptionId;
|
||||
label: string;
|
||||
scope: RuntimeOptionScope;
|
||||
valueType: RuntimeOptionValueType;
|
||||
value: RuntimeOptionValue;
|
||||
allowedValues: RuntimeOptionValue[];
|
||||
requiresRestart: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeOptionApplyResult {
|
||||
ok: boolean;
|
||||
option?: RuntimeOptionState;
|
||||
osdMessage?: string;
|
||||
requiresRestart?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AnkiConnectConfig {
|
||||
enabled?: boolean;
|
||||
url?: string;
|
||||
pollingRate?: number;
|
||||
fields?: {
|
||||
audio?: string;
|
||||
image?: string;
|
||||
sentence?: string;
|
||||
miscInfo?: string;
|
||||
translation?: string;
|
||||
};
|
||||
ai?: {
|
||||
enabled?: boolean;
|
||||
alwaysUseAiTranslation?: boolean;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
targetLanguage?: string;
|
||||
systemPrompt?: string;
|
||||
};
|
||||
openRouter?: {
|
||||
enabled?: boolean;
|
||||
alwaysUseAiTranslation?: boolean;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
targetLanguage?: string;
|
||||
systemPrompt?: string;
|
||||
};
|
||||
media?: {
|
||||
generateAudio?: boolean;
|
||||
generateImage?: boolean;
|
||||
imageType?: "static" | "avif";
|
||||
imageFormat?: "jpg" | "png" | "webp";
|
||||
imageQuality?: number;
|
||||
imageMaxWidth?: number;
|
||||
imageMaxHeight?: number;
|
||||
animatedFps?: number;
|
||||
animatedMaxWidth?: number;
|
||||
animatedMaxHeight?: number;
|
||||
animatedCrf?: number;
|
||||
audioPadding?: number;
|
||||
fallbackDuration?: number;
|
||||
maxMediaDuration?: number;
|
||||
};
|
||||
behavior?: {
|
||||
overwriteAudio?: boolean;
|
||||
overwriteImage?: boolean;
|
||||
mediaInsertMode?: "append" | "prepend";
|
||||
highlightWord?: boolean;
|
||||
notificationType?: "osd" | "system" | "both" | "none";
|
||||
autoUpdateNewCards?: boolean;
|
||||
};
|
||||
metadata?: {
|
||||
pattern?: string;
|
||||
};
|
||||
deck?: string;
|
||||
isLapis?: {
|
||||
enabled?: boolean;
|
||||
sentenceCardModel?: string;
|
||||
sentenceCardSentenceField?: string;
|
||||
sentenceCardAudioField?: string;
|
||||
};
|
||||
isKiku?: {
|
||||
enabled?: boolean;
|
||||
fieldGrouping?: "auto" | "manual" | "disabled";
|
||||
deleteDuplicateInAuto?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubtitleStyleConfig {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
fontWeight?: string;
|
||||
fontStyle?: string;
|
||||
backgroundColor?: string;
|
||||
secondary?: {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
fontWeight?: string;
|
||||
fontStyle?: string;
|
||||
backgroundColor?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ShortcutsConfig {
|
||||
toggleVisibleOverlayGlobal?: string | null;
|
||||
toggleInvisibleOverlayGlobal?: string | null;
|
||||
copySubtitle?: string | null;
|
||||
copySubtitleMultiple?: string | null;
|
||||
updateLastCardFromClipboard?: string | null;
|
||||
triggerFieldGrouping?: string | null;
|
||||
triggerSubsync?: string | null;
|
||||
mineSentence?: string | null;
|
||||
mineSentenceMultiple?: string | null;
|
||||
multiCopyTimeoutMs?: number;
|
||||
toggleSecondarySub?: string | null;
|
||||
markAudioCard?: string | null;
|
||||
openRuntimeOptions?: string | null;
|
||||
}
|
||||
|
||||
export type JimakuLanguagePreference = "ja" | "en" | "none";
|
||||
|
||||
export interface JimakuConfig {
|
||||
apiKey?: string;
|
||||
apiKeyCommand?: string;
|
||||
apiBaseUrl?: string;
|
||||
languagePreference?: JimakuLanguagePreference;
|
||||
maxEntryResults?: number;
|
||||
}
|
||||
|
||||
export interface InvisibleOverlayConfig {
|
||||
startupVisibility?: "platform-default" | "visible" | "hidden";
|
||||
}
|
||||
|
||||
export type YoutubeSubgenMode = "automatic" | "preprocess" | "off";
|
||||
|
||||
export interface YoutubeSubgenConfig {
|
||||
mode?: YoutubeSubgenMode;
|
||||
whisperBin?: string;
|
||||
whisperModel?: string;
|
||||
primarySubLanguages?: string[];
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
subtitlePosition?: SubtitlePosition;
|
||||
keybindings?: Keybinding[];
|
||||
websocket?: WebSocketConfig;
|
||||
texthooker?: TexthookerConfig;
|
||||
ankiConnect?: AnkiConnectConfig;
|
||||
shortcuts?: ShortcutsConfig;
|
||||
secondarySub?: SecondarySubConfig;
|
||||
subsync?: SubsyncConfig;
|
||||
subtitleStyle?: SubtitleStyleConfig;
|
||||
auto_start_overlay?: boolean;
|
||||
bind_visible_overlay_to_mpv_sub_visibility?: boolean;
|
||||
jimaku?: JimakuConfig;
|
||||
invisibleOverlay?: InvisibleOverlayConfig;
|
||||
youtubeSubgen?: YoutubeSubgenConfig;
|
||||
}
|
||||
|
||||
export type RawConfig = Config;
|
||||
|
||||
export interface ResolvedConfig {
|
||||
subtitlePosition: SubtitlePosition;
|
||||
keybindings: Keybinding[];
|
||||
websocket: Required<WebSocketConfig>;
|
||||
texthooker: Required<TexthookerConfig>;
|
||||
ankiConnect: AnkiConnectConfig & {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
pollingRate: number;
|
||||
fields: {
|
||||
audio: string;
|
||||
image: string;
|
||||
sentence: string;
|
||||
miscInfo: string;
|
||||
translation: string;
|
||||
};
|
||||
ai: {
|
||||
enabled: boolean;
|
||||
alwaysUseAiTranslation: boolean;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
targetLanguage: string;
|
||||
systemPrompt: string;
|
||||
};
|
||||
media: {
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
imageType: "static" | "avif";
|
||||
imageFormat: "jpg" | "png" | "webp";
|
||||
imageQuality: number;
|
||||
imageMaxWidth?: number;
|
||||
imageMaxHeight?: number;
|
||||
animatedFps: number;
|
||||
animatedMaxWidth: number;
|
||||
animatedMaxHeight?: number;
|
||||
animatedCrf: number;
|
||||
audioPadding: number;
|
||||
fallbackDuration: number;
|
||||
maxMediaDuration: number;
|
||||
};
|
||||
behavior: {
|
||||
overwriteAudio: boolean;
|
||||
overwriteImage: boolean;
|
||||
mediaInsertMode: "append" | "prepend";
|
||||
highlightWord: boolean;
|
||||
notificationType: "osd" | "system" | "both" | "none";
|
||||
autoUpdateNewCards: boolean;
|
||||
};
|
||||
metadata: {
|
||||
pattern: string;
|
||||
};
|
||||
isLapis: {
|
||||
enabled: boolean;
|
||||
sentenceCardModel: string;
|
||||
sentenceCardSentenceField: string;
|
||||
sentenceCardAudioField: string;
|
||||
};
|
||||
isKiku: {
|
||||
enabled: boolean;
|
||||
fieldGrouping: "auto" | "manual" | "disabled";
|
||||
deleteDuplicateInAuto: boolean;
|
||||
};
|
||||
};
|
||||
shortcuts: Required<ShortcutsConfig>;
|
||||
secondarySub: Required<SecondarySubConfig>;
|
||||
subsync: Required<SubsyncConfig>;
|
||||
subtitleStyle: Required<Omit<SubtitleStyleConfig, "secondary">> & {
|
||||
secondary: Required<NonNullable<SubtitleStyleConfig["secondary"]>>;
|
||||
};
|
||||
auto_start_overlay: boolean;
|
||||
bind_visible_overlay_to_mpv_sub_visibility: boolean;
|
||||
jimaku: JimakuConfig & {
|
||||
apiBaseUrl: string;
|
||||
languagePreference: JimakuLanguagePreference;
|
||||
maxEntryResults: number;
|
||||
};
|
||||
invisibleOverlay: Required<InvisibleOverlayConfig>;
|
||||
youtubeSubgen: YoutubeSubgenConfig & {
|
||||
mode: YoutubeSubgenMode;
|
||||
whisperBin: string;
|
||||
whisperModel: string;
|
||||
primarySubLanguages: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConfigValidationWarning {
|
||||
path: string;
|
||||
value: unknown;
|
||||
fallback: unknown;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SubsyncSourceTrack {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SubsyncManualPayload {
|
||||
sourceTracks: SubsyncSourceTrack[];
|
||||
}
|
||||
|
||||
export interface SubsyncManualRunRequest {
|
||||
engine: "alass" | "ffsubsync";
|
||||
sourceTrackId?: number | null;
|
||||
}
|
||||
|
||||
export interface SubsyncResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SubtitleData {
|
||||
text: string;
|
||||
tokens: MergedToken[] | null;
|
||||
}
|
||||
|
||||
export interface MpvSubtitleRenderMetrics {
|
||||
subPos: number;
|
||||
subFontSize: number;
|
||||
subScale: number;
|
||||
subMarginY: number;
|
||||
subMarginX: number;
|
||||
subFont: string;
|
||||
subSpacing: number;
|
||||
subBold: boolean;
|
||||
subItalic: boolean;
|
||||
subBorderSize: number;
|
||||
subShadowOffset: number;
|
||||
subAssOverride: string;
|
||||
subScaleByWindow: boolean;
|
||||
subUseMargins: boolean;
|
||||
osdHeight: number;
|
||||
osdDimensions: {
|
||||
w: number;
|
||||
h: number;
|
||||
ml: number;
|
||||
mr: number;
|
||||
mt: number;
|
||||
mb: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface MecabStatus {
|
||||
available: boolean;
|
||||
enabled: boolean;
|
||||
path: string | null;
|
||||
}
|
||||
|
||||
export type JimakuConfidence = "high" | "medium" | "low";
|
||||
|
||||
export interface JimakuMediaInfo {
|
||||
title: string;
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
confidence: JimakuConfidence;
|
||||
filename: string;
|
||||
rawTitle: string;
|
||||
}
|
||||
|
||||
export interface JimakuSearchQuery {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface JimakuEntryFlags {
|
||||
anime?: boolean;
|
||||
movie?: boolean;
|
||||
adult?: boolean;
|
||||
external?: boolean;
|
||||
unverified?: boolean;
|
||||
}
|
||||
|
||||
export interface JimakuEntry {
|
||||
id: number;
|
||||
name: string;
|
||||
english_name?: string | null;
|
||||
japanese_name?: string | null;
|
||||
flags?: JimakuEntryFlags;
|
||||
last_modified?: string;
|
||||
}
|
||||
|
||||
export interface JimakuFilesQuery {
|
||||
entryId: number;
|
||||
episode?: number | null;
|
||||
}
|
||||
|
||||
export interface JimakuFileEntry {
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
last_modified: string;
|
||||
}
|
||||
|
||||
export interface JimakuDownloadQuery {
|
||||
entryId: number;
|
||||
url: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface JimakuApiError {
|
||||
error: string;
|
||||
code?: number;
|
||||
retryAfter?: number;
|
||||
}
|
||||
|
||||
export type JimakuApiResponse<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; error: JimakuApiError };
|
||||
|
||||
export type JimakuDownloadResult =
|
||||
| { ok: true; path: string }
|
||||
| { ok: false; error: JimakuApiError };
|
||||
|
||||
export interface ElectronAPI {
|
||||
getOverlayLayer: () => "visible" | "invisible" | null;
|
||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||
onVisibility: (callback: (visible: boolean) => void) => void;
|
||||
onSubtitlePosition: (
|
||||
callback: (position: SubtitlePosition | null) => void,
|
||||
) => void;
|
||||
getOverlayVisibility: () => Promise<boolean>;
|
||||
getCurrentSubtitle: () => Promise<SubtitleData>;
|
||||
getCurrentSubtitleAss: () => Promise<string>;
|
||||
getMpvSubtitleRenderMetrics: () => Promise<MpvSubtitleRenderMetrics>;
|
||||
onMpvSubtitleRenderMetrics: (
|
||||
callback: (metrics: MpvSubtitleRenderMetrics) => void,
|
||||
) => void;
|
||||
onSubtitleAss: (callback: (assText: string) => void) => void;
|
||||
onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => void;
|
||||
setIgnoreMouseEvents: (
|
||||
ignore: boolean,
|
||||
options?: { forward?: boolean },
|
||||
) => void;
|
||||
openYomitanSettings: () => void;
|
||||
getSubtitlePosition: () => Promise<SubtitlePosition | null>;
|
||||
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||
getMecabStatus: () => Promise<MecabStatus>;
|
||||
setMecabEnabled: (enabled: boolean) => void;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
getKeybindings: () => Promise<Keybinding[]>;
|
||||
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
|
||||
jimakuSearchEntries: (
|
||||
query: JimakuSearchQuery,
|
||||
) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
||||
jimakuListFiles: (
|
||||
query: JimakuFilesQuery,
|
||||
) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
|
||||
jimakuDownloadFile: (
|
||||
query: JimakuDownloadQuery,
|
||||
) => Promise<JimakuDownloadResult>;
|
||||
quitApp: () => void;
|
||||
toggleDevTools: () => void;
|
||||
toggleOverlay: () => void;
|
||||
getAnkiConnectStatus: () => Promise<boolean>;
|
||||
setAnkiConnectEnabled: (enabled: boolean) => void;
|
||||
clearAnkiConnectHistory: () => void;
|
||||
onSecondarySub: (callback: (text: string) => void) => void;
|
||||
onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => void;
|
||||
getSecondarySubMode: () => Promise<SecondarySubMode>;
|
||||
getCurrentSecondarySub: () => Promise<string>;
|
||||
getSubtitleStyle: () => Promise<SubtitleStyleConfig | null>;
|
||||
onSubsyncManualOpen: (
|
||||
callback: (payload: SubsyncManualPayload) => void,
|
||||
) => void;
|
||||
runSubsyncManual: (
|
||||
request: SubsyncManualRunRequest,
|
||||
) => Promise<SubsyncResult>;
|
||||
onKikuFieldGroupingRequest: (
|
||||
callback: (data: KikuFieldGroupingRequestData) => void,
|
||||
) => void;
|
||||
kikuBuildMergePreview: (
|
||||
request: KikuMergePreviewRequest,
|
||||
) => Promise<KikuMergePreviewResponse>;
|
||||
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => void;
|
||||
getRuntimeOptions: () => Promise<RuntimeOptionState[]>;
|
||||
setRuntimeOptionValue: (
|
||||
id: RuntimeOptionId,
|
||||
value: RuntimeOptionValue,
|
||||
) => Promise<RuntimeOptionApplyResult>;
|
||||
cycleRuntimeOption: (
|
||||
id: RuntimeOptionId,
|
||||
direction: 1 | -1,
|
||||
) => Promise<RuntimeOptionApplyResult>;
|
||||
onRuntimeOptionsChanged: (
|
||||
callback: (options: RuntimeOptionState[]) => void,
|
||||
) => void;
|
||||
onOpenRuntimeOptions: (callback: () => void) => void;
|
||||
notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
}
|
||||
}
|
||||
68
src/window-trackers/base-tracker.ts
Normal file
68
src/window-trackers/base-tracker.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
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 { WindowGeometry } from "../types";
|
||||
|
||||
export type GeometryChangeCallback = (geometry: WindowGeometry) => void;
|
||||
export type WindowFoundCallback = (geometry: WindowGeometry) => void;
|
||||
export type WindowLostCallback = () => void;
|
||||
|
||||
export abstract class BaseWindowTracker {
|
||||
protected currentGeometry: WindowGeometry | null = null;
|
||||
protected windowFound: boolean = false;
|
||||
public onGeometryChange: GeometryChangeCallback | null = null;
|
||||
public onWindowFound: WindowFoundCallback | null = null;
|
||||
public onWindowLost: WindowLostCallback | null = null;
|
||||
|
||||
abstract start(): void;
|
||||
abstract stop(): void;
|
||||
|
||||
getGeometry(): WindowGeometry | null {
|
||||
return this.currentGeometry;
|
||||
}
|
||||
|
||||
isTracking(): boolean {
|
||||
return this.windowFound;
|
||||
}
|
||||
|
||||
protected updateGeometry(newGeometry: WindowGeometry | null): void {
|
||||
if (newGeometry) {
|
||||
if (!this.windowFound) {
|
||||
this.windowFound = true;
|
||||
if (this.onWindowFound) this.onWindowFound(newGeometry);
|
||||
}
|
||||
|
||||
if (
|
||||
!this.currentGeometry ||
|
||||
this.currentGeometry.x !== newGeometry.x ||
|
||||
this.currentGeometry.y !== newGeometry.y ||
|
||||
this.currentGeometry.width !== newGeometry.width ||
|
||||
this.currentGeometry.height !== newGeometry.height
|
||||
) {
|
||||
this.currentGeometry = newGeometry;
|
||||
if (this.onGeometryChange) this.onGeometryChange(newGeometry);
|
||||
}
|
||||
} else {
|
||||
if (this.windowFound) {
|
||||
this.windowFound = false;
|
||||
this.currentGeometry = null;
|
||||
if (this.onWindowLost) this.onWindowLost();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
src/window-trackers/hyprland-tracker.ts
Normal file
114
src/window-trackers/hyprland-tracker.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
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 * as net from "net";
|
||||
import { execSync } from "child_process";
|
||||
import { BaseWindowTracker } from "./base-tracker";
|
||||
import { createLogger } from "../logger";
|
||||
|
||||
const log = createLogger("tracker").child("hyprland");
|
||||
|
||||
interface HyprlandClient {
|
||||
class: string;
|
||||
at: [number, number];
|
||||
size: [number, number];
|
||||
}
|
||||
|
||||
export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private eventSocket: net.Socket | null = null;
|
||||
|
||||
start(): void {
|
||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
||||
this.pollGeometry();
|
||||
this.connectEventSocket();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
if (this.eventSocket) {
|
||||
this.eventSocket.destroy();
|
||||
this.eventSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
private connectEventSocket(): void {
|
||||
const hyprlandSig = process.env.HYPRLAND_INSTANCE_SIGNATURE;
|
||||
if (!hyprlandSig) {
|
||||
log.info("HYPRLAND_INSTANCE_SIGNATURE not set, skipping event socket");
|
||||
return;
|
||||
}
|
||||
|
||||
const xdgRuntime = process.env.XDG_RUNTIME_DIR || "/tmp";
|
||||
const socketPath = `${xdgRuntime}/hypr/${hyprlandSig}/.socket2.sock`;
|
||||
this.eventSocket = new net.Socket();
|
||||
|
||||
this.eventSocket.on("connect", () => {
|
||||
log.info("Connected to Hyprland event socket");
|
||||
});
|
||||
|
||||
this.eventSocket.on("data", (data: Buffer) => {
|
||||
const events = data.toString().split("\n");
|
||||
for (const event of events) {
|
||||
if (
|
||||
event.includes("movewindow") ||
|
||||
event.includes("windowtitle") ||
|
||||
event.includes("openwindow") ||
|
||||
event.includes("closewindow") ||
|
||||
event.includes("fullscreen")
|
||||
) {
|
||||
this.pollGeometry();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.eventSocket.on("error", (err: Error) => {
|
||||
log.error("Hyprland event socket error:", err.message);
|
||||
});
|
||||
|
||||
this.eventSocket.on("close", () => {
|
||||
log.info("Hyprland event socket closed");
|
||||
});
|
||||
|
||||
this.eventSocket.connect(socketPath);
|
||||
}
|
||||
|
||||
private pollGeometry(): void {
|
||||
try {
|
||||
const output = execSync("hyprctl clients -j", { encoding: "utf-8" });
|
||||
const clients: HyprlandClient[] = JSON.parse(output);
|
||||
const mpvWindow = clients.find((c) => c.class === "mpv");
|
||||
|
||||
if (mpvWindow) {
|
||||
this.updateGeometry({
|
||||
x: mpvWindow.at[0],
|
||||
y: mpvWindow.at[1],
|
||||
width: mpvWindow.size[0],
|
||||
height: mpvWindow.size[1],
|
||||
});
|
||||
} else {
|
||||
this.updateGeometry(null);
|
||||
}
|
||||
} catch (err) {
|
||||
// hyprctl not available or failed - silent fail
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/window-trackers/index.ts
Normal file
86
src/window-trackers/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
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 { BaseWindowTracker } from "./base-tracker";
|
||||
import { HyprlandWindowTracker } from "./hyprland-tracker";
|
||||
import { SwayWindowTracker } from "./sway-tracker";
|
||||
import { X11WindowTracker } from "./x11-tracker";
|
||||
import { MacOSWindowTracker } from "./macos-tracker";
|
||||
import { createLogger } from "../logger";
|
||||
|
||||
const log = createLogger("tracker");
|
||||
|
||||
export type Compositor = "hyprland" | "sway" | "x11" | "macos" | null;
|
||||
export type Backend = "auto" | Exclude<Compositor, null>;
|
||||
|
||||
export function detectCompositor(): Compositor {
|
||||
if (process.platform === "darwin") return "macos";
|
||||
if (process.env.HYPRLAND_INSTANCE_SIGNATURE) return "hyprland";
|
||||
if (process.env.SWAYSOCK) return "sway";
|
||||
if (process.env.XDG_SESSION_TYPE === "x11") return "x11";
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCompositor(value: string): Compositor | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "hyprland") return "hyprland";
|
||||
if (normalized === "sway") return "sway";
|
||||
if (normalized === "x11") return "x11";
|
||||
if (normalized === "macos") return "macos";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createWindowTracker(
|
||||
override?: string | null,
|
||||
): BaseWindowTracker | null {
|
||||
let compositor = detectCompositor();
|
||||
|
||||
if (override && override !== "auto") {
|
||||
const normalized = normalizeCompositor(override);
|
||||
if (normalized) {
|
||||
compositor = normalized;
|
||||
} else {
|
||||
log.warn(
|
||||
`Unsupported backend override "${override}", falling back to auto.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
log.info(`Detected compositor: ${compositor || "none"}`);
|
||||
|
||||
switch (compositor) {
|
||||
case "hyprland":
|
||||
return new HyprlandWindowTracker();
|
||||
case "sway":
|
||||
return new SwayWindowTracker();
|
||||
case "x11":
|
||||
return new X11WindowTracker();
|
||||
case "macos":
|
||||
return new MacOSWindowTracker();
|
||||
default:
|
||||
log.warn("No supported compositor detected. Window tracking disabled.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
BaseWindowTracker,
|
||||
HyprlandWindowTracker,
|
||||
SwayWindowTracker,
|
||||
X11WindowTracker,
|
||||
MacOSWindowTracker,
|
||||
};
|
||||
114
src/window-trackers/macos-tracker.ts
Normal file
114
src/window-trackers/macos-tracker.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
subminer - Yomitan integration for mpv
|
||||
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";
|
||||
import { BaseWindowTracker } from "./base-tracker";
|
||||
|
||||
export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private pollInFlight = false;
|
||||
|
||||
start(): void {
|
||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
||||
this.pollGeometry();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private pollGeometry(): void {
|
||||
if (this.pollInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollInFlight = true;
|
||||
|
||||
const script = `
|
||||
set processNames to {"mpv", "MPV", "org.mpv.mpv"}
|
||||
tell application "System Events"
|
||||
repeat with procName in processNames
|
||||
set procList to (every process whose name is procName)
|
||||
repeat with p in procList
|
||||
try
|
||||
if (count of windows of p) > 0 then
|
||||
set targetWindow to window 1 of p
|
||||
set windowPos to position of targetWindow
|
||||
set windowSize to size of targetWindow
|
||||
return (item 1 of windowPos) & "," & (item 2 of windowPos) & "," & (item 1 of windowSize) & "," & (item 2 of windowSize)
|
||||
end if
|
||||
end try
|
||||
end repeat
|
||||
end repeat
|
||||
end tell
|
||||
return "not-found"
|
||||
`;
|
||||
|
||||
execFile(
|
||||
"osascript",
|
||||
["-e", script],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 1000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
(err, stdout) => {
|
||||
if (err) {
|
||||
this.updateGeometry(null);
|
||||
this.pollInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = (stdout || "").trim();
|
||||
if (result && result !== "not-found") {
|
||||
const parts = result.split(",");
|
||||
if (parts.length === 4) {
|
||||
const x = parseInt(parts[0], 10);
|
||||
const y = parseInt(parts[1], 10);
|
||||
const width = parseInt(parts[2], 10);
|
||||
const height = parseInt(parts[3], 10);
|
||||
|
||||
if (
|
||||
Number.isFinite(x) &&
|
||||
Number.isFinite(y) &&
|
||||
Number.isFinite(width) &&
|
||||
Number.isFinite(height) &&
|
||||
width > 0 &&
|
||||
height > 0
|
||||
) {
|
||||
this.updateGeometry({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
this.pollInFlight = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.updateGeometry(null);
|
||||
this.pollInFlight = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
94
src/window-trackers/sway-tracker.ts
Normal file
94
src/window-trackers/sway-tracker.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
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 { execSync } from "child_process";
|
||||
import { BaseWindowTracker } from "./base-tracker";
|
||||
|
||||
interface SwayRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface SwayNode {
|
||||
app_id?: string;
|
||||
window_properties?: { class?: string };
|
||||
rect?: SwayRect;
|
||||
nodes?: SwayNode[];
|
||||
floating_nodes?: SwayNode[];
|
||||
}
|
||||
|
||||
export class SwayWindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
start(): void {
|
||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
||||
this.pollGeometry();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private findMpvWindow(node: SwayNode): SwayNode | null {
|
||||
if (node.app_id === "mpv" || node.window_properties?.class === "mpv") {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.nodes) {
|
||||
for (const child of node.nodes) {
|
||||
const found = this.findMpvWindow(child);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
if (node.floating_nodes) {
|
||||
for (const child of node.floating_nodes) {
|
||||
const found = this.findMpvWindow(child);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private pollGeometry(): void {
|
||||
try {
|
||||
const output = execSync("swaymsg -t get_tree", { encoding: "utf-8" });
|
||||
const tree: SwayNode = JSON.parse(output);
|
||||
const mpvWindow = this.findMpvWindow(tree);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/window-trackers/x11-tracker.ts
Normal file
73
src/window-trackers/x11-tracker.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
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 { execSync } from "child_process";
|
||||
import { BaseWindowTracker } from "./base-tracker";
|
||||
|
||||
export class X11WindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
start(): void {
|
||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
||||
this.pollGeometry();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private pollGeometry(): void {
|
||||
try {
|
||||
const windowIds = execSync("xdotool search --class mpv", {
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
if (!windowIds) {
|
||||
this.updateGeometry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const windowId = windowIds.split("\n")[0];
|
||||
|
||||
const winInfo = execSync(`xwininfo -id ${windowId}`, {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
const xMatch = winInfo.match(/Absolute upper-left X:\s*(\d+)/);
|
||||
const yMatch = winInfo.match(/Absolute upper-left Y:\s*(\d+)/);
|
||||
const widthMatch = winInfo.match(/Width:\s*(\d+)/);
|
||||
const heightMatch = winInfo.match(/Height:\s*(\d+)/);
|
||||
|
||||
if (xMatch && yMatch && widthMatch && heightMatch) {
|
||||
this.updateGeometry({
|
||||
x: parseInt(xMatch[1], 10),
|
||||
y: parseInt(yMatch[1], 10),
|
||||
width: parseInt(widthMatch[1], 10),
|
||||
height: parseInt(heightMatch[1], 10),
|
||||
});
|
||||
} else {
|
||||
this.updateGeometry(null);
|
||||
}
|
||||
} catch (err) {
|
||||
this.updateGeometry(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user