diff --git a/.gitignore b/.gitignore
index d5cf79e..a0a16a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,7 +7,7 @@ dist/
release/
# Launcher build artifact (produced by make build-launcher)
-subminer
+/subminer
# Logs
*.log
diff --git a/Makefile b/Makefile
index a396291..ae7f8dd 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,6 @@ APP_NAME := subminer
THEME_SOURCE := assets/themes/subminer.rasi
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
THEME_FILE := subminer.rasi
-PLUGIN_LUA := plugin/subminer.lua
PLUGIN_CONF := plugin/subminer.conf
# Default install prefix for the wrapper script.
@@ -226,10 +225,12 @@ install-macos: build-launcher
install-plugin:
@printf '%s\n' "[INFO] Installing mpv plugin artifacts"
@install -d "$(MPV_SCRIPTS_DIR)"
+ @rm -f "$(MPV_SCRIPTS_DIR)/subminer.lua"
+ @install -d "$(MPV_SCRIPTS_DIR)/subminer"
@install -d "$(MPV_SCRIPT_OPTS_DIR)"
- @install -m 0644 "./$(PLUGIN_LUA)" "$(MPV_SCRIPTS_DIR)/subminer.lua"
+ @cp -R ./plugin/subminer/. "$(MPV_SCRIPTS_DIR)/subminer/"
@install -m 0644 "./$(PLUGIN_CONF)" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
- @printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer.lua" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
+ @printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer/main.lua" " $(MPV_SCRIPTS_DIR)/subminer/" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
# Uninstall behavior kept unchanged by default.
uninstall: uninstall-linux
diff --git a/README.md b/README.md
index 8c122a3..641f5fd 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,7 @@ SubMiner is an Electron overlay that sits on top of mpv. It turns your video pla
- **Hover to look up** — Yomitan dictionary popups directly on subtitles
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
+- **Instant auto-enrichment** — Optional local AnkiConnect proxy enriches new Yomitan cards immediately
- **N+1 highlighting** — Marks known words from your Anki deck so unknown ones jump out
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
- **Immersion tracking** — SQLite-powered stats on your watch time and mining activity
@@ -57,7 +58,9 @@ chmod +x ~/.local/bin/subminer
```bash
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
-cp /tmp/plugin/subminer.lua ~/.config/mpv/scripts/
+mkdir -p ~/.config/mpv/scripts/subminer
+mkdir -p ~/.config/mpv/script-opts
+cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
```
diff --git a/docs/architecture.md b/docs/architecture.md
index afcbca6..f1ed391 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -4,7 +4,7 @@ SubMiner is split into three cooperating runtimes:
- Electron desktop app (`src/`) for overlay/UI/runtime orchestration.
- Launcher CLI (`launcher/`) for mpv/app command workflows.
-- mpv Lua plugin (`plugin/subminer.lua`) for player-side controls and IPC handoff.
+- mpv Lua plugin (`plugin/subminer/main.lua` + module files) for player-side controls and IPC handoff.
Within the desktop app, `src/main.ts` is a composition root that wires small runtime/domain modules plus core services.
@@ -26,7 +26,7 @@ launcher/ # Standalone CLI launcher wrapper and mpv helpers
config/ # Launcher config parsers + CLI parser builder
main.ts # Launcher entrypoint and command dispatch
plugin/
- subminer.lua # mpv plugin (auto-start, IPC, AniSkip + hover controls)
+ subminer/ # mpv plugin modules + main entrypoint
src/
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
main.ts # Entry point — delegates to runtime composers/domain modules
@@ -120,7 +120,7 @@ src/renderer/
### Launcher + Plugin Runtimes
- `launcher/main.ts` dispatches commands through `launcher/commands/*` and shared config readers in `launcher/config/*`. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.
-- `plugin/subminer.lua` runs inside mpv and handles IPC startup checks, overlay toggles, hover-token messages, and AniSkip intro-skip UX.
+- `plugin/subminer/main.lua` runs inside mpv and loads module files for overlay toggles, hover-token messages, and AniSkip intro-skip UX.
## Flow Diagram
@@ -138,7 +138,7 @@ flowchart LR
subgraph ExtRt["External Runtimes"]
Launcher["launcher/
CLI dispatch"]:::extrt
- Plugin["subminer.lua
mpv plugin"]:::extrt
+ Plugin["subminer/main.lua
mpv plugin"]:::extrt
end
subgraph Ext["External Systems"]
@@ -260,7 +260,7 @@ For domains migrated to reducer-style transitions (for example AniList token/que
- **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to 26 properties), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
- **Overlay runtime:** `initializeOverlayRuntime()` creates the primary overlay window (interactive Yomitan lookups and subtitle rendering) and registers global shortcuts and bounds tracking via the active window tracker.
-- **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check, Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, and AniList token refresh.
+- **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check, Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, and AniList token refresh. Warmup coverage is configurable through `startupWarmups` (including low-power mode that defers all but Yomitan).
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through `SubtitlePipeline` (normalize → tokenize → merge), and results are sent to the main overlay renderer and modal surfaces.
- **Shutdown:** `onWillQuitCleanup` destroys tray + config watcher, unregisters shortcuts, stops WebSocket + texthooker servers, closes the mpv socket + flushes OSD log, stops the window tracker, closes the Yomitan parser window, flushes the immersion tracker (SQLite), stops Jellyfin/Discord services, and cleans Anki/AniList state.
diff --git a/docs/installation.md b/docs/installation.md
index 75be63e..48b7a8b 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -150,7 +150,9 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-asse
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.config/SubMiner
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
-cp /tmp/plugin/subminer.lua ~/.config/mpv/scripts/
+mkdir -p ~/.config/mpv/scripts/subminer
+mkdir -p ~/.config/mpv/script-opts
+cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
# Option 2: from source checkout
diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts
index e1c28eb..a580dac 100644
--- a/launcher/commands/playback-command.ts
+++ b/launcher/commands/playback-command.ts
@@ -194,15 +194,26 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
}
await new Promise((resolve) => {
- if (!state.mpvProc) {
+ const mpvProc = state.mpvProc;
+ if (!mpvProc) {
stopOverlay(args);
resolve();
return;
}
- state.mpvProc.on('exit', (code) => {
+
+ const finalize = (code: number | null | undefined) => {
stopOverlay(args);
processAdapter.setExitCode(code ?? 0);
resolve();
+ };
+
+ if (mpvProc.exitCode !== null && mpvProc.exitCode !== undefined) {
+ finalize(mpvProc.exitCode);
+ return;
+ }
+
+ mpvProc.once('exit', (code) => {
+ finalize(code);
});
});
}
diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts
index 4135e7d..1907f48 100644
--- a/launcher/smoke.e2e.test.ts
+++ b/launcher/smoke.e2e.test.ts
@@ -62,7 +62,7 @@ function createSmokeCase(name: string): SmokeCase {
writeExecutable(
fakeMpvPath,
- `#!/usr/bin/env bun
+ `#!/usr/bin/env node
const fs = require('node:fs');
const net = require('node:net');
const path = require('node:path');
@@ -101,7 +101,7 @@ process.on('SIGTERM', closeAndExit);
writeExecutable(
fakeAppPath,
- `#!/usr/bin/env bun
+ `#!/usr/bin/env node
const fs = require('node:fs');
const logPath = ${JSON.stringify(fakeAppLogPath)};
@@ -237,8 +237,20 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
env,
'mpv-status',
);
- assert.equal(result.status, 0);
- assert.match(result.stdout, /socket ready/i);
+ const fakeMpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
+ const fakeMpvError = fakeMpvEntries.find(
+ (entry): entry is { error: string } => typeof entry.error === 'string',
+ )?.error;
+ const unixSocketDenied =
+ typeof fakeMpvError === 'string' && /eperm|operation not permitted/i.test(fakeMpvError);
+
+ if (unixSocketDenied) {
+ assert.equal(result.status, 1);
+ assert.match(result.stdout, /socket not ready/i);
+ } else {
+ assert.equal(result.status, 0);
+ assert.match(result.stdout, /socket ready/i);
+ }
} finally {
if (fakeMpv.exitCode === null) {
await new Promise((resolve) => {
@@ -262,9 +274,6 @@ test(
'overlay-start-stop',
);
- assert.equal(result.status, 0);
- assert.match(result.stdout, /Starting SubMiner overlay/i);
-
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
await waitForJsonLines(appStartPath, 1);
@@ -273,6 +282,14 @@ test(
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
+ const mpvError = mpvEntries.find(
+ (entry): entry is { error: string } => typeof entry.error === 'string',
+ )?.error;
+ const unixSocketDenied =
+ typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
+
+ assert.equal(result.status, unixSocketDenied ? 3 : 0);
+ assert.match(result.stdout, /Starting SubMiner overlay/i);
assert.equal(appStartEntries.length, 1);
assert.equal(appStopEntries.length, 1);
diff --git a/package.json b/package.json
index 85c1031..dc5d510 100644
--- a/package.json
+++ b/package.json
@@ -19,10 +19,11 @@
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts",
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js",
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
+ "test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
- "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts",
- "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
- "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
+ "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
+ "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
+ "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
diff --git a/plugin/subminer.conf b/plugin/subminer.conf
index b471877..6bea0da 100644
--- a/plugin/subminer.conf
+++ b/plugin/subminer.conf
@@ -60,5 +60,5 @@ aniskip_button_key=y-k
# OSD hint duration in seconds (shown during first 3s of intro).
aniskip_button_duration=3
-# MPV keybindings provided by plugin/subminer.lua:
+# MPV keybindings provided by plugin/subminer/main.lua:
# y-s start, y-S stop, y-t toggle visible overlay
diff --git a/plugin/subminer.lua b/plugin/subminer.lua
deleted file mode 100644
index fb76ea8..0000000
--- a/plugin/subminer.lua
+++ /dev/null
@@ -1,1862 +0,0 @@
-local input = require("mp.input")
-local mp = require("mp")
-local msg = require("mp.msg")
-local options = require("mp.options")
-local utils = require("mp.utils")
-
-local function is_windows()
- return package.config:sub(1, 1) == "\\"
-end
-
-local function is_macos()
- local platform = mp.get_property("platform") or ""
- if platform == "macos" or platform == "darwin" then
- return true
- end
- local ostype = os.getenv("OSTYPE") or ""
- return ostype:find("darwin") ~= nil
-end
-
-local function default_socket_path()
- if is_windows() then
- return "\\\\.\\pipe\\subminer-socket"
- end
- return "/tmp/subminer-socket"
-end
-
-local function is_subminer_process_running()
- local command = is_windows() and { "tasklist", "/FO", "CSV", "/NH" } or { "ps", "-A", "-o", "args=" }
- local result = mp.command_native({
- name = "subprocess",
- args = command,
- playback_only = false,
- capture_stdout = true,
- capture_stderr = false,
- })
- if not result or type(result.stdout) ~= "string" or result.status ~= 0 then
- return false
- end
-
- local process_list = result.stdout:lower()
- for line in process_list:gmatch("[^\\n]+") do
- if is_windows() then
- local image = line:match('^"([^"]+)","')
- if not image then
- image = line:match("^\"([^\"]+)\"")
- end
- if not image then
- goto continue
- end
- if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then
- return true
- end
- if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then
- return true
- end
- else
- local argv0 = line:match('^"([^"]+)"') or line:match("^%s*([^%s]+)")
- if not argv0 then
- goto continue
- end
- if argv0:find("subminer.lua", 1, true) or argv0:find("subminer.conf", 1, true) then
- goto continue
- end
- local exe = argv0:match("([^/\\]+)$") or argv0
- if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then
- return true
- end
- if exe:find("subminer", 1, true) and exe:find("%.lua", 1, true) == nil and exe:find("%.app", 1, true) == nil then
- return true
- end
- end
-
- ::continue::
- end
- return false
-end
-
-local function is_subminer_app_running()
- if is_subminer_process_running() then
- return true
- end
- return false
-end
-
-local function is_subminer_ipc_ready()
- if not is_subminer_process_running() then
- return false, "SubMiner process not running"
- end
-
- if is_windows() then
- return true, nil
- end
-
- if opts.socket_path ~= default_socket_path() then
- return false, "SubMiner socket path mismatch"
- end
-
- if not file_exists(default_socket_path()) then
- return false, "SubMiner IPC socket missing at /tmp/subminer-socket"
- end
-
- return true, nil
-end
-
-local function normalize_binary_path_candidate(candidate)
- if type(candidate) ~= "string" then
- return nil
- end
- local trimmed = candidate:match("^%s*(.-)%s*$") or ""
- if trimmed == "" then
- return nil
- end
- if #trimmed >= 2 then
- local first = trimmed:sub(1, 1)
- local last = trimmed:sub(-1)
- if (first == '"' and last == '"') or (first == "'" and last == "'") then
- trimmed = trimmed:sub(2, -2)
- end
- end
- return trimmed ~= "" and trimmed or nil
-end
-
-local function binary_candidates_from_app_path(app_path)
- return {
- utils.join_path(app_path, "Contents", "MacOS", "SubMiner"),
- utils.join_path(app_path, "Contents", "MacOS", "subminer"),
- }
-end
-
-local opts = {
- binary_path = "",
- socket_path = default_socket_path(),
- texthooker_enabled = true,
- texthooker_port = 5174,
- backend = "auto",
- auto_start = true,
- auto_start_visible_overlay = false,
- osd_messages = true,
- log_level = "info",
- aniskip_enabled = true,
- aniskip_title = "",
- aniskip_season = "",
- aniskip_mal_id = "",
- aniskip_episode = "",
- aniskip_show_button = true,
- aniskip_button_text = "You can skip by pressing %s",
- aniskip_button_key = "y-k",
- aniskip_button_duration = 3,
-}
-
-options.read_options(opts, "subminer")
-
-local state = {
- overlay_running = false,
- texthooker_running = false,
- overlay_process = nil,
- binary_available = false,
- binary_path = nil,
- detected_backend = nil,
- hover_highlight = {
- revision = -1,
- payload = nil,
- saved_sub_visibility = nil,
- saved_secondary_sub_visibility = nil,
- overlay_active = false,
- cached_ass = nil,
- clear_timer = nil,
- last_hover_update_ts = 0,
- },
- aniskip = {
- mal_id = nil,
- title = nil,
- episode = nil,
- intro_start = nil,
- intro_end = nil,
- found = false,
- prompt_shown = false,
- },
-}
-
-local STARTUP_OVERLAY_ACTION_DELAY_SECONDS = 0.6
-local STARTUP_OVERLAY_ACTION_RETRY_DELAY_SECONDS = 0.4
-local STARTUP_OVERLAY_ACTION_MAX_ATTEMPTS = 8
-
-local HOVER_MESSAGE_NAME = "subminer-hover-token"
-local HOVER_MESSAGE_NAME_LEGACY = "yomipv-hover-token"
-local DEFAULT_HOVER_BASE_COLOR = "FFFFFF"
-local DEFAULT_HOVER_COLOR = "C6A0F6"
-
-local LOG_LEVEL_PRIORITY = {
- debug = 10,
- info = 20,
- warn = 30,
- error = 40,
-}
-
-local function normalize_log_level(level)
- local normalized = (level or "info"):lower()
- if LOG_LEVEL_PRIORITY[normalized] then
- return normalized
- end
- return "info"
-end
-
-local function should_log(level)
- local current = normalize_log_level(opts.log_level)
- local target = normalize_log_level(level)
- return LOG_LEVEL_PRIORITY[target] >= LOG_LEVEL_PRIORITY[current]
-end
-
-local function subminer_log(level, scope, message)
- if not should_log(level) then
- return
- end
- local timestamp = os.date("%Y-%m-%d %H:%M:%S")
- local line = string.format("[subminer] - %s - %s - [%s] %s", timestamp, string.upper(level), scope, message)
- if level == "error" then
- msg.error(line)
- elseif level == "warn" then
- msg.warn(line)
- elseif level == "debug" then
- msg.debug(line)
- else
- msg.info(line)
- end
-end
-
-local function show_osd(message)
- if opts.osd_messages then
- mp.osd_message("SubMiner: " .. message, 3)
- end
-end
-
-local function url_encode(text)
- if type(text) ~= "string" then
- return ""
- end
- local encoded = text:gsub("\n", " ")
- encoded = encoded:gsub("([^%w%-_%.~ ])", function(char)
- return string.format("%%%02X", string.byte(char))
- end)
- return encoded:gsub(" ", "%%20")
-end
-
-local function run_json_curl(url)
- local result = mp.command_native({
- name = "subprocess",
- args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url },
- playback_only = false,
- capture_stdout = true,
- capture_stderr = true,
- })
- if not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then
- return nil, result and result.stderr or "curl failed"
- end
- local parsed, parse_error = utils.parse_json(result.stdout)
- if type(parsed) ~= "table" then
- return nil, parse_error or "invalid json"
- end
- return parsed, nil
-end
-
-local function parse_episode_hint(text)
- if type(text) ~= "string" or text == "" then
- return nil
- end
- local patterns = {
- "[Ss]%d+[Ee](%d+)",
- "[Ee][Pp]?[%s%._%-]*(%d+)",
- "[%s%._%-]+(%d+)[%s%._%-]+",
- }
- for _, pattern in ipairs(patterns) do
- local token = text:match(pattern)
- if token then
- local episode = tonumber(token)
- if episode and episode > 0 and episode < 10000 then
- return episode
- end
- end
- end
- return nil
-end
-
-local function cleanup_title(raw)
- if type(raw) ~= "string" then
- return nil
- end
- local cleaned = raw
- cleaned = cleaned:gsub("%b[]", " ")
- cleaned = cleaned:gsub("%b()", " ")
- cleaned = cleaned:gsub("[Ss]%d+[Ee]%d+", " ")
- cleaned = cleaned:gsub("[Ee][Pp]?[%s%._%-]*%d+", " ")
- cleaned = cleaned:gsub("[%._%-]+", " ")
- cleaned = cleaned:gsub("%s+", " ")
- cleaned = cleaned:match("^%s*(.-)%s*$") or ""
- if cleaned == "" then
- return nil
- end
- return cleaned
-end
-
-local function extract_show_title_from_path(media_path)
- if type(media_path) ~= "string" or media_path == "" then
- return nil
- end
- local normalized = media_path:gsub("\\", "/")
- local segments = {}
- for segment in normalized:gmatch("[^/]+") do
- segments[#segments + 1] = segment
- end
- for index = 1, #segments do
- local segment = segments[index] or ""
- if segment:match("^[Ss]eason[%s%._%-]*%d+$") or segment:match("^[Ss][%s%._%-]*%d+$") then
- local prior = segments[index - 1]
- local cleaned = cleanup_title(prior or "")
- if cleaned and cleaned ~= "" then
- return cleaned
- end
- end
- end
- return nil
-end
-
-local function normalize_for_match(value)
- if type(value) ~= "string" then
- return ""
- end
- return value:lower():gsub("[^%w]+", " "):gsub("%s+", " "):match("^%s*(.-)%s*$") or ""
-end
-
-local MATCH_STOPWORDS = {
- the = true,
- this = true,
- that = true,
- world = true,
- animated = true,
- series = true,
- season = true,
- no = true,
- on = true,
- ["and"] = true,
-}
-
-local function tokenize_match_words(value)
- local normalized = normalize_for_match(value)
- local tokens = {}
- for token in normalized:gmatch("%S+") do
- if #token >= 3 and not MATCH_STOPWORDS[token] then
- tokens[#tokens + 1] = token
- end
- end
- return tokens
-end
-
-local function token_set(tokens)
- local set = {}
- for _, token in ipairs(tokens) do
- set[token] = true
- end
- return set
-end
-
-local function title_overlap_score(expected_title, candidate_title)
- local expected = normalize_for_match(expected_title)
- local candidate = normalize_for_match(candidate_title)
- if expected == "" or candidate == "" then
- return 0
- end
- if candidate:find(expected, 1, true) then
- return 120
- end
- local expected_tokens = tokenize_match_words(expected_title)
- local candidate_tokens = token_set(tokenize_match_words(candidate_title))
- if #expected_tokens == 0 then
- return 0
- end
- local score = 0
- local matched = 0
- for _, token in ipairs(expected_tokens) do
- if candidate_tokens[token] then
- score = score + 30
- matched = matched + 1
- else
- score = score - 20
- end
- end
- if matched == 0 then
- score = score - 80
- end
- local coverage = matched / #expected_tokens
- if #expected_tokens >= 2 then
- -- Require strong multi-token agreement to avoid false positives like "Shadow Skill".
- if coverage >= 0.8 then
- score = score + 30
- elseif coverage >= 0.6 then
- score = score + 10
- else
- score = score - 50
- end
- else
- if coverage >= 1 then
- score = score + 10
- end
- end
- return score
-end
-
-local function has_any_sequel_marker(candidate_title)
- local normalized = normalize_for_match(candidate_title)
- if normalized == "" then
- return false
- end
- local markers = {
- "season 2",
- "season 3",
- "season 4",
- "2nd season",
- "3rd season",
- "4th season",
- "second season",
- "third season",
- "fourth season",
- " ii ",
- " iii ",
- " iv ",
- }
- local padded = " " .. normalized .. " "
- for _, marker in ipairs(markers) do
- if padded:find(marker, 1, true) then
- return true
- end
- end
- return false
-end
-
-local function season_signal_score(requested_season, candidate_title)
- local season = tonumber(requested_season)
- if not season or season < 1 then
- return 0
- end
- local normalized = " " .. normalize_for_match(candidate_title) .. " "
- if normalized == " " then
- return 0
- end
-
- if season == 1 then
- return has_any_sequel_marker(candidate_title) and -60 or 20
- end
-
- local numeric_marker = string.format(" season %d ", season)
- local ordinal_marker = string.format(" %dth season ", season)
- local roman_markers = {
- [2] = { " ii ", " second season ", " 2nd season " },
- [3] = { " iii ", " third season ", " 3rd season " },
- [4] = { " iv ", " fourth season ", " 4th season " },
- [5] = { " v ", " fifth season ", " 5th season " },
- }
-
- if normalized:find(numeric_marker, 1, true) or normalized:find(ordinal_marker, 1, true) then
- return 40
- end
- local aliases = roman_markers[season] or {}
- for _, marker in ipairs(aliases) do
- if normalized:find(marker, 1, true) then
- return 40
- end
- end
- if has_any_sequel_marker(candidate_title) then
- return -20
- end
- return 5
-end
-
-local function resolve_title_and_episode()
- local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
- local forced_season = tonumber(opts.aniskip_season)
- local forced_episode = tonumber(opts.aniskip_episode)
- local media_title = mp.get_property("media-title")
- local filename = mp.get_property("filename/no-ext") or mp.get_property("filename") or ""
- local path = mp.get_property("path") or ""
- local path_show_title = extract_show_title_from_path(path)
- local candidate_title = nil
- if path_show_title and path_show_title ~= "" then
- candidate_title = path_show_title
- elseif forced_title ~= "" then
- candidate_title = forced_title
- else
- candidate_title = cleanup_title(media_title) or cleanup_title(filename) or cleanup_title(path)
- end
- local episode = forced_episode
- or parse_episode_hint(media_title)
- or parse_episode_hint(filename)
- or parse_episode_hint(path)
- or 1
- return candidate_title, episode, forced_season
-end
-
-local function resolve_mal_id(title, season)
- local forced_mal_id = tonumber(opts.aniskip_mal_id)
- if forced_mal_id and forced_mal_id > 0 then
- return forced_mal_id, "(forced-mal-id)"
- end
- if type(title) == "string" and title:match("^%d+$") then
- local numeric = tonumber(title)
- if numeric and numeric > 0 then
- return numeric, title
- end
- end
- if type(title) ~= "string" or title == "" then
- return nil, nil
- end
-
- local lookup = title
- if season and season > 1 then
- lookup = string.format("%s Season %d", lookup, season)
- end
- local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup)
- local mal_json, mal_error = run_json_curl(mal_url)
- if not mal_json then
- subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error))
- return nil, lookup
- end
- local categories = mal_json.categories
- if type(categories) ~= "table" then
- return nil, lookup
- end
- for _, category in ipairs(categories) do
- if type(category) == "table" and type(category.items) == "table" then
- for _, item in ipairs(category.items) do
- if type(item) == "table" and tonumber(item.id) then
- subminer_log(
- "info",
- "aniskip",
- string.format(
- 'MAL candidate selected (first result): id=%s name="%s" season_hint=%s',
- tostring(item.id),
- tostring(item.name or ""),
- tostring(season or "-")
- )
- )
- return tonumber(item.id), lookup
- end
- end
- end
- end
- return nil, lookup
-end
-
-local function set_intro_chapters(intro_start, intro_end)
- if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
- return
- end
- local current = mp.get_property_native("chapter-list")
- local chapters = {}
- if type(current) == "table" then
- for _, chapter in ipairs(current) do
- local title = type(chapter) == "table" and chapter.title or nil
- if type(title) ~= "string" or not title:match("^AniSkip ") then
- chapters[#chapters + 1] = chapter
- end
- end
- end
- chapters[#chapters + 1] = { time = intro_start, title = "AniSkip Intro Start" }
- chapters[#chapters + 1] = { time = intro_end, title = "AniSkip Intro End" }
- table.sort(chapters, function(a, b)
- local a_time = type(a) == "table" and tonumber(a.time) or 0
- local b_time = type(b) == "table" and tonumber(b.time) or 0
- return a_time < b_time
- end)
- mp.set_property_native("chapter-list", chapters)
-end
-
-local function remove_aniskip_chapters()
- local current = mp.get_property_native("chapter-list")
- if type(current) ~= "table" then
- return
- end
- local chapters = {}
- local changed = false
- for _, chapter in ipairs(current) do
- local title = type(chapter) == "table" and chapter.title or nil
- if type(title) == "string" and title:match("^AniSkip ") then
- changed = true
- else
- chapters[#chapters + 1] = chapter
- end
- end
- if changed then
- mp.set_property_native("chapter-list", chapters)
- end
-end
-
-local function clear_aniskip_state()
- state.aniskip.prompt_shown = false
- state.aniskip.found = false
- state.aniskip.mal_id = nil
- state.aniskip.title = nil
- state.aniskip.episode = nil
- state.aniskip.intro_start = nil
- state.aniskip.intro_end = nil
- remove_aniskip_chapters()
-end
-
-local function skip_intro_now()
- if not state.aniskip.found then
- show_osd("Intro skip unavailable")
- return
- end
- local intro_start = state.aniskip.intro_start
- local intro_end = state.aniskip.intro_end
- if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
- show_osd("Intro markers missing")
- return
- end
- local now = mp.get_property_number("time-pos")
- if type(now) ~= "number" then
- show_osd("Skip unavailable")
- return
- end
- local epsilon = 0.35
- if now < (intro_start - epsilon) or now > (intro_end + epsilon) then
- show_osd("Skip intro only during intro")
- return
- end
- mp.set_property_number("time-pos", intro_end)
- show_osd("Skipped intro")
-end
-
-local function update_intro_button_visibility()
- if not opts.aniskip_enabled or not opts.aniskip_show_button or not state.aniskip.found then
- return
- end
- local now = mp.get_property_number("time-pos")
- if type(now) ~= "number" then
- return
- end
- local in_intro = now >= (state.aniskip.intro_start or -1) and now < (state.aniskip.intro_end or -1)
- local intro_start = state.aniskip.intro_start or -1
- local hint_window_end = intro_start + 3
- if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then
- local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or "y-k"
- local message = string.format(opts.aniskip_button_text, key)
- mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3)
- state.aniskip.prompt_shown = true
- end
-end
-
-local function apply_aniskip_payload(mal_id, title, episode, payload)
- local results = payload and payload.results
- if type(results) ~= "table" then
- return false
- end
- for _, item in ipairs(results) do
- if type(item) == "table" and item.skip_type == "op" and type(item.interval) == "table" then
- local intro_start = tonumber(item.interval.start_time)
- local intro_end = tonumber(item.interval.end_time)
- if intro_start and intro_end and intro_end > intro_start then
- state.aniskip.found = true
- state.aniskip.mal_id = mal_id
- state.aniskip.title = title
- state.aniskip.episode = episode
- state.aniskip.intro_start = intro_start
- state.aniskip.intro_end = intro_end
- state.aniskip.prompt_shown = false
- set_intro_chapters(intro_start, intro_end)
- subminer_log(
- "info",
- "aniskip",
- string.format("Intro window %.3f -> %.3f (MAL %d, ep %d)", intro_start, intro_end, mal_id, episode)
- )
- return true
- end
- end
- end
- return false
-end
-
-local function fetch_aniskip_for_current_media()
- if not is_subminer_app_running() then
- subminer_log("debug", "lifecycle", "Skipping aniskip lookup: SubMiner app not running")
- return
- end
-
- clear_aniskip_state()
- if not opts.aniskip_enabled then
- return
- end
- local title, episode, season = resolve_title_and_episode()
- local media_title_fallback = cleanup_title(mp.get_property("media-title"))
- local filename_fallback = cleanup_title(mp.get_property("filename/no-ext") or mp.get_property("filename") or "")
- local path_fallback = cleanup_title(mp.get_property("path") or "")
- local lookup_titles = {}
- local seen_titles = {}
- local function push_lookup_title(candidate)
- if type(candidate) ~= "string" then
- return
- end
- local trimmed = candidate:match("^%s*(.-)%s*$") or ""
- if trimmed == "" then
- return
- end
- local key = trimmed:lower()
- if seen_titles[key] then
- return
- end
- seen_titles[key] = true
- lookup_titles[#lookup_titles + 1] = trimmed
- end
- push_lookup_title(title)
- push_lookup_title(media_title_fallback)
- push_lookup_title(filename_fallback)
- push_lookup_title(path_fallback)
-
- subminer_log(
- "info",
- "aniskip",
- string.format(
- 'Query context: title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)',
- tostring(title or ""),
- tostring(season or "-"),
- tostring(episode or "-"),
- tostring(opts.aniskip_title or ""),
- tostring(opts.aniskip_season or "-"),
- tostring(opts.aniskip_episode or "-"),
- tostring(opts.aniskip_mal_id or "-"),
- #lookup_titles
- )
- )
- local mal_id, mal_lookup = nil, nil
- for index, lookup_title in ipairs(lookup_titles) do
- subminer_log(
- "info",
- "aniskip",
- string.format('MAL lookup attempt %d/%d using title="%s"', index, #lookup_titles, lookup_title)
- )
- local attempt_mal_id, attempt_lookup = resolve_mal_id(lookup_title, season)
- if attempt_mal_id then
- mal_id = attempt_mal_id
- mal_lookup = attempt_lookup
- break
- end
- mal_lookup = attempt_lookup or mal_lookup
- end
- if not mal_id then
- subminer_log(
- "info",
- "aniskip",
- string.format('Skipped: MAL id unavailable for query="%s"', tostring(mal_lookup or ""))
- )
- return
- end
- local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode)
- subminer_log(
- "info",
- "aniskip",
- string.format('Resolved MAL id=%d using query="%s"; AniSkip URL=%s', mal_id, tostring(mal_lookup or ""), url)
- )
- local payload, fetch_error = run_json_curl(url)
- if not payload then
- subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error))
- return
- end
- if payload.found ~= true then
- subminer_log("info", "aniskip", "AniSkip: no skip windows found")
- return
- end
- if not apply_aniskip_payload(mal_id, title, episode, payload) then
- subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
- end
-end
-
-local function to_hex_color(input)
- if type(input) ~= "string" then
- return nil
- end
-
- local hex = input:gsub("[%#%']", ""):gsub("^0x", "")
- if #hex ~= 6 and #hex ~= 3 then
- return nil
- end
- if #hex == 3 then
- return hex:sub(1, 1) .. hex:sub(1, 1) .. hex:sub(2, 2) .. hex:sub(2, 2) .. hex:sub(3, 3) .. hex:sub(3, 3)
- end
- return hex
-end
-
-local function fix_ass_color(input, fallback)
- local hex = to_hex_color(input)
- if not hex then
- return fallback or DEFAULT_HOVER_BASE_COLOR
- end
- local r, g, b = hex:sub(1, 2), hex:sub(3, 4), hex:sub(5, 6)
- return b .. g .. r
-end
-
-local function sanitize_hover_ass_color(input, fallback_rgb)
- local fallback = fix_ass_color(fallback_rgb or DEFAULT_HOVER_COLOR, DEFAULT_HOVER_COLOR)
- local converted = fix_ass_color(input, fallback)
- if converted == "000000" then
- return fallback
- end
- return converted
-end
-
-local function escape_ass_text(text)
- return (text or "")
- :gsub("\\", "\\\\")
- :gsub("{", "\\{")
- :gsub("}", "\\}")
- :gsub("\n", "\\N")
-end
-
-local function resolve_osd_dimensions()
- local width = mp.get_property_number("osd-width", 0) or 0
- local height = mp.get_property_number("osd-height", 0) or 0
-
- if width <= 0 or height <= 0 then
- local osd_dims = mp.get_property_native("osd-dimensions")
- if type(osd_dims) == "table" and type(osd_dims.w) == "number" and osd_dims.w > 0 then
- width = osd_dims.w
- end
- if type(osd_dims) == "table" and type(osd_dims.h) == "number" and osd_dims.h > 0 then
- height = osd_dims.h
- end
- end
-
- if width <= 0 then
- width = 1280
- end
- if height <= 0 then
- height = 720
- end
-
- return width, height
-end
-
-local function resolve_metrics()
- local sub_font_size = mp.get_property_number("sub-font-size", 36) or 36
- local sub_scale = mp.get_property_number("sub-scale", 1) or 1
- local sub_scale_by_window = mp.get_property_bool("sub-scale-by-window", true) == true
- local sub_pos = mp.get_property_number("sub-pos", 100) or 100
- local sub_margin_y = mp.get_property_number("sub-margin-y", 0) or 0
- local sub_font = mp.get_property("sub-font", "sans-serif") or "sans-serif"
- local sub_spacing = mp.get_property_number("sub-spacing", 0) or 0
- local sub_bold = mp.get_property_bool("sub-bold", false) == true
- local sub_italic = mp.get_property_bool("sub-italic", false) == true
- local sub_border_size = mp.get_property_number("sub-border-size", 2) or 2
- local sub_shadow_offset = mp.get_property_number("sub-shadow-offset", 0) or 0
- local osd_w, osd_h = resolve_osd_dimensions()
- local window_scale = 1
- if sub_scale_by_window and osd_h > 0 then
- window_scale = osd_h / 720
- end
- local effective_margin_y = sub_margin_y * window_scale
-
- return {
- font_size = sub_font_size * (sub_scale > 0 and sub_scale or 1) * window_scale,
- pos = sub_pos,
- margin_y = effective_margin_y,
- font = sub_font,
- spacing = sub_spacing,
- bold = sub_bold,
- italic = sub_italic,
- border = sub_border_size * window_scale,
- shadow = sub_shadow_offset * window_scale,
- base_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_BASE_COLOR),
- hover_color = sanitize_hover_ass_color(nil, DEFAULT_HOVER_COLOR),
- }
-end
-
-local function get_subtitle_ass_property()
- local ass_text = mp.get_property("sub-text/ass")
- if type(ass_text) == "string" and ass_text ~= "" then
- return ass_text
- end
-
- ass_text = mp.get_property("sub-text-ass")
- if type(ass_text) == "string" and ass_text ~= "" then
- return ass_text
- end
-
- return nil
-end
-
-local function plain_text_and_ass_map(text)
- local plain = {}
- local map = {}
- local plain_len = 0
- local i = 1
- local text_len = #text
-
- while i <= text_len do
- local ch = text:sub(i, i)
- if ch == "{" then
- local close = text:find("}", i + 1, true)
- if not close then
- break
- end
- i = close + 1
- elseif ch == "\\" then
- local esc = text:sub(i + 1, i + 1)
- if esc == "N" or esc == "n" then
- plain_len = plain_len + 1
- plain[plain_len] = "\n"
- map[plain_len] = i
- i = i + 2
- elseif esc == "h" then
- plain_len = plain_len + 1
- plain[plain_len] = " "
- map[plain_len] = i
- i = i + 2
- elseif esc == "{" then
- plain_len = plain_len + 1
- plain[plain_len] = "{"
- map[plain_len] = i
- i = i + 2
- elseif esc == "}" then
- plain_len = plain_len + 1
- plain[plain_len] = "}"
- map[plain_len] = i
- i = i + 2
- elseif esc == "\\" then
- plain_len = plain_len + 1
- plain[plain_len] = "\\"
- map[plain_len] = i
- i = i + 2
- else
- local seq_end = i + 1
- while seq_end <= text_len and text:sub(seq_end, seq_end):match("[%a]") do
- seq_end = seq_end + 1
- end
- if text:sub(seq_end, seq_end) == "(" then
- local close = text:find(")", seq_end, true)
- if close then
- i = close + 1
- else
- i = seq_end + 1
- end
- else
- i = seq_end + 1
- end
- end
- else
- plain_len = plain_len + 1
- plain[plain_len] = ch
- map[plain_len] = i
- i = i + 1
- end
- end
-
- return table.concat(plain), map
-end
-
-local function find_hover_span(payload, plain)
- local source_len = #plain
- local cursor = 1
- for _, token in ipairs(payload.tokens or {}) do
- if type(token) ~= "table" or type(token.text) ~= "string" or token.text == "" then
- goto continue
- end
-
- local token_text = token.text
- local start_pos = nil
- local end_pos = nil
-
- if type(token.startPos) == "number" and type(token.endPos) == "number" then
- if token.startPos >= 0 and token.endPos >= token.startPos then
- local candidate_start = token.startPos + 1
- local candidate_stop = token.endPos
- if
- candidate_start >= 1
- and candidate_stop <= source_len
- and candidate_stop >= candidate_start
- and plain:sub(candidate_start, candidate_stop) == token_text
- then
- start_pos = candidate_start
- end_pos = candidate_stop
- end
- end
- end
-
- if not start_pos or not end_pos then
- local fallback_start, fallback_stop = plain:find(token_text, cursor, true)
- if not fallback_start then
- fallback_start, fallback_stop = plain:find(token_text, 1, true)
- end
- start_pos, end_pos = fallback_start, fallback_stop
- end
-
- if start_pos and end_pos then
- if token.index == payload.hoveredTokenIndex then
- return start_pos, end_pos
- end
- cursor = end_pos + 1
- end
-
- ::continue::
- end
-
- return nil
-end
-
-local function inject_hover_color_to_ass(raw_ass, plain_map, hover_start, hover_end, hover_color, base_color)
- if hover_start == nil or hover_end == nil then
- return raw_ass
- end
-
- local raw_open_idx = plain_map[hover_start] or 1
- local raw_close_idx = plain_map[hover_end + 1] or (#raw_ass + 1)
- if raw_open_idx < 1 then
- raw_open_idx = 1
- end
- if raw_close_idx < 1 then
- raw_close_idx = 1
- end
- if raw_open_idx > #raw_ass + 1 then
- raw_open_idx = #raw_ass + 1
- end
- if raw_close_idx > #raw_ass + 1 then
- raw_close_idx = #raw_ass + 1
- end
-
- local open_tag = string.format("{\\1c&H%s&}", hover_color)
- local close_tag = string.format("{\\1c&H%s&}", base_color)
- local changes = {
- { idx = raw_open_idx, tag = open_tag },
- { idx = raw_close_idx, tag = close_tag },
- }
- table.sort(changes, function(a, b)
- return a.idx < b.idx
- end)
-
- local output = {}
- local cursor = 1
- for _, change in ipairs(changes) do
- if change.idx > #raw_ass + 1 then
- change.idx = #raw_ass + 1
- end
- if change.idx < 1 then
- change.idx = 1
- end
- if change.idx > cursor then
- output[#output + 1] = raw_ass:sub(cursor, change.idx - 1)
- end
- output[#output + 1] = change.tag
- cursor = change.idx
- end
- if cursor <= #raw_ass then
- output[#output + 1] = raw_ass:sub(cursor)
- end
-
- return table.concat(output)
-end
-
-local function build_hover_subtitle_content(payload)
- local source_ass = get_subtitle_ass_property()
- if type(source_ass) == "string" and source_ass ~= "" then
- state.hover_highlight.cached_ass = source_ass
- else
- source_ass = state.hover_highlight.cached_ass
- end
- if type(source_ass) ~= "string" or source_ass == "" then
- return nil
- end
-
- local plain_source, plain_map = plain_text_and_ass_map(source_ass)
- if type(plain_source) ~= "string" or plain_source == "" then
- return nil
- end
-
- local hover_start, hover_end = find_hover_span(payload, plain_source)
- if not hover_start or not hover_end then
- return nil
- end
-
- local metrics = resolve_metrics()
- local hover_color = sanitize_hover_ass_color(payload.colors and payload.colors.hover or nil, DEFAULT_HOVER_COLOR)
- local base_color = fix_ass_color(payload.colors and payload.colors.base or nil, metrics.base_color)
- return inject_hover_color_to_ass(source_ass, plain_map, hover_start, hover_end, hover_color, base_color)
-end
-
-local function clear_hover_overlay()
- if state.hover_highlight.clear_timer then
- state.hover_highlight.clear_timer:kill()
- state.hover_highlight.clear_timer = nil
- end
- if state.hover_highlight.overlay_active then
- if type(state.hover_highlight.saved_sub_visibility) == "string" then
- mp.set_property("sub-visibility", state.hover_highlight.saved_sub_visibility)
- else
- mp.set_property("sub-visibility", "yes")
- end
- if type(state.hover_highlight.saved_secondary_sub_visibility) == "string" then
- mp.set_property("secondary-sub-visibility", state.hover_highlight.saved_secondary_sub_visibility)
- end
- state.hover_highlight.saved_sub_visibility = nil
- state.hover_highlight.saved_secondary_sub_visibility = nil
- state.hover_highlight.overlay_active = false
- end
- mp.set_osd_ass(0, 0, "")
- state.hover_highlight.payload = nil
- state.hover_highlight.revision = -1
- state.hover_highlight.cached_ass = nil
- state.hover_highlight.last_hover_update_ts = 0
-end
-
-local function schedule_hover_clear(delay_seconds)
- if state.hover_highlight.clear_timer then
- state.hover_highlight.clear_timer:kill()
- state.hover_highlight.clear_timer = nil
- end
- state.hover_highlight.clear_timer = mp.add_timeout(delay_seconds or 0.08, function()
- state.hover_highlight.clear_timer = nil
- clear_hover_overlay()
- end)
-end
-
-local function render_hover_overlay(payload)
- if not payload or payload.hoveredTokenIndex == nil or payload.subtitle == nil then
- clear_hover_overlay()
- return
- end
-
- local ass = build_hover_subtitle_content(payload)
- if not ass then
- -- Transient parse/mapping miss; keep previous frame to avoid flicker.
- return
- end
-
- local osd_w, osd_h = resolve_osd_dimensions()
- local metrics = resolve_metrics()
- local osd_dims = mp.get_property_native("osd-dimensions")
- local ml = (type(osd_dims) == "table" and type(osd_dims.ml) == "number") and osd_dims.ml or 0
- local mr = (type(osd_dims) == "table" and type(osd_dims.mr) == "number") and osd_dims.mr or 0
- local mt = (type(osd_dims) == "table" and type(osd_dims.mt) == "number") and osd_dims.mt or 0
- local mb = (type(osd_dims) == "table" and type(osd_dims.mb) == "number") and osd_dims.mb or 0
- local usable_w = math.max(1, osd_w - ml - mr)
- local usable_h = math.max(1, osd_h - mt - mb)
- local anchor_x = math.floor(ml + usable_w / 2)
- local baseline_adjust = (metrics.border + metrics.shadow) * 5
- local anchor_y = math.floor(mt + (usable_h * metrics.pos / 100) - metrics.margin_y + baseline_adjust)
- local font_size = math.max(8, metrics.font_size)
- local anchor_tag = string.format(
- "{\\an2\\q2\\pos(%d,%d)\\fn%s\\fs%g\\b%d\\i%d\\fsp%g\\bord%g\\shad%g\\1c&H%s&}",
- anchor_x,
- anchor_y,
- escape_ass_text(metrics.font),
- font_size,
- metrics.bold and 1 or 0,
- metrics.italic and 1 or 0,
- metrics.spacing,
- metrics.border,
- metrics.shadow,
- metrics.base_color
- )
- if not state.hover_highlight.overlay_active then
- state.hover_highlight.saved_sub_visibility = mp.get_property("sub-visibility")
- state.hover_highlight.saved_secondary_sub_visibility = mp.get_property("secondary-sub-visibility")
- mp.set_property("sub-visibility", "no")
- mp.set_property("secondary-sub-visibility", "no")
- state.hover_highlight.overlay_active = true
- end
- mp.set_osd_ass(osd_w, osd_h, anchor_tag .. ass)
-end
-
-local function handle_hover_message(payload_json)
- local parsed, parse_error = utils.parse_json(payload_json)
- if not parsed then
- msg.warn("Invalid hover-highlight payload: " .. tostring(parse_error))
- clear_hover_overlay()
- return
- end
-
- if type(parsed.revision) ~= "number" then
- clear_hover_overlay()
- return
- end
-
- if parsed.revision < state.hover_highlight.revision then
- return
- end
-
- if type(parsed.hoveredTokenIndex) == "number" and type(parsed.tokens) == "table" then
- if state.hover_highlight.clear_timer then
- state.hover_highlight.clear_timer:kill()
- state.hover_highlight.clear_timer = nil
- end
- state.hover_highlight.revision = parsed.revision
- state.hover_highlight.payload = parsed
- state.hover_highlight.last_hover_update_ts = mp.get_time() or 0
- render_hover_overlay(state.hover_highlight.payload)
- return
- end
-
- local now = mp.get_time() or 0
- local elapsed_since_hover = now - (state.hover_highlight.last_hover_update_ts or 0)
- state.hover_highlight.revision = parsed.revision
- state.hover_highlight.payload = nil
- if state.hover_highlight.overlay_active then
- if elapsed_since_hover > 0.35 then
- -- Ignore stale null-hover updates while pointer is stationary.
- return
- end
- schedule_hover_clear(0.08)
- else
- clear_hover_overlay()
- end
-end
-
-local function detect_backend()
- if state.detected_backend then
- return state.detected_backend
- end
-
- local backend = nil
-
- if is_macos() then
- backend = "macos"
- elseif is_windows() then
- backend = nil
- elseif os.getenv("HYPRLAND_INSTANCE_SIGNATURE") then
- backend = "hyprland"
- elseif os.getenv("SWAYSOCK") then
- backend = "sway"
- elseif os.getenv("XDG_SESSION_TYPE") == "x11" or os.getenv("DISPLAY") then
- backend = "x11"
- else
- subminer_log("warn", "backend", "Could not detect window manager, falling back to x11")
- backend = "x11"
- end
-
- state.detected_backend = backend
- if backend then
- subminer_log("info", "backend", "Detected backend: " .. backend)
- else
- subminer_log("info", "backend", "No backend detected")
- end
- return backend
-end
-
-local function file_exists(path)
- local info = utils.file_info(path)
- if not info then return false end
- if info.is_dir ~= nil then
- return not info.is_dir
- end
- return true
-end
-
-local function resolve_binary_candidate(candidate)
- local normalized = normalize_binary_path_candidate(candidate)
- if not normalized then
- return nil
- end
-
- if file_exists(normalized) then
- return normalized
- end
-
- if not normalized:lower():find("%.app") then
- return nil
- end
-
- local app_root = normalized
- if not app_root:lower():match("%.app$") then
- app_root = normalized:match("(.+%.app)")
- end
- if not app_root then
- return nil
- end
-
- for _, path in ipairs(binary_candidates_from_app_path(app_root)) do
- if file_exists(path) then
- return path
- end
- end
-
- return nil
-end
-
-local function find_binary_override()
- local candidates = {
- resolve_binary_candidate(os.getenv("SUBMINER_APPIMAGE_PATH")),
- resolve_binary_candidate(os.getenv("SUBMINER_BINARY_PATH")),
- }
-
- for _, path in ipairs(candidates) do
- if path and path ~= "" then
- return path
- end
- end
-
- return nil
-end
-
-local function find_binary()
- local override = find_binary_override()
- if override then
- return override
- end
-
- local configured = resolve_binary_candidate(opts.binary_path)
- if configured then
- return configured
- end
-
- local search_paths = {
- "/Applications/SubMiner.app/Contents/MacOS/SubMiner",
- utils.join_path(os.getenv("HOME") or "", "Applications/SubMiner.app/Contents/MacOS/SubMiner"),
- "C:\\Program Files\\SubMiner\\SubMiner.exe",
- "C:\\Program Files (x86)\\SubMiner\\SubMiner.exe",
- "C:\\SubMiner\\SubMiner.exe",
- utils.join_path(os.getenv("HOME") or "", ".local/bin/SubMiner.AppImage"),
- "/opt/SubMiner/SubMiner.AppImage",
- "/usr/local/bin/SubMiner",
- "/usr/bin/SubMiner",
- }
-
- for _, path in ipairs(search_paths) do
- if file_exists(path) then
- subminer_log("info", "binary", "Found binary at: " .. path)
- return path
- end
- end
-
- return nil
-end
-
-local function ensure_binary_available()
- if state.binary_available and state.binary_path and file_exists(state.binary_path) then
- return true
- end
-
- local discovered = find_binary()
- if discovered then
- state.binary_path = discovered
- state.binary_available = true
- return true
- end
-
- state.binary_path = nil
- state.binary_available = false
- return false
-end
-
-local function resolve_backend(override_backend)
- local selected = override_backend
- if selected == nil or selected == "" then
- selected = opts.backend
- end
- if selected == "auto" then
- return detect_backend()
- end
- return selected
-end
-
-local function build_command_args(action, overrides)
- overrides = overrides or {}
- local args = { state.binary_path }
-
- table.insert(args, "--" .. action)
- local log_level = normalize_log_level(overrides.log_level or opts.log_level)
- if log_level ~= "info" then
- table.insert(args, "--log-level")
- table.insert(args, log_level)
- end
-
- local needs_start_context = action == "start"
-
- if needs_start_context then
- local backend = resolve_backend(overrides.backend)
- if backend and backend ~= "" then
- table.insert(args, "--backend")
- table.insert(args, backend)
- end
-
- local socket_path = overrides.socket_path or opts.socket_path
- table.insert(args, "--socket")
- table.insert(args, socket_path)
- end
-
- return args
-end
-
-local function run_control_command(action)
- local args = build_command_args(action)
- subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
- local result = mp.command_native({
- name = "subprocess",
- args = args,
- playback_only = false,
- capture_stdout = true,
- capture_stderr = true,
- })
- return result and result.status == 0
-end
-
-local function coerce_bool(value, fallback)
- if type(value) == "boolean" then
- return value
- end
- if type(value) == "string" then
- local normalized = value:lower()
- if normalized == "yes" or normalized == "true" or normalized == "1" or normalized == "on" then
- return true
- end
- if normalized == "no" or normalized == "false" or normalized == "0" or normalized == "off" then
- return false
- end
- end
- return fallback
-end
-
-local function parse_start_script_message_overrides(...)
- local overrides = {}
- for i = 1, select("#", ...) do
- local token = select(i, ...)
- if type(token) == "string" and token ~= "" then
- local key, value = token:match("^([%w_%-]+)=(.+)$")
- if key and value then
- local normalized_key = key:lower()
- if normalized_key == "backend" then
- local backend = value:lower()
- if backend == "auto" or backend == "hyprland" or backend == "sway" or backend == "x11" or backend == "macos" then
- overrides.backend = backend
- end
- elseif normalized_key == "socket" or normalized_key == "socket_path" then
- overrides.socket_path = value
- elseif normalized_key == "texthooker" or normalized_key == "texthooker_enabled" then
- local parsed = coerce_bool(value, nil)
- if parsed ~= nil then
- overrides.texthooker_enabled = parsed
- end
- elseif normalized_key == "log-level" or normalized_key == "log_level" then
- overrides.log_level = normalize_log_level(value)
- end
- end
- end
- end
- return overrides
-end
-
-local function resolve_visible_overlay_startup()
- return coerce_bool(opts.auto_start_visible_overlay, false)
-end
-
-local function apply_startup_overlay_preferences()
- local should_show_visible = resolve_visible_overlay_startup()
-
- local visible_action = should_show_visible and "show-visible-overlay" or "hide-visible-overlay"
- local function try_apply(attempt)
- if run_control_command(visible_action) then
- subminer_log(
- "debug",
- "process",
- "Applied visible startup action: " .. visible_action .. " (attempt " .. tostring(attempt) .. ")"
- )
- return
- end
-
- if attempt >= STARTUP_OVERLAY_ACTION_MAX_ATTEMPTS then
- subminer_log("warn", "process", "Failed to apply visible startup action: " .. visible_action)
- return
- end
-
- mp.add_timeout(STARTUP_OVERLAY_ACTION_RETRY_DELAY_SECONDS, function()
- try_apply(attempt + 1)
- end)
- end
-
- try_apply(1)
-end
-
-local function refresh_subminer_runtime_state()
- state.binary_path = find_binary()
- if state.binary_path then
- state.binary_available = true
- subminer_log("debug", "lifecycle", "SubMiner binary ready: " .. state.binary_path)
- else
- state.binary_available = false
- subminer_log("warn", "binary", "SubMiner binary not found - overlay features disabled")
- if opts.binary_path ~= "" then
- subminer_log("warn", "binary", "Configured path '" .. opts.binary_path .. "' does not exist")
- end
- end
-end
-
-local function build_texthooker_args()
- local args = { state.binary_path, "--texthooker", "--port", tostring(opts.texthooker_port) }
- local log_level = normalize_log_level(opts.log_level)
- if log_level ~= "info" then
- table.insert(args, "--log-level")
- table.insert(args, log_level)
- end
- return args
-end
-
-local function ensure_texthooker_running(callback)
- if not opts.texthooker_enabled then
- callback()
- return
- end
-
- if state.texthooker_running then
- callback()
- return
- end
-
- local args = build_texthooker_args()
- subminer_log("info", "texthooker", "Starting texthooker process: " .. table.concat(args, " "))
- state.texthooker_running = true
-
- mp.command_native_async({
- name = "subprocess",
- args = args,
- playback_only = false,
- capture_stdout = true,
- capture_stderr = true,
- }, function(success, result, error)
- if not success or (result and result.status ~= 0) then
- state.texthooker_running = false
- subminer_log(
- "warn",
- "texthooker",
- "Texthooker process exited unexpectedly: " .. (error or (result and result.stderr) or "unknown error")
- )
- end
- end)
-
- -- Give the process a moment to acquire the app lock before sending --start.
- mp.add_timeout(0.35, callback)
-end
-
-local function start_overlay(overrides)
- local socket_ready, reason = is_subminer_ipc_ready()
- local process_not_running = reason == "SubMiner process not running"
- if not socket_ready and not process_not_running then
- subminer_log("warn", "process", "Refusing to start overlay: " .. tostring(reason))
- show_osd("SubMiner IPC not set up. Launch mpv with --input-ipc-server=/tmp/subminer-socket")
- return
- end
-
- if not ensure_binary_available() then
- subminer_log("error", "binary", "SubMiner binary not found")
- show_osd("Error: binary not found")
- return
- end
-
- if state.overlay_running then
- subminer_log("info", "process", "Overlay already running")
- show_osd("Already running")
- return
- end
-
- overrides = overrides or {}
- local texthooker_enabled = overrides.texthooker_enabled
- if texthooker_enabled == nil then
- texthooker_enabled = opts.texthooker_enabled
- end
-
- local function launch_overlay()
- local args = build_command_args("start", overrides)
- subminer_log("info", "process", "Starting overlay: " .. table.concat(args, " "))
-
- show_osd("Starting...")
- state.overlay_running = true
-
- mp.command_native_async({
- name = "subprocess",
- args = args,
- playback_only = false,
- capture_stdout = true,
- capture_stderr = true,
- }, function(success, result, error)
- if not success or (result and result.status ~= 0) then
- state.overlay_running = false
- subminer_log(
- "error",
- "process",
- "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
- )
- show_osd("Overlay start failed")
- end
- end)
-
- -- Apply explicit startup visibility for each overlay layer.
- mp.add_timeout(STARTUP_OVERLAY_ACTION_DELAY_SECONDS, function()
- apply_startup_overlay_preferences()
- end)
- end
-
- if texthooker_enabled then
- ensure_texthooker_running(launch_overlay)
- else
- launch_overlay()
- end
-end
-
-local function start_overlay_from_script_message(...)
- local overrides = parse_start_script_message_overrides(...)
- start_overlay(overrides)
-end
-
-local function stop_overlay()
- if not ensure_binary_available() then
- subminer_log("error", "binary", "SubMiner binary not found")
- show_osd("Error: binary not found")
- return
- end
-
- local args = build_command_args("stop")
- subminer_log("info", "process", "Stopping overlay: " .. table.concat(args, " "))
-
- local result = mp.command_native({
- name = "subprocess",
- args = args,
- playback_only = false,
- capture_stdout = true,
- capture_stderr = true,
- })
-
- state.overlay_running = false
- state.texthooker_running = false
- if result.status == 0 then
- subminer_log("info", "process", "Overlay stopped")
- else
- subminer_log("warn", "process", "Stop command returned non-zero status: " .. tostring(result.status))
- end
- show_osd("Stopped")
-end
-
-local function toggle_overlay()
- if not ensure_binary_available() then
- subminer_log("error", "binary", "SubMiner binary not found")
- show_osd("Error: binary not found")
- return
- end
-
- local args = build_command_args("toggle")
- subminer_log("info", "process", "Toggling overlay: " .. table.concat(args, " "))
-
- local result = mp.command_native({
- name = "subprocess",
- args = args,
- playback_only = false,
- capture_stdout = true,
- capture_stderr = true,
- })
-
- if result and result.status ~= 0 then
- subminer_log("warn", "process", "Toggle command failed")
- show_osd("Toggle failed")
- end
-end
-
-local function open_options()
- if not state.binary_available then
- subminer_log("error", "binary", "SubMiner binary not found")
- show_osd("Error: binary not found")
- return
- end
- local args = build_command_args("settings")
- subminer_log("info", "process", "Opening options: " .. table.concat(args, " "))
- local result = mp.command_native({
- name = "subprocess",
- args = args,
- playback_only = false,
- capture_stdout = true,
- capture_stderr = true,
- })
- if result.status == 0 then
- subminer_log("info", "process", "Options window opened")
- show_osd("Options opened")
- else
- subminer_log("warn", "process", "Failed to open options")
- show_osd("Failed to open options")
- end
-end
-
-local restart_overlay
-local check_status
-
-local function show_menu()
- if not state.binary_available then
- subminer_log("error", "binary", "SubMiner binary not found")
- show_osd("Error: binary not found")
- return
- end
-
- local items = {
- "Start overlay",
- "Stop overlay",
- "Toggle overlay",
- "Open options",
- "Restart overlay",
- "Check status",
- }
-
- local actions = {
- start_overlay,
- stop_overlay,
- toggle_overlay,
- open_options,
- restart_overlay,
- check_status,
- }
-
- input.select({
- prompt = "SubMiner: ",
- items = items,
- submit = function(index)
- if index and actions[index] then
- actions[index]()
- end
- end,
- })
-end
-
-restart_overlay = function()
- if not ensure_binary_available() then
- subminer_log("error", "binary", "SubMiner binary not found")
- show_osd("Error: binary not found")
- return
- end
-
- subminer_log("info", "process", "Restarting overlay...")
- show_osd("Restarting...")
-
- local stop_args = build_command_args("stop")
- mp.command_native({
- name = "subprocess",
- args = stop_args,
- playback_only = false,
- capture_stdout = true,
- capture_stderr = true,
- })
-
- state.overlay_running = false
- state.texthooker_running = false
-
- ensure_texthooker_running(function()
- local start_args = build_command_args("start")
- subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
-
- state.overlay_running = true
- mp.command_native_async({
- name = "subprocess",
- args = start_args,
- playback_only = false,
- capture_stdout = true,
- capture_stderr = true,
- }, function(success, result, error)
- if not success or (result and result.status ~= 0) then
- state.overlay_running = false
- subminer_log(
- "error",
- "process",
- "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
- )
- show_osd("Restart failed")
- else
- show_osd("Restarted successfully")
- end
- end)
- end)
-end
-
-check_status = function()
- if not state.binary_available then
- show_osd("Status: binary not found")
- return
- end
-
- local status = state.overlay_running and "running" or "stopped"
- show_osd("Status: overlay is " .. status)
- subminer_log("info", "process", "Status check: overlay is " .. status)
-end
-
-local function on_file_loaded()
- clear_aniskip_state()
- fetch_aniskip_for_current_media()
- refresh_subminer_runtime_state()
- if not state.binary_available then
- return
- end
-
- local should_auto_start = coerce_bool(opts.auto_start, false)
- if should_auto_start then
- start_overlay()
- end
-end
-
-local function on_shutdown()
- clear_aniskip_state()
- clear_hover_overlay()
- if (state.overlay_running or state.texthooker_running) and state.binary_available then
- subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process")
- show_osd("Shutting down...")
- stop_overlay()
- end
-end
-
-local function register_keybindings()
- mp.add_key_binding("y-s", "subminer-start", start_overlay)
- mp.add_key_binding("y-S", "subminer-stop", stop_overlay)
- mp.add_key_binding("y-t", "subminer-toggle", toggle_overlay)
- mp.add_key_binding("y-y", "subminer-menu", show_menu)
- mp.add_key_binding("y-o", "subminer-options", open_options)
- mp.add_key_binding("y-r", "subminer-restart", restart_overlay)
- mp.add_key_binding("y-c", "subminer-status", check_status)
- if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then
- mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", skip_intro_now)
- end
- if opts.aniskip_button_key ~= "y-k" then
- mp.add_key_binding("y-k", "subminer-skip-intro-fallback", skip_intro_now)
- end
-end
-
-local function register_script_messages()
- mp.register_script_message("subminer-start", start_overlay_from_script_message)
- mp.register_script_message("subminer-stop", stop_overlay)
- mp.register_script_message("subminer-toggle", toggle_overlay)
- mp.register_script_message("subminer-menu", show_menu)
- mp.register_script_message("subminer-options", open_options)
- mp.register_script_message("subminer-restart", restart_overlay)
- mp.register_script_message("subminer-status", check_status)
- mp.register_script_message("subminer-aniskip-refresh", fetch_aniskip_for_current_media)
- mp.register_script_message("subminer-skip-intro", skip_intro_now)
- mp.register_script_message(HOVER_MESSAGE_NAME, function(payload_json)
- handle_hover_message(payload_json)
- end)
- mp.register_script_message(HOVER_MESSAGE_NAME_LEGACY, function(payload_json)
- handle_hover_message(payload_json)
- end)
-end
-
-local function init()
- register_keybindings()
- register_script_messages()
-
- mp.register_event("file-loaded", on_file_loaded)
- mp.register_event("shutdown", on_shutdown)
- mp.register_event("file-loaded", clear_hover_overlay)
- mp.register_event("end-file", clear_hover_overlay)
- mp.register_event("shutdown", clear_hover_overlay)
- mp.register_event("end-file", clear_aniskip_state)
- mp.register_event("shutdown", clear_aniskip_state)
- mp.add_hook("on_unload", 10, function()
- clear_hover_overlay()
- clear_aniskip_state()
- end)
- mp.observe_property("sub-start", "native", function()
- clear_hover_overlay()
- end)
- mp.observe_property("time-pos", "number", function()
- update_intro_button_visibility()
- end)
-
- subminer_log("info", "lifecycle", "SubMiner plugin loaded")
-end
-
-init()
diff --git a/plugin/subminer/aniskip_match.lua b/plugin/subminer/aniskip_match.lua
new file mode 100644
index 0000000..b33d830
--- /dev/null
+++ b/plugin/subminer/aniskip_match.lua
@@ -0,0 +1,150 @@
+local M = {}
+
+local function normalize_for_match(value)
+ if type(value) ~= "string" then
+ return ""
+ end
+ return value:lower():gsub("[^%w]+", " "):gsub("%s+", " "):match("^%s*(.-)%s*$") or ""
+end
+
+local MATCH_STOPWORDS = {
+ the = true,
+ this = true,
+ that = true,
+ world = true,
+ animated = true,
+ series = true,
+ season = true,
+ no = true,
+ on = true,
+ ["and"] = true,
+}
+
+local function tokenize_match_words(value)
+ local normalized = normalize_for_match(value)
+ local tokens = {}
+ for token in normalized:gmatch("%S+") do
+ if #token >= 3 and not MATCH_STOPWORDS[token] then
+ tokens[#tokens + 1] = token
+ end
+ end
+ return tokens
+end
+
+local function token_set(tokens)
+ local set = {}
+ for _, token in ipairs(tokens) do
+ set[token] = true
+ end
+ return set
+end
+
+function M.title_overlap_score(expected_title, candidate_title)
+ local expected = normalize_for_match(expected_title)
+ local candidate = normalize_for_match(candidate_title)
+ if expected == "" or candidate == "" then
+ return 0
+ end
+ if candidate:find(expected, 1, true) then
+ return 120
+ end
+ local expected_tokens = tokenize_match_words(expected_title)
+ local candidate_tokens = token_set(tokenize_match_words(candidate_title))
+ if #expected_tokens == 0 then
+ return 0
+ end
+ local score = 0
+ local matched = 0
+ for _, token in ipairs(expected_tokens) do
+ if candidate_tokens[token] then
+ score = score + 30
+ matched = matched + 1
+ else
+ score = score - 20
+ end
+ end
+ if matched == 0 then
+ score = score - 80
+ end
+ local coverage = matched / #expected_tokens
+ if #expected_tokens >= 2 then
+ if coverage >= 0.8 then
+ score = score + 30
+ elseif coverage >= 0.6 then
+ score = score + 10
+ else
+ score = score - 50
+ end
+ elseif coverage >= 1 then
+ score = score + 10
+ end
+ return score
+end
+
+local function has_any_sequel_marker(candidate_title)
+ local normalized = normalize_for_match(candidate_title)
+ if normalized == "" then
+ return false
+ end
+ local markers = {
+ "season 2",
+ "season 3",
+ "season 4",
+ "2nd season",
+ "3rd season",
+ "4th season",
+ "second season",
+ "third season",
+ "fourth season",
+ " ii ",
+ " iii ",
+ " iv ",
+ }
+ local padded = " " .. normalized .. " "
+ for _, marker in ipairs(markers) do
+ if padded:find(marker, 1, true) then
+ return true
+ end
+ end
+ return false
+end
+
+function M.season_signal_score(requested_season, candidate_title)
+ local season = tonumber(requested_season)
+ if not season or season < 1 then
+ return 0
+ end
+ local normalized = " " .. normalize_for_match(candidate_title) .. " "
+ if normalized == " " then
+ return 0
+ end
+
+ if season == 1 then
+ return has_any_sequel_marker(candidate_title) and -60 or 20
+ end
+
+ local numeric_marker = string.format(" season %d ", season)
+ local ordinal_marker = string.format(" %dth season ", season)
+ local roman_markers = {
+ [2] = { " ii ", " second season ", " 2nd season " },
+ [3] = { " iii ", " third season ", " 3rd season " },
+ [4] = { " iv ", " fourth season ", " 4th season " },
+ [5] = { " v ", " fifth season ", " 5th season " },
+ }
+
+ if normalized:find(numeric_marker, 1, true) or normalized:find(ordinal_marker, 1, true) then
+ return 40
+ end
+ local aliases = roman_markers[season] or {}
+ for _, marker in ipairs(aliases) do
+ if normalized:find(marker, 1, true) then
+ return 40
+ end
+ end
+ if has_any_sequel_marker(candidate_title) then
+ return -20
+ end
+ return 5
+end
+
+return M
diff --git a/plugin/subminer/binary.lua b/plugin/subminer/binary.lua
new file mode 100644
index 0000000..5a065c5
--- /dev/null
+++ b/plugin/subminer/binary.lua
@@ -0,0 +1,151 @@
+local M = {}
+
+function M.create(ctx)
+ local utils = ctx.utils
+ local opts = ctx.opts
+ local state = ctx.state
+ local environment = ctx.environment
+ local subminer_log = ctx.log.subminer_log
+
+ local function normalize_binary_path_candidate(candidate)
+ if type(candidate) ~= "string" then
+ return nil
+ end
+ local trimmed = candidate:match("^%s*(.-)%s*$") or ""
+ if trimmed == "" then
+ return nil
+ end
+ if #trimmed >= 2 then
+ local first = trimmed:sub(1, 1)
+ local last = trimmed:sub(-1)
+ if (first == '"' and last == '"') or (first == "'" and last == "'") then
+ trimmed = trimmed:sub(2, -2)
+ end
+ end
+ return trimmed ~= "" and trimmed or nil
+ end
+
+ local function binary_candidates_from_app_path(app_path)
+ return {
+ utils.join_path(app_path, "Contents", "MacOS", "SubMiner"),
+ utils.join_path(app_path, "Contents", "MacOS", "subminer"),
+ }
+ end
+
+ local function file_exists(path)
+ local info = utils.file_info(path)
+ if not info then
+ return false
+ end
+ if info.is_dir ~= nil then
+ return not info.is_dir
+ end
+ return true
+ end
+
+ local function resolve_binary_candidate(candidate)
+ local normalized = normalize_binary_path_candidate(candidate)
+ if not normalized then
+ return nil
+ end
+
+ if file_exists(normalized) then
+ return normalized
+ end
+
+ if not normalized:lower():find("%.app") then
+ return nil
+ end
+
+ local app_root = normalized
+ if not app_root:lower():match("%.app$") then
+ app_root = normalized:match("(.+%.app)")
+ end
+ if not app_root then
+ return nil
+ end
+
+ for _, path in ipairs(binary_candidates_from_app_path(app_root)) do
+ if file_exists(path) then
+ return path
+ end
+ end
+
+ return nil
+ end
+
+ local function find_binary_override()
+ local candidates = {
+ resolve_binary_candidate(os.getenv("SUBMINER_APPIMAGE_PATH")),
+ resolve_binary_candidate(os.getenv("SUBMINER_BINARY_PATH")),
+ }
+
+ for _, path in ipairs(candidates) do
+ if path and path ~= "" then
+ return path
+ end
+ end
+
+ return nil
+ end
+
+ local function find_binary()
+ local override = find_binary_override()
+ if override then
+ return override
+ end
+
+ local configured = resolve_binary_candidate(opts.binary_path)
+ if configured then
+ return configured
+ end
+
+ local search_paths = {
+ "/Applications/SubMiner.app/Contents/MacOS/SubMiner",
+ utils.join_path(os.getenv("HOME") or "", "Applications/SubMiner.app/Contents/MacOS/SubMiner"),
+ "C:\\Program Files\\SubMiner\\SubMiner.exe",
+ "C:\\Program Files (x86)\\SubMiner\\SubMiner.exe",
+ "C:\\SubMiner\\SubMiner.exe",
+ utils.join_path(os.getenv("HOME") or "", ".local/bin/SubMiner.AppImage"),
+ "/opt/SubMiner/SubMiner.AppImage",
+ "/usr/local/bin/SubMiner",
+ "/usr/bin/SubMiner",
+ }
+
+ for _, path in ipairs(search_paths) do
+ if file_exists(path) then
+ subminer_log("info", "binary", "Found binary at: " .. path)
+ return path
+ end
+ end
+
+ return nil
+ end
+
+ local function ensure_binary_available()
+ if state.binary_available and state.binary_path and file_exists(state.binary_path) then
+ return true
+ end
+
+ local discovered = find_binary()
+ if discovered then
+ state.binary_path = discovered
+ state.binary_available = true
+ return true
+ end
+
+ state.binary_path = nil
+ state.binary_available = false
+ return false
+ end
+
+ return {
+ normalize_binary_path_candidate = normalize_binary_path_candidate,
+ file_exists = file_exists,
+ find_binary = find_binary,
+ ensure_binary_available = ensure_binary_available,
+ is_windows = environment.is_windows,
+ }
+end
+
+return M
diff --git a/plugin/subminer/bootstrap.lua b/plugin/subminer/bootstrap.lua
new file mode 100644
index 0000000..8ee85d7
--- /dev/null
+++ b/plugin/subminer/bootstrap.lua
@@ -0,0 +1,74 @@
+local M = {}
+
+function M.init()
+ local input = require("mp.input")
+ local mp = require("mp")
+ local msg = require("mp.msg")
+ local options_lib = require("mp.options")
+ local utils = require("mp.utils")
+
+ local options_helper = require("options")
+ local environment = require("environment").create({ mp = mp })
+ local opts = options_helper.load(options_lib, environment.default_socket_path())
+ local state = require("state").new()
+
+ local ctx = {
+ input = input,
+ mp = mp,
+ msg = msg,
+ utils = utils,
+ opts = opts,
+ state = state,
+ options_helper = options_helper,
+ environment = environment,
+ }
+
+ local instances = {}
+
+ local function lazy_instance(key, factory)
+ if instances[key] == nil then
+ instances[key] = factory()
+ end
+ return instances[key]
+ end
+
+ local function make_lazy_proxy(key, factory)
+ return setmetatable({}, {
+ __index = function(_, member)
+ return lazy_instance(key, factory)[member]
+ end,
+ })
+ end
+
+ ctx.log = make_lazy_proxy("log", function()
+ return require("log").create(ctx)
+ end)
+ ctx.binary = make_lazy_proxy("binary", function()
+ return require("binary").create(ctx)
+ end)
+ ctx.aniskip = make_lazy_proxy("aniskip", function()
+ return require("aniskip").create(ctx)
+ end)
+ ctx.hover = make_lazy_proxy("hover", function()
+ return require("hover").create(ctx)
+ end)
+ ctx.process = make_lazy_proxy("process", function()
+ return require("process").create(ctx)
+ end)
+ ctx.ui = make_lazy_proxy("ui", function()
+ return require("ui").create(ctx)
+ end)
+ ctx.messages = make_lazy_proxy("messages", function()
+ return require("messages").create(ctx)
+ end)
+ ctx.lifecycle = make_lazy_proxy("lifecycle", function()
+ return require("lifecycle").create(ctx)
+ end)
+
+ ctx.ui.register_keybindings()
+ ctx.messages.register_script_messages()
+ ctx.lifecycle.register_lifecycle_hooks()
+ ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded")
+end
+
+return M
diff --git a/plugin/subminer/hover.lua b/plugin/subminer/hover.lua
new file mode 100644
index 0000000..e13b103
--- /dev/null
+++ b/plugin/subminer/hover.lua
@@ -0,0 +1,445 @@
+local M = {}
+
+local DEFAULT_HOVER_BASE_COLOR = "FFFFFF"
+local DEFAULT_HOVER_COLOR = "C6A0F6"
+
+function M.create(ctx)
+ local mp = ctx.mp
+ local msg = ctx.msg
+ local utils = ctx.utils
+ local state = ctx.state
+
+ local function to_hex_color(input)
+ if type(input) ~= "string" then
+ return nil
+ end
+
+ local hex = input:gsub("[%#%']", ""):gsub("^0x", "")
+ if #hex ~= 6 and #hex ~= 3 then
+ return nil
+ end
+ if #hex == 3 then
+ return hex:sub(1, 1) .. hex:sub(1, 1) .. hex:sub(2, 2) .. hex:sub(2, 2) .. hex:sub(3, 3) .. hex:sub(3, 3)
+ end
+ return hex
+ end
+
+ local function fix_ass_color(input, fallback)
+ local hex = to_hex_color(input)
+ if not hex then
+ return fallback or DEFAULT_HOVER_BASE_COLOR
+ end
+ local r, g, b = hex:sub(1, 2), hex:sub(3, 4), hex:sub(5, 6)
+ return b .. g .. r
+ end
+
+ local function sanitize_hover_ass_color(input, fallback_rgb)
+ local fallback = fix_ass_color(fallback_rgb or DEFAULT_HOVER_COLOR, DEFAULT_HOVER_COLOR)
+ local converted = fix_ass_color(input, fallback)
+ if converted == "000000" then
+ return fallback
+ end
+ return converted
+ end
+
+ local function escape_ass_text(text)
+ return (text or ""):gsub("\\", "\\\\"):gsub("{", "\\{"):gsub("}", "\\}"):gsub("\n", "\\N")
+ end
+
+ local function resolve_osd_dimensions()
+ local width = mp.get_property_number("osd-width", 0) or 0
+ local height = mp.get_property_number("osd-height", 0) or 0
+
+ if width <= 0 or height <= 0 then
+ local osd_dims = mp.get_property_native("osd-dimensions")
+ if type(osd_dims) == "table" and type(osd_dims.w) == "number" and osd_dims.w > 0 then
+ width = osd_dims.w
+ end
+ if type(osd_dims) == "table" and type(osd_dims.h) == "number" and osd_dims.h > 0 then
+ height = osd_dims.h
+ end
+ end
+
+ if width <= 0 then
+ width = 1280
+ end
+ if height <= 0 then
+ height = 720
+ end
+
+ return width, height
+ end
+
+ local function resolve_metrics()
+ local sub_font_size = mp.get_property_number("sub-font-size", 36) or 36
+ local sub_scale = mp.get_property_number("sub-scale", 1) or 1
+ local sub_scale_by_window = mp.get_property_bool("sub-scale-by-window", true) == true
+ local sub_pos = mp.get_property_number("sub-pos", 100) or 100
+ local sub_margin_y = mp.get_property_number("sub-margin-y", 0) or 0
+ local sub_font = mp.get_property("sub-font", "sans-serif") or "sans-serif"
+ local sub_spacing = mp.get_property_number("sub-spacing", 0) or 0
+ local sub_bold = mp.get_property_bool("sub-bold", false) == true
+ local sub_italic = mp.get_property_bool("sub-italic", false) == true
+ local sub_border_size = mp.get_property_number("sub-border-size", 2) or 2
+ local sub_shadow_offset = mp.get_property_number("sub-shadow-offset", 0) or 0
+ local osd_w, osd_h = resolve_osd_dimensions()
+ local window_scale = 1
+ if sub_scale_by_window and osd_h > 0 then
+ window_scale = osd_h / 720
+ end
+ local effective_margin_y = sub_margin_y * window_scale
+
+ return {
+ font_size = sub_font_size * (sub_scale > 0 and sub_scale or 1) * window_scale,
+ pos = sub_pos,
+ margin_y = effective_margin_y,
+ font = sub_font,
+ spacing = sub_spacing,
+ bold = sub_bold,
+ italic = sub_italic,
+ border = sub_border_size * window_scale,
+ shadow = sub_shadow_offset * window_scale,
+ base_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_BASE_COLOR),
+ hover_color = sanitize_hover_ass_color(nil, DEFAULT_HOVER_COLOR),
+ }
+ end
+
+ local function get_subtitle_ass_property()
+ local ass_text = mp.get_property("sub-text/ass")
+ if type(ass_text) == "string" and ass_text ~= "" then
+ return ass_text
+ end
+ ass_text = mp.get_property("sub-text-ass")
+ if type(ass_text) == "string" and ass_text ~= "" then
+ return ass_text
+ end
+ return nil
+ end
+
+ local function plain_text_and_ass_map(text)
+ local plain = {}
+ local map = {}
+ local plain_len = 0
+ local i = 1
+ local text_len = #text
+
+ while i <= text_len do
+ local ch = text:sub(i, i)
+ if ch == "{" then
+ local close = text:find("}", i + 1, true)
+ if not close then
+ break
+ end
+ i = close + 1
+ elseif ch == "\\" then
+ local esc = text:sub(i + 1, i + 1)
+ if esc == "N" or esc == "n" then
+ plain_len = plain_len + 1
+ plain[plain_len] = "\n"
+ map[plain_len] = i
+ i = i + 2
+ elseif esc == "h" then
+ plain_len = plain_len + 1
+ plain[plain_len] = " "
+ map[plain_len] = i
+ i = i + 2
+ elseif esc == "{" then
+ plain_len = plain_len + 1
+ plain[plain_len] = "{"
+ map[plain_len] = i
+ i = i + 2
+ elseif esc == "}" then
+ plain_len = plain_len + 1
+ plain[plain_len] = "}"
+ map[plain_len] = i
+ i = i + 2
+ elseif esc == "\\" then
+ plain_len = plain_len + 1
+ plain[plain_len] = "\\"
+ map[plain_len] = i
+ i = i + 2
+ else
+ local seq_end = i + 1
+ while seq_end <= text_len and text:sub(seq_end, seq_end):match("[%a]") do
+ seq_end = seq_end + 1
+ end
+ if text:sub(seq_end, seq_end) == "(" then
+ local close = text:find(")", seq_end, true)
+ if close then
+ i = close + 1
+ else
+ i = seq_end + 1
+ end
+ else
+ i = seq_end + 1
+ end
+ end
+ else
+ plain_len = plain_len + 1
+ plain[plain_len] = ch
+ map[plain_len] = i
+ i = i + 1
+ end
+ end
+
+ return table.concat(plain), map
+ end
+
+ local function find_hover_span(payload, plain)
+ local source_len = #plain
+ local cursor = 1
+ for _, token in ipairs(payload.tokens or {}) do
+ if type(token) ~= "table" or type(token.text) ~= "string" or token.text == "" then
+ goto continue
+ end
+
+ local token_text = token.text
+ local start_pos = nil
+ local end_pos = nil
+
+ if type(token.startPos) == "number" and type(token.endPos) == "number" then
+ if token.startPos >= 0 and token.endPos >= token.startPos then
+ local candidate_start = token.startPos + 1
+ local candidate_stop = token.endPos
+ if candidate_start >= 1 and candidate_stop <= source_len and candidate_stop >= candidate_start and plain:sub(candidate_start, candidate_stop) == token_text then
+ start_pos = candidate_start
+ end_pos = candidate_stop
+ end
+ end
+ end
+
+ if not start_pos or not end_pos then
+ local fallback_start, fallback_stop = plain:find(token_text, cursor, true)
+ if not fallback_start then
+ fallback_start, fallback_stop = plain:find(token_text, 1, true)
+ end
+ start_pos, end_pos = fallback_start, fallback_stop
+ end
+
+ if start_pos and end_pos then
+ if token.index == payload.hoveredTokenIndex then
+ return start_pos, end_pos
+ end
+ cursor = end_pos + 1
+ end
+
+ ::continue::
+ end
+
+ return nil
+ end
+
+ local function inject_hover_color_to_ass(raw_ass, plain_map, hover_start, hover_end, hover_color, base_color)
+ if hover_start == nil or hover_end == nil then
+ return raw_ass
+ end
+
+ local raw_open_idx = plain_map[hover_start] or 1
+ local raw_close_idx = plain_map[hover_end + 1] or (#raw_ass + 1)
+ if raw_open_idx < 1 then
+ raw_open_idx = 1
+ end
+ if raw_close_idx < 1 then
+ raw_close_idx = 1
+ end
+ if raw_open_idx > #raw_ass + 1 then
+ raw_open_idx = #raw_ass + 1
+ end
+ if raw_close_idx > #raw_ass + 1 then
+ raw_close_idx = #raw_ass + 1
+ end
+
+ local open_tag = string.format("{\\1c&H%s&}", hover_color)
+ local close_tag = string.format("{\\1c&H%s&}", base_color)
+ local changes = {
+ { idx = raw_open_idx, tag = open_tag },
+ { idx = raw_close_idx, tag = close_tag },
+ }
+ table.sort(changes, function(a, b)
+ return a.idx < b.idx
+ end)
+
+ local output = {}
+ local cursor = 1
+ for _, change in ipairs(changes) do
+ if change.idx > #raw_ass + 1 then
+ change.idx = #raw_ass + 1
+ end
+ if change.idx < 1 then
+ change.idx = 1
+ end
+ if change.idx > cursor then
+ output[#output + 1] = raw_ass:sub(cursor, change.idx - 1)
+ end
+ output[#output + 1] = change.tag
+ cursor = change.idx
+ end
+ if cursor <= #raw_ass then
+ output[#output + 1] = raw_ass:sub(cursor)
+ end
+
+ return table.concat(output)
+ end
+
+ local function build_hover_subtitle_content(payload)
+ local source_ass = get_subtitle_ass_property()
+ if type(source_ass) == "string" and source_ass ~= "" then
+ state.hover_highlight.cached_ass = source_ass
+ else
+ source_ass = state.hover_highlight.cached_ass
+ end
+ if type(source_ass) ~= "string" or source_ass == "" then
+ return nil
+ end
+
+ local plain_source, plain_map = plain_text_and_ass_map(source_ass)
+ if type(plain_source) ~= "string" or plain_source == "" then
+ return nil
+ end
+
+ local hover_start, hover_end = find_hover_span(payload, plain_source)
+ if not hover_start or not hover_end then
+ return nil
+ end
+
+ local metrics = resolve_metrics()
+ local hover_color = sanitize_hover_ass_color(payload.colors and payload.colors.hover or nil, DEFAULT_HOVER_COLOR)
+ local base_color = fix_ass_color(payload.colors and payload.colors.base or nil, metrics.base_color)
+ return inject_hover_color_to_ass(source_ass, plain_map, hover_start, hover_end, hover_color, base_color)
+ end
+
+ local function clear_hover_overlay()
+ if state.hover_highlight.clear_timer then
+ state.hover_highlight.clear_timer:kill()
+ state.hover_highlight.clear_timer = nil
+ end
+ if state.hover_highlight.overlay_active then
+ if type(state.hover_highlight.saved_sub_visibility) == "string" then
+ mp.set_property("sub-visibility", state.hover_highlight.saved_sub_visibility)
+ else
+ mp.set_property("sub-visibility", "yes")
+ end
+ if type(state.hover_highlight.saved_secondary_sub_visibility) == "string" then
+ mp.set_property("secondary-sub-visibility", state.hover_highlight.saved_secondary_sub_visibility)
+ end
+ state.hover_highlight.saved_sub_visibility = nil
+ state.hover_highlight.saved_secondary_sub_visibility = nil
+ state.hover_highlight.overlay_active = false
+ end
+ mp.set_osd_ass(0, 0, "")
+ state.hover_highlight.payload = nil
+ state.hover_highlight.revision = -1
+ state.hover_highlight.cached_ass = nil
+ state.hover_highlight.last_hover_update_ts = 0
+ end
+
+ local function schedule_hover_clear(delay_seconds)
+ if state.hover_highlight.clear_timer then
+ state.hover_highlight.clear_timer:kill()
+ state.hover_highlight.clear_timer = nil
+ end
+ state.hover_highlight.clear_timer = mp.add_timeout(delay_seconds or 0.08, function()
+ state.hover_highlight.clear_timer = nil
+ clear_hover_overlay()
+ end)
+ end
+
+ local function render_hover_overlay(payload)
+ if not payload or payload.hoveredTokenIndex == nil or payload.subtitle == nil then
+ clear_hover_overlay()
+ return
+ end
+
+ local ass = build_hover_subtitle_content(payload)
+ if not ass then
+ return
+ end
+
+ local osd_w, osd_h = resolve_osd_dimensions()
+ local metrics = resolve_metrics()
+ local osd_dims = mp.get_property_native("osd-dimensions")
+ local ml = (type(osd_dims) == "table" and type(osd_dims.ml) == "number") and osd_dims.ml or 0
+ local mr = (type(osd_dims) == "table" and type(osd_dims.mr) == "number") and osd_dims.mr or 0
+ local mt = (type(osd_dims) == "table" and type(osd_dims.mt) == "number") and osd_dims.mt or 0
+ local mb = (type(osd_dims) == "table" and type(osd_dims.mb) == "number") and osd_dims.mb or 0
+ local usable_w = math.max(1, osd_w - ml - mr)
+ local usable_h = math.max(1, osd_h - mt - mb)
+ local anchor_x = math.floor(ml + usable_w / 2)
+ local baseline_adjust = (metrics.border + metrics.shadow) * 5
+ local anchor_y = math.floor(mt + (usable_h * metrics.pos / 100) - metrics.margin_y + baseline_adjust)
+ local font_size = math.max(8, metrics.font_size)
+ local anchor_tag = string.format(
+ "{\\an2\\q2\\pos(%d,%d)\\fn%s\\fs%g\\b%d\\i%d\\fsp%g\\bord%g\\shad%g\\1c&H%s&}",
+ anchor_x,
+ anchor_y,
+ escape_ass_text(metrics.font),
+ font_size,
+ metrics.bold and 1 or 0,
+ metrics.italic and 1 or 0,
+ metrics.spacing,
+ metrics.border,
+ metrics.shadow,
+ metrics.base_color
+ )
+ if not state.hover_highlight.overlay_active then
+ state.hover_highlight.saved_sub_visibility = mp.get_property("sub-visibility")
+ state.hover_highlight.saved_secondary_sub_visibility = mp.get_property("secondary-sub-visibility")
+ mp.set_property("sub-visibility", "no")
+ mp.set_property("secondary-sub-visibility", "no")
+ state.hover_highlight.overlay_active = true
+ end
+ mp.set_osd_ass(osd_w, osd_h, anchor_tag .. ass)
+ end
+
+ local function handle_hover_message(payload_json)
+ local parsed, parse_error = utils.parse_json(payload_json)
+ if not parsed then
+ msg.warn("Invalid hover-highlight payload: " .. tostring(parse_error))
+ clear_hover_overlay()
+ return
+ end
+
+ if type(parsed.revision) ~= "number" then
+ clear_hover_overlay()
+ return
+ end
+
+ if parsed.revision < state.hover_highlight.revision then
+ return
+ end
+
+ if type(parsed.hoveredTokenIndex) == "number" and type(parsed.tokens) == "table" then
+ if state.hover_highlight.clear_timer then
+ state.hover_highlight.clear_timer:kill()
+ state.hover_highlight.clear_timer = nil
+ end
+ state.hover_highlight.revision = parsed.revision
+ state.hover_highlight.payload = parsed
+ state.hover_highlight.last_hover_update_ts = mp.get_time() or 0
+ render_hover_overlay(state.hover_highlight.payload)
+ return
+ end
+
+ local now = mp.get_time() or 0
+ local elapsed_since_hover = now - (state.hover_highlight.last_hover_update_ts or 0)
+ state.hover_highlight.revision = parsed.revision
+ state.hover_highlight.payload = nil
+ if state.hover_highlight.overlay_active then
+ if elapsed_since_hover > 0.35 then
+ return
+ end
+ schedule_hover_clear(0.08)
+ else
+ clear_hover_overlay()
+ end
+ end
+
+ return {
+ HOVER_MESSAGE_NAME = "subminer-hover-token",
+ HOVER_MESSAGE_NAME_LEGACY = "yomipv-hover-token",
+ handle_hover_message = handle_hover_message,
+ clear_hover_overlay = clear_hover_overlay,
+ }
+end
+
+return M
diff --git a/plugin/subminer/init.lua b/plugin/subminer/init.lua
new file mode 100644
index 0000000..0371a87
--- /dev/null
+++ b/plugin/subminer/init.lua
@@ -0,0 +1,7 @@
+local M = {}
+
+function M.init()
+ require("bootstrap").init()
+end
+
+return M
diff --git a/plugin/subminer/log.lua b/plugin/subminer/log.lua
new file mode 100644
index 0000000..6554a52
--- /dev/null
+++ b/plugin/subminer/log.lua
@@ -0,0 +1,60 @@
+local M = {}
+
+local LOG_LEVEL_PRIORITY = {
+ debug = 10,
+ info = 20,
+ warn = 30,
+ error = 40,
+}
+
+function M.create(ctx)
+ local mp = ctx.mp
+ local msg = ctx.msg
+ local opts = ctx.opts
+
+ local function normalize_log_level(level)
+ local normalized = (level or "info"):lower()
+ if LOG_LEVEL_PRIORITY[normalized] then
+ return normalized
+ end
+ return "info"
+ end
+
+ local function should_log(level)
+ local current = normalize_log_level(opts.log_level)
+ local target = normalize_log_level(level)
+ return LOG_LEVEL_PRIORITY[target] >= LOG_LEVEL_PRIORITY[current]
+ end
+
+ local function subminer_log(level, scope, message)
+ if not should_log(level) then
+ return
+ end
+ local timestamp = os.date("%Y-%m-%d %H:%M:%S")
+ local line = string.format("[subminer] - %s - %s - [%s] %s", timestamp, string.upper(level), scope, message)
+ if level == "error" then
+ msg.error(line)
+ elseif level == "warn" then
+ msg.warn(line)
+ elseif level == "debug" then
+ msg.debug(line)
+ else
+ msg.info(line)
+ end
+ end
+
+ local function show_osd(message)
+ if opts.osd_messages then
+ mp.osd_message("SubMiner: " .. message, 3)
+ end
+ end
+
+ return {
+ normalize_log_level = normalize_log_level,
+ should_log = should_log,
+ subminer_log = subminer_log,
+ show_osd = show_osd,
+ }
+end
+
+return M
diff --git a/plugin/subminer/main.lua b/plugin/subminer/main.lua
new file mode 100644
index 0000000..6f136ef
--- /dev/null
+++ b/plugin/subminer/main.lua
@@ -0,0 +1,25 @@
+local mp = require("mp")
+
+local function current_script_dir()
+ if type(mp.get_script_directory) == "function" then
+ local from_mpv = mp.get_script_directory()
+ if type(from_mpv) == "string" and from_mpv ~= "" then
+ return from_mpv
+ end
+ end
+
+ local source = debug.getinfo(1, "S").source or ""
+ if source:sub(1, 1) == "@" then
+ local full = source:sub(2)
+ return full:match("^(.*)[/\\][^/\\]+$") or "."
+ end
+ return "."
+end
+
+local script_dir = current_script_dir()
+local module_patterns = script_dir .. "/?.lua;" .. script_dir .. "/?/init.lua;"
+if not package.path:find(module_patterns, 1, true) then
+ package.path = module_patterns .. package.path
+end
+
+require("init").init()
diff --git a/plugin/subminer/options.lua b/plugin/subminer/options.lua
new file mode 100644
index 0000000..2efa5f9
--- /dev/null
+++ b/plugin/subminer/options.lua
@@ -0,0 +1,45 @@
+local M = {}
+
+function M.load(options_lib, default_socket_path)
+ local opts = {
+ binary_path = "",
+ socket_path = default_socket_path,
+ texthooker_enabled = true,
+ texthooker_port = 5174,
+ backend = "auto",
+ auto_start = true,
+ auto_start_visible_overlay = false,
+ osd_messages = true,
+ log_level = "info",
+ aniskip_enabled = true,
+ aniskip_title = "",
+ aniskip_season = "",
+ aniskip_mal_id = "",
+ aniskip_episode = "",
+ aniskip_show_button = true,
+ aniskip_button_text = "You can skip by pressing %s",
+ aniskip_button_key = "y-k",
+ aniskip_button_duration = 3,
+ }
+
+ options_lib.read_options(opts, "subminer")
+ return opts
+end
+
+function M.coerce_bool(value, fallback)
+ if type(value) == "boolean" then
+ return value
+ end
+ if type(value) == "string" then
+ local normalized = value:lower()
+ if normalized == "yes" or normalized == "true" or normalized == "1" or normalized == "on" then
+ return true
+ end
+ if normalized == "no" or normalized == "false" or normalized == "0" or normalized == "off" then
+ return false
+ end
+ end
+ return fallback
+end
+
+return M
diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua
new file mode 100644
index 0000000..6ea5ccf
--- /dev/null
+++ b/plugin/subminer/state.lua
@@ -0,0 +1,33 @@
+local M = {}
+
+function M.new()
+ return {
+ overlay_running = false,
+ texthooker_running = false,
+ overlay_process = nil,
+ binary_available = false,
+ binary_path = nil,
+ detected_backend = nil,
+ hover_highlight = {
+ revision = -1,
+ payload = nil,
+ saved_sub_visibility = nil,
+ saved_secondary_sub_visibility = nil,
+ overlay_active = false,
+ cached_ass = nil,
+ clear_timer = nil,
+ last_hover_update_ts = 0,
+ },
+ aniskip = {
+ mal_id = nil,
+ title = nil,
+ episode = nil,
+ intro_start = nil,
+ intro_end = nil,
+ found = false,
+ prompt_shown = false,
+ },
+ }
+end
+
+return M
diff --git a/plugin/subminer/ui.lua b/plugin/subminer/ui.lua
new file mode 100644
index 0000000..e931825
--- /dev/null
+++ b/plugin/subminer/ui.lua
@@ -0,0 +1,105 @@
+local M = {}
+
+function M.create(ctx)
+ local mp = ctx.mp
+ local input = ctx.input
+ local opts = ctx.opts
+ local process = ctx.process
+ local aniskip = ctx.aniskip
+ local subminer_log = ctx.log.subminer_log
+ local show_osd = ctx.log.show_osd
+
+ local function ensure_binary_for_menu()
+ if process.check_binary_available() then
+ return true
+ end
+ subminer_log("error", "binary", "SubMiner binary not found")
+ show_osd("Error: binary not found")
+ return false
+ end
+
+ local function show_menu()
+ if not ensure_binary_for_menu() then
+ return
+ end
+
+ local items = {
+ "Start overlay",
+ "Stop overlay",
+ "Toggle overlay",
+ "Open options",
+ "Restart overlay",
+ "Check status",
+ }
+
+ local actions = {
+ function()
+ process.start_overlay()
+ end,
+ function()
+ process.stop_overlay()
+ end,
+ function()
+ process.toggle_overlay()
+ end,
+ function()
+ process.open_options()
+ end,
+ function()
+ process.restart_overlay()
+ end,
+ function()
+ process.check_status()
+ end,
+ }
+
+ input.select({
+ prompt = "SubMiner: ",
+ items = items,
+ submit = function(index)
+ if index and actions[index] then
+ actions[index]()
+ end
+ end,
+ })
+ end
+
+ local function register_keybindings()
+ mp.add_key_binding("y-s", "subminer-start", function()
+ process.start_overlay()
+ end)
+ mp.add_key_binding("y-S", "subminer-stop", function()
+ process.stop_overlay()
+ end)
+ mp.add_key_binding("y-t", "subminer-toggle", function()
+ process.toggle_overlay()
+ end)
+ mp.add_key_binding("y-y", "subminer-menu", show_menu)
+ mp.add_key_binding("y-o", "subminer-options", function()
+ process.open_options()
+ end)
+ mp.add_key_binding("y-r", "subminer-restart", function()
+ process.restart_overlay()
+ end)
+ mp.add_key_binding("y-c", "subminer-status", function()
+ process.check_status()
+ end)
+ if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then
+ mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", function()
+ aniskip.skip_intro_now()
+ end)
+ end
+ if opts.aniskip_button_key ~= "y-k" then
+ mp.add_key_binding("y-k", "subminer-skip-intro-fallback", function()
+ aniskip.skip_intro_now()
+ end)
+ end
+ end
+
+ return {
+ show_menu = show_menu,
+ register_keybindings = register_keybindings,
+ }
+end
+
+return M