Compare commits
69 Commits
main
...
refactor-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
b17e3ea32a
|
|||
|
4b0a2ec486
|
|||
|
2c001e8017
|
|||
|
dd0ed3f849
|
|||
|
c8b65a01f6
|
|||
|
73e70b4395
|
|||
|
d0f29cfeae
|
|||
|
5e74209b61
|
|||
|
05805a3169
|
|||
|
cf9a444e08
|
|||
|
30e3e858f6
|
|||
|
3fe6b8c926
|
|||
|
0e64b630d0
|
|||
|
fb948c6feb
|
|||
|
a46f90d085
|
|||
|
33007b3f40
|
|||
|
e78e45b4e7
|
|||
|
a80d6dbea9
|
|||
|
cbff3f9ad9
|
|||
|
e4038127cb
|
|||
|
4309e0dec3
|
|||
|
55c577e911
|
|||
|
fd77f8f6a2
|
|||
|
dcc82c8052
|
|||
|
93336afa07
|
|||
|
a7d220e182
|
|||
|
498fd2d09a
|
|||
|
d2af09d941
|
|||
|
9c2618c4c7
|
|||
|
bf333c7c08
|
|||
|
dac9a3429a
|
|||
|
536db5ff85
|
|||
|
39288a62b6
|
|||
|
93e392910c
|
|||
|
185528aee6
|
|||
|
870acb45d5
|
|||
|
40787e8b71
|
|||
|
98fd2a731e
|
|||
|
de8c15fd56
|
|||
|
370274e78a
|
|||
|
9e0c5e478e
|
|||
|
3f1702b0f6
|
|||
|
66c24767fb
|
|||
|
f8e961d105
|
|||
|
34a0feae71
|
|||
|
db5e3f9e50
|
|||
|
30a76d7767
|
|||
|
1e645f961b
|
|||
|
3a1d746a2e
|
|||
|
17fa10ba36
|
|||
|
d6c4a85a3b
|
|||
|
19c7448f26
|
|||
|
b212986682
|
|||
|
d07b0aa957
|
|||
|
603af36a48
|
|||
|
5ef3396205
|
|||
|
721036342d
|
|||
|
c7c91077fd
|
|||
|
771ea5777f
|
|||
|
151752b17a
|
|||
|
62f53071ec
|
|||
|
337e3268f1
|
|||
|
fa0cb00f70
|
|||
|
a33a87bf8f
|
|||
|
3c2c8453be
|
|||
|
3c5ba3a3d3
|
|||
|
1ae46cd4ba
|
|||
|
1e2b43a7dc
|
|||
|
0de278f3ab
|
2
.gitignore
vendored
@@ -7,7 +7,7 @@ dist/
|
|||||||
release/
|
release/
|
||||||
|
|
||||||
# Launcher build artifact (produced by make build-launcher)
|
# Launcher build artifact (produced by make build-launcher)
|
||||||
subminer
|
/subminer
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
<!-- BACKLOG.MD MCP GUIDELINES START -->
|
<!-- BACKLOG.MD MCP GUIDELINES START -->
|
||||||
|
|
||||||
<CRITICAL_INSTRUCTION>
|
<CRITICAL_INSTRUCTION>
|
||||||
@@ -17,6 +16,7 @@ This project uses Backlog.md MCP for all task and project management activities.
|
|||||||
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
|
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
|
||||||
|
|
||||||
These guides cover:
|
These guides cover:
|
||||||
|
|
||||||
- Decision framework for when to create tasks
|
- Decision framework for when to create tasks
|
||||||
- Search-first workflow to avoid duplicates
|
- Search-first workflow to avoid duplicates
|
||||||
- Links to detailed guides for task creation, execution, and finalization
|
- Links to detailed guides for task creation, execution, and finalization
|
||||||
|
|||||||
21
Makefile
@@ -1,10 +1,9 @@
|
|||||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config docs-dev docs docs-preview dev-start dev-start-macos dev-toggle dev-stop
|
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config docs-dev docs docs-preview docs-watch dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
|
||||||
|
|
||||||
APP_NAME := subminer
|
APP_NAME := subminer
|
||||||
THEME_SOURCE := assets/themes/subminer.rasi
|
THEME_SOURCE := assets/themes/subminer.rasi
|
||||||
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
|
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
|
||||||
THEME_FILE := subminer.rasi
|
THEME_FILE := subminer.rasi
|
||||||
PLUGIN_LUA := plugin/subminer.lua
|
|
||||||
PLUGIN_CONF := plugin/subminer.conf
|
PLUGIN_CONF := plugin/subminer.conf
|
||||||
|
|
||||||
# Default install prefix for the wrapper script.
|
# Default install prefix for the wrapper script.
|
||||||
@@ -53,9 +52,12 @@ help:
|
|||||||
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \
|
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \
|
||||||
" dev-start Build and launch local Electron app" \
|
" dev-start Build and launch local Electron app" \
|
||||||
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \
|
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \
|
||||||
|
" dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \
|
||||||
|
" dev-watch-macos Start watch loop with forced macOS tracker backend" \
|
||||||
" dev-toggle Toggle overlay in a running local Electron app" \
|
" dev-toggle Toggle overlay in a running local Electron app" \
|
||||||
" dev-stop Stop a running local Electron app" \
|
" dev-stop Stop a running local Electron app" \
|
||||||
" docs-dev Run VitePress docs dev server" \
|
" docs-dev Run VitePress docs dev server" \
|
||||||
|
" docs-watch Run VitePress docs dev + Backlog browser together" \
|
||||||
" docs Build VitePress static docs" \
|
" docs Build VitePress static docs" \
|
||||||
" docs-preview Preview built VitePress docs" \
|
" docs-preview Preview built VitePress docs" \
|
||||||
" install-linux Install Linux wrapper/theme/app artifacts" \
|
" install-linux Install Linux wrapper/theme/app artifacts" \
|
||||||
@@ -159,6 +161,9 @@ generate-example-config: ensure-bun
|
|||||||
docs-dev: ensure-bun
|
docs-dev: ensure-bun
|
||||||
@bun run docs:dev
|
@bun run docs:dev
|
||||||
|
|
||||||
|
docs-watch: ensure-bun
|
||||||
|
@bun run docs:watch
|
||||||
|
|
||||||
docs: ensure-bun
|
docs: ensure-bun
|
||||||
@bun run docs:build
|
@bun run docs:build
|
||||||
|
|
||||||
@@ -173,6 +178,12 @@ dev-start-macos: ensure-bun
|
|||||||
@bun run build
|
@bun run build
|
||||||
@bun run electron . --start --backend macos
|
@bun run electron . --start --backend macos
|
||||||
|
|
||||||
|
dev-watch: ensure-bun
|
||||||
|
@bash scripts/dev-watch.sh
|
||||||
|
|
||||||
|
dev-watch-macos: ensure-bun
|
||||||
|
@bash scripts/dev-watch.sh --start --dev --backend macos
|
||||||
|
|
||||||
dev-toggle: ensure-bun
|
dev-toggle: ensure-bun
|
||||||
@bun run electron . --toggle
|
@bun run electron . --toggle
|
||||||
|
|
||||||
@@ -218,10 +229,12 @@ install-macos: build-launcher
|
|||||||
install-plugin:
|
install-plugin:
|
||||||
@printf '%s\n' "[INFO] Installing mpv plugin artifacts"
|
@printf '%s\n' "[INFO] Installing mpv plugin artifacts"
|
||||||
@install -d "$(MPV_SCRIPTS_DIR)"
|
@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 -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"
|
@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 behavior kept unchanged by default.
|
||||||
uninstall: uninstall-linux
|
uninstall: uninstall-linux
|
||||||
|
|||||||
16
README.md
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](./assets/minecard.mp4)
|
[](./assets/minecard.mp4)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -26,7 +26,9 @@ 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
|
- **Hover to look up** — Yomitan dictionary popups directly on subtitles
|
||||||
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
|
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
|
||||||
- **N+1 highlighting** — Marks known words from your Anki deck so unknown ones jump out
|
- **Instant auto-enrichment** — Optional local AnkiConnect proxy enriches new Yomitan cards immediately
|
||||||
|
- **Reading annotations** — Combines N+1 targeting, frequency-dictionary highlighting, and JLPT underlining while you read
|
||||||
|
- **Hover-aware playback** — By default, hovering subtitle text pauses mpv and resumes on mouse leave (`subtitleStyle.autoPauseVideoOnHover`)
|
||||||
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
|
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
|
||||||
- **Immersion tracking** — SQLite-powered stats on your watch time and mining activity
|
- **Immersion tracking** — SQLite-powered stats on your watch time and mining activity
|
||||||
- **Custom texthooker page** — Built-in custom texthooker page and websocket, no extra setup
|
- **Custom texthooker page** — Built-in custom texthooker page and websocket, no extra setup
|
||||||
@@ -57,23 +59,25 @@ chmod +x ~/.local/bin/subminer
|
|||||||
```bash
|
```bash
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
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
|
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/
|
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
||||||
mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### 3. Set up Yomitan Dictionaries
|
### 3. Set up Yomitan Dictionaries
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer app --start --yomitan
|
subminer app --yomitan
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Mine
|
### 4. Mine
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer app --start --background
|
subminer app --start --background
|
||||||
subminer video.mkv # toggle invisible overlay with y-i and visible overlay with y-t
|
subminer video.mkv # default plugin config auto-starts visible overlay + resumes playback when ready
|
||||||
|
subminer --start video.mkv # optional explicit overlay start when plugin auto_start=no
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 141 KiB |
BIN
assets/kiku-integration.mkv
Normal file
BIN
assets/kiku-integration.mp4
Normal file
BIN
assets/minecard-poster.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 13 MiB After Width: | Height: | Size: 23 MiB |
BIN
assets/minecard.jpg
Normal file
|
After Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 523 KiB |
BIN
assets/minecard.webp
Normal file
|
After Width: | Height: | Size: 21 MiB |
@@ -1,11 +1,11 @@
|
|||||||
project_name: "SubMiner"
|
project_name: 'SubMiner'
|
||||||
default_status: "To Do"
|
default_status: 'To Do'
|
||||||
statuses: ["To Do", "In Progress", "Done"]
|
statuses: ['To Do', 'In Progress', 'Done']
|
||||||
labels: []
|
labels: []
|
||||||
definition_of_done: []
|
definition_of_done: []
|
||||||
date_format: yyyy-mm-dd
|
date_format: yyyy-mm-dd
|
||||||
max_column_width: 20
|
max_column_width: 20
|
||||||
default_editor: "nvim"
|
default_editor: 'nvim'
|
||||||
auto_open_browser: false
|
auto_open_browser: false
|
||||||
default_port: 6420
|
default_port: 6420
|
||||||
remote_operations: true
|
remote_operations: true
|
||||||
@@ -13,4 +13,4 @@ auto_commit: false
|
|||||||
bypass_git_hooks: false
|
bypass_git_hooks: false
|
||||||
check_active_branches: true
|
check_active_branches: true
|
||||||
active_branch_days: 30
|
active_branch_days: 30
|
||||||
task_prefix: "task"
|
task_prefix: 'task'
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
id: TASK-70
|
||||||
|
title: >-
|
||||||
|
Overlay runtime refactor: remove invisible mode and bind visible overlay to
|
||||||
|
mpv subtitles
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-28 02:38'
|
||||||
|
updated_date: '2026-02-28 22:36'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- 'commit:a14c9da'
|
||||||
|
- 'commit:74554a3'
|
||||||
|
- 'commit:75442a4'
|
||||||
|
- 'commit:dde51f8'
|
||||||
|
- 'commit:9e4e588'
|
||||||
|
- src/main/overlay-runtime.ts
|
||||||
|
- src/main/runtime/overlay-mpv-sub-visibility.ts
|
||||||
|
- src/renderer/renderer.ts
|
||||||
|
- docs/plans/2026-02-26-secondary-subtitles-main-overlay.md
|
||||||
|
priority: medium
|
||||||
|
ordinal: 1000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Scope: Branch-only commits main..HEAD on refactor-overlay (a14c9da through 9e4e588) rebuilt overlay behavior around visible overlay mode and removed legacy invisible overlay paths.
|
||||||
|
|
||||||
|
Delivered behavior:
|
||||||
|
|
||||||
|
- Removed renderer invisible overlay layout/offset helpers and main hover-highlight runtime code paths.
|
||||||
|
- Added explicit overlay-to-mpv subtitle visibility synchronization so visible overlay state controls primary subtitle visibility consistently.
|
||||||
|
- Hardened overlay runtime/bootstrap lifecycle around modal fallback open state and bridge send path edge cases.
|
||||||
|
- Updated plugin/config/docs defaults to reflect visible-overlay-first behavior and subtitle binding controls.
|
||||||
|
|
||||||
|
Risk/impact context:
|
||||||
|
|
||||||
|
- Large cross-layer refactor touching runtime wiring, renderer event handling, and plugin behavior.
|
||||||
|
- Regression coverage added/updated for overlay runtime, mpv protocol handling, renderer cleanup, and subtitle rendering paths.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Completed and validated in branch commit set before merge. Refactor reduces dead overlay modes, centralizes subtitle visibility behavior, and documents new defaults/constraints.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-70
|
|
||||||
title: Polish YouTube subtitle generation pipeline
|
|
||||||
status: To Do
|
|
||||||
assignee: []
|
|
||||||
created_date: '2026-02-26 07:37'
|
|
||||||
labels: []
|
|
||||||
dependencies: []
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
$Current YouTube subtitle generation in launcher/youtube.ts is functional but has implicit behavior, low observability, and unnecessary full-file work. This task modernizes the existing pipeline without changing core architecture.
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Make track selection explicit (manual > auto > whisper per primary/secondary) with deterministic reasons.
|
|
||||||
- Avoid running whisper/audio work when a track is already satisfied.
|
|
||||||
- Add bounded execution for yt-dlp and whisper subprocesses.
|
|
||||||
- Improve stage-level logging for both automatic and preprocess modes.
|
|
||||||
- Make secondary track fallback decisions explicit and not implicit.
|
|
||||||
- Preserve existing user behavior except where policy is clarified.
|
|
||||||
|
|
||||||
Files expected:
|
|
||||||
- launcher/youtube.ts
|
|
||||||
- launcher/commands/playback-command.ts (if mode/status behavior requires)
|
|
||||||
- launcher/types.ts (if schema updates needed)
|
|
||||||
- launcher/config/args-normalizer.ts (if timeout/config options added)
|
|
||||||
- launcher/util.ts (if runExternalCommand timeout controls added)
|
|
||||||
- Add/update launcher subtitle-generation tests
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 Define deterministic track priority for each track: manual, then auto, then whisper (per track) and record source choice with reason.
|
|
||||||
- [ ] #2 If manual or auto satisfies a track, skip whisper for that same track and avoid unrelated full extraction/transcription work.
|
|
||||||
- [ ] #3 Introduce timeout or budget caps for yt-dlp and whisper calls; timeout should fail safe and unblock automatic playback.
|
|
||||||
- [ ] #4 Emit explicit status logs at each stage: metadata load, manual sub fetch, auto sub fetch, whisper audio extraction, whisper run, publish/load, final success/failure summary.
|
|
||||||
- [ ] #5 Make secondary handling explicit: transcribe target and translate target must only run when required by config and not by side-effect of primary logic.
|
|
||||||
- [ ] #6 Keep preprocess and automatic modes stable in success paths while making behavior in failure paths explicit and bounded.
|
|
||||||
- [ ] #7 Add tests for track-combination cases: primary available, secondary available, both missing, partial fallback, both missing with missing whisper config, timeout/error behavior.
|
|
||||||
- [ ] #8 Document any behavior changes if user-visible, especially fallback order, timeout behavior, and fallback disablement.
|
|
||||||
<!-- AC:END -->
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
id: TASK-71
|
||||||
|
title: >-
|
||||||
|
Anki integration: add local AnkiConnect proxy transport for push-based
|
||||||
|
auto-enrichment
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-28 02:38'
|
||||||
|
updated_date: '2026-02-28 22:36'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- src/anki-integration/anki-connect-proxy.ts
|
||||||
|
- src/anki-integration/anki-connect-proxy.test.ts
|
||||||
|
- src/anki-integration.ts
|
||||||
|
- src/config/resolve/anki-connect.ts
|
||||||
|
- src/core/services/tokenizer/yomitan-parser-runtime.ts
|
||||||
|
- src/core/services/tokenizer/yomitan-parser-runtime.test.ts
|
||||||
|
- docs/anki-integration.md
|
||||||
|
- config.example.jsonc
|
||||||
|
priority: medium
|
||||||
|
ordinal: 2000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Scope: Current unmerged working-tree changes implement an optional local AnkiConnect-compatible proxy and transport switching for card enrichment.
|
||||||
|
|
||||||
|
Delivered behavior:
|
||||||
|
|
||||||
|
- Added proxy server that forwards AnkiConnect requests and enqueues addNote/addNotes note IDs for post-create enrichment, with de-duplication and loop-configuration protection.
|
||||||
|
- Added follow-up response-shape compatibility handling so proxy enqueue works for both envelope (`{result,error}`) and bare JSON payloads, including `multi` variants.
|
||||||
|
- Added config schema/defaults/resolution for ankiConnect.proxy (enabled, host, port, upstreamUrl) with validation warnings and fallback behavior.
|
||||||
|
- Runtime now supports transport switching (polling vs proxy) and restarts transport when runtime config patches change transport keys.
|
||||||
|
- Added Yomitan default-profile server sync helper to keep bundled parser profile aligned with configured Anki endpoint.
|
||||||
|
- Updated user docs/config examples for proxy mode setup, troubleshooting, and mining workflow behavior.
|
||||||
|
|
||||||
|
Risk/impact context:
|
||||||
|
|
||||||
|
- New network surface on local host/port; correctness depends on safe proxy upstream configuration and robust response handling.
|
||||||
|
- Tests added for proxy queue behavior, config resolution, and parser sync routines.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Completed implementation in branch working tree; ready to merge once local changes are committed and test gate passes.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
id: TASK-72
|
||||||
|
title: 'macOS config validation UX: show full warning details in native dialog'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-28 02:38'
|
||||||
|
updated_date: '2026-02-28 22:36'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- 'commit:cc2f9ef'
|
||||||
|
- src/main/config-validation.ts
|
||||||
|
- src/main/runtime/startup-config.ts
|
||||||
|
- docs/configuration.md
|
||||||
|
priority: low
|
||||||
|
ordinal: 3000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Scope: Commit cc2f9ef improves startup config-warning visibility on macOS by ensuring full details are surfaced in the native UI path and reflected in docs.
|
||||||
|
|
||||||
|
Delivered behavior:
|
||||||
|
|
||||||
|
- Config validation/runtime wiring updated so macOS users can access complete warning details instead of truncated notification-only text.
|
||||||
|
- Added/updated tests around config validation and startup config warning flows.
|
||||||
|
- Updated configuration docs to clarify platform-specific warning presentation behavior.
|
||||||
|
|
||||||
|
Risk/impact context:
|
||||||
|
|
||||||
|
- Low runtime risk; primarily user-facing diagnostics clarity improvement.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Completed small follow-up fix to reduce config-debug friction on macOS.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
id: TASK-73
|
||||||
|
title: 'MPV plugin: split into modules and optimize startup/command runtime'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-28 20:50'
|
||||||
|
updated_date: '2026-02-28 22:36'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- plugin/subminer/main.lua
|
||||||
|
- plugin/subminer/bootstrap.lua
|
||||||
|
- plugin/subminer/process.lua
|
||||||
|
- plugin/subminer/aniskip.lua
|
||||||
|
- plugin/subminer/environment.lua
|
||||||
|
- plugin/subminer/lifecycle.lua
|
||||||
|
- plugin/subminer/messages.lua
|
||||||
|
- plugin/subminer/ui.lua
|
||||||
|
- plugin/subminer/hover.lua
|
||||||
|
- plugin/subminer/options.lua
|
||||||
|
- plugin/subminer/state.lua
|
||||||
|
- plugin/subminer.conf
|
||||||
|
- scripts/test-plugin-start-gate.lua
|
||||||
|
- scripts/test-plugin-process-start-retries.lua
|
||||||
|
- launcher/commands/playback-command.ts
|
||||||
|
- launcher/mpv.ts
|
||||||
|
- launcher/mpv.test.ts
|
||||||
|
- launcher/smoke.e2e.test.ts
|
||||||
|
- Makefile
|
||||||
|
- package.json
|
||||||
|
- docs/mpv-plugin.md
|
||||||
|
- docs/installation.md
|
||||||
|
- docs/architecture.md
|
||||||
|
- README.md
|
||||||
|
priority: medium
|
||||||
|
ordinal: 4000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Scope: Replace monolithic `plugin/subminer.lua` with modular plugin runtime; optimize command execution paths; align install/docs/tests; fix launcher smoke instability.
|
||||||
|
|
||||||
|
Delivered behavior:
|
||||||
|
|
||||||
|
- Full plugin cutover to `plugin/subminer/main.lua` + module directory (no runtime compatibility shim with old monolith file).
|
||||||
|
- Process/control command path moved toward async subprocess usage for non-start actions (`stop`, `toggle`, `settings`, restart stop leg), reducing synchronous blocking in mpv script runtime.
|
||||||
|
- AniSkip path guarded: lookup runs only in SubMiner context (launcher metadata, explicit script-message refresh, or detected running app), instead of every opened file.
|
||||||
|
- AniSkip lookup pipeline moved to async subprocess calls (no sync `ps`/`curl` on `file-loaded`) with deferred fetch after auto-start and session-level MAL/title/payload caching.
|
||||||
|
- Startup/runtime loading updated with lazy module initialization via bootstrap proxies.
|
||||||
|
- Plugin install flow updated to copy `plugin/subminer/` directory and remove legacy `~/.config/mpv/scripts/subminer.lua` file.
|
||||||
|
- Added plugin gate script wiring to package scripts (`test:plugin:src`) and launcher test flow.
|
||||||
|
- Smoke tests stabilized across sandbox environments where UNIX socket bind can return `EPERM` while preserving normal-path assertions.
|
||||||
|
- Playback command cleanup race fixed when mpv exits before exit-listener registration.
|
||||||
|
|
||||||
|
Risk/impact context:
|
||||||
|
|
||||||
|
- mpv plugin loading path changed from single-file to module directory; packaging/install paths must stay consistent with release assets.
|
||||||
|
- Async control/AniSkip path changes reduce blocking but can surface timing differences; regression checks added for cold start, file-load gating, and explicit refresh behavior.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
AniSkip gate/async update delivered in plugin runtime:
|
||||||
|
|
||||||
|
- `plugin/subminer/lifecycle.lua`: deferred AniSkip fetch and overlay-start trigger.
|
||||||
|
- `plugin/subminer/aniskip.lua`: async lookup pipeline + context guard + session caches.
|
||||||
|
- `plugin/subminer/environment.lua`: async app-running detection with short cache.
|
||||||
|
- `plugin/subminer/messages.lua`: explicit script-message trigger wiring.
|
||||||
|
|
||||||
|
Regression coverage updated:
|
||||||
|
|
||||||
|
- `scripts/test-plugin-start-gate.lua` now verifies:
|
||||||
|
- no sync `ps`/`curl` on non-context file load
|
||||||
|
- no AniSkip network lookup on non-context file load
|
||||||
|
- script-message refresh forces async AniSkip lookup
|
||||||
|
|
||||||
|
Validation run:
|
||||||
|
|
||||||
|
- `bun run test:plugin:src` pass.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
id: TASK-74
|
||||||
|
title: 'Startup warmups: configurable warmup vs defer with low-power mode'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-27 21:05'
|
||||||
|
updated_date: '2026-03-01 04:14'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- src/types.ts
|
||||||
|
- src/config/definitions/defaults-core.ts
|
||||||
|
- src/config/definitions/options-core.ts
|
||||||
|
- src/config/definitions/template-sections.ts
|
||||||
|
- src/config/resolve/core-domains.ts
|
||||||
|
- src/main/runtime/startup-warmups.ts
|
||||||
|
- src/main/runtime/startup-warmups-main-deps.ts
|
||||||
|
- src/main/runtime/composers/mpv-runtime-composer.ts
|
||||||
|
- src/core/services/startup.ts
|
||||||
|
- src/main.ts
|
||||||
|
- src/config/config.test.ts
|
||||||
|
- src/main/runtime/startup-warmups.test.ts
|
||||||
|
- src/main/runtime/startup-warmups-main-deps.test.ts
|
||||||
|
- src/core/services/app-ready.test.ts
|
||||||
|
priority: medium
|
||||||
|
ordinal: 7000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Add startup warmup controls to allow per-integration warmup or deferred first-use loading.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
- New config section `startupWarmups` with toggles for `mecab`, `yomitanExtension`, `subtitleDictionaries`, and `jellyfinRemoteSession`.
|
||||||
|
- New `startupWarmups.lowPowerMode` policy: defer everything except Yomitan extension.
|
||||||
|
- Keep default behavior as full warmup.
|
||||||
|
- Ensure deferred integrations lazy-load on first real usage path.
|
||||||
|
- Add test coverage for config parsing/defaults and warmup scheduling behavior.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
|
||||||
|
- Added `startupWarmups` to config types/defaults/options/template/resolve.
|
||||||
|
- Warmup scheduler now uses per-integration gating functions.
|
||||||
|
- Low-power mode now defers MeCab, subtitle dictionaries, and Jellyfin remote session warmups while still warming Yomitan extension.
|
||||||
|
- Tokenization path guarantees lazy first-use init for deferred dependencies (Yomitan extension, MeCab when missing, subtitle dictionaries).
|
||||||
|
- Added/updated tests across config and runtime warmup modules.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- `bun run test:config:src`
|
||||||
|
- `bun run test:core:src`
|
||||||
|
- `tsc --noEmit`
|
||||||
|
|
||||||
|
Follow-up updates:
|
||||||
|
|
||||||
|
- Startup now triggers warmups earlier in app-ready flow (right after config validation/log-level setup) instead of waiting for initial args/overlay actions. Goal: tokenization warmup is already done or mostly done by first visible-subs toggle.
|
||||||
|
- Tokenization warmup scheduling consolidated as `subtitle-tokenization` stage; when enabled by toggles, it runs Yomitan extension first, then MeCab/dictionary warmups.
|
||||||
|
- Added per-stage debug logs for warmup progress and skip reasons:
|
||||||
|
- `stage start/ready: yomitan-extension`
|
||||||
|
- `stage start/ready: mecab`
|
||||||
|
- `stage start/ready: subtitle-dictionaries`
|
||||||
|
- `stage start/ready: jellyfin-remote-session`
|
||||||
|
- `stage skipped: jellyfin-remote-session (disabled|auto-connect off)`
|
||||||
|
- Added regression tests for stage-level logging and earlier startup ordering:
|
||||||
|
- `src/main/runtime/startup-warmups.test.ts`
|
||||||
|
- `src/main/runtime/startup-warmups-main-deps.test.ts`
|
||||||
|
- `src/core/services/app-ready.test.ts`
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
id: TASK-75
|
||||||
|
title: 'Tokenizer: configurable POS exclusions for N+1 and frequency annotations'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-01 01:23'
|
||||||
|
updated_date: '2026-03-01 04:14'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
ordinal: 6000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
N+1 and frequency highlighting should ignore non-learning tokens (e.g., particles/auxiliary forms) based on MeCab POS1 tags, while remaining user-configurable.
|
||||||
|
|
||||||
|
Problem example: for subtitle phrase containing になれば, the highlighted N+1 target should not be the non-useful inflection/token piece when POS indicates an excluded class.
|
||||||
|
|
||||||
|
Implement configurable exclusion defaults with add/remove overrides so users can tune behavior without code changes.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Default exclusion set omits non-useful POS1 classes from both N+1 candidate selection and frequency highlighting.
|
||||||
|
- [x] #2 Users can add extra POS1 exclusions and remove defaults via config.
|
||||||
|
- [x] #3 Tokenizer/annotation tests cover default behavior and config add/remove overrides.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Implemented configurable annotation POS exclusions with defaults+add/remove for both MeCab POS1 and POS2, wired to N+1 candidate selection and frequency highlighting. Added POS2 default exclusion (非自立), expanded POS1 defaults for function words, added Yomitan->MeCab enrichment to carry pos2/pos3 metadata, updated config docs/examples, and added regression tests including になれば case.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
id: TASK-76
|
||||||
|
title: 'Tokenizer: remove POS exclusion config surface and keep hardcoded defaults'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-01 02:45'
|
||||||
|
updated_date: '2026-03-01 04:14'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
ordinal: 5000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Remove user-facing config keys for annotation POS exclusions. Keep N+1/frequency POS exclusion behavior as built-in defaults with no config required.
|
||||||
|
|
||||||
|
Scope: remove config parsing/registry/docs/example for annotationFilters.pos1Exclusions/pos2Exclusions while preserving runtime filtering behavior.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 No user-facing config option exists for annotation POS exclusions.
|
||||||
|
- [x] #2 Runtime N+1/frequency exclusion behavior remains active via built-in defaults.
|
||||||
|
- [x] #3 Config/docs/example/tests updated accordingly.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Removed user-facing subtitleStyle.annotationFilters POS exclusion configuration (schema/resolver/options/docs/example). POS-based N+1/frequency filtering now always uses built-in defaults in runtime. Preserved robust exclusion behavior including merged-token overlap POS handling and N+1-only MeCab enrichment path.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
id: TASK-77
|
||||||
|
title: 'Subtitle hover: auto-pause playback with config toggle'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-28 22:43'
|
||||||
|
updated_date: '2026-02-28 22:43'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
ordinal: 8000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Add a user-facing subtitle config option to pause mpv playback when the cursor hovers subtitle text and resume playback when the cursor leaves.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- New config key: `subtitleStyle.autoPauseVideoOnHover`.
|
||||||
|
- Default should be enabled.
|
||||||
|
- Hover pause/resume must not unpause if playback was already paused before hover.
|
||||||
|
- Docs/examples/tests updated.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 `subtitleStyle.autoPauseVideoOnHover` exists and defaults to `true`.
|
||||||
|
- [x] #2 Overlay pauses playback on subtitle hover and resumes on leave only when hover-triggered pause occurred.
|
||||||
|
- [x] #3 Main/renderer IPC exposes pause-state query for safe hover behavior.
|
||||||
|
- [x] #4 Config docs/examples and user docs/readme mention the new behavior and toggle.
|
||||||
|
- [x] #5 Regression tests cover config parsing/validation and hover behavior edge cases.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Implemented `subtitleStyle.autoPauseVideoOnHover` with default `true`, wired through config defaults/resolution/types, renderer state/style, and mouse hover handlers. Added playback pause-state IPC (`getPlaybackPaused`) to avoid false resume when media was already paused. Added renderer hover behavior tests (including race/cancel case) and config/resolve tests. Updated config examples and docs (`README`, usage, shortcuts, mining workflow, configuration) to document default hover pause/resume behavior and disable path.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
id: TASK-78
|
||||||
|
title: 'Launcher + mpv plugin: auto-start visible overlay pause-until-ready and single-start guard'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-28 22:45'
|
||||||
|
updated_date: '2026-02-28 22:45'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
ordinal: 9000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Add startup gating behavior for wrapper + mpv plugin flow so playback starts paused when visible overlay auto-start is enabled, then auto-resumes only after subtitle tokenization is ready.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Plugin option `auto_start_pause_until_ready` (default `yes`).
|
||||||
|
- Launcher reads plugin runtime config and starts mpv paused when `auto_start=yes`, `auto_start_visible_overlay=yes`, and `auto_start_pause_until_ready=yes`.
|
||||||
|
- Main process signals readiness via mpv script message after tokenized subtitle delivery.
|
||||||
|
- Prevent duplicate auto-start attempts from showing `SubMiner already running` OSD.
|
||||||
|
- Keep startup/loading OSD messaging visible and update docs/tests.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Launcher reads `auto_start`, `auto_start_visible_overlay`, and `auto_start_pause_until_ready` from `subminer.conf` and starts mpv with `--pause=yes` when all are enabled.
|
||||||
|
- [x] #2 Plugin pauses on eligible auto-start and resumes only on readiness signal or timeout fallback.
|
||||||
|
- [x] #3 Main process emits `script-message subminer-autoplay-ready` after subtitle tokenization is ready.
|
||||||
|
- [x] #4 Auto-start duplicate triggers are idempotent (no duplicate `--start` behavior and no spurious `Already running` OSD for auto-start path).
|
||||||
|
- [x] #5 Docs and regression tests cover defaults, startup gating behavior, and duplicate-start suppression.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Implemented startup pause gate across launcher/plugin/main runtime:
|
||||||
|
- Added plugin runtime config parsing in launcher (`auto_start`, `auto_start_visible_overlay`, `auto_start_pause_until_ready`) and mpv start-paused behavior for eligible runs.
|
||||||
|
- Added plugin auto-play gate state, timeout fallback, and readiness release via `subminer-autoplay-ready` script message.
|
||||||
|
- Added main-process readiness signaling after tokenization delivery, including unpause fallback command path.
|
||||||
|
- Split auto-start visibility control into separate control commands and added duplicate auto-start idempotency guard to suppress repeated auto-start `Already running` noise.
|
||||||
|
- Updated plugin defaults to enabled (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`) and refreshed docs (`README`, usage, launcher, installation, plugin/config docs).
|
||||||
|
- Added/updated regression coverage (`scripts/test-plugin-start-gate.lua`, launcher smoke/unit tests) validating paused startup, readiness resume, and duplicate-start suppression.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -5,26 +5,18 @@
|
|||||||
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Overlay Auto-Start
|
// Overlay Auto-Start
|
||||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Visible Overlay Subtitle Binding
|
|
||||||
// Control whether visible overlay toggles also toggle MPV subtitle visibility.
|
|
||||||
// When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.
|
|
||||||
// ==========================================
|
|
||||||
"bind_visible_overlay_to_mpv_sub_visibility": true, // Link visible overlay toggles to MPV subtitle visibility (primary and secondary). Values: true | false
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Texthooker Server
|
// Texthooker Server
|
||||||
// Control whether browser opens automatically for texthooker.
|
// Control whether browser opens automatically for texthooker.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"openBrowser": true // Open browser setting. Values: true | false
|
"openBrowser": true, // Open browser setting. Values: true | false
|
||||||
}, // Control whether browser opens automatically for texthooker.
|
}, // Control whether browser opens automatically for texthooker.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -34,7 +26,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
||||||
"port": 6677 // Built-in subtitle websocket server port.
|
"port": 6677, // Built-in subtitle websocket server port.
|
||||||
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -43,8 +35,8 @@
|
|||||||
// Set to debug for full runtime diagnostics.
|
// Set to debug for full runtime diagnostics.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
"level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||||
}, // Controls logging verbosity.
|
}, // Controls logging verbosity. Keep this as an object; do not replace with a bare string.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Keyboard Shortcuts
|
// Keyboard Shortcuts
|
||||||
@@ -53,7 +45,6 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
||||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
|
|
||||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
||||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
||||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
||||||
@@ -65,19 +56,9 @@
|
|||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
||||||
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
|
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
||||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Invisible Overlay
|
|
||||||
// Startup behavior for the invisible interactive subtitle mining layer.
|
|
||||||
// Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.
|
|
||||||
// This edit-mode shortcut is fixed and is not currently configurable.
|
|
||||||
// ==========================================
|
|
||||||
"invisibleOverlay": {
|
|
||||||
"startupVisibility": "platform-default" // Startup visibility setting.
|
|
||||||
}, // Startup behavior for the invisible interactive subtitle mining layer.
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Keybindings (MPV Commands)
|
// Keybindings (MPV Commands)
|
||||||
// Extra keybindings that are merged with built-in defaults.
|
// Extra keybindings that are merged with built-in defaults.
|
||||||
@@ -95,7 +76,7 @@
|
|||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
"secondarySubLanguages": [], // Secondary sub languages setting.
|
||||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
||||||
"defaultMode": "hover" // Default mode setting.
|
"defaultMode": "hover", // Default mode setting.
|
||||||
}, // Dual subtitle track options.
|
}, // Dual subtitle track options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -106,7 +87,7 @@
|
|||||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "" // Ffmpeg path setting.
|
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -114,7 +95,7 @@
|
|||||||
// Initial vertical subtitle position from the bottom.
|
// Initial vertical subtitle position from the bottom.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitlePosition": {
|
"subtitlePosition": {
|
||||||
"yPercent": 10 // Y percent setting.
|
"yPercent": 10, // Y percent setting.
|
||||||
}, // Initial vertical subtitle position from the bottom.
|
}, // Initial vertical subtitle position from the bottom.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -125,13 +106,22 @@
|
|||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv.
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif", // Font family setting.
|
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||||
|
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
||||||
|
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
"fontSize": 35, // Font size setting.
|
"fontSize": 35, // Font size setting.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
"fontColor": "#cad3f5", // Font color setting.
|
||||||
"fontWeight": "normal", // Font weight setting.
|
"fontWeight": "600", // Font weight setting.
|
||||||
|
"lineHeight": 1.35, // Line height setting.
|
||||||
|
"letterSpacing": "-0.01em", // Letter spacing setting.
|
||||||
|
"wordSpacing": 0, // Word spacing setting.
|
||||||
|
"fontKerning": "normal", // Font kerning setting.
|
||||||
|
"textRendering": "geometricPrecision", // Text rendering setting.
|
||||||
|
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
|
||||||
"fontStyle": "normal", // Font style setting.
|
"fontStyle": "normal", // Font style setting.
|
||||||
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
|
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
|
||||||
|
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
||||||
"knownWordColor": "#a6da95", // Known word color setting.
|
"knownWordColor": "#a6da95", // Known word color setting.
|
||||||
"jlptColors": {
|
"jlptColors": {
|
||||||
@@ -139,30 +129,32 @@
|
|||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
"N3": "#f9e2af", // N3 setting.
|
"N3": "#f9e2af", // N3 setting.
|
||||||
"N4": "#a6e3a1", // N4 setting.
|
"N4": "#a6e3a1", // N4 setting.
|
||||||
"N5": "#8aadf4" // N5 setting.
|
"N5": "#8aadf4", // N5 setting.
|
||||||
}, // Jlpt colors setting.
|
}, // Jlpt colors setting.
|
||||||
"frequencyDictionary": {
|
"frequencyDictionary": {
|
||||||
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
||||||
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used.
|
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, SubMiner searches installed/default frequency-dictionary locations.
|
||||||
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
|
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
|
||||||
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
|
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
|
||||||
|
"matchMode": "headword", // Frequency lookup text selection mode. Values: headword | surface
|
||||||
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
||||||
"bandedColors": [
|
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
"#ed8796",
|
|
||||||
"#f5a97f",
|
|
||||||
"#f9e2af",
|
|
||||||
"#a6e3a1",
|
|
||||||
"#8aadf4"
|
|
||||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
|
||||||
}, // Frequency dictionary setting.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
|
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
||||||
"fontSize": 24, // Font size setting.
|
"fontSize": 24, // Font size setting.
|
||||||
"fontColor": "#ffffff", // Font color setting.
|
"fontColor": "#cad3f5", // Font color setting.
|
||||||
|
"lineHeight": 1.35, // Line height setting.
|
||||||
|
"letterSpacing": "-0.01em", // Letter spacing setting.
|
||||||
|
"wordSpacing": 0, // Word spacing setting.
|
||||||
|
"fontKerning": "normal", // Font kerning setting.
|
||||||
|
"textRendering": "geometricPrecision", // Text rendering setting.
|
||||||
|
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
"backgroundColor": "transparent", // Background color setting.
|
||||||
|
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||||
"fontWeight": "normal", // Font weight setting.
|
"fontWeight": "normal", // Font weight setting.
|
||||||
"fontStyle": "normal", // Font style setting.
|
"fontStyle": "normal", // Font style setting.
|
||||||
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif" // Font family setting.
|
}, // Secondary setting.
|
||||||
} // Secondary setting.
|
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -175,15 +167,19 @@
|
|||||||
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
||||||
"url": "http://127.0.0.1:8765", // Url setting.
|
"url": "http://127.0.0.1:8765", // Url setting.
|
||||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||||
"tags": [
|
"proxy": {
|
||||||
"SubMiner"
|
"enabled": false, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
||||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
|
||||||
|
"port": 8766, // Bind port for local AnkiConnect proxy.
|
||||||
|
"upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
|
||||||
|
}, // Proxy setting.
|
||||||
|
"tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
"fields": {
|
"fields": {
|
||||||
"audio": "ExpressionAudio", // Audio setting.
|
"audio": "ExpressionAudio", // Audio setting.
|
||||||
"image": "Picture", // Image setting.
|
"image": "Picture", // Image setting.
|
||||||
"sentence": "Sentence", // Sentence setting.
|
"sentence": "Sentence", // Sentence setting.
|
||||||
"miscInfo": "MiscInfo", // Misc info setting.
|
"miscInfo": "MiscInfo", // Misc info setting.
|
||||||
"translation": "SelectionText" // Translation setting.
|
"translation": "SelectionText", // Translation setting.
|
||||||
}, // Fields setting.
|
}, // Fields setting.
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
@@ -192,7 +188,7 @@
|
|||||||
"model": "openai/gpt-4o-mini", // Model setting.
|
"model": "openai/gpt-4o-mini", // Model setting.
|
||||||
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
||||||
"targetLanguage": "English", // Target language setting.
|
"targetLanguage": "English", // Target language setting.
|
||||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting.
|
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
|
||||||
}, // Ai setting.
|
}, // Ai setting.
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
"generateAudio": true, // Generate audio setting. Values: true | false
|
||||||
@@ -205,7 +201,7 @@
|
|||||||
"animatedCrf": 35, // Animated crf setting.
|
"animatedCrf": 35, // Animated crf setting.
|
||||||
"audioPadding": 0.5, // Audio padding setting.
|
"audioPadding": 0.5, // Audio padding setting.
|
||||||
"fallbackDuration": 3, // Fallback duration setting.
|
"fallbackDuration": 3, // Fallback duration setting.
|
||||||
"maxMediaDuration": 30 // Max media duration setting.
|
"maxMediaDuration": 30, // Max media duration setting.
|
||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
||||||
@@ -213,7 +209,7 @@
|
|||||||
"mediaInsertMode": "append", // Media insert mode setting.
|
"mediaInsertMode": "append", // Media insert mode setting.
|
||||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
"highlightWord": true, // Highlight word setting. Values: true | false
|
||||||
"notificationType": "osd", // Notification type setting.
|
"notificationType": "osd", // Notification type setting.
|
||||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
"autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false
|
||||||
}, // Behavior setting.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||||
@@ -222,20 +218,20 @@
|
|||||||
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
|
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
|
||||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||||
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
|
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
|
||||||
"knownWord": "#a6da95" // Color used for legacy known-word highlights.
|
"knownWord": "#a6da95", // Color used for legacy known-word highlights.
|
||||||
}, // N plus one setting.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
"pattern": "[SubMiner] %f (%t)", // Pattern setting.
|
||||||
}, // Metadata setting.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
"sentenceCardModel": "Japanese sentences", // Sentence card model setting.
|
||||||
}, // Is lapis setting.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
"deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -245,7 +241,7 @@
|
|||||||
"jimaku": {
|
"jimaku": {
|
||||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
"maxEntryResults": 10, // Maximum Jimaku search results returned.
|
||||||
}, // Jimaku API configuration and defaults.
|
}, // Jimaku API configuration and defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -256,10 +252,7 @@
|
|||||||
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
|
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
|
||||||
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
|
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
|
||||||
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
|
||||||
"ja",
|
|
||||||
"jpn"
|
|
||||||
] // Comma-separated primary subtitle language priority used by the launcher.
|
|
||||||
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -268,7 +261,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"anilist": {
|
"anilist": {
|
||||||
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
||||||
"accessToken": "" // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
||||||
}, // Anilist API credentials and update behavior.
|
}, // Anilist API credentials and update behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -292,16 +285,8 @@
|
|||||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
||||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
||||||
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||||
"directPlayContainers": [
|
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
|
||||||
"mkv",
|
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
|
||||||
"mp4",
|
|
||||||
"webm",
|
|
||||||
"mov",
|
|
||||||
"flac",
|
|
||||||
"mp3",
|
|
||||||
"aac"
|
|
||||||
], // Container allowlist for direct play decisions.
|
|
||||||
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
|
|
||||||
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -312,7 +297,7 @@
|
|||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750, // Debounce delay used to collapse bursty presence updates.
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -334,7 +319,7 @@
|
|||||||
"telemetryDays": 30, // Telemetry retention window in days.
|
"telemetryDays": 30, // Telemetry retention window in days.
|
||||||
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
||||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
||||||
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
"vacuumIntervalDays": 7, // Minimum days between VACUUM runs.
|
||||||
} // Retention setting.
|
}, // Retention setting.
|
||||||
} // Enable/disable immersion tracking.
|
}, // Enable/disable immersion tracking.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export default {
|
|||||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
{ text: 'Launcher Script', link: '/launcher-script' },
|
||||||
{ text: 'Usage', link: '/usage' },
|
{ text: 'Usage', link: '/usage' },
|
||||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
||||||
|
// { text: 'Feature Demos', link: '/demos' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ make docs-preview # Preview built site at http://localhost:4173
|
|||||||
|
|
||||||
- [Installation](/installation) — Requirements, Linux/macOS/Windows install, mpv plugin setup
|
- [Installation](/installation) — Requirements, Linux/macOS/Windows install, mpv plugin setup
|
||||||
- [Usage](/usage) — `subminer` wrapper + subcommands (`jellyfin`, `yt`, `doctor`, `config`, `mpv`, `texthooker`, `app`), mpv plugin, keybindings
|
- [Usage](/usage) — `subminer` wrapper + subcommands (`jellyfin`, `yt`, `doctor`, `config`, `mpv`, `texthooker`, `app`), mpv plugin, keybindings
|
||||||
- [Mining Workflow](/mining-workflow) — End-to-end sentence mining guide, overlay layers, card creation
|
- [Mining Workflow](/mining-workflow) — End-to-end sentence mining guide, single overlay + modals, card creation
|
||||||
|
|
||||||
### Reference
|
### Reference
|
||||||
|
|
||||||
- [Configuration](/configuration) — Full config file reference and option details
|
- [Configuration](/configuration) — Full config file reference and option details
|
||||||
- [Keyboard Shortcuts](/shortcuts) — All global, overlay, mining, and plugin chord shortcuts in one place
|
- [Keyboard Shortcuts](/shortcuts) — All global, overlay, mining, and plugin chord shortcuts in one place
|
||||||
- [Anki Integration](/anki-integration) — AnkiConnect setup, field mapping, media generation, field grouping
|
- [Anki Integration](/anki-integration) — AnkiConnect setup, proxy/polling transport, field mapping, media generation, field grouping
|
||||||
- [Jellyfin Integration](/jellyfin-integration) — Optional Jellyfin auth, cast discovery, remote control, and playback launch
|
- [Jellyfin Integration](/jellyfin-integration) — Optional Jellyfin auth, cast discovery, remote control, and playback launch
|
||||||
- [Immersion Tracking](/immersion-tracking) — SQLite schema, retention/rollup policies, query templates, and extension points
|
- [Immersion Tracking](/immersion-tracking) — SQLite schema, retention/rollup policies, query templates, and extension points
|
||||||
- [Performance & Tuning](/troubleshooting#performance-and-resource-impact) — Resource usage and practical low-impact profile
|
- [Performance & Tuning](/troubleshooting#performance-and-resource-impact) — Resource usage and practical low-impact profile
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Anki Integration
|
# Anki Integration
|
||||||
|
|
||||||
SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots.
|
SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots.
|
||||||
|
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -10,9 +11,14 @@ SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-
|
|||||||
|
|
||||||
AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config.
|
AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config.
|
||||||
|
|
||||||
## How Polling Works
|
## Auto-Enrichment Transport
|
||||||
|
|
||||||
SubMiner polls AnkiConnect at a regular interval (default: 3 seconds, configurable via `ankiConnect.pollingRate`) to detect new cards. When it finds a card that was added since the last poll:
|
SubMiner supports two auto-enrichment transport modes:
|
||||||
|
|
||||||
|
1. `proxy` (default): runs a local AnkiConnect-compatible proxy and enriches cards immediately after successful `addNote` / `addNotes` / `multi` responses.
|
||||||
|
2. `polling`: polls AnkiConnect at `ankiConnect.pollingRate` (default: 3s).
|
||||||
|
|
||||||
|
In both modes, the enrichment workflow is the same:
|
||||||
|
|
||||||
1. Checks if a duplicate expression already exists (for field grouping).
|
1. Checks if a duplicate expression already exists (for field grouping).
|
||||||
2. Updates the sentence field with the current subtitle.
|
2. Updates the sentence field with the current subtitle.
|
||||||
@@ -20,7 +26,83 @@ SubMiner polls AnkiConnect at a regular interval (default: 3 seconds, configurab
|
|||||||
4. Fills the translation field from the secondary subtitle or AI.
|
4. Fills the translation field from the secondary subtitle or AI.
|
||||||
5. Writes metadata to the miscInfo field.
|
5. Writes metadata to the miscInfo field.
|
||||||
|
|
||||||
Polling uses the query `"deck:<your-deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks.
|
Polling mode uses the query `"deck:<your-deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks.
|
||||||
|
|
||||||
|
### Proxy Mode Setup (Yomitan / Texthooker)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"ankiConnect": {
|
||||||
|
"url": "http://127.0.0.1:8765", // real AnkiConnect
|
||||||
|
"proxy": {
|
||||||
|
"enabled": true,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8766,
|
||||||
|
"upstreamUrl": "http://127.0.0.1:8765"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then point Yomitan/clients to `http://127.0.0.1:8766` instead of `8765`.
|
||||||
|
|
||||||
|
When SubMiner loads the bundled Yomitan extension, it also attempts to update the **default Yomitan profile** (`profiles[0].options.anki.server`) to the active SubMiner endpoint:
|
||||||
|
|
||||||
|
- proxy URL when `ankiConnect.proxy.enabled` is `true`
|
||||||
|
- direct `ankiConnect.url` when proxy mode is disabled
|
||||||
|
|
||||||
|
To avoid clobbering custom setups, this auto-update only changes the default profile when its current server is blank or the stock Yomitan default (`http://127.0.0.1:8765`).
|
||||||
|
|
||||||
|
For browser-based Yomitan or other external clients (for example Texthooker in a normal browser profile), set their Anki server to the same proxy URL separately: `http://127.0.0.1:8766` (or your configured `proxy.host` + `proxy.port`).
|
||||||
|
|
||||||
|
### Browser/Yomitan external setup (separate profile)
|
||||||
|
|
||||||
|
If you want SubMiner to use proxy mode without touching your main/default Yomitan profile, create or select a separate Yomitan profile just for SubMiner and set its Anki server to the proxy URL.
|
||||||
|
|
||||||
|
That profile isolation gives you both benefits:
|
||||||
|
|
||||||
|
- SubMiner can auto-enrich immediately via proxy.
|
||||||
|
- Your default Yomitan profile keeps its existing Anki server setting.
|
||||||
|
|
||||||
|
In Yomitan, go to Settings → Profile and:
|
||||||
|
|
||||||
|
1. Create a profile for SubMiner (or choose one dedicated profile).
|
||||||
|
2. Open Anki settings for that profile.
|
||||||
|
3. Set server to `http://127.0.0.1:8766` (or your configured proxy URL).
|
||||||
|
4. Save and make that profile active when using SubMiner.
|
||||||
|
|
||||||
|
This is only for non-bundled, external/browser Yomitan or other clients. The bundled profile auto-update logic only targets `profiles[0]` when it is blank or still default.
|
||||||
|
|
||||||
|
### Proxy Troubleshooting (quick checks)
|
||||||
|
|
||||||
|
If auto-enrichment appears to do nothing:
|
||||||
|
|
||||||
|
1. Confirm proxy listener is running while SubMiner is active:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ss -ltnp | rg 8766
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Confirm requests can pass through the proxy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS http://127.0.0.1:8766 \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"action":"version","version":2}'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check both log sinks:
|
||||||
|
|
||||||
|
- Launcher/mpv-integrated log: `~/.cache/SubMiner/mp.log`
|
||||||
|
- App runtime log: `~/.config/SubMiner/logs/SubMiner-YYYY-MM-DD.log`
|
||||||
|
|
||||||
|
4. Ensure config JSONC is valid and logging shape is correct:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"logging": {
|
||||||
|
"level": "debug"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`"logging": "debug"` is invalid for current schema and can break reload/start behavior.
|
||||||
|
|
||||||
## Field Mapping
|
## Field Mapping
|
||||||
|
|
||||||
@@ -186,17 +268,17 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
|
|
||||||
**Disabled** (`"disabled"`): No duplicate detection. Each card is independent.
|
**Disabled** (`"disabled"`): No duplicate detection. Each card is independent.
|
||||||
|
|
||||||
**Auto** (`"auto"`): When a duplicate expression is found, SubMiner merges the new card into the existing one automatically. Both sentences, audio clips, and images are preserved. If `deleteDuplicateInAuto` is true, the new card is deleted after merging.
|
**Auto** (`"auto"`): When a duplicate expression is found, SubMiner merges the new card into the existing one automatically. Both sentences, audio clips, and images are preserved, and exact duplicate values are collapsed to one entry. If `deleteDuplicateInAuto` is true, the new card is deleted after merging.
|
||||||
|
|
||||||
**Manual** (`"manual"`): A modal appears in the overlay showing both cards. You choose which card to keep, preview the merge result, then confirm. The modal has a 90-second timeout, after which it cancels automatically.
|
**Manual** (`"manual"`): A modal appears in the overlay showing both cards. You choose which card to keep, preview the merge result, then confirm. The modal has a 90-second timeout, after which it cancels automatically.
|
||||||
|
|
||||||
### What Gets Merged
|
### What Gets Merged
|
||||||
|
|
||||||
| Field | Merge behavior |
|
| Field | Merge behavior |
|
||||||
| -------- | -------------------------------------------------------------- |
|
| -------- | --------------------------------------------------------------- |
|
||||||
| Sentence | Both sentences preserved, labeled `[Original]` / `[Duplicate]` |
|
| Sentence | Both sentences preserved (exact duplicate text is deduplicated) |
|
||||||
| Audio | Both `[sound:...]` entries kept |
|
| Audio | Both `[sound:...]` entries kept (exact duplicates deduplicated) |
|
||||||
| Image | Both images kept |
|
| Image | Both images kept (exact duplicates deduplicated) |
|
||||||
|
|
||||||
### Keyboard Shortcuts in the Modal
|
### Keyboard Shortcuts in the Modal
|
||||||
|
|
||||||
@@ -214,6 +296,12 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"url": "http://127.0.0.1:8765",
|
"url": "http://127.0.0.1:8765",
|
||||||
"pollingRate": 3000,
|
"pollingRate": 3000,
|
||||||
|
"proxy": {
|
||||||
|
"enabled": false,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8766,
|
||||||
|
"upstreamUrl": "http://127.0.0.1:8765",
|
||||||
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"audio": "ExpressionAudio",
|
"audio": "ExpressionAudio",
|
||||||
"image": "Picture",
|
"image": "Picture",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ SubMiner is split into three cooperating runtimes:
|
|||||||
|
|
||||||
- Electron desktop app (`src/`) for overlay/UI/runtime orchestration.
|
- Electron desktop app (`src/`) for overlay/UI/runtime orchestration.
|
||||||
- Launcher CLI (`launcher/`) for mpv/app command workflows.
|
- 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/init.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.
|
Within the desktop app, `src/main.ts` is a composition root that wires small runtime/domain modules plus core services.
|
||||||
|
|
||||||
@@ -26,7 +26,9 @@ launcher/ # Standalone CLI launcher wrapper and mpv helpers
|
|||||||
config/ # Launcher config parsers + CLI parser builder
|
config/ # Launcher config parsers + CLI parser builder
|
||||||
main.ts # Launcher entrypoint and command dispatch
|
main.ts # Launcher entrypoint and command dispatch
|
||||||
plugin/
|
plugin/
|
||||||
subminer.lua # mpv plugin (auto-start, IPC, AniSkip + hover controls)
|
subminer/ # Modular mpv plugin (init · main · bootstrap · lifecycle · process
|
||||||
|
# state · messages · hover · ui · options · environment · log
|
||||||
|
# binary · aniskip · aniskip_match)
|
||||||
src/
|
src/
|
||||||
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
|
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
|
||||||
main.ts # Entry point — delegates to runtime composers/domain modules
|
main.ts # Entry point — delegates to runtime composers/domain modules
|
||||||
@@ -66,24 +68,26 @@ src/
|
|||||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
renderer/ # Overlay renderer (modularized UI/runtime)
|
||||||
handlers/ # Keyboard/mouse interaction modules
|
handlers/ # Keyboard/mouse interaction modules
|
||||||
modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals
|
modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals
|
||||||
positioning/ # Invisible-layer layout + offset controllers
|
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||||
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS)
|
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS)
|
||||||
jimaku/ # Jimaku API integration helpers
|
jimaku/ # Jimaku API integration helpers
|
||||||
subsync/ # Subtitle sync (alass/ffsubsync) helpers
|
subsync/ # Subtitle sync (alass/ffsubsync) helpers
|
||||||
subtitle/ # Subtitle processing utilities
|
subtitle/ # Subtitle processing utilities
|
||||||
tokenizers/ # Tokenizer implementations
|
tokenizers/ # Tokenizer implementations
|
||||||
|
anki-integration/ # AnkiConnect proxy server + note-update enrichment workflow
|
||||||
token-mergers/ # Token merge strategies
|
token-mergers/ # Token merge strategies
|
||||||
translators/ # AI translation providers
|
translators/ # AI translation providers
|
||||||
```
|
```
|
||||||
|
|
||||||
### Service Layer (`src/core/services/`)
|
### Service Layer (`src/core/services/`)
|
||||||
|
|
||||||
- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-window-geometry.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`, `overlay-drop.ts`
|
- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`
|
||||||
- **Shortcuts/input:** `shortcut.ts`, `overlay-shortcut.ts`, `overlay-shortcut-handler.ts`, `shortcut-fallback.ts`, `numeric-shortcut.ts`
|
- **Shortcuts/input:** `shortcut.ts`, `overlay-shortcut.ts`, `overlay-shortcut-handler.ts`, `shortcut-fallback.ts`, `numeric-shortcut.ts`
|
||||||
- **MPV runtime:** `mpv.ts`, `mpv-transport.ts`, `mpv-protocol.ts`, `mpv-properties.ts`, `mpv-render-metrics.ts`
|
- **MPV runtime:** `mpv.ts`, `mpv-transport.ts`, `mpv-protocol.ts`, `mpv-properties.ts`, `mpv-render-metrics.ts`
|
||||||
- **Mining + Anki/Jimaku runtime:** `mining.ts`, `field-grouping.ts`, `field-grouping-overlay.ts`, `anki-jimaku.ts`, `anki-jimaku-ipc.ts`
|
- **Mining + Anki/Jimaku runtime:** `mining.ts`, `field-grouping.ts`, `field-grouping-overlay.ts`, `anki-jimaku.ts`, `anki-jimaku-ipc.ts`
|
||||||
- **Subtitle/token pipeline:** `subtitle-processing-controller.ts`, `subtitle-position.ts`, `subtitle-ws.ts`, `tokenizer.ts` + `tokenizer/*` stage modules
|
- **Subtitle/token pipeline:** `subtitle-processing-controller.ts`, `subtitle-position.ts`, `subtitle-ws.ts`, `tokenizer.ts` + `tokenizer/*` stage modules (including `parser-enrichment-worker-runtime.ts` for async MeCab enrichment and `yomitan-parser-runtime.ts`)
|
||||||
- **Integrations:** `jimaku.ts`, `subsync.ts`, `subsync-runner.ts`, `texthooker.ts`, `jellyfin.ts`, `jellyfin-remote.ts`, `discord-presence.ts`, `yomitan-extension-loader.ts`, `yomitan-settings.ts`
|
- **Integrations:** `jimaku.ts`, `subsync.ts`, `subsync-runner.ts`, `texthooker.ts`, `jellyfin.ts`, `jellyfin-remote.ts`, `discord-presence.ts`, `yomitan-extension-loader.ts`, `yomitan-settings.ts`
|
||||||
|
- **Anki integration:** `anki-integration.ts`, `anki-integration/anki-connect-proxy.ts` (local proxy for push-based auto-enrichment), `anki-integration/note-update-workflow.ts`
|
||||||
- **Config/runtime controls:** `config-hot-reload.ts`, `runtime-options-ipc.ts`, `cli-command.ts`, `startup.ts`
|
- **Config/runtime controls:** `config-hot-reload.ts`, `runtime-options-ipc.ts`, `cli-command.ts`, `startup.ts`
|
||||||
- **Domain submodules:** `anilist/*` (token/update queue/updater), `immersion-tracker/*` (storage/session/metadata/query/reducer)
|
- **Domain submodules:** `anilist/*` (token/update queue/updater), `immersion-tracker/*` (storage/session/metadata/query/reducer)
|
||||||
|
|
||||||
@@ -95,15 +99,15 @@ The renderer keeps `renderer.ts` focused on orchestration. UI behavior is delega
|
|||||||
src/renderer/
|
src/renderer/
|
||||||
renderer.ts # Entrypoint/orchestration only
|
renderer.ts # Entrypoint/orchestration only
|
||||||
context.ts # Shared runtime context contract
|
context.ts # Shared runtime context contract
|
||||||
state.ts # Centralized renderer mutable state
|
state.ts # Centralized renderer mutable state (visible overlay only)
|
||||||
error-recovery.ts # Global renderer error boundary + recovery actions
|
error-recovery.ts # Global renderer error boundary + recovery actions
|
||||||
overlay-content-measurement.ts # Reports rendered bounds to main process
|
overlay-content-measurement.ts # Reports rendered bounds to main process
|
||||||
subtitle-render.ts # Primary/secondary subtitle rendering + style application
|
subtitle-render.ts # Primary/secondary subtitle rendering + style application
|
||||||
positioning.ts # Facade export for positioning controller
|
positioning.ts # Facade export for positioning controller
|
||||||
|
yomitan-popup.ts # Yomitan popup iframe detection utilities
|
||||||
positioning/
|
positioning/
|
||||||
controller.ts # Position controller orchestration
|
controller.ts # Subtitle drag-position controller
|
||||||
invisible-layout*.ts # Invisible layer layout computations
|
position-state.ts # Position state helpers (yPercent)
|
||||||
position-state.ts # Position state helpers
|
|
||||||
handlers/
|
handlers/
|
||||||
keyboard.ts # Keybindings, chord handling, modal key routing
|
keyboard.ts # Keybindings, chord handling, modal key routing
|
||||||
mouse.ts # Hover/drag behavior, selection + observer wiring
|
mouse.ts # Hover/drag behavior, selection + observer wiring
|
||||||
@@ -121,11 +125,11 @@ src/renderer/
|
|||||||
### Launcher + Plugin Runtimes
|
### 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.
|
- `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/init.lua` runs inside mpv and loads modular Lua files: `main.lua` (orchestration), `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution), `aniskip.lua` + `aniskip_match.lua` (intro-skip UX).
|
||||||
|
|
||||||
## Flow Diagram
|
## Flow Diagram
|
||||||
|
|
||||||
The main process has three layers: `main.ts` delegates to composition modules that wire together domain services. Three overlay windows (visible, invisible, secondary) run in separate Electron renderer processes, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough.
|
The main process orchestrates a single primary overlay window plus modal surfaces: `main.ts` delegates to composition modules that wire together domain services. Subtitle layers (primary + secondary bar) are rendered in the same overlay renderer process, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -139,7 +143,7 @@ flowchart LR
|
|||||||
|
|
||||||
subgraph ExtRt["External Runtimes"]
|
subgraph ExtRt["External Runtimes"]
|
||||||
Launcher["launcher/<br/>CLI dispatch"]:::extrt
|
Launcher["launcher/<br/>CLI dispatch"]:::extrt
|
||||||
Plugin["subminer.lua<br/>mpv plugin"]:::extrt
|
Plugin["subminer/init.lua<br/>mpv plugin"]:::extrt
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Ext["External Systems"]
|
subgraph Ext["External Systems"]
|
||||||
@@ -162,8 +166,9 @@ flowchart LR
|
|||||||
|
|
||||||
subgraph Svc["Services — src/core/services/"]
|
subgraph Svc["Services — src/core/services/"]
|
||||||
Mpv["MPV Stack<br/>transport · protocol<br/>properties · metrics"]:::svc
|
Mpv["MPV Stack<br/>transport · protocol<br/>properties · metrics"]:::svc
|
||||||
Overlay["Overlay Manager<br/>window · geometry<br/>visibility · bridge"]:::svc
|
OverlaySvc["Overlay Manager<br/>window · visibility · bridge<br/>mpv-sub-visibility"]:::svc
|
||||||
Mining["Mining & Subtitles<br/>mining · field-grouping<br/>subtitle-ws · tokenizer"]:::svc
|
Mining["Mining & Subtitles<br/>mining · field-grouping<br/>subtitle-ws · tokenizer"]:::svc
|
||||||
|
AnkiProxy["Anki Integration<br/>anki-connect-proxy<br/>note-update-workflow"]:::svc
|
||||||
Integrations["Integrations<br/>jimaku · subsync<br/>texthooker · yomitan"]:::svc
|
Integrations["Integrations<br/>jimaku · subsync<br/>texthooker · yomitan"]:::svc
|
||||||
Tracking["Tracking<br/>anilist · jellyfin<br/>immersion · discord"]:::svc
|
Tracking["Tracking<br/>anilist · jellyfin<br/>immersion · discord"]:::svc
|
||||||
Config["Config & Runtime<br/>hot-reload<br/>runtime-options"]:::svc
|
Config["Config & Runtime<br/>hot-reload<br/>runtime-options"]:::svc
|
||||||
@@ -172,9 +177,7 @@ flowchart LR
|
|||||||
Bridge(["preload.ts<br/>Electron IPC"]):::bridge
|
Bridge(["preload.ts<br/>Electron IPC"]):::bridge
|
||||||
|
|
||||||
subgraph Rend["Renderer — src/renderer/"]
|
subgraph Rend["Renderer — src/renderer/"]
|
||||||
Visible["Visible window<br/>Yomitan lookups"]:::rend
|
OverlayWin["Main overlay window<br/>primary + secondary subtitles"]:::rend
|
||||||
Invisible["Invisible window<br/>mpv positioning"]:::rend
|
|
||||||
Secondary["Secondary window<br/>subtitle bar"]:::rend
|
|
||||||
UI["subtitle-render<br/>positioning<br/>handlers · modals"]:::rend
|
UI["subtitle-render<br/>positioning<br/>handlers · modals"]:::rend
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -185,18 +188,16 @@ flowchart LR
|
|||||||
Comp --> Svc
|
Comp --> Svc
|
||||||
|
|
||||||
mpvExt <-->|"JSON socket"| Mpv
|
mpvExt <-->|"JSON socket"| Mpv
|
||||||
AnkiExt <-->|"HTTP"| Mining
|
AnkiExt <-->|"HTTP"| AnkiProxy
|
||||||
JimakuExt <-->|"HTTP"| Integrations
|
JimakuExt <-->|"HTTP"| Integrations
|
||||||
TrackerExt <-->|"platform"| Overlay
|
TrackerExt <-->|"platform"| OverlaySvc
|
||||||
AnilistExt <-->|"HTTP"| Tracking
|
AnilistExt <-->|"HTTP"| Tracking
|
||||||
JellyfinExt <-->|"HTTP"| Tracking
|
JellyfinExt <-->|"HTTP"| Tracking
|
||||||
DiscordExt <-->|"RPC"| Integrations
|
DiscordExt <-->|"RPC"| Integrations
|
||||||
|
|
||||||
Overlay & Mining --> Bridge
|
OverlaySvc & Mining --> Bridge
|
||||||
Bridge --> Visible
|
Bridge --> OverlayWin
|
||||||
Bridge --> Invisible
|
OverlayWin --> UI
|
||||||
Bridge --> Secondary
|
|
||||||
Visible & Invisible & Secondary --> UI
|
|
||||||
|
|
||||||
style Comp fill:#363a4f,stroke:#494d64,color:#cad3f5
|
style Comp fill:#363a4f,stroke:#494d64,color:#cad3f5
|
||||||
style Svc fill:#363a4f,stroke:#494d64,color:#cad3f5
|
style Svc fill:#363a4f,stroke:#494d64,color:#cad3f5
|
||||||
@@ -264,10 +265,10 @@ For domains migrated to reducer-style transitions (for example AniList token/que
|
|||||||
- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
|
- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
|
||||||
- **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.
|
- **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`.
|
- **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 three overlay windows — **visible** (interactive Yomitan lookups), **invisible** (mpv-matched subtitle positioning), and **secondary** (secondary subtitle bar, top 20% via `splitOverlayGeometryForSecondaryBar`) — then registers global shortcuts and sets initial bounds from the window tracker.
|
- **Overlay runtime:** `initializeOverlayRuntime()` creates the primary overlay window (interactive Yomitan lookups and subtitle rendering), registers global shortcuts, and sets up bounds tracking via the active window tracker. mpv subtitle suppression is handled by a dedicated `overlay-mpv-sub-visibility` service.
|
||||||
- **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 (with async worker thread), Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, AniList token refresh, and optional AnkiConnect proxy server. 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 broadcast to all overlay windows.
|
- **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.
|
- **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, stops the AnkiConnect proxy server, and cleans Anki/AniList state.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -298,27 +299,24 @@ flowchart LR
|
|||||||
|
|
||||||
OverlayInit["initializeOverlay<br/>Runtime()"]:::phase
|
OverlayInit["initializeOverlay<br/>Runtime()"]:::phase
|
||||||
|
|
||||||
OverlayInit --> VisWin["Visible window<br/>Yomitan lookups"]:::init
|
OverlayInit --> MainWin["Main overlay window<br/>primary + secondary subtitles"]:::init
|
||||||
OverlayInit --> InvWin["Invisible window<br/>mpv positioning"]:::init
|
|
||||||
OverlayInit --> SecWin["Secondary window<br/>subtitle bar"]:::init
|
|
||||||
OverlayInit --> Shortcuts["Register global<br/>shortcuts"]:::init
|
OverlayInit --> Shortcuts["Register global<br/>shortcuts"]:::init
|
||||||
|
|
||||||
VisWin --> Warmups
|
MainWin --> Warmups
|
||||||
InvWin --> Warmups
|
|
||||||
SecWin --> Warmups
|
|
||||||
Shortcuts --> Warmups
|
Shortcuts --> Warmups
|
||||||
|
|
||||||
Warmups["Background<br/>warmups"]:::phase
|
Warmups["Background<br/>warmups"]:::phase
|
||||||
|
|
||||||
subgraph WarmupGroup[" "]
|
subgraph WarmupGroup[" "]
|
||||||
direction TB
|
direction TB
|
||||||
W1["MeCab"]:::warmup
|
W1["MeCab<br/>+ worker thread"]:::warmup
|
||||||
W2["Yomitan"]:::warmup
|
W2["Yomitan"]:::warmup
|
||||||
W3["JLPT + freq<br/>dictionaries"]:::warmup
|
W3["JLPT + freq<br/>dictionaries"]:::warmup
|
||||||
W4["Jellyfin"]:::warmup
|
W4["Jellyfin"]:::warmup
|
||||||
W5["Discord"]:::warmup
|
W5["Discord"]:::warmup
|
||||||
W6["AniList"]:::warmup
|
W6["AniList"]:::warmup
|
||||||
W1 ~~~ W2 ~~~ W3 ~~~ W4 ~~~ W5 ~~~ W6
|
W7["AnkiConnect<br/>proxy"]:::warmup
|
||||||
|
W1 ~~~ W2 ~~~ W3 ~~~ W4 ~~~ W5 ~~~ W6 ~~~ W7
|
||||||
end
|
end
|
||||||
|
|
||||||
Warmups --> WarmupGroup
|
Warmups --> WarmupGroup
|
||||||
@@ -330,7 +328,7 @@ flowchart LR
|
|||||||
ExtEvt["Shortcuts · config hot-reload"]:::runtime
|
ExtEvt["Shortcuts · config hot-reload"]:::runtime
|
||||||
MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::runtime
|
MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::runtime
|
||||||
Route --> Process["SubtitlePipeline<br/>normalize → tokenize → merge"]:::runtime
|
Route --> Process["SubtitlePipeline<br/>normalize → tokenize → merge"]:::runtime
|
||||||
Process --> Broadcast["Update AppState<br/>broadcast to windows"]:::runtime
|
Process --> Broadcast["Update AppState<br/>broadcast to renderer + modals"]:::runtime
|
||||||
end
|
end
|
||||||
|
|
||||||
WarmupGroup --> Loop
|
WarmupGroup --> Loop
|
||||||
@@ -342,7 +340,7 @@ flowchart LR
|
|||||||
Quit --> T1["Tray · config watcher<br/>global shortcuts"]:::shutdown
|
Quit --> T1["Tray · config watcher<br/>global shortcuts"]:::shutdown
|
||||||
Quit --> T2["WebSocket · texthooker<br/>mpv socket · OSD log"]:::shutdown
|
Quit --> T2["WebSocket · texthooker<br/>mpv socket · OSD log"]:::shutdown
|
||||||
Quit --> T3["Window tracker<br/>Yomitan parser"]:::shutdown
|
Quit --> T3["Window tracker<br/>Yomitan parser"]:::shutdown
|
||||||
Quit --> T4["Immersion tracker<br/>Jellyfin · Discord<br/>Anki · AniList"]:::shutdown
|
Quit --> T4["Immersion tracker<br/>Jellyfin · Discord<br/>Anki proxy · AniList"]:::shutdown
|
||||||
|
|
||||||
style Loop fill:#363a4f,stroke:#494d64,color:#cad3f5
|
style Loop fill:#363a4f,stroke:#494d64,color:#cad3f5
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ Malformed config syntax (invalid JSON/JSONC) is startup-blocking: SubMiner shows
|
|||||||
|
|
||||||
For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback behavior: it logs the bad key/value and continues with the default for that option.
|
For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback behavior: it logs the bad key/value and continues with the default for that option.
|
||||||
|
|
||||||
|
On macOS, these validation warnings also open a native dialog with full details (desktop notification banners can truncate long messages).
|
||||||
|
|
||||||
### Hot-Reload Behavior
|
### Hot-Reload Behavior
|
||||||
|
|
||||||
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
|
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
|
||||||
@@ -70,26 +72,467 @@ Restart-required changes:
|
|||||||
|
|
||||||
The configuration file includes several main sections:
|
The configuration file includes several main sections:
|
||||||
|
|
||||||
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
|
**Core Settings**
|
||||||
|
|
||||||
|
- [**Logging**](#logging) - Runtime log level
|
||||||
- [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection
|
- [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection
|
||||||
- [**Visible Overlay Subtitle Binding**](#visible-overlay-subtitle-binding) - Link visible overlay toggles to MPV subtitle visibility
|
- [**Startup Warmups**](#startup-warmups) - Control what preloads on startup vs first-use defer
|
||||||
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
- [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server
|
||||||
- [**Invisible Overlay**](#invisible-overlay) - Startup visibility behavior for the invisible mining layer
|
- [**Texthooker**](#texthooker) - Control browser opening behavior
|
||||||
|
|
||||||
|
**Subtitle Display**
|
||||||
|
|
||||||
|
- [**Subtitle Style**](#subtitle-style) - Appearance customization
|
||||||
|
- [**Subtitle Position**](#subtitle-position) - Overlay vertical positioning
|
||||||
|
- [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support
|
||||||
|
|
||||||
|
**Keyboard & Controls**
|
||||||
|
|
||||||
|
- [**Keybindings**](#keybindings) - MPV command shortcuts
|
||||||
|
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
|
||||||
|
- [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows
|
||||||
|
- [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference
|
||||||
|
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
|
||||||
|
|
||||||
|
**Anki Integration**
|
||||||
|
|
||||||
|
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
|
||||||
|
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
|
||||||
|
- [**N+1 Word Highlighting**](#n1-word-highlighting) - Known-word cache and single-target highlighting
|
||||||
|
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
|
||||||
|
|
||||||
|
**External Integrations**
|
||||||
|
|
||||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||||
|
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||||
- [**AniList**](#anilist) - Optional post-watch progress updates
|
- [**AniList**](#anilist) - Optional post-watch progress updates
|
||||||
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
|
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
|
||||||
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
|
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
|
||||||
- [**Keybindings**](#keybindings) - MPV command shortcuts
|
|
||||||
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
|
|
||||||
- [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support
|
|
||||||
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
|
|
||||||
- [**Subtitle Position**](#subtitle-position) - Overlay vertical positioning
|
|
||||||
- [**Subtitle Style**](#subtitle-style) - Appearance customization
|
|
||||||
- [**Texthooker**](#texthooker) - Control browser opening behavior
|
|
||||||
- [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server
|
|
||||||
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
|
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
|
||||||
- [**YouTube Subtitle Generation**](#youtube-subtitle-generation) - Launcher defaults for yt-dlp + local whisper fallback
|
- [**YouTube Subtitle Generation**](#youtube-subtitle-generation) - Launcher defaults for yt-dlp + local whisper fallback
|
||||||
|
|
||||||
|
## Core Settings
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Control the minimum log level for runtime output:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"logging": {
|
||||||
|
"level": "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| ------- | ----------------------------------- | ------------------------------------------------ |
|
||||||
|
| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) |
|
||||||
|
|
||||||
|
### Auto-Start Overlay
|
||||||
|
|
||||||
|
Control whether the overlay automatically becomes visible when it connects to mpv:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auto_start_overlay": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| -------------------- | --------------- | ------------------------------------------------------ |
|
||||||
|
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) |
|
||||||
|
|
||||||
|
The mpv plugin controls startup overlay visibility via `auto_start_visible_overlay` in `subminer.conf`.
|
||||||
|
For wrapper-driven playback, `subminer.conf` can also enable startup pause gating with
|
||||||
|
`auto_start_pause_until_ready` (requires `auto_start=yes` + `auto_start_visible_overlay=yes`).
|
||||||
|
Current plugin defaults in `subminer.conf` are:
|
||||||
|
|
||||||
|
- `auto_start=yes`
|
||||||
|
- `auto_start_visible_overlay=yes`
|
||||||
|
- `auto_start_pause_until_ready=yes`
|
||||||
|
|
||||||
|
### Startup Warmups
|
||||||
|
|
||||||
|
Control which startup warmups run in the background versus deferring to first real usage:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"startupWarmups": {
|
||||||
|
"lowPowerMode": false,
|
||||||
|
"mecab": true,
|
||||||
|
"yomitanExtension": true,
|
||||||
|
"subtitleDictionaries": true,
|
||||||
|
"jellyfinRemoteSession": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| ----------------------- | --------------- | ------------------------------------------------------------------------------------------------- |
|
||||||
|
| `lowPowerMode` | `true`, `false` | Defer all warmups except Yomitan extension |
|
||||||
|
| `mecab` | `true`, `false` | Warm up MeCab tokenizer at startup |
|
||||||
|
| `yomitanExtension` | `true`, `false` | Warm up Yomitan extension at startup |
|
||||||
|
| `subtitleDictionaries` | `true`, `false` | Warm up JLPT + frequency dictionaries at startup |
|
||||||
|
| `jellyfinRemoteSession` | `true`, `false` | Warm up Jellyfin remote session at startup (still requires Jellyfin remote auto-connect settings) |
|
||||||
|
|
||||||
|
Defaults warm everything (`true` for all toggles, `lowPowerMode: false`). Setting a warmup toggle to `false` defers that work until first usage.
|
||||||
|
|
||||||
|
### WebSocket Server
|
||||||
|
|
||||||
|
The overlay includes a built-in WebSocket server that broadcasts subtitle text to connected clients (such as texthooker-ui) for external processing.
|
||||||
|
|
||||||
|
By default, the server uses "auto" mode: it starts automatically unless [mpv_websocket](https://github.com/kuroahna/mpv_websocket) is detected at `~/.config/mpv/mpv_websocket`. If you have mpv_websocket installed, the built-in server is skipped to avoid conflicts.
|
||||||
|
|
||||||
|
See `config.example.jsonc` for detailed configuration options.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"websocket": {
|
||||||
|
"enabled": "auto",
|
||||||
|
"port": 6677
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| --------- | ------------------------- | -------------------------------------------------------- |
|
||||||
|
| `enabled` | `true`, `false`, `"auto"` | `"auto"` (default) disables if mpv_websocket is detected |
|
||||||
|
| `port` | number | WebSocket server port (default: 6677) |
|
||||||
|
|
||||||
|
### Texthooker
|
||||||
|
|
||||||
|
Control whether the browser opens automatically when texthooker starts:
|
||||||
|
|
||||||
|
See `config.example.jsonc` for detailed configuration options.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"texthooker": {
|
||||||
|
"openBrowser": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subtitle Display
|
||||||
|
|
||||||
|
### Subtitle Style
|
||||||
|
|
||||||
|
Customize the appearance of primary and secondary subtitles:
|
||||||
|
|
||||||
|
See `config.example.jsonc` for detailed configuration options.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP",
|
||||||
|
"fontSize": 35,
|
||||||
|
"fontColor": "#cad3f5",
|
||||||
|
"fontWeight": "600",
|
||||||
|
"lineHeight": 1.35,
|
||||||
|
"letterSpacing": "-0.01em",
|
||||||
|
"wordSpacing": 0,
|
||||||
|
"fontKerning": "normal",
|
||||||
|
"textRendering": "geometricPrecision",
|
||||||
|
"textShadow": "0 3px 10px rgba(0,0,0,0.69)",
|
||||||
|
"fontStyle": "normal",
|
||||||
|
"backgroundColor": "rgb(30, 32, 48, 0.88)",
|
||||||
|
"backdropFilter": "blur(6px)",
|
||||||
|
"secondary": {
|
||||||
|
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif",
|
||||||
|
"fontSize": 24,
|
||||||
|
"fontColor": "#cad3f5",
|
||||||
|
"backgroundColor": "transparent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| ---------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `fontFamily` | string | CSS font-family value (default: `"M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP"`) |
|
||||||
|
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
||||||
|
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
||||||
|
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
||||||
|
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
||||||
|
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
|
||||||
|
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||||
|
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||||
|
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
||||||
|
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
||||||
|
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
|
||||||
|
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||||
|
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
||||||
|
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
||||||
|
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
||||||
|
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
||||||
|
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||||
|
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||||
|
| `nPlusOneColor` | string | Existing n+1 highlight color (default: `#c6a0f6`) |
|
||||||
|
| `knownWordColor` | string | Existing known-word highlight color (default: `#a6da95`) |
|
||||||
|
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||||
|
| `secondary` | object | Override any of the above for secondary subtitles (optional) |
|
||||||
|
|
||||||
|
JLPT underlining is powered by offline term-meta bank files at runtime. See [`docs/jlpt-vocab-bundle.md`](jlpt-vocab-bundle.md) for required files, source/version refresh steps, and deterministic fallback behavior.
|
||||||
|
|
||||||
|
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
|
||||||
|
|
||||||
|
Lookup behavior:
|
||||||
|
|
||||||
|
- Set `frequencyDictionary.sourcePath` to a directory containing `term_meta_bank_*.json` for a fully custom source.
|
||||||
|
- If `sourcePath` is missing or empty, SubMiner searches default install/runtime locations for `frequency-dictionary` directories (for example app resources, user data paths, and current working directory).
|
||||||
|
- In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting.
|
||||||
|
- `frequencyDictionary.matchMode` controls which token text is used for frequency lookups: `headword` (dictionary form) or `surface` (visible subtitle text).
|
||||||
|
- Frequency highlighting skips tokens that look like non-lexical SFX/interjection noise (for example kana reduplication or short kana endings like `っ`), even when dictionary ranks exist.
|
||||||
|
|
||||||
|
In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window.
|
||||||
|
|
||||||
|
Secondary subtitle defaults: `fontFamily: "Inter, Noto Sans, Helvetica Neue, sans-serif"`, `fontSize: 24`, `fontColor: "#cad3f5"`, `backgroundColor: "transparent"`. Any property not set in `secondary` falls back to the CSS defaults.
|
||||||
|
|
||||||
|
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
|
||||||
|
|
||||||
|
`jlptColors` keys are:
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
| ---- | --------- | ----------------------- |
|
||||||
|
| `N1` | `#ed8796` | JLPT N1 underline color |
|
||||||
|
| `N2` | `#f5a97f` | JLPT N2 underline color |
|
||||||
|
| `N3` | `#f9e2af` | JLPT N3 underline color |
|
||||||
|
| `N4` | `#a6e3a1` | JLPT N4 underline color |
|
||||||
|
| `N5` | `#8aadf4` | JLPT N5 underline color |
|
||||||
|
|
||||||
|
**Image Quality Notes:**
|
||||||
|
|
||||||
|
- `imageQuality` affects JPG and WebP only; PNG is lossless and ignores this setting
|
||||||
|
- JPG quality is mapped to FFmpeg's scale (2-31, lower = better)
|
||||||
|
- WebP quality uses FFmpeg's native 0-100 scale
|
||||||
|
|
||||||
|
### Subtitle Position
|
||||||
|
|
||||||
|
Set the initial vertical subtitle position (measured from the bottom of the screen):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subtitlePosition": {
|
||||||
|
"yPercent": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| ---------- | ---------------- | ---------------------------------------------------------------------- |
|
||||||
|
| `yPercent` | number (0 - 100) | Distance from the bottom as a percent of screen height (default: `10`) |
|
||||||
|
In the overlay, you can fine-tune subtitle position at runtime with `Right-click + drag` on subtitle text.
|
||||||
|
|
||||||
|
### Secondary Subtitles
|
||||||
|
|
||||||
|
Display a second subtitle track (e.g., English alongside Japanese) in the overlay:
|
||||||
|
|
||||||
|
See `config.example.jsonc` for detailed configuration options.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"secondarySub": {
|
||||||
|
"secondarySubLanguages": ["eng", "en"],
|
||||||
|
"autoLoadSecondarySub": true,
|
||||||
|
"defaultMode": "hover"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| ----------------------- | ---------------------------------- | ------------------------------------------------------ |
|
||||||
|
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) |
|
||||||
|
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
||||||
|
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||||
|
|
||||||
|
**Display modes:**
|
||||||
|
|
||||||
|
- **hidden** — Secondary subtitles not shown
|
||||||
|
- **visible** — Always visible at top of overlay
|
||||||
|
- **hover** — Only visible when hovering over the subtitle area (default)
|
||||||
|
|
||||||
|
**See `config.example.jsonc`** for additional secondary subtitle configuration options.
|
||||||
|
|
||||||
|
## Keyboard & Controls
|
||||||
|
|
||||||
|
### Keybindings
|
||||||
|
|
||||||
|
Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv:
|
||||||
|
|
||||||
|
See `config.example.jsonc` for detailed configuration options and more examples.
|
||||||
|
|
||||||
|
**Default keybindings:**
|
||||||
|
|
||||||
|
| Key | Command | Description |
|
||||||
|
| ----------------- | ---------------------------- | ------------------------------------- |
|
||||||
|
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||||
|
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||||
|
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
||||||
|
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||||
|
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||||
|
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
||||||
|
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
||||||
|
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
||||||
|
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
||||||
|
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
||||||
|
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
||||||
|
| `KeyQ` | `["quit"]` | Quit mpv |
|
||||||
|
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
||||||
|
|
||||||
|
**Custom keybindings example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"keybindings": [
|
||||||
|
{ "key": "ArrowRight", "command": ["seek", 5] },
|
||||||
|
{ "key": "ArrowLeft", "command": ["seek", -5] },
|
||||||
|
{ "key": "Shift+ArrowRight", "command": ["seek", 30] },
|
||||||
|
{ "key": "KeyR", "command": ["script-binding", "immersive/auto-replay"] },
|
||||||
|
{ "key": "KeyA", "command": ["script-message", "ankiconnect-add-note"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`).
|
||||||
|
|
||||||
|
**Disable a default binding:** Set command to `null`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "key": "Space", "command": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
||||||
|
|
||||||
|
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
||||||
|
|
||||||
|
For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`), SubMiner also shows an mpv OSD notification after the command runs.
|
||||||
|
|
||||||
|
**See `config.example.jsonc`** for more keybinding examples and configuration options.
|
||||||
|
|
||||||
|
### Shortcuts Configuration
|
||||||
|
|
||||||
|
Customize or disable the overlay keyboard shortcuts:
|
||||||
|
|
||||||
|
See `config.example.jsonc` for detailed configuration options.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"shortcuts": {
|
||||||
|
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
||||||
|
"copySubtitle": "CommandOrControl+C",
|
||||||
|
"copySubtitleMultiple": "CommandOrControl+Shift+C",
|
||||||
|
"updateLastCardFromClipboard": "CommandOrControl+V",
|
||||||
|
"triggerFieldGrouping": "CommandOrControl+G",
|
||||||
|
"triggerSubsync": "Ctrl+Alt+S",
|
||||||
|
"mineSentence": "CommandOrControl+S",
|
||||||
|
"mineSentenceMultiple": "CommandOrControl+Shift+S",
|
||||||
|
"markAudioCard": "CommandOrControl+Shift+A",
|
||||||
|
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
||||||
|
"openJimaku": "Ctrl+Shift+J",
|
||||||
|
"multiCopyTimeoutMs": 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| ----------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
||||||
|
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
||||||
|
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
||||||
|
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
||||||
|
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when `behavior.autoUpdateNewCards` is `false`) |
|
||||||
|
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
|
||||||
|
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
|
||||||
|
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
|
||||||
|
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
|
||||||
|
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
||||||
|
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
||||||
|
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
||||||
|
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
||||||
|
|
||||||
|
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
||||||
|
|
||||||
|
Set any shortcut to `null` to disable it.
|
||||||
|
|
||||||
|
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
|
||||||
|
|
||||||
|
### Manual Card Update Shortcuts
|
||||||
|
|
||||||
|
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| -------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) |
|
||||||
|
| `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds |
|
||||||
|
| `Ctrl+V` | Update the last added Anki card using subtitles from clipboard |
|
||||||
|
| `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when `behavior.autoUpdateNewCards` is `false`) |
|
||||||
|
| `Ctrl+S` | Create a sentence card from the current subtitle line |
|
||||||
|
| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel |
|
||||||
|
| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) |
|
||||||
|
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
|
||||||
|
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
|
||||||
|
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
|
||||||
|
|
||||||
|
**Multi-line copy workflow:**
|
||||||
|
|
||||||
|
1. Press `Ctrl+Shift+C`
|
||||||
|
2. Press a number key (`1-9`) within 3 seconds
|
||||||
|
3. The specified number of most recent subtitle lines are copied
|
||||||
|
4. Press `Ctrl+V` to update the last added card with the copied lines
|
||||||
|
|
||||||
|
These shortcuts are only active when the overlay window is visible and automatically disabled when hidden.
|
||||||
|
|
||||||
|
### Session Help Modal
|
||||||
|
|
||||||
|
The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend.
|
||||||
|
|
||||||
|
You can filter the modal quickly with `/`:
|
||||||
|
|
||||||
|
- Type any part of the action name or shortcut in the search bar.
|
||||||
|
- Search is case-insensitive and ignores spaces/punctuation (`+`, `-`, `_`, `/`) so `ctrl w`, `ctrl+w`, and `ctrl+s` all match.
|
||||||
|
- Results are filtered across active MPV shortcuts, configured overlay shortcuts, and color legend items.
|
||||||
|
|
||||||
|
While the modal is open:
|
||||||
|
|
||||||
|
- `Esc`: close the modal (or clear the filter when text is entered)
|
||||||
|
- `↑/↓`, `j/k`: move selection
|
||||||
|
- Mouse/trackpad: click to select and activate rows
|
||||||
|
|
||||||
|
The list is generated at runtime from:
|
||||||
|
|
||||||
|
- Your active mpv keybindings (`keybindings`).
|
||||||
|
- Your configured overlay shortcuts (`shortcuts`, including runtime-loaded config values).
|
||||||
|
- Current subtitle color settings from `subtitleStyle`.
|
||||||
|
|
||||||
|
When config hot-reload updates shortcut/keybinding/style values, close and reopen the help modal to refresh the displayed entries.
|
||||||
|
|
||||||
|
### Runtime Option Palette
|
||||||
|
|
||||||
|
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
||||||
|
|
||||||
|
Current runtime options:
|
||||||
|
|
||||||
|
- `ankiConnect.behavior.autoUpdateNewCards` (`On` / `Off`)
|
||||||
|
- `ankiConnect.nPlusOne.highlightEnabled` (`On` / `Off`)
|
||||||
|
- `subtitleStyle.enableJlpt` (`On` / `Off`)
|
||||||
|
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
|
||||||
|
- `ankiConnect.nPlusOne.matchMode` (`headword` / `surface`)
|
||||||
|
- `ankiConnect.isKiku.fieldGrouping` (`auto` / `manual` / `disabled`)
|
||||||
|
|
||||||
|
Annotation toggles (`nPlusOne`, `enableJlpt`, `frequencyDictionary.enabled`) only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
||||||
|
|
||||||
|
Default shortcut: `Ctrl+Shift+O`
|
||||||
|
|
||||||
|
Palette controls:
|
||||||
|
|
||||||
|
- `Arrow Up/Down`: select option
|
||||||
|
- `Arrow Left/Right`: change selected value
|
||||||
|
- `Enter`: apply selected value
|
||||||
|
- `Esc`: close
|
||||||
|
|
||||||
|
## Anki Integration
|
||||||
|
|
||||||
### AnkiConnect
|
### AnkiConnect
|
||||||
|
|
||||||
Enable automatic Anki card creation and updates with media generation:
|
Enable automatic Anki card creation and updates with media generation:
|
||||||
@@ -100,6 +543,12 @@ Enable automatic Anki card creation and updates with media generation:
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"url": "http://127.0.0.1:8765",
|
"url": "http://127.0.0.1:8765",
|
||||||
"pollingRate": 3000,
|
"pollingRate": 3000,
|
||||||
|
"proxy": {
|
||||||
|
"enabled": false,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8766,
|
||||||
|
"upstreamUrl": "http://127.0.0.1:8765"
|
||||||
|
},
|
||||||
"tags": ["SubMiner"],
|
"tags": ["SubMiner"],
|
||||||
"deck": "Learning::Japanese",
|
"deck": "Learning::Japanese",
|
||||||
"fields": {
|
"fields": {
|
||||||
@@ -163,7 +612,11 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `false`) |
|
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `false`) |
|
||||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||||
| `pollingRate` | number (ms) | How often to check for new cards (default: `3000`) |
|
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||||
|
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||||
|
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
||||||
|
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||||
|
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||||
| `deck` | string | Anki deck to monitor for new cards |
|
| `deck` | string | Anki deck to monitor for new cards |
|
||||||
| `ankiConnect.nPlusOne.decks` | array of strings | Decks used for N+1 known-word cache lookups. When omitted/empty, falls back to `ankiConnect.deck`. |
|
| `ankiConnect.nPlusOne.decks` | array of strings | Decks used for N+1 known-word cache lookups. When omitted/empty, falls back to `ankiConnect.deck`. |
|
||||||
@@ -210,13 +663,28 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||||
|
|
||||||
**Kiku / Lapis Note Type Support:**
|
### Kiku/Lapis Integration
|
||||||
|
|
||||||
SubMiner supports the [Lapis](https://github.com/donkuri/lapis) and [Kiku](https://kiku.youyoumu.my.id/) note types. Both `isLapis.enabled` and `isKiku.enabled` can be true; Kiku takes precedence for grouping behavior, while sentence-card model/field settings come from `isLapis`.
|
SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) workflows, with note-type-specific behavior built into Anki settings.
|
||||||
|
|
||||||
When enabled, sentence cards automatically set `IsSentenceCard` to `"x"` and populate the `Expression` field. Audio cards set `IsAudioCard` to `"x"`.
|
```jsonc
|
||||||
|
"ankiConnect": {
|
||||||
|
"isLapis": {
|
||||||
|
"enabled": true,
|
||||||
|
"sentenceCardModel": "Japanese sentences"
|
||||||
|
},
|
||||||
|
"isKiku": {
|
||||||
|
"enabled": true,
|
||||||
|
"fieldGrouping": "manual",
|
||||||
|
"deleteDuplicateInAuto": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Kiku extends Lapis with **field grouping** — when a duplicate card is detected (same Word/Expression), SubMiner merges the two cards' content into one using Kiku's `data-group-id` HTML structure, organizing each mining instance into separate pages within the note.
|
- Enable `isLapis` to mine dedicated sentence cards. SubMiner sets `IsSentenceCard` to `"x"` and fills the sentence fields for the configured model.
|
||||||
|
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
|
||||||
|
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
|
||||||
|
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
|
||||||
|
|
||||||
### N+1 Word Highlighting
|
### N+1 Word Highlighting
|
||||||
|
|
||||||
@@ -268,91 +736,27 @@ To refresh roughly once per day, set:
|
|||||||
|
|
||||||
<a :href="'/assets/kiku-integration.webm'" target="_blank" rel="noreferrer">Open demo in a new tab</a>
|
<a :href="'/assets/kiku-integration.webm'" target="_blank" rel="noreferrer">Open demo in a new tab</a>
|
||||||
|
|
||||||
**Image Quality Notes:**
|
## External Integrations
|
||||||
|
|
||||||
- `imageQuality` affects JPG and WebP only; PNG is lossless and ignores this setting
|
### Jimaku
|
||||||
- JPG quality is mapped to FFmpeg's scale (2-31, lower = better)
|
|
||||||
- WebP quality uses FFmpeg's native 0-100 scale
|
|
||||||
|
|
||||||
### Manual Card Update Shortcuts
|
Configure Jimaku API access and defaults:
|
||||||
|
|
||||||
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| -------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) |
|
|
||||||
| `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds |
|
|
||||||
| `Ctrl+V` | Update the last added Anki card using subtitles from clipboard |
|
|
||||||
| `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when `behavior.autoUpdateNewCards` is `false`) |
|
|
||||||
| `Ctrl+S` | Create a sentence card from the current subtitle line |
|
|
||||||
| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel |
|
|
||||||
| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) |
|
|
||||||
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
|
|
||||||
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
|
|
||||||
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
|
|
||||||
|
|
||||||
**Multi-line copy workflow:**
|
|
||||||
|
|
||||||
1. Press `Ctrl+Shift+C`
|
|
||||||
2. Press a number key (`1-9`) within 3 seconds
|
|
||||||
3. The specified number of most recent subtitle lines are copied
|
|
||||||
4. Press `Ctrl+V` to update the last added card with the copied lines
|
|
||||||
|
|
||||||
These shortcuts are only active when the overlay window is visible and automatically disabled when hidden.
|
|
||||||
|
|
||||||
### Session help modal
|
|
||||||
|
|
||||||
The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend.
|
|
||||||
|
|
||||||
You can filter the modal quickly with `/`:
|
|
||||||
|
|
||||||
- Type any part of the action name or shortcut in the search bar.
|
|
||||||
- Search is case-insensitive and ignores spaces/punctuation (`+`, `-`, `_`, `/`) so `ctrl w`, `ctrl+w`, and `ctrl+s` all match.
|
|
||||||
- Results are filtered across active MPV shortcuts, configured overlay shortcuts, and color legend items.
|
|
||||||
|
|
||||||
While the modal is open:
|
|
||||||
|
|
||||||
- `Esc`: close the modal (or clear the filter when text is entered)
|
|
||||||
- `↑/↓`, `j/k`: move selection
|
|
||||||
- Mouse/trackpad: click to select and activate rows
|
|
||||||
|
|
||||||
The list is generated at runtime from:
|
|
||||||
|
|
||||||
- Your active mpv keybindings (`keybindings`).
|
|
||||||
- Your configured overlay shortcuts (`shortcuts`, including runtime-loaded config values).
|
|
||||||
- Current subtitle color settings from `subtitleStyle`.
|
|
||||||
|
|
||||||
When config hot-reload updates shortcut/keybinding/style values, close and reopen the help modal to refresh the displayed entries.
|
|
||||||
|
|
||||||
### Auto-Start Overlay
|
|
||||||
|
|
||||||
Control whether the overlay automatically becomes visible when it connects to mpv:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"auto_start_overlay": false
|
"jimaku": {
|
||||||
|
"apiKey": "YOUR_API_KEY",
|
||||||
|
"apiKeyCommand": "cat ~/.jimaku_key",
|
||||||
|
"apiBaseUrl": "https://jimaku.cc",
|
||||||
|
"languagePreference": "ja",
|
||||||
|
"maxEntryResults": 10
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
|
||||||
| -------------------- | --------------- | ------------------------------------------------------ |
|
|
||||||
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) |
|
|
||||||
|
|
||||||
The mpv plugin controls startup per layer via `auto_start_visible_overlay` and `auto_start_invisible_overlay` in `subminer.conf` (`platform-default` for invisible means hidden on Linux, visible on macOS/Windows).
|
Set `openBrowser` to `false` to only print the URL without opening a browser.
|
||||||
|
|
||||||
### Visible Overlay Subtitle Binding
|
|
||||||
|
|
||||||
Control whether toggling the visible overlay also toggles MPV subtitle visibility:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bind_visible_overlay_to_mpv_sub_visibility": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| -------------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `bind_visible_overlay_to_mpv_sub_visibility` | `true`, `false` | When `true` (default), visible overlay hides MPV primary/secondary subtitles and restores them when hidden. When `false`, visible overlay toggles do not change MPV subtitle visibility. |
|
|
||||||
|
|
||||||
### Auto Subtitle Sync
|
### Auto Subtitle Sync
|
||||||
|
|
||||||
@@ -379,43 +783,6 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
|
|||||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||||
Customize it there, or set it to `null` to disable.
|
Customize it there, or set it to `null` to disable.
|
||||||
|
|
||||||
### Invisible Overlay
|
|
||||||
|
|
||||||
SubMiner includes a second subtitle mining layer that can be visually invisible while still interactive for Yomitan lookups.
|
|
||||||
|
|
||||||
- `invisibleOverlay.startupVisibility` values:
|
|
||||||
|
|
||||||
1. `"platform-default"`: hidden on Wayland, visible on Windows/macOS/other sessions.
|
|
||||||
2. `"visible"`: always shown on startup.
|
|
||||||
3. `"hidden"`: always hidden on startup.
|
|
||||||
|
|
||||||
Invisible subtitle positioning can be adjusted directly in the invisible layer:
|
|
||||||
|
|
||||||
- `Ctrl/Cmd+Shift+P` toggles position edit mode.
|
|
||||||
- Use arrow keys to move the invisible subtitle text.
|
|
||||||
- Press `Enter` or `Ctrl/Cmd+S` to save, or `Esc` to cancel.
|
|
||||||
- This edit-mode shortcut is fixed (not currently configurable in `shortcuts`/`keybindings`).
|
|
||||||
|
|
||||||
### Jimaku
|
|
||||||
|
|
||||||
Configure Jimaku API access and defaults:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jimaku": {
|
|
||||||
"apiKey": "YOUR_API_KEY",
|
|
||||||
"apiKeyCommand": "cat ~/.jimaku_key",
|
|
||||||
"apiBaseUrl": "https://jimaku.cc",
|
|
||||||
"languagePreference": "ja",
|
|
||||||
"maxEntryResults": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
|
|
||||||
|
|
||||||
Set `openBrowser` to `false` to only print the URL without opening a browser.
|
|
||||||
|
|
||||||
### AniList
|
### AniList
|
||||||
|
|
||||||
AniList integration is opt-in and disabled by default. Enable it to allow SubMiner to update watched episode progress after playback.
|
AniList integration is opt-in and disabled by default. Enable it to allow SubMiner to update watched episode progress after playback.
|
||||||
@@ -508,6 +875,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
|||||||
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
||||||
|
|
||||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup.
|
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup.
|
||||||
|
|
||||||
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
||||||
|
|
||||||
Launcher subcommands:
|
Launcher subcommands:
|
||||||
@@ -562,276 +930,6 @@ Troubleshooting:
|
|||||||
- If images do not render, confirm asset keys exactly match uploaded Discord asset names.
|
- If images do not render, confirm asset keys exactly match uploaded Discord asset names.
|
||||||
- If Discord is closed/not installed/disconnects, SubMiner continues running and quietly skips presence updates.
|
- If Discord is closed/not installed/disconnects, SubMiner continues running and quietly skips presence updates.
|
||||||
|
|
||||||
### Keybindings
|
|
||||||
|
|
||||||
Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv:
|
|
||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options and more examples.
|
|
||||||
|
|
||||||
**Default keybindings:**
|
|
||||||
|
|
||||||
| Key | Command | Description |
|
|
||||||
| ----------------- | -------------------------- | ------------------------------------- |
|
|
||||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
|
||||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
|
||||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
|
||||||
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
|
||||||
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
|
||||||
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
|
||||||
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
|
||||||
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
|
||||||
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
|
||||||
| `KeyQ` | `["quit"]` | Quit mpv |
|
|
||||||
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
|
||||||
|
|
||||||
**Custom keybindings example:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"keybindings": [
|
|
||||||
{ "key": "ArrowRight", "command": ["seek", 5] },
|
|
||||||
{ "key": "ArrowLeft", "command": ["seek", -5] },
|
|
||||||
{ "key": "Shift+ArrowRight", "command": ["seek", 30] },
|
|
||||||
{ "key": "KeyR", "command": ["script-binding", "immersive/auto-replay"] },
|
|
||||||
{ "key": "KeyA", "command": ["script-message", "ankiconnect-add-note"] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`).
|
|
||||||
|
|
||||||
**Disable a default binding:** Set command to `null`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "key": "Space", "command": null }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
|
||||||
|
|
||||||
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
|
||||||
|
|
||||||
**See `config.example.jsonc`** for more keybinding examples and configuration options.
|
|
||||||
|
|
||||||
### Runtime Option Palette
|
|
||||||
|
|
||||||
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
|
||||||
|
|
||||||
Current runtime options:
|
|
||||||
|
|
||||||
- `ankiConnect.behavior.autoUpdateNewCards` (`On` / `Off`)
|
|
||||||
- `ankiConnect.isKiku.fieldGrouping` (`auto` / `manual` / `disabled`)
|
|
||||||
|
|
||||||
Default shortcut: `Ctrl+Shift+O`
|
|
||||||
|
|
||||||
Palette controls:
|
|
||||||
|
|
||||||
- `Arrow Up/Down`: select option
|
|
||||||
- `Arrow Left/Right`: change selected value
|
|
||||||
- `Enter`: apply selected value
|
|
||||||
- `Esc`: close
|
|
||||||
|
|
||||||
### Secondary Subtitles
|
|
||||||
|
|
||||||
Display a second subtitle track (e.g., English alongside Japanese) in the overlay:
|
|
||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"secondarySub": {
|
|
||||||
"secondarySubLanguages": ["eng", "en"],
|
|
||||||
"autoLoadSecondarySub": true,
|
|
||||||
"defaultMode": "hover"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------ |
|
|
||||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) |
|
|
||||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
|
||||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
|
||||||
|
|
||||||
**Display modes:**
|
|
||||||
|
|
||||||
- **hidden** — Secondary subtitles not shown
|
|
||||||
- **visible** — Always visible at top of overlay
|
|
||||||
- **hover** — Only visible when hovering over the subtitle area (default)
|
|
||||||
|
|
||||||
**See `config.example.jsonc`** for additional secondary subtitle configuration options.
|
|
||||||
|
|
||||||
### Shortcuts Configuration
|
|
||||||
|
|
||||||
Customize or disable the overlay keyboard shortcuts:
|
|
||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"shortcuts": {
|
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
|
||||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
|
|
||||||
"copySubtitle": "CommandOrControl+C",
|
|
||||||
"copySubtitleMultiple": "CommandOrControl+Shift+C",
|
|
||||||
"updateLastCardFromClipboard": "CommandOrControl+V",
|
|
||||||
"triggerFieldGrouping": "CommandOrControl+G",
|
|
||||||
"triggerSubsync": "Ctrl+Alt+S",
|
|
||||||
"mineSentence": "CommandOrControl+S",
|
|
||||||
"mineSentenceMultiple": "CommandOrControl+Shift+S",
|
|
||||||
"markAudioCard": "CommandOrControl+Shift+A",
|
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
|
||||||
"openJimaku": "Ctrl+Shift+J",
|
|
||||||
"multiCopyTimeoutMs": 3000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| ------------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
|
||||||
| `toggleInvisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling invisible interactive overlay (default: `"Alt+Shift+I"`) |
|
|
||||||
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
|
||||||
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
|
||||||
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
|
||||||
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when `behavior.autoUpdateNewCards` is `false`) |
|
|
||||||
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
|
|
||||||
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
|
|
||||||
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
|
|
||||||
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
|
|
||||||
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
|
||||||
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
|
||||||
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
|
||||||
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
|
||||||
|
|
||||||
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
|
||||||
|
|
||||||
Set any shortcut to `null` to disable it.
|
|
||||||
|
|
||||||
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
|
|
||||||
|
|
||||||
### Subtitle Position
|
|
||||||
|
|
||||||
Set the initial vertical subtitle position (measured from the bottom of the screen):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"subtitlePosition": {
|
|
||||||
"yPercent": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| ---------- | ---------------- | ---------------------------------------------------------------------- |
|
|
||||||
| `yPercent` | number (0 - 100) | Distance from the bottom as a percent of screen height (default: `10`) |
|
|
||||||
|
|
||||||
### Subtitle Style
|
|
||||||
|
|
||||||
Customize the appearance of primary and secondary subtitles:
|
|
||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"subtitleStyle": {
|
|
||||||
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
|
|
||||||
"fontSize": 35,
|
|
||||||
"fontColor": "#cad3f5",
|
|
||||||
"fontWeight": "normal",
|
|
||||||
"fontStyle": "normal",
|
|
||||||
"backgroundColor": "rgb(30, 32, 48, 0.88)",
|
|
||||||
"secondary": {
|
|
||||||
"fontSize": 24,
|
|
||||||
"fontColor": "#ffffff",
|
|
||||||
"backgroundColor": "transparent"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `fontFamily` | string | CSS font-family value (default: `"Noto Sans CJK JP Regular, ..."`) |
|
|
||||||
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
|
||||||
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
|
||||||
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"normal"`) |
|
|
||||||
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
|
||||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
|
|
||||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
|
||||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
|
||||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
|
||||||
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use the built-in bundled dictionary search paths. |
|
|
||||||
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
|
||||||
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
|
||||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
|
||||||
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
|
||||||
| `nPlusOneColor` | string | Existing n+1 highlight color (default: `#c6a0f6`) |
|
|
||||||
| `knownWordColor` | string | Existing known-word highlight color (default: `#a6da95`) |
|
|
||||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
|
||||||
| `secondary` | object | Override any of the above for secondary subtitles (optional) |
|
|
||||||
|
|
||||||
JLPT underlining is powered by offline term-meta bank files at runtime. See [`docs/jlpt-vocab-bundle.md`](jlpt-vocab-bundle.md) for required files, source/version refresh steps, and deterministic fallback behavior.
|
|
||||||
|
|
||||||
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
|
|
||||||
|
|
||||||
Lookup behavior:
|
|
||||||
|
|
||||||
- Set `frequencyDictionary.sourcePath` to a directory containing `term_meta_bank_*.json` for a fully custom source.
|
|
||||||
- If `sourcePath` is missing or empty, SubMiner uses bundled defaults from `vendor/jiten_freq_global` (packaged under `<resources>/jiten_freq_global` in distribution builds).
|
|
||||||
- In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting.
|
|
||||||
|
|
||||||
In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window.
|
|
||||||
|
|
||||||
Secondary subtitle defaults: `fontSize: 24`, `fontColor: "#ffffff"`, `backgroundColor: "transparent"`. Any property not set in `secondary` falls back to the CSS defaults.
|
|
||||||
|
|
||||||
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
|
|
||||||
|
|
||||||
`jlptColors` keys are:
|
|
||||||
|
|
||||||
| Key | Default | Description |
|
|
||||||
| ---- | --------- | ----------------------- |
|
|
||||||
| `N1` | `#ed8796` | JLPT N1 underline color |
|
|
||||||
| `N2` | `#f5a97f` | JLPT N2 underline color |
|
|
||||||
| `N3` | `#f9e2af` | JLPT N3 underline color |
|
|
||||||
| `N4` | `#a6e3a1` | JLPT N4 underline color |
|
|
||||||
| `N5` | `#8aadf4` | JLPT N5 underline color |
|
|
||||||
|
|
||||||
### Texthooker
|
|
||||||
|
|
||||||
Control whether the browser opens automatically when texthooker starts:
|
|
||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"texthooker": {
|
|
||||||
"openBrowser": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### WebSocket Server
|
|
||||||
|
|
||||||
The overlay includes a built-in WebSocket server that broadcasts subtitle text to connected clients (such as texthooker-ui) for external processing.
|
|
||||||
|
|
||||||
By default, the server uses "auto" mode: it starts automatically unless [mpv_websocket](https://github.com/kuroahna/mpv_websocket) is detected at `~/.config/mpv/mpv_websocket`. If you have mpv_websocket installed, the built-in server is skipped to avoid conflicts.
|
|
||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"websocket": {
|
|
||||||
"enabled": "auto",
|
|
||||||
"port": 6677
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| --------- | ------------------------- | -------------------------------------------------------- |
|
|
||||||
| `enabled` | `true`, `false`, `"auto"` | `"auto"` (default) disables if mpv_websocket is detected |
|
|
||||||
| `port` | number | WebSocket server port (default: 6677) |
|
|
||||||
|
|
||||||
### Immersion Tracking
|
### Immersion Tracking
|
||||||
|
|
||||||
Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions:
|
Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions:
|
||||||
|
|||||||
72
docs/demos.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Feature Demos
|
||||||
|
|
||||||
|
Short recordings of SubMiner's key features and integrations from real playback sessions.
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const v = '20260301-1';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
## Anki Card Mining & Enrichment
|
||||||
|
|
||||||
|
Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner automatically attaches the sentence, a timing-accurate audio clip, a screenshot, and a translation.
|
||||||
|
|
||||||
|
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`">
|
||||||
|
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
||||||
|
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
||||||
|
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
|
||||||
|
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
||||||
|
</a>
|
||||||
|
</video>
|
||||||
|
|
||||||
|
::: info VIDEO COMING SOON
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Subtitle Download & Sync
|
||||||
|
|
||||||
|
Search and download subtitles from Jimaku, then automatically synchronize them with alass or ffsubsync — all from within SubMiner.
|
||||||
|
|
||||||
|
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/subtitle-sync-poster.jpg?v=${v}`">
|
||||||
|
<source :src="`/assets/demos/subtitle-sync.webm?v=${v}`" type="video/webm" />
|
||||||
|
<source :src="`/assets/demos/subtitle-sync.mp4?v=${v}`" type="video/mp4" />
|
||||||
|
</video> -->
|
||||||
|
|
||||||
|
::: info VIDEO COMING SOON
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Jellyfin Integration
|
||||||
|
|
||||||
|
Browse your Jellyfin library, cast to devices, and launch playback directly from SubMiner. Watch progress syncs back to your Jellyfin server.
|
||||||
|
|
||||||
|
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/jellyfin-poster.jpg?v=${v}`">
|
||||||
|
<source :src="`/assets/demos/jellyfin.webm?v=${v}`" type="video/webm" />
|
||||||
|
<source :src="`/assets/demos/jellyfin.mp4?v=${v}`" type="video/mp4" />
|
||||||
|
</video> -->
|
||||||
|
|
||||||
|
::: info VIDEO COMING SOON
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Texthooker
|
||||||
|
|
||||||
|
Open subtitles in an external texthooker page for use with browser-based tools and extensions alongside the overlay.
|
||||||
|
|
||||||
|
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/texthooker-poster.jpg?v=${v}`">
|
||||||
|
<source :src="`/assets/demos/texthooker.webm?v=${v}`" type="video/webm" />
|
||||||
|
<source :src="`/assets/demos/texthooker.mp4?v=${v}`" type="video/mp4" />
|
||||||
|
</video> -->
|
||||||
|
|
||||||
|
::: info VIDEO COMING SOON
|
||||||
|
:::
|
||||||
|
|
||||||
|
<style>
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--vp-c-divider);
|
||||||
|
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
|
||||||
|
margin: 0.75rem 0 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 2.5rem !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -60,6 +60,15 @@ bun run dev # builds + launches with --start --dev
|
|||||||
electron . --start --dev --log-level debug # equivalent Electron launch with verbose logging
|
electron . --start --dev --log-level debug # equivalent Electron launch with verbose logging
|
||||||
electron . --background # tray/background mode, minimal default logging
|
electron . --background # tray/background mode, minimal default logging
|
||||||
make dev-start # build + launch via Makefile
|
make dev-start # build + launch via Makefile
|
||||||
|
make dev-watch # watch TS + renderer and launch Electron (faster edit loop)
|
||||||
|
make dev-watch-macos # same as dev-watch, forcing --backend macos
|
||||||
|
```
|
||||||
|
|
||||||
|
For mpv-plugin-driven testing without exporting `SUBMINER_BINARY_PATH` each run, set a one-time
|
||||||
|
dev binary path in `~/.config/mpv/script-opts/subminer.conf`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
binary_path=/absolute/path/to/SubMiner/scripts/subminer-dev.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ SubMiner stores immersion analytics in local SQLite (`immersion.sqlite`) by defa
|
|||||||
|
|
||||||
- Write path is asynchronous and queue-backed.
|
- Write path is asynchronous and queue-backed.
|
||||||
- Hot paths (subtitle parsing/render/token flows) enqueue telemetry/events and never await SQLite writes.
|
- Hot paths (subtitle parsing/render/token flows) enqueue telemetry/events and never await SQLite writes.
|
||||||
|
- Background line processing also upserts to `imm_words` and `imm_kanji`.
|
||||||
- Queue overflow policy is deterministic: drop oldest queued writes, keep newest.
|
- Queue overflow policy is deterministic: drop oldest queued writes, keep newest.
|
||||||
- Flush policy defaults to `25` writes or `500ms` max delay.
|
- Flush policy defaults to `25` writes or `500ms` max delay.
|
||||||
- SQLite pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=2500`.
|
- SQLite pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=2500`.
|
||||||
|
- Rollups now run incrementally from the last processed telemetry sample; startup performs a one-time bootstrap rebuild-equivalent pass.
|
||||||
|
- If retention pruning removes telemetry/session rows, maintenance triggers a full rollup rebuild to resync historical aggregates.
|
||||||
|
|
||||||
## Schema (v1)
|
## Schema (v3)
|
||||||
|
|
||||||
Schema versioning table:
|
Schema versioning table:
|
||||||
|
|
||||||
@@ -18,15 +21,21 @@ Schema versioning table:
|
|||||||
|
|
||||||
Core entities:
|
Core entities:
|
||||||
|
|
||||||
- `imm_videos`: video key/title/source metadata + optional media metadata fields
|
- `imm_videos`: video key/title/source metadata + optional media metadata fields, `CREATED_DATE`/`LAST_UPDATE_DATE`
|
||||||
- `imm_sessions`: session UUID, video reference, timing/status fields
|
- `imm_sessions`: session UUID, video reference, timing/status fields, `CREATED_DATE`/`LAST_UPDATE_DATE`
|
||||||
- `imm_session_telemetry`: high-frequency session aggregates over time
|
- `imm_session_telemetry`: high-frequency session aggregates over time, `CREATED_DATE`/`LAST_UPDATE_DATE`
|
||||||
- `imm_session_events`: event stream with compact numeric event types
|
- `imm_session_events`: event stream with compact numeric event types, `CREATED_DATE`/`LAST_UPDATE_DATE`
|
||||||
|
|
||||||
Rollups:
|
Rollups:
|
||||||
|
|
||||||
- `imm_daily_rollups`
|
- `imm_daily_rollups`: includes `CREATED_DATE`/`LAST_UPDATE_DATE`
|
||||||
- `imm_monthly_rollups`
|
- `imm_monthly_rollups`: includes `CREATED_DATE`/`LAST_UPDATE_DATE`
|
||||||
|
|
||||||
|
Vocabulary:
|
||||||
|
|
||||||
|
- `imm_words(id, headword, word, reading, first_seen, last_seen, frequency)`
|
||||||
|
- `imm_kanji(id, kanji, first_seen, last_seen, frequency)`
|
||||||
|
- `first_seen`/`last_seen` store Unix timestamps and are upserted with line ingestion
|
||||||
|
|
||||||
Primary index coverage:
|
Primary index coverage:
|
||||||
|
|
||||||
@@ -147,4 +156,3 @@ FROM imm_monthly_rollups
|
|||||||
ORDER BY rollup_month DESC, video_id DESC
|
ORDER BY rollup_month DESC, video_id DESC
|
||||||
LIMIT ?;
|
LIMIT ?;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,19 @@ const docsIndexContents = readFileSync(docsIndexPath, 'utf8');
|
|||||||
|
|
||||||
test('docs demo media uses shared cache-busting asset version token', () => {
|
test('docs demo media uses shared cache-busting asset version token', () => {
|
||||||
expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/);
|
expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/);
|
||||||
expect(docsIndexContents).toContain(':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"');
|
expect(docsIndexContents).toContain(
|
||||||
expect(docsIndexContents).toContain('<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />');
|
':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"',
|
||||||
expect(docsIndexContents).toContain('<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />');
|
);
|
||||||
expect(docsIndexContents).toContain('<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">');
|
expect(docsIndexContents).toContain(
|
||||||
expect(docsIndexContents).toContain('<img :src="`/assets/minecard.gif?v=${demoAssetVersion}`" alt="SubMiner demo GIF fallback" style="width: 100%; height: auto;" />');
|
'<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />',
|
||||||
|
);
|
||||||
|
expect(docsIndexContents).toContain(
|
||||||
|
'<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />',
|
||||||
|
);
|
||||||
|
expect(docsIndexContents).toContain(
|
||||||
|
'<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">',
|
||||||
|
);
|
||||||
|
expect(docsIndexContents).toContain(
|
||||||
|
'<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ titleTemplate: Immersion Mining Workflow for MPV
|
|||||||
hero:
|
hero:
|
||||||
name: SubMiner
|
name: SubMiner
|
||||||
text: Immersion Mining for MPV
|
text: Immersion Mining for MPV
|
||||||
tagline: Watch media, mine vocabulary, and build cards without leaving the scene.
|
tagline: Watch media, mine vocabulary, and craft anki cards without leaving the scene.
|
||||||
image:
|
image:
|
||||||
src: /assets/SubMiner.png
|
src: /assets/SubMiner.png
|
||||||
alt: SubMiner logo
|
alt: SubMiner logo
|
||||||
@@ -35,16 +35,11 @@ features:
|
|||||||
alt: Anki card icon
|
alt: Anki card icon
|
||||||
title: Anki Card Enrichment
|
title: Anki Card Enrichment
|
||||||
details: Auto-fills card fields with subtitle sentence, clipping, image, and translation so you can focus on learning.
|
details: Auto-fills card fields with subtitle sentence, clipping, image, and translation so you can focus on learning.
|
||||||
- icon:
|
|
||||||
src: /assets/dual-layer.svg
|
|
||||||
alt: Dual layer icon
|
|
||||||
title: Three-Plane Overlay Stack
|
|
||||||
details: Secondary context plane + visible interactive layer + invisible interaction plane, each with independent behavior and startup state.
|
|
||||||
- icon:
|
- icon:
|
||||||
src: /assets/highlight.svg
|
src: /assets/highlight.svg
|
||||||
alt: Highlight icon
|
alt: Highlight icon
|
||||||
title: N+1 Highlighting
|
title: Reading Annotations
|
||||||
details: Surfaces known words from your deck so unknown targets stand out during immersion sessions.
|
details: Combines N+1 targeting, Jiten frequency highlighting, and JLPT tagging so useful cues stay visible while you read.
|
||||||
- icon:
|
- icon:
|
||||||
src: /assets/tokenization.svg
|
src: /assets/tokenization.svg
|
||||||
alt: Tokenization icon
|
alt: Tokenization icon
|
||||||
@@ -55,16 +50,6 @@ features:
|
|||||||
alt: Subtitle download icon
|
alt: Subtitle download icon
|
||||||
title: Subtitle Download & Sync
|
title: Subtitle Download & Sync
|
||||||
details: Pull and synchronize subtitles with Jimaku plus alass/ffsubsync in one cohesive workflow.
|
details: Pull and synchronize subtitles with Jimaku plus alass/ffsubsync in one cohesive workflow.
|
||||||
- icon:
|
|
||||||
src: /assets/keyboard.svg
|
|
||||||
alt: Keyboard icon
|
|
||||||
title: Keyboard-Driven
|
|
||||||
details: Run lookups, mining actions, clipping, and workflow toggles with one configurable shortcut surface.
|
|
||||||
- icon:
|
|
||||||
src: /assets/texthooker.svg
|
|
||||||
alt: Texthooker icon
|
|
||||||
title: Texthooker & WebSocket
|
|
||||||
details: Stream subtitles in real time to browser tools via local WebSocket and keep your stack integrated.
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -110,7 +95,7 @@ const demoAssetVersion = '20260223-2';
|
|||||||
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
||||||
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
||||||
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
|
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
|
||||||
<img :src="`/assets/minecard.gif?v=${demoAssetVersion}`" alt="SubMiner demo GIF fallback" style="width: 100%; height: auto;" />
|
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
||||||
</a>
|
</a>
|
||||||
</video>
|
</video>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -150,7 +150,9 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-asse
|
|||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.config/SubMiner
|
mkdir -p ~/.config/SubMiner
|
||||||
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
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/
|
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
||||||
|
|
||||||
# Option 2: from source checkout
|
# Option 2: from source checkout
|
||||||
@@ -181,9 +183,6 @@ All keybindings use a `y` chord prefix — press `y`, then the second key:
|
|||||||
| `y-s` | Start overlay |
|
| `y-s` | Start overlay |
|
||||||
| `y-S` | Stop overlay |
|
| `y-S` | Stop overlay |
|
||||||
| `y-t` | Toggle visible overlay |
|
| `y-t` | Toggle visible overlay |
|
||||||
| `y-i` | Toggle invisible overlay |
|
|
||||||
| `y-I` | Show invisible overlay |
|
|
||||||
| `y-u` | Hide invisible overlay |
|
|
||||||
| `y-o` | Open Yomitan settings |
|
| `y-o` | Open Yomitan settings |
|
||||||
| `y-r` | Restart overlay |
|
| `y-r` | Restart overlay |
|
||||||
| `y-c` | Check overlay status |
|
| `y-c` | Check overlay status |
|
||||||
@@ -195,7 +194,10 @@ See [MPV Plugin](/mpv-plugin) for the full configuration reference, script messa
|
|||||||
After installing, confirm SubMiner is working:
|
After installing, confirm SubMiner is working:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start the overlay (connects to mpv IPC)
|
# Play a file (default plugin config auto-starts visible overlay and waits for annotation readiness)
|
||||||
|
subminer video.mkv
|
||||||
|
|
||||||
|
# Optional explicit overlay start for setups with plugin auto_start=no
|
||||||
subminer --start video.mkv
|
subminer --start video.mkv
|
||||||
|
|
||||||
# Useful launch modes for troubleshooting
|
# Useful launch modes for troubleshooting
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ The expected files are:
|
|||||||
|
|
||||||
Each bank maps terms to frequency metadata; only entries with a `frequency.displayValue` are considered for JLPT tagging.
|
Each bank maps terms to frequency metadata; only entries with a `frequency.displayValue` are considered for JLPT tagging.
|
||||||
|
|
||||||
SubMiner also reuses the same `term_meta_bank_*.json` format for frequency-based subtitle highlighting. The default frequency source is now bundled as `vendor/jiten_freq_global`, so users can enable `subtitleStyle.frequencyDictionary` without extra setup.
|
SubMiner also reuses the same `term_meta_bank_*.json` format for frequency-based subtitle highlighting, using installed/default `frequency-dictionary` locations or an explicit `subtitleStyle.frequencyDictionary.sourcePath`.
|
||||||
|
|
||||||
## Source and update process
|
## Source and update process
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ subminer -r -d ~/Anime # recursive search
|
|||||||
fzf shows video files in a fuzzy-searchable list. If `chafa` is installed, you get thumbnail previews in the right pane. Thumbnails are sourced from the freedesktop thumbnail cache first, then generated on the fly with `ffmpegthumbnailer` or `ffmpeg` as fallback.
|
fzf shows video files in a fuzzy-searchable list. If `chafa` is installed, you get thumbnail previews in the right pane. Thumbnails are sourced from the freedesktop thumbnail cache first, then generated on the fly with `ffmpegthumbnailer` or `ffmpeg` as fallback.
|
||||||
|
|
||||||
| Optional tool | Purpose |
|
| Optional tool | Purpose |
|
||||||
| --------------------- | -------------------------------- |
|
| ------------------- | --------------------------------- |
|
||||||
| `chafa` | Render thumbnails in the terminal |
|
| `chafa` | Render thumbnails in the terminal |
|
||||||
| `ffmpegthumbnailer` | Generate thumbnails on the fly |
|
| `ffmpegthumbnailer` | Generate thumbnails on the fly |
|
||||||
|
|
||||||
@@ -53,8 +53,9 @@ SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
|
|||||||
## Common Commands
|
## Common Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer video.mkv # play a specific file
|
subminer video.mkv # play a specific file (default plugin config auto-starts visible overlay)
|
||||||
subminer --start video.mkv # play + explicitly start overlay
|
subminer --start video.mkv # optional explicit overlay start when plugin auto_start=no
|
||||||
|
subminer -S video.mkv # same as above via --start-overlay
|
||||||
subminer https://youtu.be/... # YouTube playback (requires yt-dlp)
|
subminer https://youtu.be/... # YouTube playback (requires yt-dlp)
|
||||||
subminer ytsearch:"jp news" # YouTube search
|
subminer ytsearch:"jp news" # YouTube search
|
||||||
```
|
```
|
||||||
@@ -62,7 +63,7 @@ subminer ytsearch:"jp news" # YouTube search
|
|||||||
## Subcommands
|
## Subcommands
|
||||||
|
|
||||||
| Subcommand | Purpose |
|
| Subcommand | Purpose |
|
||||||
| ------------------------- | ---------------------------------------------- |
|
| -------------------------- | ---------------------------------------------------------- |
|
||||||
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) |
|
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) |
|
||||||
| `subminer yt` / `youtube` | YouTube shorthand (`-o`, `-m`) |
|
| `subminer yt` / `youtube` | YouTube shorthand (`-o`, `-m`) |
|
||||||
| `subminer doctor` | Dependency + config + socket diagnostics |
|
| `subminer doctor` | Dependency + config + socket diagnostics |
|
||||||
@@ -79,20 +80,22 @@ Use `subminer <subcommand> -h` for command-specific help.
|
|||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
| -------------------- | -------------------------------------------- |
|
| ----------------------- | --------------------------------------------------- |
|
||||||
| `-d, --directory` | Video search directory (default: cwd) |
|
| `-d, --directory` | Video search directory (default: cwd) |
|
||||||
| `-r, --recursive` | Search directories recursively |
|
| `-r, --recursive` | Search directories recursively |
|
||||||
| `-R, --rofi` | Use rofi instead of fzf |
|
| `-R, --rofi` | Use rofi instead of fzf |
|
||||||
| `-S, --start` | Start overlay after mpv launches |
|
| `--start` | Explicitly start overlay after mpv launches |
|
||||||
| `-T, --no-texthooker`| Disable texthooker server |
|
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
||||||
|
| `-T, --no-texthooker` | Disable texthooker server |
|
||||||
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
||||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
|
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
|
||||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
||||||
|
|
||||||
|
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
- Default log level is `info`
|
- Default log level is `info`
|
||||||
- `--background` mode defaults to `warn` unless `--log-level` is explicitly set
|
- `--background` mode defaults to `warn` unless `--log-level` is explicitly set
|
||||||
- `--dev` / `--debug` control app behavior, not logging verbosity — use `--log-level` for that
|
- `--dev` / `--debug` control app behavior, not logging verbosity — use `--log-level` for that
|
||||||
|
|
||||||
|
|||||||
@@ -20,49 +20,36 @@ SubMiner prioritizes subtitle responsiveness over heavy initialization:
|
|||||||
1. The first subtitle render is **plain text first** (no tokenization wait).
|
1. The first subtitle render is **plain text first** (no tokenization wait).
|
||||||
2. Tokenized enrichment (word spans, known-word flags, JLPT/frequency metadata) is applied right after parsing completes.
|
2. Tokenized enrichment (word spans, known-word flags, JLPT/frequency metadata) is applied right after parsing completes.
|
||||||
3. Under rapid subtitle churn, SubMiner uses a **latest-only tokenization queue** so stale lines are dropped instead of building lag.
|
3. Under rapid subtitle churn, SubMiner uses a **latest-only tokenization queue** so stale lines are dropped instead of building lag.
|
||||||
4. MeCab, Yomitan extension load, and dictionary prewarm run as background warmups after overlay initialization.
|
4. MeCab, Yomitan extension load, and dictionary prewarm run as background warmups after overlay initialization (configurable via `startupWarmups`, including low-power mode).
|
||||||
|
|
||||||
This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes.
|
This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes.
|
||||||
|
|
||||||
## The Three Overlay Planes
|
## Overlay Model
|
||||||
|
|
||||||
SubMiner uses three overlay planes, each serving a different purpose.
|
SubMiner uses one overlay window with modal surfaces.
|
||||||
|
|
||||||
### Visible Overlay
|
### Primary Subtitle Layer
|
||||||
|
|
||||||
The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
|
The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
|
||||||
|
|
||||||
- Word-level click targets for Yomitan lookup
|
- Word-level click targets for Yomitan lookup
|
||||||
|
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
||||||
- Right-click to pause/resume
|
- Right-click to pause/resume
|
||||||
- Right-click + drag to reposition subtitles
|
- Right-click + drag to reposition subtitles
|
||||||
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
||||||
- **N+1 highlighting** — known words from your Anki deck are visually highlighted
|
- **N+1 highlighting** — known words from your Anki deck are visually highlighted
|
||||||
|
|
||||||
Toggle with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
|
Toggle visibility with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
|
||||||
|
|
||||||
### Secondary Subtitle Plane
|
### Secondary Subtitle Bar
|
||||||
|
|
||||||
The secondary plane is a compact top-strip layer for translation and context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden.
|
The secondary subtitle bar is a compact top-strip region in the same overlay window for translation/context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden.
|
||||||
|
|
||||||
It is controlled by `secondarySub` configuration and shares lifecycle with the overlay stack.
|
It is controlled by `secondarySub` configuration and shares lifecycle with the main overlay window.
|
||||||
|
|
||||||
### Invisible Overlay
|
### Modal Surfaces
|
||||||
|
|
||||||
The invisible overlay is a transparent layer aligned with mpv's own subtitle rendering. It uses mpv's subtitle metrics (font size, margins, position, scaling) to map click targets accurately.
|
Jimaku search, field-grouping, runtime options, and manual subsync open as modal surfaces on top of the same overlay window.
|
||||||
|
|
||||||
This layer still supports:
|
|
||||||
|
|
||||||
- Word-level click-through lookups over the text region
|
|
||||||
- Optional manual position fine-tuning in pixel mode
|
|
||||||
- Independent toggle behavior with global shortcuts
|
|
||||||
|
|
||||||
Position edit mode is available via `Ctrl/Cmd+Shift+P`, then arrow keys / `hjkl` to nudge position; `Shift` moves faster. Save with `Enter` or `Ctrl+S`, cancel with `Esc`.
|
|
||||||
|
|
||||||
Toggle controls:
|
|
||||||
|
|
||||||
- `Alt+Shift+O` / `y-t`: visible overlay
|
|
||||||
- `Alt+Shift+I` / `y-i`: invisible overlay
|
|
||||||
- Secondary plane visibility is controlled via `secondarySub` config and matching global shortcuts.
|
|
||||||
|
|
||||||
## Looking Up Words
|
## Looking Up Words
|
||||||
|
|
||||||
@@ -73,10 +60,10 @@ Toggle controls:
|
|||||||
3. Yomitan detects the text selection and opens its popup with dictionary results.
|
3. Yomitan detects the text selection and opens its popup with dictionary results.
|
||||||
4. From the Yomitan popup, you can add the word directly to Anki.
|
4. From the Yomitan popup, you can add the word directly to Anki.
|
||||||
|
|
||||||
### On the Invisible Overlay
|
### On Overlay Subtitles
|
||||||
|
|
||||||
1. The invisible layer sits over mpv's own subtitle text.
|
1. Subtitles are rendered directly in the overlay.
|
||||||
2. Click on any word in the subtitle — SubMiner maps your click position to the underlying text.
|
2. Click on any word in the subtitle.
|
||||||
3. On macOS, word selection happens automatically on hover.
|
3. On macOS, word selection happens automatically on hover.
|
||||||
4. Yomitan popup appears for lookup and card creation.
|
4. Yomitan popup appears for lookup and card creation.
|
||||||
|
|
||||||
@@ -86,11 +73,13 @@ There are three ways to create cards, depending on your workflow.
|
|||||||
|
|
||||||
### 1. Auto-Update from Yomitan
|
### 1. Auto-Update from Yomitan
|
||||||
|
|
||||||
This is the most common flow. Yomitan creates a card in Anki, and SubMiner detects it via polling and enriches it automatically.
|
This is the most common flow. Yomitan creates a card in Anki, and SubMiner enriches it automatically.
|
||||||
|
|
||||||
1. Click a word → Yomitan popup appears.
|
1. Click a word → Yomitan popup appears.
|
||||||
2. Click the Anki icon in Yomitan to add the word.
|
2. Click the Anki icon in Yomitan to add the word.
|
||||||
3. SubMiner detects the new card (polls AnkiConnect every 3 seconds by default).
|
3. SubMiner receives or detects the new card:
|
||||||
|
- **Proxy mode** (`ankiConnect.proxy.enabled: true`): immediate enrich after successful `addNote` / `addNotes`.
|
||||||
|
- **Polling mode** (default): detects via AnkiConnect polling (`ankiConnect.pollingRate`, default 3 seconds).
|
||||||
4. SubMiner updates the card with:
|
4. SubMiner updates the card with:
|
||||||
- **Sentence**: The current subtitle line.
|
- **Sentence**: The current subtitle line.
|
||||||
- **Audio**: Extracted from the video using the subtitle's start/end timing (plus configurable padding).
|
- **Audio**: Extracted from the video using the subtitle's start/end timing (plus configurable padding).
|
||||||
@@ -109,10 +98,10 @@ If you prefer a hands-on approach (animecards-style), you can copy the current s
|
|||||||
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1`–`9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
|
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1`–`9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
|
||||||
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill.
|
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill.
|
||||||
|
|
||||||
This is useful when auto-update polling is disabled or when you want explicit control over which subtitle line gets attached to the card.
|
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
|
||||||
|
|
||||||
| Shortcut | Action | Config key |
|
| Shortcut | Action | Config key |
|
||||||
| --------------------------- | ----------------------------------------- | ------------------------------------- |
|
| -------------------------- | ------------------------------- | --------------------------------------- |
|
||||||
| `Ctrl/Cmd+C` | Copy current subtitle | `shortcuts.copySubtitle` |
|
| `Ctrl/Cmd+C` | Copy current subtitle | `shortcuts.copySubtitle` |
|
||||||
| `Ctrl/Cmd+Shift+C` + digit | Copy multiple recent lines | `shortcuts.copySubtitleMultiple` |
|
| `Ctrl/Cmd+Shift+C` + digit | Copy multiple recent lines | `shortcuts.copySubtitleMultiple` |
|
||||||
| `Ctrl/Cmd+V` | Update last card from clipboard | `shortcuts.updateLastCardFromClipboard` |
|
| `Ctrl/Cmd+V` | Update last card from clipboard | `shortcuts.updateLastCardFromClipboard` |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# MPV Plugin
|
# MPV Plugin
|
||||||
|
|
||||||
The SubMiner mpv plugin (`subminer.lua`) provides in-player keybindings to control the overlay without leaving mpv. It communicates with SubMiner by invoking the AppImage (or binary) with CLI flags.
|
The SubMiner mpv plugin (`subminer/main.lua`) provides in-player keybindings to control the overlay without leaving mpv. It communicates with SubMiner by invoking the AppImage (or binary) with CLI flags.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -10,7 +10,9 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-asse
|
|||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.config/SubMiner
|
mkdir -p ~/.config/SubMiner
|
||||||
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
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/
|
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
||||||
|
|
||||||
# Or from source checkout: make install-plugin
|
# Or from source checkout: make install-plugin
|
||||||
@@ -28,14 +30,11 @@ input-ipc-server=/tmp/subminer-socket
|
|||||||
All keybindings use a `y` chord prefix — press `y`, then the second key:
|
All keybindings use a `y` chord prefix — press `y`, then the second key:
|
||||||
|
|
||||||
| Chord | Action |
|
| Chord | Action |
|
||||||
| ----- | ------------------------ |
|
| ----- | ---------------------- |
|
||||||
| `y-y` | Open menu |
|
| `y-y` | Open menu |
|
||||||
| `y-s` | Start overlay |
|
| `y-s` | Start overlay |
|
||||||
| `y-S` | Stop overlay |
|
| `y-S` | Stop overlay |
|
||||||
| `y-t` | Toggle visible overlay |
|
| `y-t` | Toggle visible overlay |
|
||||||
| `y-i` | Toggle invisible overlay |
|
|
||||||
| `y-I` | Show invisible overlay |
|
|
||||||
| `y-u` | Hide invisible overlay |
|
|
||||||
| `y-o` | Open settings window |
|
| `y-o` | Open settings window |
|
||||||
| `y-r` | Restart overlay |
|
| `y-r` | Restart overlay |
|
||||||
| `y-c` | Check status |
|
| `y-c` | Check status |
|
||||||
@@ -50,10 +49,9 @@ SubMiner:
|
|||||||
1. Start overlay
|
1. Start overlay
|
||||||
2. Stop overlay
|
2. Stop overlay
|
||||||
3. Toggle overlay
|
3. Toggle overlay
|
||||||
4. Toggle invisible overlay
|
4. Open options
|
||||||
5. Open options
|
5. Restart overlay
|
||||||
6. Restart overlay
|
6. Check status
|
||||||
7. Check status
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Select an item by pressing its number.
|
Select an item by pressing its number.
|
||||||
@@ -79,14 +77,16 @@ texthooker_port=5174
|
|||||||
backend=auto
|
backend=auto
|
||||||
|
|
||||||
# Start the overlay automatically when a file is loaded.
|
# Start the overlay automatically when a file is loaded.
|
||||||
auto_start=no
|
# Runs only when mpv input-ipc-server matches socket_path.
|
||||||
|
auto_start=yes
|
||||||
|
|
||||||
# Show the visible overlay on auto-start.
|
# Show the visible overlay on auto-start.
|
||||||
auto_start_visible_overlay=no
|
# Runs only when mpv input-ipc-server matches socket_path.
|
||||||
|
auto_start_visible_overlay=yes
|
||||||
|
|
||||||
# Invisible overlay startup: platform-default, visible, hidden.
|
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
|
||||||
# platform-default = hidden on Linux, visible on macOS/Windows.
|
# Requires auto_start=yes and auto_start_visible_overlay=yes.
|
||||||
auto_start_invisible_overlay=platform-default
|
auto_start_pause_until_ready=yes
|
||||||
|
|
||||||
# Show OSD messages for overlay status changes.
|
# Show OSD messages for overlay status changes.
|
||||||
osd_messages=yes
|
osd_messages=yes
|
||||||
@@ -121,15 +121,15 @@ aniskip_button_duration=3
|
|||||||
### Option Reference
|
### Option Reference
|
||||||
|
|
||||||
| Option | Default | Values | Description |
|
| Option | Default | Values | Description |
|
||||||
| ------------------------------ | ---------------------- | ------------------------------------------ | -------------------------------- |
|
| ---------------------------- | ----------------------------- | ------------------------------------------ | ---------------------------------------------------------------------- |
|
||||||
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
|
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
|
||||||
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
|
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
|
||||||
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
|
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
|
||||||
| `texthooker_port` | `5174` | 1–65535 | Texthooker server port |
|
| `texthooker_port` | `5174` | 1–65535 | Texthooker server port |
|
||||||
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
|
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
|
||||||
| `auto_start` | `no` | `yes` / `no` | Auto-start overlay on file load |
|
| `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
|
||||||
| `auto_start_visible_overlay` | `no` | `yes` / `no` | Show visible layer on auto-start |
|
| `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
|
||||||
| `auto_start_invisible_overlay` | `platform-default` | `platform-default`, `visible`, `hidden` | Invisible layer on auto-start |
|
| `auto_start_pause_until_ready` | `yes` | `yes` / `no` | Pause mpv on visible auto-start; resume when SubMiner signals tokenization-ready |
|
||||||
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
||||||
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
||||||
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
|
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
|
||||||
@@ -182,13 +182,11 @@ The plugin can be controlled from other mpv scripts or the mpv command line usin
|
|||||||
script-message subminer-start
|
script-message subminer-start
|
||||||
script-message subminer-stop
|
script-message subminer-stop
|
||||||
script-message subminer-toggle
|
script-message subminer-toggle
|
||||||
script-message subminer-toggle-invisible
|
|
||||||
script-message subminer-show-invisible
|
|
||||||
script-message subminer-hide-invisible
|
|
||||||
script-message subminer-menu
|
script-message subminer-menu
|
||||||
script-message subminer-options
|
script-message subminer-options
|
||||||
script-message subminer-restart
|
script-message subminer-restart
|
||||||
script-message subminer-status
|
script-message subminer-status
|
||||||
|
script-message subminer-autoplay-ready
|
||||||
script-message subminer-aniskip-refresh
|
script-message subminer-aniskip-refresh
|
||||||
script-message subminer-skip-intro
|
script-message subminer-skip-intro
|
||||||
```
|
```
|
||||||
@@ -204,7 +202,12 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
|||||||
|
|
||||||
## AniSkip Intro Skip
|
## AniSkip Intro Skip
|
||||||
|
|
||||||
- On file load, plugin resolves title + episode, resolves MAL id, then calls AniSkip API.
|
- AniSkip lookups are gated. The plugin only runs lookup when:
|
||||||
|
- SubMiner launcher metadata is present, or
|
||||||
|
- SubMiner app process is already running, or
|
||||||
|
- You explicitly call `script-message subminer-aniskip-refresh`.
|
||||||
|
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
||||||
|
- MAL/title resolution is cached for the current mpv session.
|
||||||
- When launched via `subminer`, launcher runs `guessit` first (file targets) and passes title/season/episode to the plugin; fallback is filename-derived title.
|
- When launched via `subminer`, launcher runs `guessit` first (file targets) and passes title/season/episode to the plugin; fallback is filename-derived title.
|
||||||
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
||||||
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
||||||
@@ -213,7 +216,9 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
|||||||
|
|
||||||
## Lifecycle
|
## Lifecycle
|
||||||
|
|
||||||
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay and applies visibility preferences after a short delay.
|
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
|
||||||
|
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
|
||||||
|
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
|
||||||
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
|
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
|
||||||
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.
|
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.
|
||||||
|
|
||||||
|
|||||||
60
docs/plans/2026-02-26-secondary-subtitles-main-overlay.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Secondary Subtitles Main Overlay Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Ensure secondary subtitles render in the unified main overlay window and remove stale secondary-window/layer paths.
|
||||||
|
|
||||||
|
**Architecture:** Keep secondary subtitle DOM in the shared renderer tree, rely on mode classes (`secondary-sub-hidden|visible|hover`) for visibility, and remove obsolete legacy overlay-layer assumptions. Preserve modal behavior and existing subtitle rendering flow.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Electron renderer CSS/DOM, Bun test runner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Regression Tests For Main Overlay Secondary Rendering
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/renderer/subtitle-render.test.ts`
|
||||||
|
- Modify: `src/renderer/error-recovery.test.ts`
|
||||||
|
|
||||||
|
**Step 1: Write failing tests**
|
||||||
|
|
||||||
|
- Assert stylesheet no longer hides secondary subtitles in `layer-visible`.
|
||||||
|
- Assert renderer platform resolution ignores legacy `secondary` overlay layer.
|
||||||
|
|
||||||
|
**Step 2: Run tests to verify failures**
|
||||||
|
|
||||||
|
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts`
|
||||||
|
Expected: FAIL on secondary subtitle hide rule + legacy secondary layer handling.
|
||||||
|
|
||||||
|
### Task 2: Remove Secondary-Window CSS/Routing Assumptions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/renderer/style.css`
|
||||||
|
- Modify: `src/renderer/utils/platform.ts`
|
||||||
|
- Modify: `src/renderer/error-recovery.ts`
|
||||||
|
- Modify: `src/types.ts`
|
||||||
|
|
||||||
|
**Step 1: Implement minimal changes**
|
||||||
|
|
||||||
|
- Remove legacy forced hide on `#secondarySubContainer`.
|
||||||
|
- Remove obsolete layer-specific secondary-subtitle CSS blocks.
|
||||||
|
- Drop legacy `secondary` overlay-layer parsing path from renderer platform resolver.
|
||||||
|
- Narrow related overlay layer type unions.
|
||||||
|
|
||||||
|
**Step 2: Run targeted tests**
|
||||||
|
|
||||||
|
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Validate Wider Related Surface
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- No additional code changes required.
|
||||||
|
|
||||||
|
**Step 1: Run broader related tests**
|
||||||
|
|
||||||
|
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/overlay-window-factory.test.ts src/core/services/overlay-manager.test.ts`
|
||||||
|
Expected: Renderer tests pass; report any unrelated pre-existing failures.
|
||||||
@@ -1,15 +1,40 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="ac" x1="6" y1="6" x2="36" y2="42" gradientUnits="userSpaceOnUse">
|
<linearGradient id="ac-card" x1="6" y1="8" x2="38" y2="44" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#34d399"/>
|
<stop stop-color="#34d399"/>
|
||||||
<stop offset="1" stop-color="#059669"/>
|
<stop offset="1" stop-color="#059669"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="ac-glow" x1="8" y1="10" x2="36" y2="42" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#6ee7b7" stop-opacity="0.5"/>
|
||||||
|
<stop offset="1" stop-color="#059669" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="ac-soft" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="1.2"/>
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="12" y="5" width="24" height="34" rx="3" fill="#059669" opacity="0.18"/>
|
<!-- Glow aura behind card -->
|
||||||
<rect x="8" y="9" width="24" height="34" rx="3" fill="url(#ac)"/>
|
<rect x="6" y="8" width="28" height="36" rx="5" fill="url(#ac-glow)" filter="url(#ac-soft)"/>
|
||||||
<rect x="13" y="18" width="14" height="2.5" rx="1.25" fill="white" opacity="0.85"/>
|
<!-- Shadow card (back) -->
|
||||||
<rect x="13" y="24" width="10" height="2.5" rx="1.25" fill="white" opacity="0.4"/>
|
<rect x="14" y="5" width="26" height="34" rx="4" fill="#059669" opacity="0.15"/>
|
||||||
<rect x="13" y="30" width="12" height="2.5" rx="1.25" fill="white" opacity="0.4"/>
|
<!-- Main card -->
|
||||||
<path d="M39.5 8l1.8 4.2 4.2 1.8-4.2 1.8L39.5 20l-1.8-4.2L33.5 14l4.2-1.8z" fill="#34d399"/>
|
<rect x="6" y="9" width="26" height="34" rx="4" fill="url(#ac-card)"/>
|
||||||
<path d="M36 27l1 2.3 2.3 1-2.3 1L36 33.5l-1-2.2-2.3-1 2.3-1z" fill="#34d399" opacity="0.45"/>
|
<!-- Sentence line -->
|
||||||
|
<rect x="10" y="16" width="16" height="2.5" rx="1.25" fill="white" opacity="0.9"/>
|
||||||
|
<!-- Audio waveform mini -->
|
||||||
|
<rect x="10" y="22" width="1.8" height="5" rx="0.9" fill="white" opacity="0.55"/>
|
||||||
|
<rect x="13" y="20.5" width="1.8" height="8" rx="0.9" fill="white" opacity="0.55"/>
|
||||||
|
<rect x="16" y="21.5" width="1.8" height="6" rx="0.9" fill="white" opacity="0.55"/>
|
||||||
|
<rect x="19" y="23" width="1.8" height="3" rx="0.9" fill="white" opacity="0.55"/>
|
||||||
|
<rect x="22" y="21" width="1.8" height="7" rx="0.9" fill="white" opacity="0.55"/>
|
||||||
|
<rect x="25" y="22.5" width="1.8" height="4" rx="0.9" fill="white" opacity="0.55"/>
|
||||||
|
<!-- Image thumbnail placeholder -->
|
||||||
|
<rect x="10" y="30" width="10" height="8" rx="2" fill="white" opacity="0.25"/>
|
||||||
|
<path d="M12.5 35.5l2-2.5 2 1.8 1.5-1 2.5 3h-8z" fill="white" opacity="0.5"/>
|
||||||
|
<!-- Translation line -->
|
||||||
|
<rect x="22" y="32" width="7" height="2" rx="1" fill="white" opacity="0.35"/>
|
||||||
|
<rect x="22" y="35.5" width="5" height="2" rx="1" fill="white" opacity="0.25"/>
|
||||||
|
<!-- Enrichment sparkle burst -->
|
||||||
|
<path d="M40 10l1.6 3.8 3.8 1.6-3.8 1.6L40 20.8l-1.6-3.8L34.6 15.4l3.8-1.6z" fill="#6ee7b7"/>
|
||||||
|
<path d="M37 29l0.9 2.1 2.1 0.9-2.1 0.9L37 35l-0.9-2.1-2.1-0.9 2.1-0.9z" fill="#6ee7b7" opacity="0.5"/>
|
||||||
|
<circle cx="43" cy="25" r="1.2" fill="#34d399" opacity="0.4"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 2.3 KiB |
@@ -1,13 +1,39 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="hl" x1="20" y1="14" x2="38" y2="34" gradientUnits="userSpaceOnUse">
|
<linearGradient id="hl-freq" x1="0" y1="0" x2="14" y2="8" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#fbbf24"/>
|
<stop stop-color="#fbbf24"/>
|
||||||
<stop offset="1" stop-color="#f59e0b"/>
|
<stop offset="1" stop-color="#f59e0b"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="hl-n1" x1="0" y1="0" x2="10" y2="10" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#60a5fa"/>
|
||||||
|
<stop offset="1" stop-color="#3b82f6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="hl-jlpt" x1="0" y1="0" x2="12" y2="8" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#a78bfa"/>
|
||||||
|
<stop offset="1" stop-color="#7c3aed"/>
|
||||||
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="2" y="17" width="10" height="14" rx="3" fill="#fbbf24" opacity="0.3"/>
|
<!-- Viewport / video frame background -->
|
||||||
<rect x="14" y="17" width="7" height="14" rx="3" fill="#fbbf24" opacity="0.3"/>
|
<rect x="1" y="5" width="46" height="38" rx="4" fill="#1e293b" opacity="0.55"/>
|
||||||
<rect x="23" y="13" width="13" height="22" rx="3.5" fill="url(#hl)"/>
|
<rect x="1" y="5" width="46" height="38" rx="4" stroke="#334155" stroke-width="0.8" fill="none" opacity="0.5"/>
|
||||||
<rect x="38" y="17" width="8" height="14" rx="3" fill="#fbbf24" opacity="0.3"/>
|
<!-- Subtitle line 1 — tokens with frequency highlight -->
|
||||||
<path d="M28.2 4l1 2.4 2.4 1-2.4 1-1 2.4-1-2.4-2.4-1 2.4-1z" fill="#fbbf24" opacity="0.7"/>
|
<rect x="6" y="18" width="9" height="5" rx="1.5" fill="#cbd5e1" opacity="0.2"/>
|
||||||
|
<!-- Frequency-highlighted token -->
|
||||||
|
<rect x="17" y="17" width="14" height="7" rx="2" fill="url(#hl-freq)" opacity="0.2"/>
|
||||||
|
<rect x="17.5" y="17.5" width="13" height="6" rx="1.8" fill="url(#hl-freq)"/>
|
||||||
|
<rect x="20" y="19.5" width="8" height="2" rx="1" fill="white" opacity="0.85"/>
|
||||||
|
<rect x="33" y="18" width="8" height="5" rx="1.5" fill="#cbd5e1" opacity="0.2"/>
|
||||||
|
<!-- Subtitle line 2 — tokens with N+1 dot and JLPT badge -->
|
||||||
|
<rect x="8" y="28" width="8" height="5" rx="1.5" fill="#cbd5e1" opacity="0.2"/>
|
||||||
|
<!-- N+1 targeted token with dot -->
|
||||||
|
<rect x="18" y="28" width="10" height="5" rx="1.5" fill="#60a5fa" opacity="0.15"/>
|
||||||
|
<rect x="18.5" y="28.5" width="9" height="4" rx="1.2" fill="#cbd5e1" opacity="0.3"/>
|
||||||
|
<circle cx="16.5" cy="30.5" r="2.2" fill="url(#hl-n1)"/>
|
||||||
|
<text x="16.5" y="31.9" text-anchor="middle" font-size="2.6" font-weight="800" fill="white" font-family="sans-serif">+1</text>
|
||||||
|
<!-- JLPT badge token -->
|
||||||
|
<rect x="30" y="28" width="7" height="5" rx="1.5" fill="#cbd5e1" opacity="0.3"/>
|
||||||
|
<rect x="37.5" y="27" width="9" height="7" rx="2" fill="url(#hl-jlpt)"/>
|
||||||
|
<text x="42" y="31.8" text-anchor="middle" font-size="3.5" font-weight="700" fill="white" font-family="sans-serif">N2</text>
|
||||||
|
<!-- Subtle sparkle -->
|
||||||
|
<path d="M43 10l0.7 1.6 1.6 0.7-1.6 0.7L43 14.6l-0.7-1.6-1.6-0.7 1.6-0.7z" fill="#fbbf24" opacity="0.5"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 729 B After Width: | Height: | Size: 2.4 KiB |
@@ -1,21 +1,31 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="kb" x1="2" y1="10" x2="46" y2="42" gradientUnits="userSpaceOnUse">
|
<linearGradient id="kb-main" x1="2" y1="10" x2="46" y2="42" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#c084fc"/>
|
<stop stop-color="#c084fc"/>
|
||||||
<stop offset="1" stop-color="#7c3aed"/>
|
<stop offset="1" stop-color="#7c3aed"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<filter id="kb-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="2"/>
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="2" y="12" width="44" height="30" rx="5" fill="url(#kb)" opacity="0.12"/>
|
<!-- Keyboard body -->
|
||||||
<rect x="2" y="12" width="44" height="30" rx="5" stroke="url(#kb)" stroke-width="1.5" fill="none"/>
|
<rect x="2" y="14" width="44" height="28" rx="4.5" fill="url(#kb-main)" opacity="0.1"/>
|
||||||
<rect x="6" y="16" width="8" height="6" rx="2" fill="url(#kb)"/>
|
<rect x="2" y="14" width="44" height="28" rx="4.5" stroke="url(#kb-main)" stroke-width="1.4" fill="none"/>
|
||||||
<rect x="16" y="16" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<!-- Row 1 -->
|
||||||
<rect x="26" y="16" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<rect x="6" y="18" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
<rect x="36" y="16" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<rect x="15" y="18" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
<rect x="6" y="24" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<rect x="24" y="18" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
<rect x="16" y="24" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<rect x="33" y="18" width="11" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
<rect x="26" y="24" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<!-- Row 2 — active key with glow -->
|
||||||
<rect x="36" y="24" width="8" height="6" rx="2" fill="url(#kb)"/>
|
<rect x="6" y="25.5" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
<rect x="6" y="32" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<!-- Active/pressed key glow -->
|
||||||
<rect x="16" y="32" width="16" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<rect x="15" y="25.5" width="7" height="5.5" rx="1.8" fill="#c084fc" opacity="0.25" filter="url(#kb-glow)"/>
|
||||||
<rect x="34" y="32" width="10" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<rect x="15" y="25.5" width="7" height="5.5" rx="1.8" fill="url(#kb-main)"/>
|
||||||
|
<text x="18.5" y="30" text-anchor="middle" font-size="3.5" font-weight="700" fill="white" font-family="sans-serif">M</text>
|
||||||
|
<rect x="24" y="25.5" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
|
<rect x="33" y="25.5" width="11" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
|
<!-- Row 3 — spacebar -->
|
||||||
|
<rect x="6" y="33" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
|
<rect x="15" y="33" width="16" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.25"/>
|
||||||
|
<rect x="33" y="33" width="11" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 141 KiB |
BIN
docs/public/assets/kiku-integration.mkv
Normal file
BIN
docs/public/assets/kiku-integration.mp4
Normal file
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 13 MiB After Width: | Height: | Size: 23 MiB |
BIN
docs/public/assets/minecard.webp
Normal file
|
After Width: | Height: | Size: 21 MiB |
@@ -1,16 +1,35 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="sd" x1="4" y1="4" x2="44" y2="44" gradientUnits="userSpaceOnUse">
|
<linearGradient id="sd-main" x1="4" y1="4" x2="44" y2="44" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#22d3ee"/>
|
<stop stop-color="#22d3ee"/>
|
||||||
<stop offset="1" stop-color="#0891b2"/>
|
<stop offset="1" stop-color="#0891b2"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="sd-sync" x1="30" y1="28" x2="46" y2="44" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#34d399"/>
|
||||||
|
<stop offset="1" stop-color="#059669"/>
|
||||||
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="8" y="4" width="24" height="32" rx="3" fill="url(#sd)" opacity="0.15"/>
|
<!-- Subtitle file -->
|
||||||
<rect x="8" y="4" width="24" height="32" rx="3" stroke="url(#sd)" stroke-width="1.5" fill="none"/>
|
<rect x="4" y="3" width="26" height="34" rx="3.5" fill="url(#sd-main)" opacity="0.12"/>
|
||||||
<rect x="13" y="12" width="14" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.5"/>
|
<rect x="4" y="3" width="26" height="34" rx="3.5" stroke="url(#sd-main)" stroke-width="1.4" fill="none"/>
|
||||||
<rect x="13" y="18" width="10" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.35"/>
|
<!-- SRT-style timing line -->
|
||||||
<rect x="13" y="24" width="12" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.35"/>
|
<rect x="8.5" y="10" width="10" height="2" rx="1" fill="#22d3ee" opacity="0.35"/>
|
||||||
<line x1="38" y1="16" x2="38" y2="32" stroke="url(#sd)" stroke-width="2.5" stroke-linecap="round"/>
|
<rect x="20" y="10" width="3" height="2" rx="1" fill="#22d3ee" opacity="0.25"/>
|
||||||
<path d="M33 28l5 5 5-5" stroke="url(#sd)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
<!-- Subtitle text lines -->
|
||||||
<line x1="33" y1="40" x2="43" y2="40" stroke="url(#sd)" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
|
<rect x="8.5" y="15" width="17" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.6"/>
|
||||||
|
<rect x="8.5" y="20" width="12" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.4"/>
|
||||||
|
<!-- Divider -->
|
||||||
|
<line x1="8.5" y1="25.5" x2="26" y2="25.5" stroke="#22d3ee" stroke-width="0.6" opacity="0.2"/>
|
||||||
|
<!-- Second block timing -->
|
||||||
|
<rect x="8.5" y="28" width="10" height="2" rx="1" fill="#22d3ee" opacity="0.35"/>
|
||||||
|
<!-- Second block text -->
|
||||||
|
<rect x="8.5" y="32.5" width="14" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.4"/>
|
||||||
|
<!-- Download arrow -->
|
||||||
|
<line x1="38" y1="6" x2="38" y2="20" stroke="url(#sd-main)" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<path d="M33 16.5l5 5.5 5-5.5" stroke="url(#sd-main)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<!-- Sync arrows (circular) -->
|
||||||
|
<path d="M35 35a6 6 0 0 1 8.5-1.5" stroke="url(#sd-sync)" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
||||||
|
<path d="M44.5 35.5l-1-2.8-2.8 1" stroke="url(#sd-sync)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<path d="M43.5 41a6 6 0 0 1-8.5 1.5" stroke="url(#sd-sync)" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
||||||
|
<path d="M34 40.5l1 2.8 2.8-1" stroke="url(#sd-sync)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,19 +1,46 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="th" x1="4" y1="6" x2="44" y2="42" gradientUnits="userSpaceOnUse">
|
<linearGradient id="th-main" x1="2" y1="6" x2="22" y2="42" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#f97316"/>
|
<stop stop-color="#f97316"/>
|
||||||
<stop offset="1" stop-color="#c2410c"/>
|
<stop offset="1" stop-color="#c2410c"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="th-browser" x1="28" y1="6" x2="46" y2="42" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#fb923c"/>
|
||||||
|
<stop offset="1" stop-color="#ea580c"/>
|
||||||
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="4" y="6" width="30" height="36" rx="4" fill="url(#th)" opacity="0.12"/>
|
<!-- Source panel (subtitle/text source) -->
|
||||||
<rect x="4" y="6" width="30" height="36" rx="4" stroke="url(#th)" stroke-width="1.5" fill="none"/>
|
<rect x="2" y="8" width="18" height="32" rx="3" fill="url(#th-main)" opacity="0.12"/>
|
||||||
<rect x="9" y="14" width="14" height="2.5" rx="1.25" fill="#f97316" opacity="0.6"/>
|
<rect x="2" y="8" width="18" height="32" rx="3" stroke="url(#th-main)" stroke-width="1.3" fill="none"/>
|
||||||
<rect x="9" y="20" width="18" height="2.5" rx="1.25" fill="#f97316" opacity="0.4"/>
|
<!-- Subtitle text lines streaming out -->
|
||||||
<rect x="9" y="26" width="12" height="2.5" rx="1.25" fill="#f97316" opacity="0.4"/>
|
<rect x="5" y="14" width="12" height="2" rx="1" fill="#f97316" opacity="0.6"/>
|
||||||
<rect x="9" y="32" width="16" height="2.5" rx="1.25" fill="#f97316" opacity="0.4"/>
|
<rect x="5" y="19" width="10" height="2" rx="1" fill="#f97316" opacity="0.5"/>
|
||||||
<circle cx="40" cy="18" r="3.5" fill="url(#th)" opacity="0.8"/>
|
<rect x="5" y="24" width="11" height="2" rx="1" fill="#f97316" opacity="0.4"/>
|
||||||
<circle cx="40" cy="30" r="3.5" fill="url(#th)" opacity="0.8"/>
|
<rect x="5" y="29" width="9" height="2" rx="1" fill="#f97316" opacity="0.35"/>
|
||||||
<line x1="36" y1="18" x2="34" y2="18" stroke="url(#th)" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/>
|
<rect x="5" y="34" width="12" height="2" rx="1" fill="#f97316" opacity="0.3"/>
|
||||||
<line x1="36" y1="30" x2="34" y2="30" stroke="url(#th)" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/>
|
<!-- WebSocket stream particles -->
|
||||||
<line x1="40" y1="21.5" x2="40" y2="26.5" stroke="url(#th)" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
|
<circle cx="23" cy="18" r="1.2" fill="#fb923c" opacity="0.7"/>
|
||||||
|
<circle cx="25" cy="24" r="1" fill="#fb923c" opacity="0.5"/>
|
||||||
|
<circle cx="23.5" cy="30" r="1.1" fill="#fb923c" opacity="0.4"/>
|
||||||
|
<!-- Connection line (wavy/flowing) -->
|
||||||
|
<path d="M20 15c2-1 4 2 6 1s3-3 5-2" stroke="#fb923c" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.3"/>
|
||||||
|
<path d="M20 24c2.5 0 3 2 5 1.5s3-2.5 5.5-1.5" stroke="#fb923c" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.25"/>
|
||||||
|
<path d="M20 33c2-1 3.5 1.5 5.5 0.5s3-2 5-1" stroke="#fb923c" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.2"/>
|
||||||
|
<!-- Browser window (destination) -->
|
||||||
|
<rect x="28" y="8" width="18" height="32" rx="3" fill="url(#th-browser)" opacity="0.12"/>
|
||||||
|
<rect x="28" y="8" width="18" height="32" rx="3" stroke="url(#th-browser)" stroke-width="1.3" fill="none"/>
|
||||||
|
<!-- Browser chrome dots -->
|
||||||
|
<circle cx="32" cy="12" r="1.2" fill="#f97316" opacity="0.45"/>
|
||||||
|
<circle cx="35.5" cy="12" r="1.2" fill="#f97316" opacity="0.35"/>
|
||||||
|
<circle cx="39" cy="12" r="1.2" fill="#f97316" opacity="0.25"/>
|
||||||
|
<!-- Browser address bar -->
|
||||||
|
<rect x="31" y="15.5" width="12" height="2.5" rx="1.25" fill="#f97316" opacity="0.15"/>
|
||||||
|
<!-- Received text lines in browser -->
|
||||||
|
<rect x="31" y="21" width="11" height="2" rx="1" fill="#fb923c" opacity="0.55"/>
|
||||||
|
<rect x="31" y="25.5" width="9" height="2" rx="1" fill="#fb923c" opacity="0.45"/>
|
||||||
|
<rect x="31" y="30" width="10" height="2" rx="1" fill="#fb923c" opacity="0.35"/>
|
||||||
|
<rect x="31" y="34.5" width="8" height="2" rx="1" fill="#fb923c" opacity="0.25"/>
|
||||||
|
<!-- WS label -->
|
||||||
|
<rect x="21" y="5" width="8" height="5.5" rx="2.5" fill="#c2410c"/>
|
||||||
|
<text x="25" y="9.2" text-anchor="middle" font-size="3.2" font-weight="800" fill="white" font-family="sans-serif">WS</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -1,16 +1,34 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="tk" x1="0" y1="14" x2="48" y2="34" gradientUnits="userSpaceOnUse">
|
<linearGradient id="tk-bar" x1="0" y1="40" x2="0" y2="10" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#22d3ee"/>
|
<stop stop-color="#0891b2"/>
|
||||||
<stop offset="1" stop-color="#0891b2"/>
|
<stop offset="1" stop-color="#22d3ee"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="tk-glow" x1="4" y1="40" x2="44" y2="10" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#22d3ee" stop-opacity="0.25"/>
|
||||||
|
<stop offset="1" stop-color="#06b6d4" stop-opacity="0"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="2" y="12" width="12" height="24" rx="3.5" fill="url(#tk)"/>
|
<!-- Subtle grid lines -->
|
||||||
<rect x="18" y="12" width="12" height="24" rx="3.5" fill="url(#tk)"/>
|
<line x1="4" y1="14" x2="44" y2="14" stroke="#22d3ee" stroke-width="0.5" opacity="0.12"/>
|
||||||
<rect x="34" y="12" width="12" height="24" rx="3.5" fill="url(#tk)"/>
|
<line x1="4" y1="22" x2="44" y2="22" stroke="#22d3ee" stroke-width="0.5" opacity="0.12"/>
|
||||||
<line x1="15.5" y1="10" x2="15.5" y2="38" stroke="#22d3ee" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="3 3" opacity="0.45"/>
|
<line x1="4" y1="30" x2="44" y2="30" stroke="#22d3ee" stroke-width="0.5" opacity="0.12"/>
|
||||||
<line x1="32.5" y1="10" x2="32.5" y2="38" stroke="#22d3ee" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="3 3" opacity="0.45"/>
|
<!-- Base line -->
|
||||||
<rect x="5" y="22" width="6" height="2.5" rx="1.25" fill="white" opacity="0.7"/>
|
<line x1="4" y1="40" x2="44" y2="40" stroke="#0891b2" stroke-width="1" opacity="0.3"/>
|
||||||
<rect x="21" y="22" width="6" height="2.5" rx="1.25" fill="white" opacity="0.7"/>
|
<!-- Activity bars (daily rollups) -->
|
||||||
<rect x="37" y="22" width="6" height="2.5" rx="1.25" fill="white" opacity="0.7"/>
|
<rect x="5" y="30" width="4" height="10" rx="1.5" fill="url(#tk-bar)" opacity="0.4"/>
|
||||||
|
<rect x="11" y="24" width="4" height="16" rx="1.5" fill="url(#tk-bar)" opacity="0.55"/>
|
||||||
|
<rect x="17" y="28" width="4" height="12" rx="1.5" fill="url(#tk-bar)" opacity="0.5"/>
|
||||||
|
<rect x="23" y="18" width="4" height="22" rx="1.5" fill="url(#tk-bar)" opacity="0.7"/>
|
||||||
|
<rect x="29" y="22" width="4" height="18" rx="1.5" fill="url(#tk-bar)" opacity="0.6"/>
|
||||||
|
<rect x="35" y="14" width="4" height="26" rx="1.5" fill="url(#tk-bar)"/>
|
||||||
|
<rect x="41" y="20" width="4" height="20" rx="1.5" fill="url(#tk-bar)" opacity="0.65"/>
|
||||||
|
<!-- Trend line -->
|
||||||
|
<polyline points="7,28 13,22 19,25.5 25,16 31,20 37,12 43,18" stroke="#67e8f9" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.7"/>
|
||||||
|
<!-- Trend dot on peak -->
|
||||||
|
<circle cx="37" cy="12" r="2.2" fill="#22d3ee" opacity="0.6"/>
|
||||||
|
<circle cx="37" cy="12" r="1" fill="white" opacity="0.9"/>
|
||||||
|
<!-- Mini counter badge -->
|
||||||
|
<rect x="33" y="4" width="12" height="7" rx="3.5" fill="#0891b2"/>
|
||||||
|
<text x="39" y="9" text-anchor="middle" font-size="4" font-weight="700" fill="white" font-family="sans-serif">42d</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -5,26 +5,18 @@
|
|||||||
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Overlay Auto-Start
|
// Overlay Auto-Start
|
||||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Visible Overlay Subtitle Binding
|
|
||||||
// Control whether visible overlay toggles also toggle MPV subtitle visibility.
|
|
||||||
// When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.
|
|
||||||
// ==========================================
|
|
||||||
"bind_visible_overlay_to_mpv_sub_visibility": true, // Link visible overlay toggles to MPV subtitle visibility (primary and secondary). Values: true | false
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Texthooker Server
|
// Texthooker Server
|
||||||
// Control whether browser opens automatically for texthooker.
|
// Control whether browser opens automatically for texthooker.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"openBrowser": true // Open browser setting. Values: true | false
|
"openBrowser": true, // Open browser setting. Values: true | false
|
||||||
}, // Control whether browser opens automatically for texthooker.
|
}, // Control whether browser opens automatically for texthooker.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -34,7 +26,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
||||||
"port": 6677 // Built-in subtitle websocket server port.
|
"port": 6677, // Built-in subtitle websocket server port.
|
||||||
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -43,7 +35,7 @@
|
|||||||
// Set to debug for full runtime diagnostics.
|
// Set to debug for full runtime diagnostics.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
"level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||||
}, // Controls logging verbosity.
|
}, // Controls logging verbosity.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -53,7 +45,6 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
||||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
|
|
||||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
||||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
||||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
||||||
@@ -65,19 +56,9 @@
|
|||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
||||||
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
|
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
||||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Invisible Overlay
|
|
||||||
// Startup behavior for the invisible interactive subtitle mining layer.
|
|
||||||
// Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.
|
|
||||||
// This edit-mode shortcut is fixed and is not currently configurable.
|
|
||||||
// ==========================================
|
|
||||||
"invisibleOverlay": {
|
|
||||||
"startupVisibility": "platform-default" // Startup visibility setting.
|
|
||||||
}, // Startup behavior for the invisible interactive subtitle mining layer.
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Keybindings (MPV Commands)
|
// Keybindings (MPV Commands)
|
||||||
// Extra keybindings that are merged with built-in defaults.
|
// Extra keybindings that are merged with built-in defaults.
|
||||||
@@ -95,7 +76,7 @@
|
|||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
"secondarySubLanguages": [], // Secondary sub languages setting.
|
||||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
||||||
"defaultMode": "hover" // Default mode setting.
|
"defaultMode": "hover", // Default mode setting.
|
||||||
}, // Dual subtitle track options.
|
}, // Dual subtitle track options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -106,7 +87,7 @@
|
|||||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "" // Ffmpeg path setting.
|
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -114,7 +95,7 @@
|
|||||||
// Initial vertical subtitle position from the bottom.
|
// Initial vertical subtitle position from the bottom.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitlePosition": {
|
"subtitlePosition": {
|
||||||
"yPercent": 10 // Y percent setting.
|
"yPercent": 10, // Y percent setting.
|
||||||
}, // Initial vertical subtitle position from the bottom.
|
}, // Initial vertical subtitle position from the bottom.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -125,13 +106,22 @@
|
|||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv.
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif", // Font family setting.
|
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||||
|
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
||||||
|
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
"fontSize": 35, // Font size setting.
|
"fontSize": 35, // Font size setting.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
"fontColor": "#cad3f5", // Font color setting.
|
||||||
"fontWeight": "normal", // Font weight setting.
|
"fontWeight": "600", // Font weight setting.
|
||||||
|
"lineHeight": 1.35, // Line height setting.
|
||||||
|
"letterSpacing": "-0.01em", // Letter spacing setting.
|
||||||
|
"wordSpacing": 0, // Word spacing setting.
|
||||||
|
"fontKerning": "normal", // Font kerning setting.
|
||||||
|
"textRendering": "geometricPrecision", // Text rendering setting.
|
||||||
|
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
|
||||||
"fontStyle": "normal", // Font style setting.
|
"fontStyle": "normal", // Font style setting.
|
||||||
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
|
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
|
||||||
|
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
||||||
"knownWordColor": "#a6da95", // Known word color setting.
|
"knownWordColor": "#a6da95", // Known word color setting.
|
||||||
"jlptColors": {
|
"jlptColors": {
|
||||||
@@ -139,30 +129,32 @@
|
|||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
"N3": "#f9e2af", // N3 setting.
|
"N3": "#f9e2af", // N3 setting.
|
||||||
"N4": "#a6e3a1", // N4 setting.
|
"N4": "#a6e3a1", // N4 setting.
|
||||||
"N5": "#8aadf4" // N5 setting.
|
"N5": "#8aadf4", // N5 setting.
|
||||||
}, // Jlpt colors setting.
|
}, // Jlpt colors setting.
|
||||||
"frequencyDictionary": {
|
"frequencyDictionary": {
|
||||||
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
||||||
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used.
|
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, SubMiner searches installed/default frequency-dictionary locations.
|
||||||
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
|
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
|
||||||
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
|
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
|
||||||
|
"matchMode": "headword", // Frequency lookup text selection mode. Values: headword | surface
|
||||||
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
||||||
"bandedColors": [
|
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
"#ed8796",
|
|
||||||
"#f5a97f",
|
|
||||||
"#f9e2af",
|
|
||||||
"#a6e3a1",
|
|
||||||
"#8aadf4"
|
|
||||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
|
||||||
}, // Frequency dictionary setting.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
|
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
||||||
"fontSize": 24, // Font size setting.
|
"fontSize": 24, // Font size setting.
|
||||||
"fontColor": "#ffffff", // Font color setting.
|
"fontColor": "#cad3f5", // Font color setting.
|
||||||
|
"lineHeight": 1.35, // Line height setting.
|
||||||
|
"letterSpacing": "-0.01em", // Letter spacing setting.
|
||||||
|
"wordSpacing": 0, // Word spacing setting.
|
||||||
|
"fontKerning": "normal", // Font kerning setting.
|
||||||
|
"textRendering": "geometricPrecision", // Text rendering setting.
|
||||||
|
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
"backgroundColor": "transparent", // Background color setting.
|
||||||
|
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||||
"fontWeight": "normal", // Font weight setting.
|
"fontWeight": "normal", // Font weight setting.
|
||||||
"fontStyle": "normal", // Font style setting.
|
"fontStyle": "normal", // Font style setting.
|
||||||
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif" // Font family setting.
|
}, // Secondary setting.
|
||||||
} // Secondary setting.
|
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -175,15 +167,19 @@
|
|||||||
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
||||||
"url": "http://127.0.0.1:8765", // Url setting.
|
"url": "http://127.0.0.1:8765", // Url setting.
|
||||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||||
"tags": [
|
"proxy": {
|
||||||
"SubMiner"
|
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
||||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
|
||||||
|
"port": 8766, // Bind port for local AnkiConnect proxy.
|
||||||
|
"upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
|
||||||
|
}, // Proxy setting.
|
||||||
|
"tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
"fields": {
|
"fields": {
|
||||||
"audio": "ExpressionAudio", // Audio setting.
|
"audio": "ExpressionAudio", // Audio setting.
|
||||||
"image": "Picture", // Image setting.
|
"image": "Picture", // Image setting.
|
||||||
"sentence": "Sentence", // Sentence setting.
|
"sentence": "Sentence", // Sentence setting.
|
||||||
"miscInfo": "MiscInfo", // Misc info setting.
|
"miscInfo": "MiscInfo", // Misc info setting.
|
||||||
"translation": "SelectionText" // Translation setting.
|
"translation": "SelectionText", // Translation setting.
|
||||||
}, // Fields setting.
|
}, // Fields setting.
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
@@ -192,7 +188,7 @@
|
|||||||
"model": "openai/gpt-4o-mini", // Model setting.
|
"model": "openai/gpt-4o-mini", // Model setting.
|
||||||
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
||||||
"targetLanguage": "English", // Target language setting.
|
"targetLanguage": "English", // Target language setting.
|
||||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting.
|
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
|
||||||
}, // Ai setting.
|
}, // Ai setting.
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
"generateAudio": true, // Generate audio setting. Values: true | false
|
||||||
@@ -205,7 +201,7 @@
|
|||||||
"animatedCrf": 35, // Animated crf setting.
|
"animatedCrf": 35, // Animated crf setting.
|
||||||
"audioPadding": 0.5, // Audio padding setting.
|
"audioPadding": 0.5, // Audio padding setting.
|
||||||
"fallbackDuration": 3, // Fallback duration setting.
|
"fallbackDuration": 3, // Fallback duration setting.
|
||||||
"maxMediaDuration": 30 // Max media duration setting.
|
"maxMediaDuration": 30, // Max media duration setting.
|
||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
||||||
@@ -213,7 +209,7 @@
|
|||||||
"mediaInsertMode": "append", // Media insert mode setting.
|
"mediaInsertMode": "append", // Media insert mode setting.
|
||||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
"highlightWord": true, // Highlight word setting. Values: true | false
|
||||||
"notificationType": "osd", // Notification type setting.
|
"notificationType": "osd", // Notification type setting.
|
||||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
"autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false
|
||||||
}, // Behavior setting.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||||
@@ -222,20 +218,20 @@
|
|||||||
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
|
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
|
||||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||||
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
|
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
|
||||||
"knownWord": "#a6da95" // Color used for legacy known-word highlights.
|
"knownWord": "#a6da95", // Color used for legacy known-word highlights.
|
||||||
}, // N plus one setting.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
"pattern": "[SubMiner] %f (%t)", // Pattern setting.
|
||||||
}, // Metadata setting.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
"sentenceCardModel": "Japanese sentences", // Sentence card model setting.
|
||||||
}, // Is lapis setting.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
"deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -245,7 +241,7 @@
|
|||||||
"jimaku": {
|
"jimaku": {
|
||||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
"maxEntryResults": 10, // Maximum Jimaku search results returned.
|
||||||
}, // Jimaku API configuration and defaults.
|
}, // Jimaku API configuration and defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -256,10 +252,7 @@
|
|||||||
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
|
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
|
||||||
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
|
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
|
||||||
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
|
||||||
"ja",
|
|
||||||
"jpn"
|
|
||||||
] // Comma-separated primary subtitle language priority used by the launcher.
|
|
||||||
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -268,7 +261,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"anilist": {
|
"anilist": {
|
||||||
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
||||||
"accessToken": "" // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
||||||
}, // Anilist API credentials and update behavior.
|
}, // Anilist API credentials and update behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -292,16 +285,8 @@
|
|||||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
||||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
||||||
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||||
"directPlayContainers": [
|
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
|
||||||
"mkv",
|
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
|
||||||
"mp4",
|
|
||||||
"webm",
|
|
||||||
"mov",
|
|
||||||
"flac",
|
|
||||||
"mp3",
|
|
||||||
"aac"
|
|
||||||
], // Container allowlist for direct play decisions.
|
|
||||||
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
|
|
||||||
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -312,7 +297,7 @@
|
|||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750, // Debounce delay used to collapse bursty presence updates.
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -334,7 +319,7 @@
|
|||||||
"telemetryDays": 30, // Telemetry retention window in days.
|
"telemetryDays": 30, // Telemetry retention window in days.
|
||||||
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
||||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
||||||
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
"vacuumIntervalDays": 7, // Minimum days between VACUUM runs.
|
||||||
} // Retention setting.
|
}, // Retention setting.
|
||||||
} // Enable/disable immersion tracking.
|
}, // Enable/disable immersion tracking.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
|
|||||||
These work system-wide regardless of which window has focus.
|
These work system-wide regardless of which window has focus.
|
||||||
|
|
||||||
| Shortcut | Action | Configurable |
|
| Shortcut | Action | Configurable |
|
||||||
| ------------- | ------------------------ | ---------------------------------------- |
|
| ------------- | ---------------------- | -------------------------------------- |
|
||||||
| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` |
|
| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` |
|
||||||
| `Alt+Shift+I` | Toggle invisible overlay | `shortcuts.toggleInvisibleOverlayGlobal` |
|
|
||||||
| `Alt+Shift+Y` | Open Yomitan settings | Fixed (not configurable) |
|
| `Alt+Shift+Y` | Open Yomitan settings | Fixed (not configurable) |
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
@@ -39,6 +38,8 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| -------------------- | -------------------------------------------------- |
|
| -------------------- | -------------------------------------------------- |
|
||||||
| `Space` | Toggle mpv pause |
|
| `Space` | Toggle mpv pause |
|
||||||
|
| `J` | Cycle primary subtitle track |
|
||||||
|
| `Shift+J` | Cycle secondary subtitle track |
|
||||||
| `ArrowRight` | Seek forward 5 seconds |
|
| `ArrowRight` | Seek forward 5 seconds |
|
||||||
| `ArrowLeft` | Seek backward 5 seconds |
|
| `ArrowLeft` | Seek backward 5 seconds |
|
||||||
| `ArrowUp` | Seek forward 60 seconds |
|
| `ArrowUp` | Seek forward 60 seconds |
|
||||||
@@ -55,6 +56,8 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
|
|
||||||
These keybindings can be overridden or disabled via the `keybindings` config array.
|
These keybindings can be overridden or disabled via the `keybindings` config array.
|
||||||
|
|
||||||
|
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
|
||||||
|
|
||||||
## Subtitle & Feature Shortcuts
|
## Subtitle & Feature Shortcuts
|
||||||
|
|
||||||
| Shortcut | Action | Config key |
|
| Shortcut | Action | Config key |
|
||||||
@@ -64,31 +67,16 @@ These keybindings can be overridden or disabled via the `keybindings` config arr
|
|||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||||
|
|
||||||
## Invisible Subtitle Position Edit Mode
|
|
||||||
|
|
||||||
Enter edit mode to fine-tune invisible overlay alignment with mpv's native subtitles.
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| --------------------- | -------------------------------- |
|
|
||||||
| `Ctrl/Cmd+Shift+P` | Toggle position edit mode |
|
|
||||||
| `ArrowKeys` or `hjkl` | Nudge position by 1 px |
|
|
||||||
| `Shift+Arrow` | Nudge position by 4 px |
|
|
||||||
| `Enter` or `Ctrl+S` | Save position and exit edit mode |
|
|
||||||
| `Esc` | Cancel and discard changes |
|
|
||||||
|
|
||||||
## MPV Plugin Chords
|
## MPV Plugin Chords
|
||||||
|
|
||||||
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
||||||
|
|
||||||
| Chord | Action |
|
| Chord | Action |
|
||||||
| ----- | --------------------------------------- |
|
| ----- | ------------------------ |
|
||||||
| `y-y` | Open SubMiner menu (OSD) |
|
| `y-y` | Open SubMiner menu (OSD) |
|
||||||
| `y-s` | Start overlay |
|
| `y-s` | Start overlay |
|
||||||
| `y-S` | Stop overlay |
|
| `y-S` | Stop overlay |
|
||||||
| `y-t` | Toggle visible overlay |
|
| `y-t` | Toggle visible overlay |
|
||||||
| `y-i` | Toggle invisible overlay |
|
|
||||||
| `y-I` | Show invisible overlay |
|
|
||||||
| `y-u` | Hide invisible overlay |
|
|
||||||
| `y-o` | Open Yomitan settings |
|
| `y-o` | Open Yomitan settings |
|
||||||
| `y-r` | Restart overlay |
|
| `y-r` | Restart overlay |
|
||||||
| `y-c` | Check overlay status |
|
| `y-c` | Check overlay status |
|
||||||
@@ -112,7 +100,6 @@ All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electro
|
|||||||
"mineSentence": "CommandOrControl+S",
|
"mineSentence": "CommandOrControl+S",
|
||||||
"copySubtitle": "CommandOrControl+C",
|
"copySubtitle": "CommandOrControl+C",
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
||||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
|
|
||||||
"openJimaku": null, // disabled
|
"openJimaku": null, // disabled
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ SubMiner retries the connection automatically with increasing delays (200 ms, 50
|
|||||||
- first subtitle parse/tokenization bursts
|
- first subtitle parse/tokenization bursts
|
||||||
- media generation (`ffmpeg` audio/image and AVIF paths)
|
- media generation (`ffmpeg` audio/image and AVIF paths)
|
||||||
- media sync and subtitle tooling (`alass`, `ffsubsync`, `whisper` fallback path)
|
- media sync and subtitle tooling (`alass`, `ffsubsync`, `whisper` fallback path)
|
||||||
- `ankiConnect` enrichment and frequent polling
|
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
|
||||||
|
|
||||||
### If playback feels sluggish
|
### If playback feels sluggish
|
||||||
|
|
||||||
@@ -104,11 +104,17 @@ Logged when a malformed JSON line arrives from the mpv socket. Usually harmless
|
|||||||
|
|
||||||
**"AnkiConnect: unable to connect"**
|
**"AnkiConnect: unable to connect"**
|
||||||
|
|
||||||
SubMiner polls AnkiConnect at `http://127.0.0.1:8765` (configurable via `ankiConnect.url`). This error means Anki is not running or the AnkiConnect add-on is not installed.
|
SubMiner connects to the active Anki endpoint:
|
||||||
|
|
||||||
|
- `ankiConnect.url` (direct mode, default `http://127.0.0.1:8765`)
|
||||||
|
- `http://<ankiConnect.proxy.host>:<ankiConnect.proxy.port>` (proxy mode)
|
||||||
|
|
||||||
|
This error means the active endpoint is unavailable, or (in proxy mode) the proxy cannot reach `ankiConnect.proxy.upstreamUrl`.
|
||||||
|
|
||||||
- Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on in Anki.
|
- Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on in Anki.
|
||||||
- Make sure Anki is running before you start mining.
|
- Make sure Anki is running before you start mining.
|
||||||
- If you changed the AnkiConnect port, update `ankiConnect.url` in your config.
|
- If you changed the AnkiConnect port, update `ankiConnect.url` (or `ankiConnect.proxy.upstreamUrl` if using proxy mode).
|
||||||
|
- If using external Yomitan/browser clients, confirm they point to your SubMiner proxy URL.
|
||||||
|
|
||||||
SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated error logs after 5 consecutive failures. When Anki comes back, you will see "AnkiConnect connection restored".
|
SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated error logs after 5 consecutive failures. When Anki comes back, you will see "AnkiConnect connection restored".
|
||||||
|
|
||||||
@@ -122,7 +128,7 @@ See [Anki Integration](/anki-integration) for the full field mapping reference.
|
|||||||
|
|
||||||
Shown when SubMiner tries to update a card that no longer exists, or when AnkiConnect rejects the update. Common causes:
|
Shown when SubMiner tries to update a card that no longer exists, or when AnkiConnect rejects the update. Common causes:
|
||||||
|
|
||||||
- The card was deleted in Anki between polling and update.
|
- The card was deleted in Anki between creation and enrichment update.
|
||||||
- The note type changed and a mapped field no longer exists.
|
- The note type changed and a mapped field no longer exists.
|
||||||
|
|
||||||
## Overlay
|
## Overlay
|
||||||
@@ -153,7 +159,7 @@ SubMiner positions the overlay by tracking the mpv window. If tracking fails:
|
|||||||
- Sway: Ensure `swaymsg` is available.
|
- Sway: Ensure `swaymsg` is available.
|
||||||
- X11: Ensure `xdotool` and `xwininfo` are installed.
|
- X11: Ensure `xdotool` and `xwininfo` are installed.
|
||||||
|
|
||||||
If the overlay position is slightly off, use invisible subtitle position edit mode (`Ctrl/Cmd+Shift+P`) to fine-tune the offset with arrow keys, then save with `Enter` or `Ctrl+S`.
|
If the overlay position is slightly off, right-click and drag on subtitle text to fine-tune the overlay subtitle offset.
|
||||||
|
|
||||||
## Yomitan
|
## Yomitan
|
||||||
|
|
||||||
@@ -217,10 +223,10 @@ Media generation has a 30-second timeout (60 seconds for animated AVIF). If your
|
|||||||
|
|
||||||
**"Failed to register global shortcut"**
|
**"Failed to register global shortcut"**
|
||||||
|
|
||||||
Global shortcuts (`Alt+Shift+O`, `Alt+Shift+I`, `Alt+Shift+Y`) may conflict with other applications or desktop environment keybindings.
|
Global shortcuts (`Alt+Shift+O`, `Alt+Shift+Y`) may conflict with other applications or desktop environment keybindings.
|
||||||
|
|
||||||
- Check your DE/WM keybinding settings for conflicts.
|
- Check your DE/WM keybinding settings for conflicts.
|
||||||
- Change the shortcuts in your config under `shortcuts.toggleVisibleOverlayGlobal`, `shortcuts.toggleInvisibleOverlayGlobal`.
|
- Change the shortcut in your config under `shortcuts.toggleVisibleOverlayGlobal`.
|
||||||
- On Wayland, global shortcut registration has limitations depending on the compositor.
|
- On Wayland, global shortcut registration has limitations depending on the compositor.
|
||||||
|
|
||||||
**Overlay keybindings not working**
|
**Overlay keybindings not working**
|
||||||
@@ -273,5 +279,5 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
|
|||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility.
|
- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility.
|
||||||
- **Font rendering**: macOS uses a 0.87x font compensation factor for subtitle alignment between mpv and the overlay. If text alignment looks off, adjust the invisible subtitle offset.
|
- **Font rendering**: macOS uses a 0.87x font compensation factor for subtitle alignment between mpv and the overlay. If text alignment looks off, adjust subtitle offset by right-click dragging subtitle text.
|
||||||
- **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app`
|
- **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app`
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
|
||||||
|
> See [Yomitan setup](#yomitan-setup) for details.
|
||||||
|
|
||||||
There are two ways to use SubMiner — the `subminer` wrapper script or the mpv plugin:
|
There are two ways to use SubMiner — the `subminer` wrapper script or the mpv plugin:
|
||||||
|
|
||||||
| Approach | Best For |
|
| Approach | Best For |
|
||||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. Overlay start is explicit (`--start`, `-S`, or `y-s`). |
|
| **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. With default plugin settings, overlay auto-starts visible and playback resumes after annotation readiness. |
|
||||||
| **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control visible and invisible overlay layers. Requires `--input-ipc-server=/tmp/subminer-socket`. |
|
| **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control overlay visibility. Requires `--input-ipc-server=/tmp/subminer-socket`. |
|
||||||
|
|
||||||
You can use both together—install the plugin for on-demand control, but use `subminer` when you want the streamlined workflow.
|
You can use both together—install the plugin for on-demand control, but use `subminer` when you want the streamlined workflow.
|
||||||
|
|
||||||
`subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer --start video.mkv`.
|
`subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer video.mkv`.
|
||||||
|
|
||||||
## Live Config Reload
|
## Live Config Reload
|
||||||
|
|
||||||
@@ -34,8 +38,9 @@ subminer # Current directory (uses fzf)
|
|||||||
subminer -R # Use rofi instead of fzf
|
subminer -R # Use rofi instead of fzf
|
||||||
subminer -d ~/Videos # Specific directory
|
subminer -d ~/Videos # Specific directory
|
||||||
subminer -r -d ~/Anime # Recursive search
|
subminer -r -d ~/Anime # Recursive search
|
||||||
subminer video.mkv # Play specific file
|
subminer video.mkv # Play specific file (default plugin config auto-starts visible overlay)
|
||||||
subminer --start video.mkv # Play + explicitly start overlay
|
subminer --start video.mkv # Optional explicit overlay start (use when plugin auto_start=no)
|
||||||
|
subminer -S video.mkv # Same as above via --start-overlay
|
||||||
subminer https://youtu.be/... # Play a YouTube URL
|
subminer https://youtu.be/... # Play a YouTube URL
|
||||||
subminer ytsearch:"jp news" # Play first YouTube search result
|
subminer ytsearch:"jp news" # Play first YouTube search result
|
||||||
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
||||||
@@ -68,11 +73,8 @@ SubMiner.AppImage --start --texthooker # Start overlay with texthooker
|
|||||||
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
|
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
|
||||||
SubMiner.AppImage --stop # Stop overlay
|
SubMiner.AppImage --stop # Stop overlay
|
||||||
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
|
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
|
||||||
SubMiner.AppImage --start --toggle-invisible-overlay # Start MPV IPC + toggle invisible layer
|
|
||||||
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
|
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
|
||||||
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
|
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
|
||||||
SubMiner.AppImage --show-invisible-overlay # Force show invisible overlay
|
|
||||||
SubMiner.AppImage --hide-invisible-overlay # Force hide invisible overlay
|
|
||||||
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
||||||
SubMiner.AppImage --start --debug # Alias for --dev
|
SubMiner.AppImage --start --debug # Alias for --dev
|
||||||
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
||||||
@@ -149,6 +151,14 @@ secondary-sub-visibility=no
|
|||||||
|
|
||||||
`secondary-slang` is not an mpv option; use `slang` with `sid=auto` / `secondary-sid=auto` instead.
|
`secondary-slang` is not an mpv option; use `slang` with `sid=auto` / `secondary-sid=auto` instead.
|
||||||
|
|
||||||
|
### Yomitan setup
|
||||||
|
|
||||||
|
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
|
||||||
|
|
||||||
|
For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --settings` or `SubMiner.AppImage --settings`) and import at least one dictionary in the bundled Yomitan instance.
|
||||||
|
|
||||||
|
If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance.
|
||||||
|
|
||||||
### YouTube Playback
|
### YouTube Playback
|
||||||
|
|
||||||
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets, and forwards them to mpv.
|
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets, and forwards them to mpv.
|
||||||
@@ -171,9 +181,8 @@ Notes:
|
|||||||
### Global Shortcuts
|
### Global Shortcuts
|
||||||
|
|
||||||
| Keybind | Action |
|
| Keybind | Action |
|
||||||
| ------------- | ------------------------ |
|
| ------------- | ---------------------- |
|
||||||
| `Alt+Shift+O` | Toggle visible overlay |
|
| `Alt+Shift+O` | Toggle visible overlay |
|
||||||
| `Alt+Shift+I` | Toggle invisible overlay |
|
|
||||||
| `Alt+Shift+Y` | Open Yomitan settings |
|
| `Alt+Shift+Y` | Open Yomitan settings |
|
||||||
|
|
||||||
`Alt+Shift+Y` is a fixed global shortcut; it is not part of `shortcuts` config.
|
`Alt+Shift+Y` is a fixed global shortcut; it is not part of `shortcuts` config.
|
||||||
@@ -195,14 +204,12 @@ Notes:
|
|||||||
| `Ctrl+W` | Quit mpv |
|
| `Ctrl+W` | Quit mpv |
|
||||||
| `Right-click` | Toggle MPV pause (outside subtitle area) |
|
| `Right-click` | Toggle MPV pause (outside subtitle area) |
|
||||||
| `Right-click + drag` | Move subtitle position (on subtitle) |
|
| `Right-click + drag` | Move subtitle position (on subtitle) |
|
||||||
| `Ctrl/Cmd+Shift+P` | Toggle invisible subtitle position edit mode |
|
|
||||||
| `Arrow keys` | Move invisible subtitles while edit mode is active |
|
|
||||||
| `Enter` / `Ctrl+S` | Save invisible subtitle position in edit mode |
|
|
||||||
| `Esc` | Cancel invisible subtitle position edit mode |
|
|
||||||
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist |
|
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist |
|
||||||
|
|
||||||
These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization.
|
These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization.
|
||||||
|
|
||||||
|
By default, hovering over subtitle text pauses mpv playback and leaving the subtitle area resumes playback. Set `subtitleStyle.autoPauseVideoOnHover` to `false` to disable this behavior.
|
||||||
|
|
||||||
### Drag-and-drop Queueing
|
### Drag-and-drop Queueing
|
||||||
|
|
||||||
- Drag and drop one or more video files onto the overlay to replace current playback (`loadfile ... replace` for first file, then append remainder).
|
- Drag and drop one or more video files onto the overlay to replace current playback (`loadfile ... replace` for first file, then append remainder).
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ test('inferAniSkipMetadataForFile falls back to anime directory title when filen
|
|||||||
|
|
||||||
test('buildSubminerScriptOpts includes aniskip metadata fields', () => {
|
test('buildSubminerScriptOpts includes aniskip metadata fields', () => {
|
||||||
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
|
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
|
||||||
title: 'Frieren: Beyond Journey\'s End',
|
title: "Frieren: Beyond Journey's End",
|
||||||
season: 1,
|
season: 1,
|
||||||
episode: 5,
|
episode: 5,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ function toPositiveInt(value: unknown): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detectEpisodeFromName(baseName: string): number | null {
|
function detectEpisodeFromName(baseName: string): number | null {
|
||||||
const patterns = [/[Ss]\d+[Ee](\d{1,3})/, /(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/, /[-\s](\d{1,3})$/];
|
const patterns = [
|
||||||
|
/[Ss]\d+[Ee](\d{1,3})/,
|
||||||
|
/(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/,
|
||||||
|
/[-\s](\d{1,3})$/,
|
||||||
|
];
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const match = baseName.match(pattern);
|
const match = baseName.match(pattern);
|
||||||
if (!match || !match[1]) continue;
|
if (!match || !match[1]) continue;
|
||||||
@@ -171,7 +175,11 @@ export function inferAniSkipMetadataForFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeScriptOptValue(value: string): string {
|
function sanitizeScriptOptValue(value: string): string {
|
||||||
return value.replace(/,/g, ' ').replace(/[\r\n]/g, ' ').replace(/\s+/g, ' ').trim();
|
return value
|
||||||
|
.replace(/,/g, ' ')
|
||||||
|
.replace(/[\r\n]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSubminerScriptOpts(
|
export function buildSubminerScriptOpts(
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
|||||||
scriptPath: '/tmp/subminer',
|
scriptPath: '/tmp/subminer',
|
||||||
scriptName: 'subminer',
|
scriptName: 'subminer',
|
||||||
mpvSocketPath: '/tmp/subminer.sock',
|
mpvSocketPath: '/tmp/subminer.sock',
|
||||||
|
pluginRuntimeConfig: {
|
||||||
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: true,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
},
|
||||||
appPath: '/tmp/subminer.app',
|
appPath: '/tmp/subminer.app',
|
||||||
launcherJellyfinConfig: {},
|
launcherJellyfinConfig: {},
|
||||||
processAdapter: adapter,
|
processAdapter: adapter,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Args, LauncherJellyfinConfig } from '../types.js';
|
import type { Args, LauncherJellyfinConfig, PluginRuntimeConfig } from '../types.js';
|
||||||
import type { ProcessAdapter } from '../process-adapter.js';
|
import type { ProcessAdapter } from '../process-adapter.js';
|
||||||
|
|
||||||
export interface LauncherCommandContext {
|
export interface LauncherCommandContext {
|
||||||
@@ -6,6 +6,7 @@ export interface LauncherCommandContext {
|
|||||||
scriptPath: string;
|
scriptPath: string;
|
||||||
scriptName: string;
|
scriptName: string;
|
||||||
mpvSocketPath: string;
|
mpvSocketPath: string;
|
||||||
|
pluginRuntimeConfig: PluginRuntimeConfig;
|
||||||
appPath: string | null;
|
appPath: string | null;
|
||||||
launcherJellyfinConfig: LauncherJellyfinConfig;
|
launcherJellyfinConfig: LauncherJellyfinConfig;
|
||||||
processAdapter: ProcessAdapter;
|
processAdapter: ProcessAdapter;
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function registerCleanup(context: LauncherCommandContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
||||||
const { args, appPath, scriptPath, mpvSocketPath, processAdapter } = context;
|
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context;
|
||||||
if (!appPath) {
|
if (!appPath) {
|
||||||
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||||
}
|
}
|
||||||
@@ -137,6 +137,19 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
log('info', args.logLevel, 'YouTube subtitle mode: off');
|
log('info', args.logLevel, 'YouTube subtitle mode: off');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldPauseUntilOverlayReady =
|
||||||
|
pluginRuntimeConfig.autoStart &&
|
||||||
|
pluginRuntimeConfig.autoStartVisibleOverlay &&
|
||||||
|
pluginRuntimeConfig.autoStartPauseUntilReady;
|
||||||
|
|
||||||
|
if (shouldPauseUntilOverlayReady) {
|
||||||
|
log(
|
||||||
|
'info',
|
||||||
|
args.logLevel,
|
||||||
|
'Configured to pause mpv until overlay and tokenization are ready',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
startMpv(
|
startMpv(
|
||||||
selectedTarget.target,
|
selectedTarget.target,
|
||||||
selectedTarget.kind,
|
selectedTarget.kind,
|
||||||
@@ -144,6 +157,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
mpvSocketPath,
|
mpvSocketPath,
|
||||||
appPath,
|
appPath,
|
||||||
preloadedSubtitles,
|
preloadedSubtitles,
|
||||||
|
{ startPaused: shouldPauseUntilOverlayReady },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
|
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
|
||||||
@@ -167,6 +181,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
|
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||||
|
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
||||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
|
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
|
||||||
if (shouldStartOverlay) {
|
if (shouldStartOverlay) {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
@@ -179,6 +194,16 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await startOverlay(appPath, args, mpvSocketPath);
|
await startOverlay(appPath, args, mpvSocketPath);
|
||||||
|
} else if (pluginAutoStartEnabled) {
|
||||||
|
if (ready) {
|
||||||
|
log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
'info',
|
||||||
|
args.logLevel,
|
||||||
|
'MPV IPC socket not ready yet, relying on mpv plugin auto-start',
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (ready) {
|
} else if (ready) {
|
||||||
log(
|
log(
|
||||||
'info',
|
'info',
|
||||||
@@ -194,15 +219,26 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
if (!state.mpvProc) {
|
const mpvProc = state.mpvProc;
|
||||||
|
if (!mpvProc) {
|
||||||
stopOverlay(args);
|
stopOverlay(args);
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.mpvProc.on('exit', (code) => {
|
|
||||||
|
const finalize = (code: number | null | undefined) => {
|
||||||
stopOverlay(args);
|
stopOverlay(args);
|
||||||
processAdapter.setExitCode(code ?? 0);
|
processAdapter.setExitCode(code ?? 0);
|
||||||
resolve();
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mpvProc.exitCode !== null && mpvProc.exitCode !== undefined) {
|
||||||
|
finalize(mpvProc.exitCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mpvProc.once('exit', (code) => {
|
||||||
|
finalize(code);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,10 +51,27 @@ test('parseLauncherJellyfinConfig omits legacy token and user id fields', () =>
|
|||||||
assert.equal('userId' in parsed, false);
|
assert.equal('userId' in parsed, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parsePluginRuntimeConfigContent reads socket_path and ignores inline comments', () => {
|
test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => {
|
||||||
const parsed = parsePluginRuntimeConfigContent(`
|
const parsed = parsePluginRuntimeConfigContent(`
|
||||||
# comment
|
# comment
|
||||||
socket_path = /tmp/custom.sock # trailing comment
|
socket_path = /tmp/custom.sock # trailing comment
|
||||||
|
auto_start = yes
|
||||||
|
auto_start_visible_overlay = true
|
||||||
|
auto_start_pause_until_ready = 1
|
||||||
`);
|
`);
|
||||||
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
||||||
|
assert.equal(parsed.autoStart, true);
|
||||||
|
assert.equal(parsed.autoStartVisibleOverlay, true);
|
||||||
|
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePluginRuntimeConfigContent falls back to disabled startup gate options', () => {
|
||||||
|
const parsed = parsePluginRuntimeConfigContent(`
|
||||||
|
auto_start = maybe
|
||||||
|
auto_start_visible_overlay = no
|
||||||
|
auto_start_pause_until_ready = off
|
||||||
|
`);
|
||||||
|
assert.equal(parsed.autoStart, false);
|
||||||
|
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||||
|
assert.equal(parsed.autoStartPauseUntilReady, false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import { execFileSync } from 'node:child_process';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
test('launcher root help lists subcommands', () => {
|
test('launcher root help lists subcommands', () => {
|
||||||
const output = execFileSync(
|
const output = execFileSync('bun', ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'], {
|
||||||
'bun',
|
encoding: 'utf8',
|
||||||
['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'],
|
});
|
||||||
{ encoding: 'utf8' },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(output, /Commands:/);
|
assert.match(output, /Commands:/);
|
||||||
assert.match(output, /jellyfin\|jf/);
|
assert.match(output, /jellyfin\|jf/);
|
||||||
|
|||||||
@@ -182,7 +182,8 @@ export function parseCliPrograms(
|
|||||||
server: typeof options.server === 'string' ? options.server : undefined,
|
server: typeof options.server === 'string' ? options.server : undefined,
|
||||||
username: typeof options.username === 'string' ? options.username : undefined,
|
username: typeof options.username === 'string' ? options.username : undefined,
|
||||||
password: typeof options.password === 'string' ? options.password : undefined,
|
password: typeof options.password === 'string' ? options.password : undefined,
|
||||||
passwordStore: typeof options.passwordStore === 'string' ? options.passwordStore : undefined,
|
passwordStore:
|
||||||
|
typeof options.passwordStore === 'string' ? options.passwordStore : undefined,
|
||||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,22 +15,64 @@ export function getPluginConfigCandidates(): string[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig {
|
export function parsePluginRuntimeConfigContent(
|
||||||
const runtimeConfig: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
|
content: string,
|
||||||
|
logLevel: LogLevel = 'warn',
|
||||||
|
): PluginRuntimeConfig {
|
||||||
|
const runtimeConfig: PluginRuntimeConfig = {
|
||||||
|
socketPath: DEFAULT_SOCKET_PATH,
|
||||||
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: true,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseBooleanValue = (key: string, value: string): boolean => {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (['yes', 'true', '1', 'on'].includes(normalized)) return true;
|
||||||
|
if (['no', 'false', '0', 'off'].includes(normalized)) return false;
|
||||||
|
log('warn', logLevel, `Invalid boolean value for ${key}: "${value}". Using false.`);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
for (const line of content.split(/\r?\n/)) {
|
for (const line of content.split(/\r?\n/)) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
||||||
const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i);
|
const keyValueMatch = trimmed.match(/^([a-z0-9_-]+)\s*=\s*(.+)$/i);
|
||||||
if (!socketMatch) continue;
|
if (!keyValueMatch) continue;
|
||||||
const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || '';
|
const key = (keyValueMatch[1] || '').toLowerCase();
|
||||||
if (value) runtimeConfig.socketPath = value;
|
const value = (keyValueMatch[2] || '').split('#', 1)[0]?.trim() || '';
|
||||||
|
if (!value) continue;
|
||||||
|
|
||||||
|
if (key === 'socket_path') {
|
||||||
|
runtimeConfig.socketPath = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'auto_start') {
|
||||||
|
runtimeConfig.autoStart = parseBooleanValue('auto_start', value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'auto_start_visible_overlay') {
|
||||||
|
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue('auto_start_visible_overlay', value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'auto_start_pause_until_ready') {
|
||||||
|
runtimeConfig.autoStartPauseUntilReady = parseBooleanValue(
|
||||||
|
'auto_start_pause_until_ready',
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return runtimeConfig;
|
return runtimeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||||
const candidates = getPluginConfigCandidates();
|
const candidates = getPluginConfigCandidates();
|
||||||
const defaults: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
|
const defaults: PluginRuntimeConfig = {
|
||||||
|
socketPath: DEFAULT_SOCKET_PATH,
|
||||||
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: true,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
};
|
||||||
|
|
||||||
for (const configPath of candidates) {
|
for (const configPath of candidates) {
|
||||||
if (!fs.existsSync(configPath)) continue;
|
if (!fs.existsSync(configPath)) continue;
|
||||||
@@ -39,7 +81,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
|
|||||||
log(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
logLevel,
|
logLevel,
|
||||||
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}`,
|
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}`,
|
||||||
);
|
);
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -51,7 +93,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
|
|||||||
log(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
logLevel,
|
logLevel,
|
||||||
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath})`,
|
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath}, auto_start=${defaults.autoStart}, auto_start_visible_overlay=${defaults.autoStartVisibleOverlay}, auto_start_pause_until_ready=${defaults.autoStartPauseUntilReady})`,
|
||||||
);
|
);
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,14 @@ function withTempDir<T>(fn: (dir: string) => T): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
|
function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
|
||||||
const result = spawnSync(process.execPath, ['run', path.join(process.cwd(), 'launcher/main.ts'), ...argv], {
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
['run', path.join(process.cwd(), 'launcher/main.ts'), ...argv],
|
||||||
|
{
|
||||||
env,
|
env,
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
status: result.status,
|
status: result.status,
|
||||||
stdout: result.stdout || '',
|
stdout: result.stdout || '',
|
||||||
@@ -225,10 +229,7 @@ test('jellyfin setup forwards password-store to app command', () => {
|
|||||||
SUBMINER_APPIMAGE_PATH: appPath,
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
SUBMINER_TEST_CAPTURE: capturePath,
|
SUBMINER_TEST_CAPTURE: capturePath,
|
||||||
};
|
};
|
||||||
const result = runLauncher(
|
const result = runLauncher(['jf', 'setup', '--password-store', 'gnome-libsecret'], env);
|
||||||
['jf', 'setup', '--password-store', 'gnome-libsecret'],
|
|
||||||
env,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result.status, 0);
|
assert.equal(result.status, 0);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -19,14 +19,15 @@ import { runPlaybackCommand } from './commands/playback-command.js';
|
|||||||
function createCommandContext(
|
function createCommandContext(
|
||||||
args: ReturnType<typeof parseArgs>,
|
args: ReturnType<typeof parseArgs>,
|
||||||
scriptPath: string,
|
scriptPath: string,
|
||||||
mpvSocketPath: string,
|
pluginRuntimeConfig: ReturnType<typeof readPluginRuntimeConfig>,
|
||||||
appPath: string | null,
|
appPath: string | null,
|
||||||
): LauncherCommandContext {
|
): LauncherCommandContext {
|
||||||
return {
|
return {
|
||||||
args,
|
args,
|
||||||
scriptPath,
|
scriptPath,
|
||||||
scriptName: path.basename(scriptPath),
|
scriptName: path.basename(scriptPath),
|
||||||
mpvSocketPath,
|
mpvSocketPath: pluginRuntimeConfig.socketPath,
|
||||||
|
pluginRuntimeConfig,
|
||||||
appPath,
|
appPath,
|
||||||
launcherJellyfinConfig: loadLauncherJellyfinConfig(),
|
launcherJellyfinConfig: loadLauncherJellyfinConfig(),
|
||||||
processAdapter: nodeProcessAdapter,
|
processAdapter: nodeProcessAdapter,
|
||||||
@@ -55,7 +56,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
|
log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
|
||||||
|
|
||||||
const context = createCommandContext(args, scriptPath, pluginRuntimeConfig.socketPath, appPath);
|
const context = createCommandContext(args, scriptPath, pluginRuntimeConfig, appPath);
|
||||||
|
|
||||||
if (runDoctorCommand(context)) {
|
if (runDoctorCommand(context)) {
|
||||||
return;
|
return;
|
||||||
@@ -71,6 +72,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
const resolvedAppPath = ensureAppPath(context);
|
const resolvedAppPath = ensureAppPath(context);
|
||||||
state.appPath = resolvedAppPath;
|
state.appPath = resolvedAppPath;
|
||||||
|
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
|
||||||
const appContext: LauncherCommandContext = {
|
const appContext: LauncherCommandContext = {
|
||||||
...context,
|
...context,
|
||||||
appPath: resolvedAppPath,
|
appPath: resolvedAppPath,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { waitForUnixSocketReady } from './mpv';
|
import type { Args } from './types';
|
||||||
|
import { startOverlay, state, waitForUnixSocketReady } from './mpv';
|
||||||
import * as mpvModule from './mpv';
|
import * as mpvModule from './mpv';
|
||||||
|
|
||||||
function createTempSocketPath(): { dir: string; socketPath: string } {
|
function createTempSocketPath(): { dir: string; socketPath: string } {
|
||||||
@@ -59,3 +60,82 @@ test('waitForUnixSocketReady returns true when socket becomes connectable before
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||||
|
return {
|
||||||
|
backend: 'x11',
|
||||||
|
directory: '.',
|
||||||
|
recursive: false,
|
||||||
|
profile: '',
|
||||||
|
startOverlay: false,
|
||||||
|
youtubeSubgenMode: 'off',
|
||||||
|
whisperBin: '',
|
||||||
|
whisperModel: '',
|
||||||
|
youtubeSubgenOutDir: '',
|
||||||
|
youtubeSubgenAudioFormat: 'wav',
|
||||||
|
youtubeSubgenKeepTemp: false,
|
||||||
|
youtubePrimarySubLangs: [],
|
||||||
|
youtubeSecondarySubLangs: [],
|
||||||
|
youtubeAudioLangs: [],
|
||||||
|
youtubeWhisperSourceLanguage: 'ja',
|
||||||
|
useTexthooker: false,
|
||||||
|
autoStartOverlay: false,
|
||||||
|
texthookerOnly: false,
|
||||||
|
useRofi: false,
|
||||||
|
logLevel: 'error',
|
||||||
|
passwordStore: '',
|
||||||
|
target: '',
|
||||||
|
targetKind: '',
|
||||||
|
jimakuApiKey: '',
|
||||||
|
jimakuApiKeyCommand: '',
|
||||||
|
jimakuApiBaseUrl: '',
|
||||||
|
jimakuLanguagePreference: 'none',
|
||||||
|
jimakuMaxEntryResults: 10,
|
||||||
|
jellyfin: false,
|
||||||
|
jellyfinLogin: false,
|
||||||
|
jellyfinLogout: false,
|
||||||
|
jellyfinPlay: false,
|
||||||
|
jellyfinDiscovery: false,
|
||||||
|
doctor: false,
|
||||||
|
configPath: false,
|
||||||
|
configShow: false,
|
||||||
|
mpvIdle: false,
|
||||||
|
mpvSocket: false,
|
||||||
|
mpvStatus: false,
|
||||||
|
appPassthrough: false,
|
||||||
|
appArgs: [],
|
||||||
|
jellyfinServer: '',
|
||||||
|
jellyfinUsername: '',
|
||||||
|
jellyfinPassword: '',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('startOverlay resolves without fixed 2s sleep when readiness signals arrive quickly', async () => {
|
||||||
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
|
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||||
|
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
fs.writeFileSync(socketPath, '');
|
||||||
|
const originalCreateConnection = net.createConnection;
|
||||||
|
try {
|
||||||
|
net.createConnection = (() => {
|
||||||
|
const socket = new EventEmitter() as net.Socket;
|
||||||
|
socket.destroy = (() => socket) as net.Socket['destroy'];
|
||||||
|
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
|
||||||
|
setTimeout(() => socket.emit('connect'), 10);
|
||||||
|
return socket;
|
||||||
|
}) as typeof net.createConnection;
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await startOverlay(appPath, makeArgs(), socketPath);
|
||||||
|
const elapsedMs = Date.now() - startedAt;
|
||||||
|
|
||||||
|
assert.ok(elapsedMs < 1200, `expected startOverlay <1200ms, got ${elapsedMs}ms`);
|
||||||
|
} finally {
|
||||||
|
net.createConnection = originalCreateConnection;
|
||||||
|
state.overlayProc = null;
|
||||||
|
state.overlayManagedByLauncher = false;
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
|
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
|
||||||
|
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
|
||||||
|
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
||||||
|
|
||||||
function readTrackedDetachedMpvPid(): number | null {
|
function readTrackedDetachedMpvPid(): number | null {
|
||||||
try {
|
try {
|
||||||
@@ -424,6 +426,7 @@ export function startMpv(
|
|||||||
socketPath: string,
|
socketPath: string,
|
||||||
appPath: string,
|
appPath: string,
|
||||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||||
|
options?: { startPaused?: boolean },
|
||||||
): void {
|
): void {
|
||||||
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
||||||
fail(`Video file not found: ${target}`);
|
fail(`Video file not found: ${target}`);
|
||||||
@@ -473,8 +476,10 @@ export function startMpv(
|
|||||||
if (preloadedSubtitles?.secondaryPath) {
|
if (preloadedSubtitles?.secondaryPath) {
|
||||||
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
|
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
|
||||||
}
|
}
|
||||||
const aniSkipMetadata =
|
if (options?.startPaused) {
|
||||||
targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
|
mpvArgs.push('--pause=yes');
|
||||||
|
}
|
||||||
|
const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
|
||||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
||||||
if (aniSkipMetadata) {
|
if (aniSkipMetadata) {
|
||||||
log(
|
log(
|
||||||
@@ -498,7 +503,47 @@ export function startMpv(
|
|||||||
state.mpvProc = spawn('mpv', mpvArgs, { stdio: 'inherit' });
|
state.mpvProc = spawn('mpv', mpvArgs, { stdio: 'inherit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startOverlay(appPath: string, args: Args, socketPath: string): Promise<void> {
|
async function waitForOverlayStartCommandSettled(
|
||||||
|
proc: ReturnType<typeof spawn>,
|
||||||
|
logLevel: LogLevel,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
proc.off('exit', onExit);
|
||||||
|
proc.off('error', onError);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onExit = (code: number | null) => {
|
||||||
|
if (typeof code === 'number' && code !== 0) {
|
||||||
|
log('warn', logLevel, `Overlay start command exited with status ${code}`);
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (error: Error) => {
|
||||||
|
log('warn', logLevel, `Overlay start command failed: ${error.message}`);
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
|
proc.once('exit', onExit);
|
||||||
|
proc.once('error', onError);
|
||||||
|
timer = setTimeout(finish, timeoutMs);
|
||||||
|
|
||||||
|
if (proc.exitCode !== null && proc.exitCode !== undefined) {
|
||||||
|
onExit(proc.exitCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startOverlay(appPath: string, args: Args, socketPath: string): Promise<void> {
|
||||||
const backend = detectBackend(args.backend);
|
const backend = detectBackend(args.backend);
|
||||||
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
|
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
|
||||||
|
|
||||||
@@ -512,9 +557,22 @@ export function startOverlay(appPath: string, args: Args, socketPath: string): P
|
|||||||
});
|
});
|
||||||
state.overlayManagedByLauncher = true;
|
state.overlayManagedByLauncher = true;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
const [socketReady] = await Promise.all([
|
||||||
setTimeout(resolve, 2000);
|
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
|
||||||
});
|
waitForOverlayStartCommandSettled(
|
||||||
|
state.overlayProc,
|
||||||
|
args.logLevel,
|
||||||
|
OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!socketReady) {
|
||||||
|
log(
|
||||||
|
'debug',
|
||||||
|
args.logLevel,
|
||||||
|
'Overlay start continuing before mpv socket readiness was confirmed',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function launchTexthookerOnly(appPath: string, args: Args): never {
|
export function launchTexthookerOnly(appPath: string, args: Args): never {
|
||||||
|
|||||||
@@ -31,11 +31,7 @@ test('parseArgs maps jellyfin play action and log-level override', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs forwards jellyfin password-store option', () => {
|
test('parseArgs forwards jellyfin password-store option', () => {
|
||||||
const parsed = parseArgs(
|
const parsed = parseArgs(['jf', 'setup', '--password-store', 'gnome-libsecret'], 'subminer', {});
|
||||||
['jf', 'setup', '--password-store', 'gnome-libsecret'],
|
|
||||||
'subminer',
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(parsed.jellyfin, true);
|
assert.equal(parsed.jellyfin, true);
|
||||||
assert.equal(parsed.passwordStore, 'gnome-libsecret');
|
assert.equal(parsed.passwordStore, 'gnome-libsecret');
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function createSmokeCase(name: string): SmokeCase {
|
|||||||
|
|
||||||
writeExecutable(
|
writeExecutable(
|
||||||
fakeMpvPath,
|
fakeMpvPath,
|
||||||
`#!/usr/bin/env bun
|
`#!/usr/bin/env node
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const net = require('node:net');
|
const net = require('node:net');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
@@ -101,7 +101,7 @@ process.on('SIGTERM', closeAndExit);
|
|||||||
|
|
||||||
writeExecutable(
|
writeExecutable(
|
||||||
fakeAppPath,
|
fakeAppPath,
|
||||||
`#!/usr/bin/env bun
|
`#!/usr/bin/env node
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
|
|
||||||
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
||||||
@@ -237,8 +237,20 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
|
|||||||
env,
|
env,
|
||||||
'mpv-status',
|
'mpv-status',
|
||||||
);
|
);
|
||||||
|
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.equal(result.status, 0);
|
||||||
assert.match(result.stdout, /socket ready/i);
|
assert.match(result.stdout, /socket ready/i);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (fakeMpv.exitCode === null) {
|
if (fakeMpv.exitCode === null) {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
@@ -262,9 +274,6 @@ test(
|
|||||||
'overlay-start-stop',
|
'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 appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
||||||
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
||||||
await waitForJsonLines(appStartPath, 1);
|
await waitForJsonLines(appStartPath, 1);
|
||||||
@@ -273,6 +282,14 @@ test(
|
|||||||
const appStartEntries = readJsonLines(appStartPath);
|
const appStartEntries = readJsonLines(appStartPath);
|
||||||
const appStopEntries = readJsonLines(appStopPath);
|
const appStopEntries = readJsonLines(appStopPath);
|
||||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
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(appStartEntries.length, 1);
|
||||||
assert.equal(appStopEntries.length, 1);
|
assert.equal(appStopEntries.length, 1);
|
||||||
@@ -302,3 +319,43 @@ test(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
|
||||||
|
{ timeout: 20000 },
|
||||||
|
async () => {
|
||||||
|
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(smokeCase.xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||||
|
[
|
||||||
|
`socket_path=${smokeCase.socketPath}`,
|
||||||
|
'auto_start=yes',
|
||||||
|
'auto_start_visible_overlay=yes',
|
||||||
|
'auto_start_pause_until_ready=yes',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const env = makeTestEnv(smokeCase);
|
||||||
|
const result = runLauncher(
|
||||||
|
smokeCase,
|
||||||
|
[smokeCase.videoPath, '--log-level', 'debug'],
|
||||||
|
env,
|
||||||
|
'autoplay-ready-gate',
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
const mpvFirstArgs = mpvEntries[0]?.argv;
|
||||||
|
|
||||||
|
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||||
|
assert.equal(Array.isArray(mpvFirstArgs), true);
|
||||||
|
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
|
||||||
|
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ export interface LauncherJellyfinConfig {
|
|||||||
|
|
||||||
export interface PluginRuntimeConfig {
|
export interface PluginRuntimeConfig {
|
||||||
socketPath: string;
|
socketPath: string;
|
||||||
|
autoStart: boolean;
|
||||||
|
autoStartVisibleOverlay: boolean;
|
||||||
|
autoStartPauseUntilReady: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandExecOptions {
|
export interface CommandExecOptions {
|
||||||
|
|||||||
14
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"build": "tsc && bun run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && cp -r src/renderer/fonts dist/renderer/ && bash scripts/build-macos-helper.sh",
|
"build": "tsc && bun run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && cp -r src/renderer/fonts dist/renderer/ && bash scripts/build-macos-helper.sh",
|
||||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||||
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev docs --host 0.0.0.0 --port 5173 --strictPort",
|
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev docs --host 0.0.0.0 --port 5173 --strictPort",
|
||||||
|
"docs:watch": "bunx concurrently -n docs,backlog \"bun run docs:dev\" \"backlog browser\"",
|
||||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs",
|
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs",
|
||||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort",
|
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
@@ -19,10 +20,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: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: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: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: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: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/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/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: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/renderer/handlers/mouse.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/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
"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/renderer/handlers/mouse.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: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:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||||
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
||||||
@@ -118,10 +120,6 @@
|
|||||||
"from": "vendor/yomitan-jlpt-vocab",
|
"from": "vendor/yomitan-jlpt-vocab",
|
||||||
"to": "yomitan-jlpt-vocab"
|
"to": "yomitan-jlpt-vocab"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"from": "vendor/jiten_freq_global",
|
|
||||||
"to": "jiten_freq_global"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"from": "assets",
|
"from": "assets",
|
||||||
"to": "assets"
|
"to": "assets"
|
||||||
|
|||||||
@@ -21,18 +21,16 @@ texthooker_port=5174
|
|||||||
backend=auto
|
backend=auto
|
||||||
|
|
||||||
# Automatically start overlay when a file is loaded
|
# Automatically start overlay when a file is loaded
|
||||||
auto_start=no
|
# Runs only when mpv input-ipc-server matches socket_path.
|
||||||
|
auto_start=yes
|
||||||
|
|
||||||
# Automatically show visible overlay when overlay starts
|
# Automatically show visible overlay when overlay starts
|
||||||
auto_start_visible_overlay=no
|
# Runs only when mpv input-ipc-server matches socket_path.
|
||||||
|
auto_start_visible_overlay=yes
|
||||||
|
|
||||||
# Automatically show invisible overlay when overlay starts
|
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
|
||||||
# Values: platform-default, visible, hidden
|
# Requires auto_start=yes and auto_start_visible_overlay=yes.
|
||||||
# platform-default => hidden on Linux, visible on macOS/Windows
|
auto_start_pause_until_ready=yes
|
||||||
auto_start_invisible_overlay=platform-default
|
|
||||||
|
|
||||||
# Legacy alias (maps to auto_start_visible_overlay)
|
|
||||||
# auto_start_overlay=no
|
|
||||||
|
|
||||||
# Show OSD messages for overlay status
|
# Show OSD messages for overlay status
|
||||||
osd_messages=yes
|
osd_messages=yes
|
||||||
@@ -68,6 +66,5 @@ aniskip_button_key=y-k
|
|||||||
# OSD hint duration in seconds (shown during first 3s of intro).
|
# OSD hint duration in seconds (shown during first 3s of intro).
|
||||||
aniskip_button_duration=3
|
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
|
# y-s start, y-S stop, y-t toggle visible overlay
|
||||||
# y-i toggle invisible overlay, y-I show invisible overlay, y-u hide invisible overlay
|
|
||||||
|
|||||||
1959
plugin/subminer.lua
577
plugin/subminer/aniskip.lua
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
local M = {}
|
||||||
|
local matcher = require("aniskip_match")
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local utils = ctx.utils
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local environment = ctx.environment
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
local request_generation = 0
|
||||||
|
local mal_lookup_cache = {}
|
||||||
|
local payload_cache = {}
|
||||||
|
local title_context_cache = {}
|
||||||
|
|
||||||
|
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_async(url, callback)
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url },
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
if not success or not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then
|
||||||
|
local detail = error or (result and result.stderr) or "curl failed"
|
||||||
|
callback(nil, detail)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local parsed, parse_error = utils.parse_json(result.stdout)
|
||||||
|
if type(parsed) ~= "table" then
|
||||||
|
callback(nil, parse_error or "invalid json")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
callback(parsed, nil)
|
||||||
|
end)
|
||||||
|
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 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 cache_key = table.concat({
|
||||||
|
tostring(forced_title or ""),
|
||||||
|
tostring(forced_season or ""),
|
||||||
|
tostring(forced_episode or ""),
|
||||||
|
tostring(media_title or ""),
|
||||||
|
tostring(filename or ""),
|
||||||
|
tostring(path or ""),
|
||||||
|
}, "\31")
|
||||||
|
local cached = title_context_cache[cache_key]
|
||||||
|
if type(cached) == "table" then
|
||||||
|
return cached.title, cached.episode, cached.season
|
||||||
|
end
|
||||||
|
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
|
||||||
|
title_context_cache[cache_key] = {
|
||||||
|
title = candidate_title,
|
||||||
|
episode = episode,
|
||||||
|
season = forced_season,
|
||||||
|
}
|
||||||
|
return candidate_title, episode, forced_season
|
||||||
|
end
|
||||||
|
|
||||||
|
local function select_best_mal_item(items, title, season)
|
||||||
|
if type(items) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local best_item = nil
|
||||||
|
local best_score = -math.huge
|
||||||
|
for _, item in ipairs(items) do
|
||||||
|
if type(item) == "table" and tonumber(item.id) then
|
||||||
|
local candidate_name = tostring(item.name or "")
|
||||||
|
local score = matcher.title_overlap_score(title, candidate_name) + matcher.season_signal_score(season, candidate_name)
|
||||||
|
if score > best_score then
|
||||||
|
best_score = score
|
||||||
|
best_item = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best_item
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_mal_id_async(title, season, request_id, callback)
|
||||||
|
local forced_mal_id = tonumber(opts.aniskip_mal_id)
|
||||||
|
if forced_mal_id and forced_mal_id > 0 then
|
||||||
|
callback(forced_mal_id, "(forced-mal-id)")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(title) == "string" and title:match("^%d+$") then
|
||||||
|
local numeric = tonumber(title)
|
||||||
|
if numeric and numeric > 0 then
|
||||||
|
callback(numeric, title)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(title) ~= "string" or title == "" then
|
||||||
|
callback(nil, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local lookup = title
|
||||||
|
if season and season > 1 then
|
||||||
|
lookup = string.format("%s Season %d", lookup, season)
|
||||||
|
end
|
||||||
|
local cache_key = string.format("%s|%s", lookup:lower(), tostring(season or "-"))
|
||||||
|
local cached = mal_lookup_cache[cache_key]
|
||||||
|
if cached ~= nil then
|
||||||
|
if cached == false then
|
||||||
|
callback(nil, lookup)
|
||||||
|
else
|
||||||
|
callback(cached, lookup)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup)
|
||||||
|
run_json_curl_async(mal_url, function(mal_json, mal_error)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not mal_json then
|
||||||
|
subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error))
|
||||||
|
callback(nil, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local categories = mal_json.categories
|
||||||
|
if type(categories) ~= "table" then
|
||||||
|
mal_lookup_cache[cache_key] = false
|
||||||
|
callback(nil, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local all_items = {}
|
||||||
|
for _, category in ipairs(categories) do
|
||||||
|
if type(category) == "table" and type(category.items) == "table" then
|
||||||
|
for _, item in ipairs(category.items) do
|
||||||
|
all_items[#all_items + 1] = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local best_item = select_best_mal_item(all_items, title, season)
|
||||||
|
if best_item and tonumber(best_item.id) then
|
||||||
|
local matched_id = tonumber(best_item.id)
|
||||||
|
mal_lookup_cache[cache_key] = matched_id
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
'MAL candidate selected (score-based): id=%s name="%s" season_hint=%s',
|
||||||
|
tostring(best_item.id),
|
||||||
|
tostring(best_item.name or ""),
|
||||||
|
tostring(season or "-")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
callback(matched_id, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
mal_lookup_cache[cache_key] = false
|
||||||
|
callback(nil, lookup)
|
||||||
|
end)
|
||||||
|
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 reset_aniskip_fields()
|
||||||
|
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 clear_aniskip_state()
|
||||||
|
request_generation = request_generation + 1
|
||||||
|
reset_aniskip_fields()
|
||||||
|
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 is_launcher_context()
|
||||||
|
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||||
|
if forced_title ~= "" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local forced_mal_id = tonumber(opts.aniskip_mal_id)
|
||||||
|
if forced_mal_id and forced_mal_id > 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local forced_episode = tonumber(opts.aniskip_episode)
|
||||||
|
if forced_episode and forced_episode > 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local forced_season = tonumber(opts.aniskip_season)
|
||||||
|
if forced_season and forced_season > 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function should_fetch_aniskip_async(trigger_source, callback)
|
||||||
|
if trigger_source == "script-message" or trigger_source == "overlay-start" then
|
||||||
|
callback(true, trigger_source)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if is_launcher_context() then
|
||||||
|
callback(true, "launcher-context")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(environment.is_subminer_app_running_async) == "function" then
|
||||||
|
environment.is_subminer_app_running_async(function(running)
|
||||||
|
if running then
|
||||||
|
callback(true, "subminer-app-running")
|
||||||
|
else
|
||||||
|
callback(false, "subminer-context-missing")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if environment.is_subminer_app_running() then
|
||||||
|
callback(true, "subminer-app-running")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
callback(false, "subminer-context-missing")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_lookup_titles(primary_title)
|
||||||
|
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(primary_title)
|
||||||
|
push_lookup_title(media_title_fallback)
|
||||||
|
push_lookup_title(filename_fallback)
|
||||||
|
push_lookup_title(path_fallback)
|
||||||
|
return lookup_titles
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, index, last_lookup)
|
||||||
|
local current_index = index or 1
|
||||||
|
local current_lookup = last_lookup
|
||||||
|
if current_index > #lookup_titles then
|
||||||
|
callback(nil, current_lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local lookup_title = lookup_titles[current_index]
|
||||||
|
subminer_log("info", "aniskip", string.format('MAL lookup attempt %d/%d using title="%s"', current_index, #lookup_titles, lookup_title))
|
||||||
|
resolve_mal_id_async(lookup_title, season, request_id, function(mal_id, lookup)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if mal_id then
|
||||||
|
callback(mal_id, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, current_index + 1, lookup or current_lookup)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetch_payload_for_episode_async(mal_id, episode, request_id, callback)
|
||||||
|
local payload_cache_key = string.format("%d:%d", mal_id, episode)
|
||||||
|
local cached_payload = payload_cache[payload_cache_key]
|
||||||
|
if cached_payload ~= nil then
|
||||||
|
if cached_payload == false then
|
||||||
|
callback(nil, nil, true)
|
||||||
|
else
|
||||||
|
callback(cached_payload, nil, true)
|
||||||
|
end
|
||||||
|
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("AniSkip URL=%s", url))
|
||||||
|
run_json_curl_async(url, function(payload, fetch_error)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not payload then
|
||||||
|
callback(nil, fetch_error, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if payload.found ~= true then
|
||||||
|
payload_cache[payload_cache_key] = false
|
||||||
|
callback(nil, nil, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
payload_cache[payload_cache_key] = payload
|
||||||
|
callback(payload, nil, false)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetch_aniskip_for_current_media(trigger_source)
|
||||||
|
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
||||||
|
if not opts.aniskip_enabled then
|
||||||
|
clear_aniskip_state()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
should_fetch_aniskip_async(trigger, function(allowed, reason)
|
||||||
|
if not allowed then
|
||||||
|
subminer_log("debug", "aniskip", "Skipping lookup: " .. tostring(reason))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
request_generation = request_generation + 1
|
||||||
|
local request_id = request_generation
|
||||||
|
reset_aniskip_fields()
|
||||||
|
local title, episode, season = resolve_title_and_episode()
|
||||||
|
local lookup_titles = resolve_lookup_titles(title)
|
||||||
|
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
'Query context: trigger=%s reason=%s title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)',
|
||||||
|
tostring(trigger),
|
||||||
|
tostring(reason or "-"),
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resolve_mal_from_candidates_async(lookup_titles, season, request_id, function(mal_id, mal_lookup)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
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
|
||||||
|
subminer_log("info", "aniskip", string.format('Resolved MAL id=%d using query="%s"', mal_id, tostring(mal_lookup or "")))
|
||||||
|
fetch_payload_for_episode_async(mal_id, episode, request_id, function(payload, fetch_error)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not payload then
|
||||||
|
if fetch_error then
|
||||||
|
subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error))
|
||||||
|
else
|
||||||
|
subminer_log("info", "aniskip", "AniSkip: no skip windows found")
|
||||||
|
end
|
||||||
|
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)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
clear_aniskip_state = clear_aniskip_state,
|
||||||
|
skip_intro_now = skip_intro_now,
|
||||||
|
update_intro_button_visibility = update_intro_button_visibility,
|
||||||
|
fetch_aniskip_for_current_media = fetch_aniskip_for_current_media,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
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
@@ -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
@@ -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
|
||||||
210
plugin/subminer/environment.lua
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
|
||||||
|
local detected_backend = nil
|
||||||
|
local app_running_cache_value = nil
|
||||||
|
local app_running_cache_time = nil
|
||||||
|
local app_running_check_inflight = false
|
||||||
|
local app_running_waiters = {}
|
||||||
|
local APP_RUNNING_CACHE_TTL_SECONDS = 2
|
||||||
|
|
||||||
|
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_linux()
|
||||||
|
return not is_windows() and not is_macos()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function now_seconds()
|
||||||
|
if type(mp.get_time) == "function" then
|
||||||
|
local value = tonumber(mp.get_time())
|
||||||
|
if value then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return os.time()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function process_list_has_subminer(raw_process_list)
|
||||||
|
if type(raw_process_list) ~= "string" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local process_list = raw_process_list: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 process_scan_command()
|
||||||
|
if is_windows() then
|
||||||
|
return { "tasklist", "/FO", "CSV", "/NH" }
|
||||||
|
end
|
||||||
|
return { "ps", "-A", "-o", "args=" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_process_running()
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = process_scan_command(),
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = false,
|
||||||
|
})
|
||||||
|
if not result or result.status ~= 0 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return process_list_has_subminer(result.stdout)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function flush_app_running_waiters(value)
|
||||||
|
local waiters = app_running_waiters
|
||||||
|
app_running_waiters = {}
|
||||||
|
for _, waiter in ipairs(waiters) do
|
||||||
|
waiter(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_app_running_async(callback, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local force_refresh = opts.force_refresh == true
|
||||||
|
local now = now_seconds()
|
||||||
|
if not force_refresh and app_running_cache_value ~= nil and app_running_cache_time ~= nil then
|
||||||
|
if (now - app_running_cache_time) <= APP_RUNNING_CACHE_TTL_SECONDS then
|
||||||
|
callback(app_running_cache_value)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
app_running_waiters[#app_running_waiters + 1] = callback
|
||||||
|
if app_running_check_inflight then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
app_running_check_inflight = true
|
||||||
|
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = process_scan_command(),
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = false,
|
||||||
|
}, function(success, result)
|
||||||
|
app_running_check_inflight = false
|
||||||
|
local running = false
|
||||||
|
if success and result and result.status == 0 then
|
||||||
|
running = process_list_has_subminer(result.stdout)
|
||||||
|
end
|
||||||
|
app_running_cache_value = running
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
flush_app_running_waiters(running)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_app_running()
|
||||||
|
local running = is_subminer_process_running()
|
||||||
|
app_running_cache_value = running
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
return running
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_subminer_app_running_cache(running)
|
||||||
|
app_running_cache_value = running == true
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function detect_backend()
|
||||||
|
if detected_backend then
|
||||||
|
return detected_backend
|
||||||
|
end
|
||||||
|
|
||||||
|
local backend = nil
|
||||||
|
local subminer_log = ctx.log and ctx.log.subminer_log or function() end
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return {
|
||||||
|
is_windows = is_windows,
|
||||||
|
is_macos = is_macos,
|
||||||
|
is_linux = is_linux,
|
||||||
|
default_socket_path = default_socket_path,
|
||||||
|
is_subminer_process_running = is_subminer_process_running,
|
||||||
|
is_subminer_app_running = is_subminer_app_running,
|
||||||
|
is_subminer_app_running_async = is_subminer_app_running_async,
|
||||||
|
set_subminer_app_running_cache = set_subminer_app_running_cache,
|
||||||
|
detect_backend = detect_backend,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
431
plugin/subminer/hover.lua
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
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 before = raw_ass:sub(1, raw_open_idx - 1)
|
||||||
|
local hovered = raw_ass:sub(raw_open_idx, raw_close_idx - 1)
|
||||||
|
local after = raw_ass:sub(raw_close_idx)
|
||||||
|
local hover_suffix = string.format("\\1c&H%s&", hover_color)
|
||||||
|
|
||||||
|
-- Keep hover foreground stable even when inline ASS override tags (\1c/\c/\r) appear inside token.
|
||||||
|
hovered = hovered:gsub("{([^}]*)}", function(inner)
|
||||||
|
if inner:find("\\1c&H", 1, true) or inner:find("\\c&H", 1, true) or inner:find("\\r", 1, true) then
|
||||||
|
return "{" .. inner .. hover_suffix .. "}"
|
||||||
|
end
|
||||||
|
return "{" .. inner .. "}"
|
||||||
|
end)
|
||||||
|
|
||||||
|
local open_tag = string.format("{\\1c&H%s&}", hover_color)
|
||||||
|
local close_tag = string.format("{\\1c&H%s&}", base_color)
|
||||||
|
return before .. open_tag .. hovered .. close_tag .. after
|
||||||
|
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
@@ -0,0 +1,7 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.init()
|
||||||
|
require("bootstrap").init()
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
109
plugin/subminer/lifecycle.lua
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local options_helper = ctx.options_helper
|
||||||
|
local process = ctx.process
|
||||||
|
local aniskip = ctx.aniskip
|
||||||
|
local hover = ctx.hover
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
|
||||||
|
local function schedule_aniskip_fetch(trigger_source, delay_seconds)
|
||||||
|
local delay = tonumber(delay_seconds) or 0
|
||||||
|
mp.add_timeout(delay, function()
|
||||||
|
aniskip.fetch_aniskip_for_current_media(trigger_source)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_auto_start_enabled()
|
||||||
|
local raw_auto_start = opts.auto_start
|
||||||
|
if raw_auto_start == nil then
|
||||||
|
raw_auto_start = opts.auto_start_overlay
|
||||||
|
end
|
||||||
|
if raw_auto_start == nil then
|
||||||
|
raw_auto_start = opts["auto-start"]
|
||||||
|
end
|
||||||
|
return options_helper.coerce_bool(raw_auto_start, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_file_loaded()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
process.disarm_auto_play_ready_gate()
|
||||||
|
|
||||||
|
local should_auto_start = resolve_auto_start_enabled()
|
||||||
|
if should_auto_start then
|
||||||
|
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"lifecycle",
|
||||||
|
"Skipping auto-start: input-ipc-server does not match configured socket_path"
|
||||||
|
)
|
||||||
|
schedule_aniskip_fetch("file-loaded", 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
process.start_overlay({
|
||||||
|
auto_start_trigger = true,
|
||||||
|
socket_path = opts.socket_path,
|
||||||
|
})
|
||||||
|
-- Give the overlay process a moment to initialize before querying AniSkip.
|
||||||
|
schedule_aniskip_fetch("overlay-start", 0.8)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
schedule_aniskip_fetch("file-loaded", 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_shutdown()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
process.disarm_auto_play_ready_gate()
|
||||||
|
if state.overlay_running or state.texthooker_running then
|
||||||
|
subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process")
|
||||||
|
show_osd("Shutting down...")
|
||||||
|
process.stop_overlay()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function register_lifecycle_hooks()
|
||||||
|
mp.register_event("file-loaded", on_file_loaded)
|
||||||
|
mp.register_event("shutdown", on_shutdown)
|
||||||
|
mp.register_event("file-loaded", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_event("end-file", function()
|
||||||
|
process.disarm_auto_play_ready_gate()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_event("shutdown", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_event("end-file", function()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
end)
|
||||||
|
mp.register_event("shutdown", function()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
end)
|
||||||
|
mp.add_hook("on_unload", 10, function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
end)
|
||||||
|
mp.observe_property("sub-start", "native", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.observe_property("time-pos", "number", function()
|
||||||
|
aniskip.update_intro_button_visibility()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
on_file_loaded = on_file_loaded,
|
||||||
|
on_shutdown = on_shutdown,
|
||||||
|
register_lifecycle_hooks = register_lifecycle_hooks,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
67
plugin/subminer/log.lua
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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
|
||||||
|
local payload = "SubMiner: " .. message
|
||||||
|
local sent = false
|
||||||
|
if type(mp.osd_message) == "function" then
|
||||||
|
sent = pcall(mp.osd_message, payload, 3)
|
||||||
|
end
|
||||||
|
if not sent and type(mp.commandv) == "function" then
|
||||||
|
pcall(mp.commandv, "show-text", payload, "3000")
|
||||||
|
end
|
||||||
|
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
@@ -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()
|
||||||
54
plugin/subminer/messages.lua
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local process = ctx.process
|
||||||
|
local aniskip = ctx.aniskip
|
||||||
|
local hover = ctx.hover
|
||||||
|
local ui = ctx.ui
|
||||||
|
|
||||||
|
local function register_script_messages()
|
||||||
|
mp.register_script_message("subminer-start", function(...)
|
||||||
|
process.start_overlay_from_script_message(...)
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-stop", function()
|
||||||
|
process.stop_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-toggle", function()
|
||||||
|
process.toggle_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-menu", function()
|
||||||
|
ui.show_menu()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-options", function()
|
||||||
|
process.open_options()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-restart", function()
|
||||||
|
process.restart_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-status", function()
|
||||||
|
process.check_status()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-autoplay-ready", function()
|
||||||
|
process.notify_auto_play_ready()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-aniskip-refresh", function()
|
||||||
|
aniskip.fetch_aniskip_for_current_media("script-message")
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-skip-intro", function()
|
||||||
|
aniskip.skip_intro_now()
|
||||||
|
end)
|
||||||
|
mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json)
|
||||||
|
hover.handle_hover_message(payload_json)
|
||||||
|
end)
|
||||||
|
mp.register_script_message(hover.HOVER_MESSAGE_NAME_LEGACY, function(payload_json)
|
||||||
|
hover.handle_hover_message(payload_json)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
register_script_messages = register_script_messages,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
46
plugin/subminer/options.lua
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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 = true,
|
||||||
|
auto_start_pause_until_ready = true,
|
||||||
|
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
|
||||||
479
plugin/subminer/process.lua
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
||||||
|
local OVERLAY_START_MAX_ATTEMPTS = 6
|
||||||
|
local AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local binary = ctx.binary
|
||||||
|
local environment = ctx.environment
|
||||||
|
local options_helper = ctx.options_helper
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
local normalize_log_level = ctx.log.normalize_log_level
|
||||||
|
|
||||||
|
local function resolve_visible_overlay_startup()
|
||||||
|
local raw_visible_overlay = opts.auto_start_visible_overlay
|
||||||
|
if raw_visible_overlay == nil then
|
||||||
|
raw_visible_overlay = opts["auto-start-visible-overlay"]
|
||||||
|
end
|
||||||
|
return options_helper.coerce_bool(raw_visible_overlay, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_pause_until_ready()
|
||||||
|
local raw_pause_until_ready = opts.auto_start_pause_until_ready
|
||||||
|
if raw_pause_until_ready == nil then
|
||||||
|
raw_pause_until_ready = opts["auto-start-pause-until-ready"]
|
||||||
|
end
|
||||||
|
return options_helper.coerce_bool(raw_pause_until_ready, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function normalize_socket_path(path)
|
||||||
|
if type(path) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local trimmed = path:match("^%s*(.-)%s*$")
|
||||||
|
if trimmed == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return trimmed
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_matching_mpv_ipc_socket(target_socket_path)
|
||||||
|
local expected_socket = normalize_socket_path(target_socket_path or opts.socket_path)
|
||||||
|
local active_socket = normalize_socket_path(mp.get_property("input-ipc-server"))
|
||||||
|
if expected_socket == nil or active_socket == nil then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return expected_socket == active_socket
|
||||||
|
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 environment.detect_backend()
|
||||||
|
end
|
||||||
|
return selected
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_auto_play_ready_timeout()
|
||||||
|
local timeout = state.auto_play_ready_timeout
|
||||||
|
if timeout and timeout.kill then
|
||||||
|
timeout:kill()
|
||||||
|
end
|
||||||
|
state.auto_play_ready_timeout = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function disarm_auto_play_ready_gate()
|
||||||
|
clear_auto_play_ready_timeout()
|
||||||
|
state.auto_play_ready_gate_armed = false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function release_auto_play_ready_gate(reason)
|
||||||
|
if not state.auto_play_ready_gate_armed then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
disarm_auto_play_ready_gate()
|
||||||
|
mp.set_property_native("pause", false)
|
||||||
|
show_osd("Subtitle annotations loaded")
|
||||||
|
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function arm_auto_play_ready_gate()
|
||||||
|
if state.auto_play_ready_gate_armed then
|
||||||
|
clear_auto_play_ready_timeout()
|
||||||
|
end
|
||||||
|
state.auto_play_ready_gate_armed = true
|
||||||
|
mp.set_property_native("pause", true)
|
||||||
|
show_osd("Loading subtitle annotations...")
|
||||||
|
subminer_log("info", "process", "Pausing playback until SubMiner overlay/tokenization readiness signal")
|
||||||
|
state.auto_play_ready_timeout = mp.add_timeout(AUTO_PLAY_READY_TIMEOUT_SECONDS, function()
|
||||||
|
if not state.auto_play_ready_gate_armed then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
subminer_log(
|
||||||
|
"warn",
|
||||||
|
"process",
|
||||||
|
"Startup readiness signal timed out; resuming playback to avoid stalled pause"
|
||||||
|
)
|
||||||
|
release_auto_play_ready_gate("timeout")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function notify_auto_play_ready()
|
||||||
|
release_auto_play_ready_gate("tokenization-ready")
|
||||||
|
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
|
||||||
|
|
||||||
|
if action == "start" 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)
|
||||||
|
|
||||||
|
-- Keep auto-start --start requests idempotent for second-instance handling.
|
||||||
|
-- Visibility is applied as a separate control command after startup.
|
||||||
|
if overrides.auto_start_trigger ~= true then
|
||||||
|
local should_show_visible = resolve_visible_overlay_startup()
|
||||||
|
if should_show_visible then
|
||||||
|
table.insert(args, "--show-visible-overlay")
|
||||||
|
else
|
||||||
|
table.insert(args, "--hide-visible-overlay")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
local function run_control_command_async(action, overrides, callback)
|
||||||
|
local args = build_command_args(action, overrides)
|
||||||
|
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
local ok = success and (result == nil or result.status == 0)
|
||||||
|
if callback then
|
||||||
|
callback(ok, result, error)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
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 = options_helper.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 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)
|
||||||
|
|
||||||
|
-- Start overlay immediately; overlay start path retries on readiness failures.
|
||||||
|
callback()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function start_overlay(overrides)
|
||||||
|
overrides = overrides or {}
|
||||||
|
|
||||||
|
if not binary.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
|
||||||
|
if overrides.auto_start_trigger == true then
|
||||||
|
subminer_log("debug", "process", "Auto-start ignored because overlay is already running")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
subminer_log("info", "process", "Overlay already running")
|
||||||
|
show_osd("Already running")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local texthooker_enabled = overrides.texthooker_enabled
|
||||||
|
if texthooker_enabled == nil then
|
||||||
|
texthooker_enabled = opts.texthooker_enabled
|
||||||
|
end
|
||||||
|
local socket_path = overrides.socket_path or opts.socket_path
|
||||||
|
local should_pause_until_ready = (
|
||||||
|
overrides.auto_start_trigger == true
|
||||||
|
and resolve_visible_overlay_startup()
|
||||||
|
and resolve_pause_until_ready()
|
||||||
|
and has_matching_mpv_ipc_socket(socket_path)
|
||||||
|
)
|
||||||
|
if should_pause_until_ready then
|
||||||
|
arm_auto_play_ready_gate()
|
||||||
|
else
|
||||||
|
disarm_auto_play_ready_gate()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function launch_overlay_with_retry(attempt)
|
||||||
|
local args = build_command_args("start", overrides)
|
||||||
|
if attempt == 1 then
|
||||||
|
subminer_log("info", "process", "Starting overlay: " .. table.concat(args, " "))
|
||||||
|
else
|
||||||
|
subminer_log(
|
||||||
|
"warn",
|
||||||
|
"process",
|
||||||
|
"Retrying overlay start (attempt " .. tostring(attempt) .. "): " .. table.concat(args, " ")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if attempt == 1 then
|
||||||
|
show_osd("Starting...")
|
||||||
|
end
|
||||||
|
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
|
||||||
|
local reason = error or (result and result.stderr) or "unknown error"
|
||||||
|
if attempt < OVERLAY_START_MAX_ATTEMPTS then
|
||||||
|
mp.add_timeout(OVERLAY_START_RETRY_DELAY_SECONDS, function()
|
||||||
|
launch_overlay_with_retry(attempt + 1)
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
state.overlay_running = false
|
||||||
|
subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
|
||||||
|
show_osd("Overlay start failed")
|
||||||
|
release_auto_play_ready_gate("overlay-start-failed")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if overrides.auto_start_trigger == true then
|
||||||
|
local visibility_action = resolve_visible_overlay_startup()
|
||||||
|
and "show-visible-overlay"
|
||||||
|
or "hide-visible-overlay"
|
||||||
|
run_control_command_async(visibility_action, {
|
||||||
|
log_level = overrides.log_level,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
if texthooker_enabled then
|
||||||
|
ensure_texthooker_running(function()
|
||||||
|
launch_overlay_with_retry(1)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
launch_overlay_with_retry(1)
|
||||||
|
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 binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
run_control_command_async("stop", nil, function(ok, result)
|
||||||
|
if ok then
|
||||||
|
subminer_log("info", "process", "Overlay stopped")
|
||||||
|
else
|
||||||
|
subminer_log(
|
||||||
|
"warn",
|
||||||
|
"process",
|
||||||
|
"Stop command returned non-zero status: " .. tostring(result and result.status or "unknown")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
state.overlay_running = false
|
||||||
|
state.texthooker_running = false
|
||||||
|
disarm_auto_play_ready_gate()
|
||||||
|
show_osd("Stopped")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function toggle_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
run_control_command_async("toggle", nil, function(ok)
|
||||||
|
if not ok then
|
||||||
|
subminer_log("warn", "process", "Toggle command failed")
|
||||||
|
show_osd("Toggle failed")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function open_options()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
run_control_command_async("settings", nil, function(ok)
|
||||||
|
if ok 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)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restart_overlay()
|
||||||
|
if not binary.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...")
|
||||||
|
|
||||||
|
run_control_command_async("stop", nil, function()
|
||||||
|
state.overlay_running = false
|
||||||
|
state.texthooker_running = false
|
||||||
|
disarm_auto_play_ready_gate()
|
||||||
|
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function check_status()
|
||||||
|
if not binary.ensure_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 check_binary_available()
|
||||||
|
return binary.ensure_binary_available()
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
build_command_args = build_command_args,
|
||||||
|
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
|
||||||
|
run_control_command_async = run_control_command_async,
|
||||||
|
parse_start_script_message_overrides = parse_start_script_message_overrides,
|
||||||
|
ensure_texthooker_running = ensure_texthooker_running,
|
||||||
|
start_overlay = start_overlay,
|
||||||
|
start_overlay_from_script_message = start_overlay_from_script_message,
|
||||||
|
stop_overlay = stop_overlay,
|
||||||
|
toggle_overlay = toggle_overlay,
|
||||||
|
open_options = open_options,
|
||||||
|
restart_overlay = restart_overlay,
|
||||||
|
check_status = check_status,
|
||||||
|
check_binary_available = check_binary_available,
|
||||||
|
notify_auto_play_ready = notify_auto_play_ready,
|
||||||
|
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
35
plugin/subminer/state.lua
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
auto_play_ready_gate_armed = false,
|
||||||
|
auto_play_ready_timeout = nil,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
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
|
||||||
63
scripts/dev-watch.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
electron_args=("$@")
|
||||||
|
if [[ ${#electron_args[@]} -eq 0 ]]; then
|
||||||
|
electron_args=(--start --dev)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v bun >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] bun not found in PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TS_WATCH_PID=""
|
||||||
|
RENDER_WATCH_PID=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local pids=("$TS_WATCH_PID" "$RENDER_WATCH_PID")
|
||||||
|
for pid in "${pids[@]}"; do
|
||||||
|
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
sync_renderer_assets() {
|
||||||
|
mkdir -p dist/renderer
|
||||||
|
cp src/renderer/index.html src/renderer/style.css dist/renderer/
|
||||||
|
mkdir -p dist/renderer/fonts
|
||||||
|
cp -R src/renderer/fonts/. dist/renderer/fonts/
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[INFO] Syncing renderer static assets"
|
||||||
|
sync_renderer_assets
|
||||||
|
|
||||||
|
echo "[INFO] Running initial compile"
|
||||||
|
bun run tsc
|
||||||
|
bun run build:renderer
|
||||||
|
|
||||||
|
echo "[INFO] Starting TypeScript watch"
|
||||||
|
bun run tsc --watch --preserveWatchOutput &
|
||||||
|
TS_WATCH_PID=$!
|
||||||
|
|
||||||
|
echo "[INFO] Starting renderer watch"
|
||||||
|
bunx esbuild src/renderer/renderer.ts \
|
||||||
|
--bundle \
|
||||||
|
--platform=browser \
|
||||||
|
--format=esm \
|
||||||
|
--target=es2022 \
|
||||||
|
--outfile=dist/renderer/renderer.js \
|
||||||
|
--sourcemap \
|
||||||
|
--watch &
|
||||||
|
RENDER_WATCH_PID=$!
|
||||||
|
|
||||||
|
echo "[INFO] Launching Electron with args: ${electron_args[*]}"
|
||||||
|
bun run electron . "${electron_args[@]}"
|
||||||
@@ -33,7 +33,7 @@ interface CliOptions {
|
|||||||
function parseCliArgs(argv: string[]): CliOptions {
|
function parseCliArgs(argv: string[]): CliOptions {
|
||||||
const args = [...argv];
|
const args = [...argv];
|
||||||
let inputParts: string[] = [];
|
let inputParts: string[] = [];
|
||||||
let dictionaryPath = path.join(process.cwd(), 'vendor', 'jiten_freq_global');
|
let dictionaryPath = path.join(process.cwd(), 'vendor', 'frequency-dictionary');
|
||||||
let emitPretty = false;
|
let emitPretty = false;
|
||||||
let emitDiagnostics = false;
|
let emitDiagnostics = false;
|
||||||
let mecabCommand: string | undefined;
|
let mecabCommand: string | undefined;
|
||||||
@@ -394,7 +394,7 @@ function printUsage(): void {
|
|||||||
--color-band-5 <#hex> Frequency band-5 color.
|
--color-band-5 <#hex> Frequency band-5 color.
|
||||||
--color-known <#hex> Known-word color (default: #a6da95).
|
--color-known <#hex> Known-word color (default: #a6da95).
|
||||||
--color-n-plus-one <#hex> N+1 target color (default: #c6a0f6).
|
--color-n-plus-one <#hex> N+1 target color (default: #c6a0f6).
|
||||||
--dictionary <path> Frequency dictionary root path (default: ./vendor/jiten_freq_global)
|
--dictionary <path> Frequency dictionary root path (default: ./vendor/frequency-dictionary)
|
||||||
--mecab-command <path> Optional MeCab binary path (default: mecab)
|
--mecab-command <path> Optional MeCab binary path (default: mecab)
|
||||||
--mecab-dictionary <path> Optional MeCab dictionary directory (default: system default)
|
--mecab-dictionary <path> Optional MeCab dictionary directory (default: system default)
|
||||||
-h, --help Show usage.
|
-h, --help Show usage.
|
||||||
|
|||||||