mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
refactor(plugin): split mpv plugin into modules and trim startup overhead
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,7 +7,7 @@ dist/
|
||||
release/
|
||||
|
||||
# Launcher build artifact (produced by make build-launcher)
|
||||
subminer
|
||||
/subminer
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
7
Makefile
7
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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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/<br/>CLI dispatch"]:::extrt
|
||||
Plugin["subminer.lua<br/>mpv plugin"]:::extrt
|
||||
Plugin["subminer/main.lua<br/>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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -194,15 +194,26 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
}
|
||||
|
||||
await new Promise<void>((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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void>((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);
|
||||
|
||||
@@ -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\"",
|
||||
|
||||
@@ -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
|
||||
|
||||
1862
plugin/subminer.lua
1862
plugin/subminer.lua
File diff suppressed because it is too large
Load Diff
150
plugin/subminer/aniskip_match.lua
Normal file
150
plugin/subminer/aniskip_match.lua
Normal file
@@ -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
|
||||
151
plugin/subminer/binary.lua
Normal file
151
plugin/subminer/binary.lua
Normal file
@@ -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
|
||||
74
plugin/subminer/bootstrap.lua
Normal file
74
plugin/subminer/bootstrap.lua
Normal file
@@ -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
|
||||
445
plugin/subminer/hover.lua
Normal file
445
plugin/subminer/hover.lua
Normal file
@@ -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
|
||||
7
plugin/subminer/init.lua
Normal file
7
plugin/subminer/init.lua
Normal file
@@ -0,0 +1,7 @@
|
||||
local M = {}
|
||||
|
||||
function M.init()
|
||||
require("bootstrap").init()
|
||||
end
|
||||
|
||||
return M
|
||||
60
plugin/subminer/log.lua
Normal file
60
plugin/subminer/log.lua
Normal file
@@ -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
|
||||
25
plugin/subminer/main.lua
Normal file
25
plugin/subminer/main.lua
Normal file
@@ -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()
|
||||
45
plugin/subminer/options.lua
Normal file
45
plugin/subminer/options.lua
Normal file
@@ -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
|
||||
33
plugin/subminer/state.lua
Normal file
33
plugin/subminer/state.lua
Normal file
@@ -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
|
||||
105
plugin/subminer/ui.lua
Normal file
105
plugin/subminer/ui.lua
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user