3 Commits

Author SHA1 Message Date
49434bf0cd fix release job 2026-03-01 02:50:51 -08:00
44c7761c7c Overlay 2.0 (#12) 2026-03-01 02:36:51 -08:00
45df3c466b add task 2026-02-26 23:10:47 -08:00
395 changed files with 13394 additions and 5718 deletions

View File

@@ -242,7 +242,7 @@ jobs:
run: |
tar -czf "release/subminer-assets.tar.gz" \
config.example.jsonc \
plugin/subminer.lua \
plugin/subminer \
plugin/subminer.conf \
assets/themes/subminer.rasi
@@ -304,7 +304,7 @@ jobs:
### Optional Assets (config example + mpv plugin + rofi theme)
1. Download `subminer-assets.tar.gz`
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
3. Copy `plugin/subminer.lua` to `~/.config/mpv/scripts/`
3. Copy `plugin/subminer/` directory contents to `~/.config/mpv/scripts/`
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
5. Copy `assets/themes/subminer.rasi` to:
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`

4
.gitignore vendored
View File

@@ -7,9 +7,7 @@ dist/
release/
# Launcher build artifact (produced by make build-launcher)
subminer
!plugin/subminer/
!plugin/subminer/*.lua
/subminer
# Logs
*.log

View File

@@ -1,4 +1,3 @@
<!-- BACKLOG.MD MCP GUIDELINES START -->
<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
These guides cover:
- Decision framework for when to create tasks
- Search-first workflow to avoid duplicates
- Links to detailed guides for task creation, execution, and finalization

View File

@@ -1,4 +1,4 @@
.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 uninstall-plugin 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
THEME_SOURCE := assets/themes/subminer.rasi
@@ -52,9 +52,12 @@ help:
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \
" dev-start Build and launch local Electron app" \
" 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-stop Stop a running local Electron app" \
" docs-dev Run VitePress docs dev server" \
" docs-watch Run VitePress docs dev + Backlog browser together" \
" docs Build VitePress static docs" \
" docs-preview Preview built VitePress docs" \
" install-linux Install Linux wrapper/theme/app artifacts" \
@@ -66,7 +69,6 @@ help:
" deps Install JS dependencies (root + texthooker-ui)" \
" uninstall-linux Remove Linux install artifacts" \
" uninstall-macos Remove macOS install artifacts" \
" uninstall-plugin Remove mpv Lua plugin and plugin config" \
" print-dirs Show resolved install locations" \
"" \
"Variables:" \
@@ -159,6 +161,9 @@ generate-example-config: ensure-bun
docs-dev: ensure-bun
@bun run docs:dev
docs-watch: ensure-bun
@bun run docs:watch
docs: ensure-bun
@bun run docs:build
@@ -173,6 +178,12 @@ dev-start-macos: ensure-bun
@bun run build
@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
@bun run electron . --toggle
@@ -218,25 +229,22 @@ install-macos: build-launcher
install-plugin:
@printf '%s\n' "[INFO] Installing mpv plugin artifacts"
@install -d "$(MPV_SCRIPTS_DIR)"
@rm -f "$(MPV_SCRIPTS_DIR)/subminer.lua"
@install -d "$(MPV_SCRIPTS_DIR)/subminer"
@install -d "$(MPV_SCRIPT_OPTS_DIR)"
@cp -R ./plugin/subminer/. "$(MPV_SCRIPTS_DIR)/subminer/"
@install -m 0644 "./$(PLUGIN_CONF)" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
@printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer/main.lua" " $(MPV_SCRIPTS_DIR)/subminer/" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
# Uninstall behavior kept unchanged by default.
uninstall: uninstall-linux
uninstall-plugin:
@rm -rf "$(MPV_SCRIPTS_DIR)/subminer"
@rm -f "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
@printf '%s\n' "Removed:" " $(MPV_SCRIPTS_DIR)/subminer/" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
uninstall-linux: uninstall-plugin
uninstall-linux:
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
@rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
uninstall-macos: uninstall-plugin
uninstall-macos:
@rm -f "$(BINDIR)/subminer"
@rm -f "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)"
@rm -rf "$(MACOS_APP_DEST)"

View File

@@ -14,7 +14,7 @@
<div align="center">
[![SubMiner demo (GIF preview)](./assets/minecard.gif)](./assets/minecard.mp4)
[![SubMiner demo (Animated preview)](./assets/minecard.webp)](./assets/minecard.mp4)
</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
- **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
- **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
@@ -58,23 +60,24 @@ chmod +x ~/.local/bin/subminer
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
mkdir -p ~/.config/mpv/scripts/subminer
mkdir -p ~/.config/mpv/script-opts
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
```
### 3. Set up Yomitan Dictionaries
```bash
subminer app --start --yomitan
subminer app --yomitan
```
### 4. Mine
```bash
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 141 KiB

BIN
assets/kiku-integration.mkv Normal file

Binary file not shown.

BIN
assets/kiku-integration.mp4 Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/minecard-poster.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 MiB

After

Width:  |  Height:  |  Size: 23 MiB

BIN
assets/minecard.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

BIN
assets/minecard.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 MiB

View File

@@ -1,11 +1,11 @@
project_name: "SubMiner"
default_status: "To Do"
statuses: ["To Do", "In Progress", "Done"]
project_name: 'SubMiner'
default_status: 'To Do'
statuses: ['To Do', 'In Progress', 'Done']
labels: []
definition_of_done: []
date_format: yyyy-mm-dd
max_column_width: 20
default_editor: "nvim"
default_editor: 'nvim'
auto_open_browser: false
default_port: 6420
remote_operations: true
@@ -13,4 +13,4 @@ auto_commit: false
bypass_git_hooks: false
check_active_branches: true
active_branch_days: 30
task_prefix: "task"
task_prefix: 'task'

View File

@@ -1,24 +0,0 @@
---
id: TASK-69
title: Refactor mpv plugin into modular script components
status: To Do
assignee: []
created_date: '2026-02-24 17:09'
labels: []
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Break plugin/subminer.lua into smaller Lua modules under mpv scripts subdirectory while preserving user-visible behavior and keybindings. Include migration + docs updates for install paths and smoke/regression checks.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Plugin entrypoint stays at scripts/subminer.lua and loads modules from scripts/subminer/
- [ ] #2 No behavior change for keybindings, script messages, auto-start, AniSkip, hover highlight
- [ ] #3 Install/docs updated for recursive plugin copy
- [ ] #4 Add or update regression checks for start/stop + module loading
<!-- AC:END -->

View File

@@ -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 -->

View File

@@ -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 -->

View File

@@ -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 -->

View File

@@ -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 -->

View File

@@ -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 -->

View File

@@ -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 -->

View File

@@ -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 -->

View File

@@ -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 -->

View File

@@ -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 -->

View File

@@ -5,26 +5,18 @@
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
*/
{
// ==========================================
// Overlay Auto-Start
// 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
// ==========================================
// 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
// Control whether browser opens automatically for 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.
// ==========================================
@@ -34,7 +26,7 @@
// ==========================================
"websocket": {
"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.
// ==========================================
@@ -43,8 +35,8 @@
// Set to debug for full runtime diagnostics.
// ==========================================
"logging": {
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
}, // Controls logging verbosity.
"level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error
}, // Controls logging verbosity. Keep this as an object; do not replace with a bare string.
// ==========================================
// Keyboard Shortcuts
@@ -53,7 +45,6 @@
// ==========================================
"shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
@@ -65,19 +56,9 @@
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card 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.
// ==========================================
// 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)
// Extra keybindings that are merged with built-in defaults.
@@ -95,7 +76,7 @@
"secondarySub": {
"secondarySubLanguages": [], // Secondary sub languages setting.
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
"defaultMode": "hover" // Default mode setting.
"defaultMode": "hover", // Default mode setting.
}, // Dual subtitle track options.
// ==========================================
@@ -106,7 +87,7 @@
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
"alass_path": "", // Alass path setting.
"ffsubsync_path": "", // Ffsubsync path setting.
"ffmpeg_path": "" // Ffmpeg path setting.
"ffmpeg_path": "", // Ffmpeg path setting.
}, // Subsync engine and executable paths.
// ==========================================
@@ -114,7 +95,7 @@
// Initial vertical subtitle position from the bottom.
// ==========================================
"subtitlePosition": {
"yPercent": 10 // Y percent setting.
"yPercent": 10, // Y percent setting.
}, // Initial vertical subtitle position from the bottom.
// ==========================================
@@ -125,13 +106,22 @@
"subtitleStyle": {
"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
"hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv.
"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.
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"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.
"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.
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
"knownWordColor": "#a6da95", // Known word color setting.
"jlptColors": {
@@ -139,30 +129,32 @@
"N2": "#f5a97f", // N2 setting.
"N3": "#f9e2af", // N3 setting.
"N4": "#a6e3a1", // N4 setting.
"N5": "#8aadf4" // N5 setting.
"N5": "#8aadf4", // N5 setting.
}, // Jlpt colors setting.
"frequencyDictionary": {
"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).
"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`.
"bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
"bandedColors": ["#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.
"secondary": {
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family 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.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"fontWeight": "normal", // Font weight 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.
// ==========================================
@@ -175,15 +167,19 @@
"enabled": false, // Enable AnkiConnect integration. Values: true | false
"url": "http://127.0.0.1:8765", // Url setting.
"pollingRate": 3000, // Polling interval in milliseconds.
"tags": [
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"proxy": {
"enabled": false, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
"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": {
"audio": "ExpressionAudio", // Audio setting.
"image": "Picture", // Image setting.
"sentence": "Sentence", // Sentence setting.
"miscInfo": "MiscInfo", // Misc info setting.
"translation": "SelectionText" // Translation setting.
"translation": "SelectionText", // Translation setting.
}, // Fields setting.
"ai": {
"enabled": false, // Enabled setting. Values: true | false
@@ -192,7 +188,7 @@
"model": "openai/gpt-4o-mini", // Model setting.
"baseUrl": "https://openrouter.ai/api", // Base url 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.
"media": {
"generateAudio": true, // Generate audio setting. Values: true | false
@@ -205,7 +201,7 @@
"animatedCrf": 35, // Animated crf setting.
"audioPadding": 0.5, // Audio padding setting.
"fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30 // Max media duration setting.
"maxMediaDuration": 30, // Max media duration setting.
}, // Media setting.
"behavior": {
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
@@ -213,7 +209,7 @@
"mediaInsertMode": "append", // Media insert mode setting.
"highlightWord": true, // Highlight word setting. Values: true | false
"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.
"nPlusOne": {
"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.
"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.
"knownWord": "#a6da95" // Color used for legacy known-word highlights.
"knownWord": "#a6da95", // Color used for legacy known-word highlights.
}, // N plus one setting.
"metadata": {
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
"pattern": "[SubMiner] %f (%t)", // Pattern setting.
}, // Metadata setting.
"isLapis": {
"enabled": false, // Enabled setting. Values: true | false
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
"sentenceCardModel": "Japanese sentences", // Sentence card model setting.
}, // Is lapis setting.
"isKiku": {
"enabled": false, // Enabled setting. Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
} // Is kiku setting.
"deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false
}, // Is kiku setting.
}, // Automatic Anki updates and media generation options.
// ==========================================
@@ -245,7 +241,7 @@
"jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
"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.
// ==========================================
@@ -256,10 +252,7 @@
"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.
"whisperModel": "", // Path to whisper model used for fallback transcription.
"primarySubLanguages": [
"ja",
"jpn"
] // Comma-separated primary subtitle language priority used by the launcher.
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
// ==========================================
@@ -268,7 +261,7 @@
// ==========================================
"anilist": {
"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.
// ==========================================
@@ -292,16 +285,8 @@
"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.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
"directPlayContainers": [
"mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
"directPlayContainers": ["mkv", "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.
// ==========================================
@@ -312,7 +297,7 @@
"discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"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.
// ==========================================
@@ -334,7 +319,7 @@
"telemetryDays": 30, // Telemetry retention window in days.
"dailyRollupsDays": 365, // Daily rollup retention window in days.
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
} // Retention setting.
} // Enable/disable immersion tracking.
"vacuumIntervalDays": 7, // Minimum days between VACUUM runs.
}, // Retention setting.
}, // Enable/disable immersion tracking.
}

View File

@@ -69,6 +69,7 @@ export default {
{ text: 'Launcher Script', link: '/launcher-script' },
{ text: 'Usage', link: '/usage' },
{ text: 'Mining Workflow', link: '/mining-workflow' },
// { text: 'Feature Demos', link: '/demos' },
],
},
{

View File

@@ -16,13 +16,13 @@ make docs-preview # Preview built site at http://localhost:4173
- [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
- [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
- [Configuration](/configuration) — Full config file reference and option details
- [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
- [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

View File

@@ -1,6 +1,7 @@
# 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.
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
@@ -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.
## 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).
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.
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
@@ -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.
**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.
### What Gets Merged
| Field | Merge behavior |
| -------- | -------------------------------------------------------------- |
| Sentence | Both sentences preserved, labeled `[Original]` / `[Duplicate]` |
| Audio | Both `[sound:...]` entries kept |
| Image | Both images kept |
| -------- | --------------------------------------------------------------- |
| Sentence | Both sentences preserved (exact duplicate text is deduplicated) |
| Audio | Both `[sound:...]` entries kept (exact duplicates deduplicated) |
| Image | Both images kept (exact duplicates deduplicated) |
### 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,
"url": "http://127.0.0.1:8765",
"pollingRate": 3000,
"proxy": {
"enabled": false,
"host": "127.0.0.1",
"port": 8766,
"upstreamUrl": "http://127.0.0.1:8765",
},
"fields": {
"audio": "ExpressionAudio",
"image": "Picture",

View File

@@ -4,7 +4,7 @@ SubMiner is split into three cooperating runtimes:
- Electron desktop app (`src/`) for overlay/UI/runtime orchestration.
- Launcher CLI (`launcher/`) for mpv/app command workflows.
- mpv Lua plugin (`plugin/subminer.lua`) for player-side controls and IPC handoff.
- mpv Lua plugin (`plugin/subminer/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.
@@ -26,7 +26,9 @@ launcher/ # Standalone CLI launcher wrapper and mpv helpers
config/ # Launcher config parsers + CLI parser builder
main.ts # Launcher entrypoint and command dispatch
plugin/
subminer.lua # mpv plugin (auto-start, IPC, AniSkip + hover controls)
subminer/ # Modular mpv plugin (init · main · bootstrap · lifecycle · process
# state · messages · hover · ui · options · environment · log
# binary · aniskip · aniskip_match)
src/
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
main.ts # Entry point — delegates to runtime composers/domain modules
@@ -66,24 +68,26 @@ src/
renderer/ # Overlay renderer (modularized UI/runtime)
handlers/ # Keyboard/mouse interaction modules
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)
jimaku/ # Jimaku API integration helpers
subsync/ # Subtitle sync (alass/ffsubsync) helpers
subtitle/ # Subtitle processing utilities
tokenizers/ # Tokenizer implementations
anki-integration/ # AnkiConnect proxy server + note-update enrichment workflow
token-mergers/ # Token merge strategies
translators/ # AI translation providers
```
### 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`
- **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`
- **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`
- **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`
- **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/
renderer.ts # Entrypoint/orchestration only
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
overlay-content-measurement.ts # Reports rendered bounds to main process
subtitle-render.ts # Primary/secondary subtitle rendering + style application
positioning.ts # Facade export for positioning controller
yomitan-popup.ts # Yomitan popup iframe detection utilities
positioning/
controller.ts # Position controller orchestration
invisible-layout*.ts # Invisible layer layout computations
position-state.ts # Position state helpers
controller.ts # Subtitle drag-position controller
position-state.ts # Position state helpers (yPercent)
handlers/
keyboard.ts # Keybindings, chord handling, modal key routing
mouse.ts # Hover/drag behavior, selection + observer wiring
@@ -121,11 +125,11 @@ src/renderer/
### Launcher + Plugin Runtimes
- `launcher/main.ts` dispatches commands through `launcher/commands/*` and shared config readers in `launcher/config/*`. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.
- `plugin/subminer.lua` runs inside mpv and handles IPC startup checks, overlay toggles, hover-token messages, and AniSkip intro-skip UX.
- `plugin/subminer/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
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
flowchart LR
@@ -139,7 +143,7 @@ flowchart LR
subgraph ExtRt["External Runtimes"]
Launcher["launcher/<br/>CLI dispatch"]:::extrt
Plugin["subminer.lua<br/>mpv plugin"]:::extrt
Plugin["subminer/init.lua<br/>mpv plugin"]:::extrt
end
subgraph Ext["External Systems"]
@@ -162,8 +166,9 @@ flowchart LR
subgraph Svc["Services — src/core/services/"]
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
AnkiProxy["Anki Integration<br/>anki-connect-proxy<br/>note-update-workflow"]:::svc
Integrations["Integrations<br/>jimaku · subsync<br/>texthooker · yomitan"]:::svc
Tracking["Tracking<br/>anilist · jellyfin<br/>immersion · discord"]:::svc
Config["Config & Runtime<br/>hot-reload<br/>runtime-options"]:::svc
@@ -172,9 +177,7 @@ flowchart LR
Bridge(["preload.ts<br/>Electron IPC"]):::bridge
subgraph Rend["Renderer — src/renderer/"]
Visible["Visible window<br/>Yomitan lookups"]:::rend
Invisible["Invisible window<br/>mpv positioning"]:::rend
Secondary["Secondary window<br/>subtitle bar"]:::rend
OverlayWin["Main overlay window<br/>primary + secondary subtitles"]:::rend
UI["subtitle-render<br/>positioning<br/>handlers · modals"]:::rend
end
@@ -185,18 +188,16 @@ flowchart LR
Comp --> Svc
mpvExt <-->|"JSON socket"| Mpv
AnkiExt <-->|"HTTP"| Mining
AnkiExt <-->|"HTTP"| AnkiProxy
JimakuExt <-->|"HTTP"| Integrations
TrackerExt <-->|"platform"| Overlay
TrackerExt <-->|"platform"| OverlaySvc
AnilistExt <-->|"HTTP"| Tracking
JellyfinExt <-->|"HTTP"| Tracking
DiscordExt <-->|"RPC"| Integrations
Overlay & Mining --> Bridge
Bridge --> Visible
Bridge --> Invisible
Bridge --> Secondary
Visible & Invisible & Secondary --> UI
OverlaySvc & Mining --> Bridge
Bridge --> OverlayWin
OverlayWin --> UI
style Comp 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.
- **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to 26 properties), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
- **Overlay runtime:** `initializeOverlayRuntime()` creates 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.
- **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.
- **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.
- **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.
- **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 (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 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, stops the AnkiConnect proxy server, and cleans Anki/AniList state.
```mermaid
flowchart LR
@@ -298,27 +299,24 @@ flowchart LR
OverlayInit["initializeOverlay<br/>Runtime()"]:::phase
OverlayInit --> VisWin["Visible window<br/>Yomitan lookups"]:::init
OverlayInit --> InvWin["Invisible window<br/>mpv positioning"]:::init
OverlayInit --> SecWin["Secondary window<br/>subtitle bar"]:::init
OverlayInit --> MainWin["Main overlay window<br/>primary + secondary subtitles"]:::init
OverlayInit --> Shortcuts["Register global<br/>shortcuts"]:::init
VisWin --> Warmups
InvWin --> Warmups
SecWin --> Warmups
MainWin --> Warmups
Shortcuts --> Warmups
Warmups["Background<br/>warmups"]:::phase
subgraph WarmupGroup[" "]
direction TB
W1["MeCab"]:::warmup
W1["MeCab<br/>+ worker thread"]:::warmup
W2["Yomitan"]:::warmup
W3["JLPT + freq<br/>dictionaries"]:::warmup
W4["Jellyfin"]:::warmup
W5["Discord"]:::warmup
W6["AniList"]:::warmup
W1 ~~~ W2 ~~~ W3 ~~~ W4 ~~~ W5 ~~~ W6
W7["AnkiConnect<br/>proxy"]:::warmup
W1 ~~~ W2 ~~~ W3 ~~~ W4 ~~~ W5 ~~~ W6 ~~~ W7
end
Warmups --> WarmupGroup
@@ -330,7 +328,7 @@ flowchart LR
ExtEvt["Shortcuts · config hot-reload"]:::runtime
MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::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
WarmupGroup --> Loop
@@ -342,7 +340,7 @@ flowchart LR
Quit --> T1["Tray · config watcher<br/>global shortcuts"]:::shutdown
Quit --> T2["WebSocket · texthooker<br/>mpv socket · OSD log"]:::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
```

View File

@@ -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.
On macOS, these validation warnings also open a native dialog with full details (desktop notification banners can truncate long messages).
### Hot-Reload Behavior
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:
- [**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
- [**Visible Overlay Subtitle Binding**](#visible-overlay-subtitle-binding) - Link visible overlay toggles to MPV subtitle visibility
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**Invisible Overlay**](#invisible-overlay) - Startup visibility behavior for the invisible mining layer
- [**Startup Warmups**](#startup-warmups) - Control what preloads on startup vs first-use defer
- [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server
- [**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
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
- [**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
- [**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
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,
"url": "http://127.0.0.1:8765",
"pollingRate": 3000,
"proxy": {
"enabled": false,
"host": "127.0.0.1",
"port": 8766,
"upstreamUrl": "http://127.0.0.1:8765"
},
"tags": ["SubMiner"],
"deck": "Learning::Japanese",
"fields": {
@@ -163,7 +612,11 @@ This example is intentionally compact. The option table below documents availabl
| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `false`) |
| `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). |
| `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`. |
@@ -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`. |
| `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
@@ -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>
**Image Quality Notes:**
## External Integrations
- `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
### Jimaku
### 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.
### Auto-Start Overlay
Control whether the overlay automatically becomes visible when it connects to mpv:
Configure Jimaku API access and defaults:
```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 |
| -------------------- | --------------- | ------------------------------------------------------ |
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) |
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
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).
### 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. |
Set `openBrowser` to `false` to only print the URL without opening a browser.
### 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`.
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 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`) |
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.
Launcher subcommands:
@@ -562,276 +930,6 @@ Troubleshooting:
- 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.
### 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
Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions:

72
docs/demos.md Normal file
View 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>

View File

@@ -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 . --background # tray/background mode, minimal default logging
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

View File

@@ -6,11 +6,14 @@ SubMiner stores immersion analytics in local SQLite (`immersion.sqlite`) by defa
- Write path is asynchronous and queue-backed.
- 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.
- Flush policy defaults to `25` writes or `500ms` max delay.
- 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:
@@ -18,15 +21,21 @@ Schema versioning table:
Core entities:
- `imm_videos`: video key/title/source metadata + optional media metadata fields
- `imm_sessions`: session UUID, video reference, timing/status fields
- `imm_session_telemetry`: high-frequency session aggregates over time
- `imm_session_events`: event stream with compact numeric event types
- `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, `CREATED_DATE`/`LAST_UPDATE_DATE`
- `imm_session_telemetry`: high-frequency session aggregates over time, `CREATED_DATE`/`LAST_UPDATE_DATE`
- `imm_session_events`: event stream with compact numeric event types, `CREATED_DATE`/`LAST_UPDATE_DATE`
Rollups:
- `imm_daily_rollups`
- `imm_monthly_rollups`
- `imm_daily_rollups`: includes `CREATED_DATE`/`LAST_UPDATE_DATE`
- `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:
@@ -147,4 +156,3 @@ FROM imm_monthly_rollups
ORDER BY rollup_month DESC, video_id DESC
LIMIT ?;
```

View File

@@ -6,9 +6,19 @@ const docsIndexContents = readFileSync(docsIndexPath, 'utf8');
test('docs demo media uses shared cache-busting asset version token', () => {
expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/);
expect(docsIndexContents).toContain(':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"');
expect(docsIndexContents).toContain('<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.gif?v=${demoAssetVersion}`" alt="SubMiner demo GIF fallback" style="width: 100%; height: auto;" />');
expect(docsIndexContents).toContain(
':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"',
);
expect(docsIndexContents).toContain(
'<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;" />',
);
});

View File

@@ -7,7 +7,7 @@ titleTemplate: Immersion Mining Workflow for MPV
hero:
name: SubMiner
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:
src: /assets/SubMiner.png
alt: SubMiner logo
@@ -35,16 +35,11 @@ features:
alt: Anki card icon
title: Anki Card Enrichment
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:
src: /assets/highlight.svg
alt: Highlight icon
title: N+1 Highlighting
details: Surfaces known words from your deck so unknown targets stand out during immersion sessions.
title: Reading Annotations
details: Combines N+1 targeting, Jiten frequency highlighting, and JLPT tagging so useful cues stay visible while you read.
- icon:
src: /assets/tokenization.svg
alt: Tokenization icon
@@ -55,16 +50,6 @@ features:
alt: Subtitle download icon
title: Subtitle Download & Sync
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>
@@ -110,7 +95,7 @@ const demoAssetVersion = '20260223-2';
<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.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>
</video>
</section>

View File

@@ -151,6 +151,7 @@ tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.config/SubMiner
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
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/
@@ -182,9 +183,6 @@ All keybindings use a `y` chord prefix — press `y`, then the second key:
| `y-s` | Start overlay |
| `y-S` | Stop 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-r` | Restart overlay |
| `y-c` | Check overlay status |
@@ -196,7 +194,10 @@ See [MPV Plugin](/mpv-plugin) for the full configuration reference, script messa
After installing, confirm SubMiner is working:
```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
# Useful launch modes for troubleshooting

View File

@@ -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.
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

View File

@@ -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.
| Optional tool | Purpose |
| --------------------- | -------------------------------- |
| ------------------- | --------------------------------- |
| `chafa` | Render thumbnails in the terminal |
| `ffmpegthumbnailer` | Generate thumbnails on the fly |
@@ -53,8 +53,9 @@ SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
## Common Commands
```bash
subminer video.mkv # play a specific file
subminer --start video.mkv # play + explicitly start overlay
subminer video.mkv # play a specific file (default plugin config auto-starts visible 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 ytsearch:"jp news" # YouTube search
```
@@ -62,7 +63,7 @@ subminer ytsearch:"jp news" # YouTube search
## Subcommands
| Subcommand | Purpose |
| ------------------------- | ---------------------------------------------- |
| -------------------------- | ---------------------------------------------------------- |
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) |
| `subminer yt` / `youtube` | YouTube shorthand (`-o`, `-m`) |
| `subminer doctor` | Dependency + config + socket diagnostics |
@@ -79,20 +80,22 @@ Use `subminer <subcommand> -h` for command-specific help.
## Options
| Flag | Description |
| -------------------- | -------------------------------------------- |
| ----------------------- | --------------------------------------------------- |
| `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
| `-S, --start` | Start overlay after mpv launches |
| `-T, --no-texthooker`| Disable texthooker server |
| `--start` | Explicitly start overlay after mpv launches |
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
| `-T, --no-texthooker` | Disable texthooker server |
| `-p, --profile` | mpv profile name (default: `subminer`) |
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `--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
- Default log level is `info`
- `--background` mode defaults to `warn` unless `--log-level` is explicitly set
- `--dev` / `--debug` control app behavior, not logging verbosity — use `--log-level` for that

View File

@@ -20,49 +20,36 @@ SubMiner prioritizes subtitle responsiveness over heavy initialization:
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.
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.
## 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:
- 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 + drag to reposition subtitles
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
- **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.
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.
Jimaku search, field-grouping, runtime options, and manual subsync open as modal surfaces on top of the same overlay window.
## Looking Up Words
@@ -73,10 +60,10 @@ Toggle controls:
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.
### On the Invisible Overlay
### On Overlay Subtitles
1. The invisible layer sits over mpv's own subtitle text.
2. Click on any word in the subtitle — SubMiner maps your click position to the underlying text.
1. Subtitles are rendered directly in the overlay.
2. Click on any word in the subtitle.
3. On macOS, word selection happens automatically on hover.
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
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.
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:
- **Sentence**: The current subtitle line.
- **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.
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 |
| --------------------------- | ----------------------------------------- | ------------------------------------- |
| -------------------------- | ------------------------------- | --------------------------------------- |
| `Ctrl/Cmd+C` | Copy current subtitle | `shortcuts.copySubtitle` |
| `Ctrl/Cmd+Shift+C` + digit | Copy multiple recent lines | `shortcuts.copySubtitleMultiple` |
| `Ctrl/Cmd+V` | Update last card from clipboard | `shortcuts.updateLastCardFromClipboard` |

View File

@@ -11,6 +11,7 @@ tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.config/SubMiner
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
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/
@@ -29,14 +30,11 @@ input-ipc-server=/tmp/subminer-socket
All keybindings use a `y` chord prefix — press `y`, then the second key:
| Chord | Action |
| ----- | ------------------------ |
| ----- | ---------------------- |
| `y-y` | Open menu |
| `y-s` | Start overlay |
| `y-S` | Stop 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-r` | Restart overlay |
| `y-c` | Check status |
@@ -51,10 +49,9 @@ SubMiner:
1. Start overlay
2. Stop overlay
3. Toggle overlay
4. Toggle invisible overlay
5. Open options
6. Restart overlay
7. Check status
4. Open options
5. Restart overlay
6. Check status
```
Select an item by pressing its number.
@@ -80,14 +77,16 @@ texthooker_port=5174
backend=auto
# 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.
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.
# platform-default = hidden on Linux, visible on macOS/Windows.
auto_start_invisible_overlay=platform-default
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
# Requires auto_start=yes and auto_start_visible_overlay=yes.
auto_start_pause_until_ready=yes
# Show OSD messages for overlay status changes.
osd_messages=yes
@@ -122,15 +121,15 @@ aniskip_button_duration=3
### Option Reference
| Option | Default | Values | Description |
| ------------------------------ | ---------------------- | ------------------------------------------ | -------------------------------- |
| ---------------------------- | ----------------------------- | ------------------------------------------ | ---------------------------------------------------------------------- |
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
| `texthooker_port` | `5174` | 165535 | Texthooker server port |
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
| `auto_start` | `no` | `yes` / `no` | Auto-start overlay on file load |
| `auto_start_visible_overlay` | `no` | `yes` / `no` | Show visible layer on auto-start |
| `auto_start_invisible_overlay` | `platform-default` | `platform-default`, `visible`, `hidden` | Invisible layer on auto-start |
| `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
| `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
| `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 |
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
@@ -183,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-stop
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-options
script-message subminer-restart
script-message subminer-status
script-message subminer-autoplay-ready
script-message subminer-aniskip-refresh
script-message subminer-skip-intro
```
@@ -205,7 +202,12 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
## 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.
- 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.
@@ -214,7 +216,9 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
## 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.
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.

View 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.

View File

@@ -1,15 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
<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 offset="1" stop-color="#059669"/>
</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>
<rect x="12" y="5" width="24" height="34" rx="3" fill="#059669" opacity="0.18"/>
<rect x="8" y="9" width="24" height="34" rx="3" fill="url(#ac)"/>
<rect x="13" y="18" width="14" height="2.5" rx="1.25" fill="white" opacity="0.85"/>
<rect x="13" y="24" width="10" height="2.5" rx="1.25" fill="white" opacity="0.4"/>
<rect x="13" y="30" width="12" height="2.5" rx="1.25" fill="white" opacity="0.4"/>
<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"/>
<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"/>
<!-- Glow aura behind card -->
<rect x="6" y="8" width="28" height="36" rx="5" fill="url(#ac-glow)" filter="url(#ac-soft)"/>
<!-- Shadow card (back) -->
<rect x="14" y="5" width="26" height="34" rx="4" fill="#059669" opacity="0.15"/>
<!-- Main card -->
<rect x="6" y="9" width="26" height="34" rx="4" fill="url(#ac-card)"/>
<!-- 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>

Before

Width:  |  Height:  |  Size: 914 B

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,13 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
<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 offset="1" stop-color="#f59e0b"/>
</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>
<rect x="2" y="17" width="10" height="14" rx="3" fill="#fbbf24" opacity="0.3"/>
<rect x="14" y="17" width="7" height="14" rx="3" fill="#fbbf24" opacity="0.3"/>
<rect x="23" y="13" width="13" height="22" rx="3.5" fill="url(#hl)"/>
<rect x="38" y="17" width="8" height="14" rx="3" fill="#fbbf24" opacity="0.3"/>
<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"/>
<!-- Viewport / video frame background -->
<rect x="1" y="5" width="46" height="38" rx="4" fill="#1e293b" opacity="0.55"/>
<rect x="1" y="5" width="46" height="38" rx="4" stroke="#334155" stroke-width="0.8" fill="none" opacity="0.5"/>
<!-- Subtitle line 1 — tokens with frequency highlight -->
<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>

Before

Width:  |  Height:  |  Size: 729 B

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,21 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
<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 offset="1" stop-color="#7c3aed"/>
</linearGradient>
<filter id="kb-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="2"/>
</filter>
</defs>
<rect x="2" y="12" width="44" height="30" rx="5" fill="url(#kb)" opacity="0.12"/>
<rect x="2" y="12" width="44" height="30" rx="5" stroke="url(#kb)" stroke-width="1.5" fill="none"/>
<rect x="6" y="16" width="8" height="6" rx="2" fill="url(#kb)"/>
<rect x="16" y="16" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
<rect x="26" y="16" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
<rect x="36" y="16" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
<rect x="6" y="24" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
<rect x="16" y="24" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
<rect x="26" y="24" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
<rect x="36" y="24" width="8" height="6" rx="2" fill="url(#kb)"/>
<rect x="6" y="32" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
<rect x="16" y="32" width="16" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
<rect x="34" y="32" width="10" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
<!-- Keyboard body -->
<rect x="2" y="14" width="44" height="28" rx="4.5" fill="url(#kb-main)" opacity="0.1"/>
<rect x="2" y="14" width="44" height="28" rx="4.5" stroke="url(#kb-main)" stroke-width="1.4" fill="none"/>
<!-- Row 1 -->
<rect x="6" y="18" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
<rect x="15" y="18" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
<rect x="24" y="18" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
<rect x="33" y="18" width="11" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
<!-- Row 2 — active key with glow -->
<rect x="6" y="25.5" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
<!-- Active/pressed key glow -->
<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="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>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 MiB

After

Width:  |  Height:  |  Size: 23 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 MiB

View File

@@ -1,16 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
<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 offset="1" stop-color="#0891b2"/>
</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>
<rect x="8" y="4" width="24" height="32" rx="3" fill="url(#sd)" opacity="0.15"/>
<rect x="8" y="4" width="24" height="32" rx="3" stroke="url(#sd)" stroke-width="1.5" fill="none"/>
<rect x="13" y="12" width="14" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.5"/>
<rect x="13" y="18" width="10" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.35"/>
<rect x="13" y="24" width="12" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.35"/>
<line x1="38" y1="16" x2="38" y2="32" stroke="url(#sd)" stroke-width="2.5" stroke-linecap="round"/>
<path d="M33 28l5 5 5-5" stroke="url(#sd)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<line x1="33" y1="40" x2="43" y2="40" stroke="url(#sd)" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
<!-- Subtitle file -->
<rect x="4" y="3" width="26" height="34" rx="3.5" fill="url(#sd-main)" opacity="0.12"/>
<rect x="4" y="3" width="26" height="34" rx="3.5" stroke="url(#sd-main)" stroke-width="1.4" fill="none"/>
<!-- SRT-style timing line -->
<rect x="8.5" y="10" width="10" height="2" rx="1" fill="#22d3ee" opacity="0.35"/>
<rect x="20" y="10" width="3" height="2" rx="1" fill="#22d3ee" opacity="0.25"/>
<!-- Subtitle text lines -->
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,19 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
<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 offset="1" stop-color="#c2410c"/>
</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>
<rect x="4" y="6" width="30" height="36" rx="4" fill="url(#th)" opacity="0.12"/>
<rect x="4" y="6" width="30" height="36" rx="4" stroke="url(#th)" stroke-width="1.5" fill="none"/>
<rect x="9" y="14" width="14" height="2.5" rx="1.25" fill="#f97316" opacity="0.6"/>
<rect x="9" y="20" width="18" height="2.5" rx="1.25" fill="#f97316" opacity="0.4"/>
<rect x="9" y="26" width="12" height="2.5" rx="1.25" fill="#f97316" opacity="0.4"/>
<rect x="9" y="32" width="16" height="2.5" rx="1.25" fill="#f97316" opacity="0.4"/>
<circle cx="40" cy="18" r="3.5" fill="url(#th)" opacity="0.8"/>
<circle cx="40" cy="30" r="3.5" fill="url(#th)" opacity="0.8"/>
<line x1="36" y1="18" x2="34" y2="18" stroke="url(#th)" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/>
<line x1="36" y1="30" x2="34" y2="30" stroke="url(#th)" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/>
<line x1="40" y1="21.5" x2="40" y2="26.5" stroke="url(#th)" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
<!-- Source panel (subtitle/text source) -->
<rect x="2" y="8" width="18" height="32" rx="3" fill="url(#th-main)" opacity="0.12"/>
<rect x="2" y="8" width="18" height="32" rx="3" stroke="url(#th-main)" stroke-width="1.3" fill="none"/>
<!-- Subtitle text lines streaming out -->
<rect x="5" y="14" width="12" height="2" rx="1" fill="#f97316" opacity="0.6"/>
<rect x="5" y="19" width="10" height="2" rx="1" fill="#f97316" opacity="0.5"/>
<rect x="5" y="24" width="11" height="2" rx="1" fill="#f97316" opacity="0.4"/>
<rect x="5" y="29" width="9" height="2" rx="1" fill="#f97316" opacity="0.35"/>
<rect x="5" y="34" width="12" height="2" rx="1" fill="#f97316" opacity="0.3"/>
<!-- WebSocket stream particles -->
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,16 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
<defs>
<linearGradient id="tk" x1="0" y1="14" x2="48" y2="34" gradientUnits="userSpaceOnUse">
<stop stop-color="#22d3ee"/>
<stop offset="1" stop-color="#0891b2"/>
<linearGradient id="tk-bar" x1="0" y1="40" x2="0" y2="10" gradientUnits="userSpaceOnUse">
<stop 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>
</defs>
<rect x="2" y="12" width="12" height="24" rx="3.5" fill="url(#tk)"/>
<rect x="18" y="12" width="12" height="24" rx="3.5" fill="url(#tk)"/>
<rect x="34" y="12" width="12" height="24" rx="3.5" fill="url(#tk)"/>
<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="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"/>
<rect x="5" y="22" width="6" height="2.5" rx="1.25" fill="white" opacity="0.7"/>
<rect x="21" y="22" width="6" height="2.5" rx="1.25" fill="white" opacity="0.7"/>
<rect x="37" y="22" width="6" height="2.5" rx="1.25" fill="white" opacity="0.7"/>
<!-- Subtle grid lines -->
<line x1="4" y1="14" x2="44" y2="14" stroke="#22d3ee" stroke-width="0.5" opacity="0.12"/>
<line x1="4" y1="22" x2="44" y2="22" stroke="#22d3ee" stroke-width="0.5" opacity="0.12"/>
<line x1="4" y1="30" x2="44" y2="30" stroke="#22d3ee" stroke-width="0.5" opacity="0.12"/>
<!-- Base line -->
<line x1="4" y1="40" x2="44" y2="40" stroke="#0891b2" stroke-width="1" opacity="0.3"/>
<!-- Activity bars (daily rollups) -->
<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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -5,26 +5,18 @@
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
*/
{
// ==========================================
// Overlay Auto-Start
// 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
// ==========================================
// 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
// Control whether browser opens automatically for 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.
// ==========================================
@@ -34,7 +26,7 @@
// ==========================================
"websocket": {
"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.
// ==========================================
@@ -43,7 +35,7 @@
// Set to debug for full runtime diagnostics.
// ==========================================
"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.
// ==========================================
@@ -53,7 +45,6 @@
// ==========================================
"shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
@@ -65,19 +56,9 @@
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card 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.
// ==========================================
// 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)
// Extra keybindings that are merged with built-in defaults.
@@ -95,7 +76,7 @@
"secondarySub": {
"secondarySubLanguages": [], // Secondary sub languages setting.
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
"defaultMode": "hover" // Default mode setting.
"defaultMode": "hover", // Default mode setting.
}, // Dual subtitle track options.
// ==========================================
@@ -106,7 +87,7 @@
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
"alass_path": "", // Alass path setting.
"ffsubsync_path": "", // Ffsubsync path setting.
"ffmpeg_path": "" // Ffmpeg path setting.
"ffmpeg_path": "", // Ffmpeg path setting.
}, // Subsync engine and executable paths.
// ==========================================
@@ -114,7 +95,7 @@
// Initial vertical subtitle position from the bottom.
// ==========================================
"subtitlePosition": {
"yPercent": 10 // Y percent setting.
"yPercent": 10, // Y percent setting.
}, // Initial vertical subtitle position from the bottom.
// ==========================================
@@ -125,13 +106,22 @@
"subtitleStyle": {
"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
"hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv.
"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.
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"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.
"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.
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
"knownWordColor": "#a6da95", // Known word color setting.
"jlptColors": {
@@ -139,30 +129,32 @@
"N2": "#f5a97f", // N2 setting.
"N3": "#f9e2af", // N3 setting.
"N4": "#a6e3a1", // N4 setting.
"N5": "#8aadf4" // N5 setting.
"N5": "#8aadf4", // N5 setting.
}, // Jlpt colors setting.
"frequencyDictionary": {
"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).
"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`.
"bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
"bandedColors": ["#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.
"secondary": {
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family 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.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"fontWeight": "normal", // Font weight 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.
// ==========================================
@@ -175,15 +167,19 @@
"enabled": false, // Enable AnkiConnect integration. Values: true | false
"url": "http://127.0.0.1:8765", // Url setting.
"pollingRate": 3000, // Polling interval in milliseconds.
"tags": [
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"proxy": {
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
"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": {
"audio": "ExpressionAudio", // Audio setting.
"image": "Picture", // Image setting.
"sentence": "Sentence", // Sentence setting.
"miscInfo": "MiscInfo", // Misc info setting.
"translation": "SelectionText" // Translation setting.
"translation": "SelectionText", // Translation setting.
}, // Fields setting.
"ai": {
"enabled": false, // Enabled setting. Values: true | false
@@ -192,7 +188,7 @@
"model": "openai/gpt-4o-mini", // Model setting.
"baseUrl": "https://openrouter.ai/api", // Base url 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.
"media": {
"generateAudio": true, // Generate audio setting. Values: true | false
@@ -205,7 +201,7 @@
"animatedCrf": 35, // Animated crf setting.
"audioPadding": 0.5, // Audio padding setting.
"fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30 // Max media duration setting.
"maxMediaDuration": 30, // Max media duration setting.
}, // Media setting.
"behavior": {
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
@@ -213,7 +209,7 @@
"mediaInsertMode": "append", // Media insert mode setting.
"highlightWord": true, // Highlight word setting. Values: true | false
"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.
"nPlusOne": {
"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.
"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.
"knownWord": "#a6da95" // Color used for legacy known-word highlights.
"knownWord": "#a6da95", // Color used for legacy known-word highlights.
}, // N plus one setting.
"metadata": {
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
"pattern": "[SubMiner] %f (%t)", // Pattern setting.
}, // Metadata setting.
"isLapis": {
"enabled": false, // Enabled setting. Values: true | false
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
"sentenceCardModel": "Japanese sentences", // Sentence card model setting.
}, // Is lapis setting.
"isKiku": {
"enabled": false, // Enabled setting. Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
} // Is kiku setting.
"deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false
}, // Is kiku setting.
}, // Automatic Anki updates and media generation options.
// ==========================================
@@ -245,7 +241,7 @@
"jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
"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.
// ==========================================
@@ -256,10 +252,7 @@
"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.
"whisperModel": "", // Path to whisper model used for fallback transcription.
"primarySubLanguages": [
"ja",
"jpn"
] // Comma-separated primary subtitle language priority used by the launcher.
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
// ==========================================
@@ -268,7 +261,7 @@
// ==========================================
"anilist": {
"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.
// ==========================================
@@ -292,16 +285,8 @@
"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.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
"directPlayContainers": [
"mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
"directPlayContainers": ["mkv", "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.
// ==========================================
@@ -312,7 +297,7 @@
"discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"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.
// ==========================================
@@ -334,7 +319,7 @@
"telemetryDays": 30, // Telemetry retention window in days.
"dailyRollupsDays": 365, // Daily rollup retention window in days.
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
} // Retention setting.
} // Enable/disable immersion tracking.
"vacuumIntervalDays": 7, // Minimum days between VACUUM runs.
}, // Retention setting.
}, // Enable/disable immersion tracking.
}

View File

@@ -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.
| Shortcut | Action | Configurable |
| ------------- | ------------------------ | ---------------------------------------- |
| ------------- | ---------------------- | -------------------------------------- |
| `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) |
::: tip
@@ -39,6 +38,8 @@ These control playback and subtitle display. They require overlay window focus.
| Shortcut | Action |
| -------------------- | -------------------------------------------------- |
| `Space` | Toggle mpv pause |
| `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track |
| `ArrowRight` | Seek forward 5 seconds |
| `ArrowLeft` | Seek backward 5 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.
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
## Subtitle & Feature Shortcuts
| 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+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
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
| Chord | Action |
| ----- | --------------------------------------- |
| ----- | ------------------------ |
| `y-y` | Open SubMiner menu (OSD) |
| `y-s` | Start overlay |
| `y-S` | Stop 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-r` | Restart overlay |
| `y-c` | Check overlay status |
@@ -112,7 +100,6 @@ All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electro
"mineSentence": "CommandOrControl+S",
"copySubtitle": "CommandOrControl+C",
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
"openJimaku": null, // disabled
},
}

View File

@@ -30,7 +30,7 @@ SubMiner retries the connection automatically with increasing delays (200 ms, 50
- first subtitle parse/tokenization bursts
- media generation (`ffmpeg` audio/image and AVIF paths)
- 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
@@ -104,11 +104,17 @@ Logged when a malformed JSON line arrives from the mpv socket. Usually harmless
**"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.
- 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".
@@ -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:
- 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.
## Overlay
@@ -153,7 +159,7 @@ SubMiner positions the overlay by tracking the mpv window. If tracking fails:
- Sway: Ensure `swaymsg` is available.
- 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
@@ -217,10 +223,10 @@ Media generation has a 30-second timeout (60 seconds for animated AVIF). If your
**"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.
- 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.
**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
- **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`

View File

@@ -1,15 +1,19 @@
# 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:
| 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`). |
| **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`. |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **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 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.
`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
@@ -34,8 +38,9 @@ subminer # Current directory (uses fzf)
subminer -R # Use rofi instead of fzf
subminer -d ~/Videos # Specific directory
subminer -r -d ~/Anime # Recursive search
subminer video.mkv # Play specific file
subminer --start video.mkv # Play + explicitly start overlay
subminer video.mkv # Play specific file (default plugin config auto-starts visible 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 ytsearch:"jp news" # Play first YouTube search result
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 --stop # Stop overlay
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 --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 --debug # Alias for --dev
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.
### 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
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets, and forwards them to mpv.
@@ -171,9 +181,8 @@ Notes:
### Global Shortcuts
| Keybind | Action |
| ------------- | ------------------------ |
| ------------- | ---------------------- |
| `Alt+Shift+O` | Toggle visible overlay |
| `Alt+Shift+I` | Toggle invisible overlay |
| `Alt+Shift+Y` | Open Yomitan settings |
`Alt+Shift+Y` is a fixed global shortcut; it is not part of `shortcuts` config.
@@ -195,14 +204,12 @@ Notes:
| `Ctrl+W` | Quit mpv |
| `Right-click` | Toggle MPV pause (outside subtitle area) |
| `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 |
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 one or more video files onto the overlay to replace current playback (`loadfile ... replace` for first file, then append remainder).

View File

@@ -62,7 +62,7 @@ test('inferAniSkipMetadataForFile falls back to anime directory title when filen
test('buildSubminerScriptOpts includes aniskip metadata fields', () => {
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
title: 'Frieren: Beyond Journey\'s End',
title: "Frieren: Beyond Journey's End",
season: 1,
episode: 5,
source: 'guessit',

View File

@@ -28,7 +28,11 @@ function toPositiveInt(value: unknown): 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) {
const match = baseName.match(pattern);
if (!match || !match[1]) continue;
@@ -171,7 +175,11 @@ export function inferAniSkipMetadataForFile(
}
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(

View File

@@ -33,6 +33,12 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
scriptPath: '/tmp/subminer',
scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {
socketPath: '/tmp/subminer.sock',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
},
appPath: '/tmp/subminer.app',
launcherJellyfinConfig: {},
processAdapter: adapter,

View File

@@ -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';
export interface LauncherCommandContext {
@@ -6,6 +6,7 @@ export interface LauncherCommandContext {
scriptPath: string;
scriptName: string;
mpvSocketPath: string;
pluginRuntimeConfig: PluginRuntimeConfig;
appPath: string | null;
launcherJellyfinConfig: LauncherJellyfinConfig;
processAdapter: ProcessAdapter;

View File

@@ -86,7 +86,7 @@ function registerCleanup(context: LauncherCommandContext): 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) {
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');
}
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(
selectedTarget.target,
selectedTarget.kind,
@@ -144,6 +157,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
mpvSocketPath,
appPath,
preloadedSubtitles,
{ startPaused: shouldPauseUntilOverlayReady },
);
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
@@ -167,6 +181,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
}
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
if (shouldStartOverlay) {
if (ready) {
@@ -179,6 +194,16 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
);
}
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) {
log(
'info',
@@ -194,15 +219,26 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
}
await new Promise<void>((resolve) => {
if (!state.mpvProc) {
const mpvProc = state.mpvProc;
if (!mpvProc) {
stopOverlay(args);
resolve();
return;
}
state.mpvProc.on('exit', (code) => {
const finalize = (code: number | null | undefined) => {
stopOverlay(args);
processAdapter.setExitCode(code ?? 0);
resolve();
};
if (mpvProc.exitCode !== null && mpvProc.exitCode !== undefined) {
finalize(mpvProc.exitCode);
return;
}
mpvProc.once('exit', (code) => {
finalize(code);
});
});
}

View File

@@ -51,10 +51,27 @@ test('parseLauncherJellyfinConfig omits legacy token and user id fields', () =>
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(`
# 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.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);
});

View File

@@ -4,11 +4,9 @@ import { execFileSync } from 'node:child_process';
import path from 'node:path';
test('launcher root help lists subcommands', () => {
const output = execFileSync(
'bun',
['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'],
{ encoding: 'utf8' },
);
const output = execFileSync('bun', ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'], {
encoding: 'utf8',
});
assert.match(output, /Commands:/);
assert.match(output, /jellyfin\|jf/);

View File

@@ -182,7 +182,8 @@ export function parseCliPrograms(
server: typeof options.server === 'string' ? options.server : undefined,
username: typeof options.username === 'string' ? options.username : 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,
};
});

View File

@@ -15,22 +15,64 @@ export function getPluginConfigCandidates(): string[] {
);
}
export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig {
const runtimeConfig: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
export function parsePluginRuntimeConfigContent(
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/)) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i);
if (!socketMatch) continue;
const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || '';
if (value) runtimeConfig.socketPath = value;
const keyValueMatch = trimmed.match(/^([a-z0-9_-]+)\s*=\s*(.+)$/i);
if (!keyValueMatch) continue;
const key = (keyValueMatch[1] || '').toLowerCase();
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;
}
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
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) {
if (!fs.existsSync(configPath)) continue;
@@ -39,7 +81,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
log(
'debug',
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;
} catch {
@@ -51,7 +93,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
log(
'debug',
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;
}

View File

@@ -22,10 +22,14 @@ function withTempDir<T>(fn: (dir: string) => T): T {
}
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,
encoding: 'utf8',
});
},
);
return {
status: result.status,
stdout: result.stdout || '',
@@ -225,10 +229,7 @@ test('jellyfin setup forwards password-store to app command', () => {
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(
['jf', 'setup', '--password-store', 'gnome-libsecret'],
env,
);
const result = runLauncher(['jf', 'setup', '--password-store', 'gnome-libsecret'], env);
assert.equal(result.status, 0);
assert.equal(

View File

@@ -19,14 +19,15 @@ import { runPlaybackCommand } from './commands/playback-command.js';
function createCommandContext(
args: ReturnType<typeof parseArgs>,
scriptPath: string,
mpvSocketPath: string,
pluginRuntimeConfig: ReturnType<typeof readPluginRuntimeConfig>,
appPath: string | null,
): LauncherCommandContext {
return {
args,
scriptPath,
scriptName: path.basename(scriptPath),
mpvSocketPath,
mpvSocketPath: pluginRuntimeConfig.socketPath,
pluginRuntimeConfig,
appPath,
launcherJellyfinConfig: loadLauncherJellyfinConfig(),
processAdapter: nodeProcessAdapter,
@@ -55,7 +56,7 @@ async function main(): Promise<void> {
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)) {
return;
@@ -71,6 +72,7 @@ async function main(): Promise<void> {
const resolvedAppPath = ensureAppPath(context);
state.appPath = resolvedAppPath;
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
const appContext: LauncherCommandContext = {
...context,
appPath: resolvedAppPath,

View File

@@ -4,7 +4,8 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
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';
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 });
}
});
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 });
}
});

View File

@@ -28,6 +28,8 @@ export const state = {
};
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 {
try {
@@ -424,6 +426,7 @@ export function startMpv(
socketPath: string,
appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
options?: { startPaused?: boolean },
): void {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`);
@@ -473,8 +476,10 @@ export function startMpv(
if (preloadedSubtitles?.secondaryPath) {
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
}
const aniSkipMetadata =
targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
if (options?.startPaused) {
mpvArgs.push('--pause=yes');
}
const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
if (aniSkipMetadata) {
log(
@@ -498,7 +503,47 @@ export function startMpv(
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);
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;
return new Promise((resolve) => {
setTimeout(resolve, 2000);
});
const [socketReady] = await Promise.all([
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 {

View File

@@ -31,11 +31,7 @@ test('parseArgs maps jellyfin play action and log-level override', () => {
});
test('parseArgs forwards jellyfin password-store option', () => {
const parsed = parseArgs(
['jf', 'setup', '--password-store', 'gnome-libsecret'],
'subminer',
{},
);
const parsed = parseArgs(['jf', 'setup', '--password-store', 'gnome-libsecret'], 'subminer', {});
assert.equal(parsed.jellyfin, true);
assert.equal(parsed.passwordStore, 'gnome-libsecret');

View File

@@ -62,7 +62,7 @@ function createSmokeCase(name: string): SmokeCase {
writeExecutable(
fakeMpvPath,
`#!/usr/bin/env bun
`#!/usr/bin/env node
const fs = require('node:fs');
const net = require('node:net');
const path = require('node:path');
@@ -101,7 +101,7 @@ process.on('SIGTERM', closeAndExit);
writeExecutable(
fakeAppPath,
`#!/usr/bin/env bun
`#!/usr/bin/env node
const fs = require('node:fs');
const logPath = ${JSON.stringify(fakeAppLogPath)};
@@ -237,8 +237,20 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
env,
'mpv-status',
);
const fakeMpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const fakeMpvError = fakeMpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof fakeMpvError === 'string' && /eperm|operation not permitted/i.test(fakeMpvError);
if (unixSocketDenied) {
assert.equal(result.status, 1);
assert.match(result.stdout, /socket not ready/i);
} else {
assert.equal(result.status, 0);
assert.match(result.stdout, /socket ready/i);
}
} finally {
if (fakeMpv.exitCode === null) {
await new Promise<void>((resolve) => {
@@ -262,9 +274,6 @@ test(
'overlay-start-stop',
);
assert.equal(result.status, 0);
assert.match(result.stdout, /Starting SubMiner overlay/i);
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
await waitForJsonLines(appStartPath, 1);
@@ -273,6 +282,14 @@ test(
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const mpvError = mpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.match(result.stdout, /Starting SubMiner overlay/i);
assert.equal(appStartEntries.length, 1);
assert.equal(appStopEntries.length, 1);
@@ -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);
});
},
);

View File

@@ -129,6 +129,9 @@ export interface LauncherJellyfinConfig {
export interface PluginRuntimeConfig {
socketPath: string;
autoStart: boolean;
autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean;
}
export interface CommandExecOptions {

View File

@@ -1,6 +1,6 @@
{
"name": "subminer",
"version": "0.1.2",
"version": "0.2.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"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: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:watch": "bunx concurrently -n docs,backlog \"bun run docs:dev\" \"backlog browser\"",
"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",
"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:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js",
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/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:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/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/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:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
@@ -118,10 +120,6 @@
"from": "vendor/yomitan-jlpt-vocab",
"to": "yomitan-jlpt-vocab"
},
{
"from": "vendor/jiten_freq_global",
"to": "jiten_freq_global"
},
{
"from": "assets",
"to": "assets"

View File

@@ -21,18 +21,16 @@ texthooker_port=5174
backend=auto
# 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
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
# Values: platform-default, visible, hidden
# platform-default => hidden on Linux, visible on macOS/Windows
auto_start_invisible_overlay=platform-default
# Legacy alias (maps to auto_start_visible_overlay)
# auto_start_overlay=no
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
# Requires auto_start=yes and auto_start_visible_overlay=yes.
auto_start_pause_until_ready=yes
# Show OSD messages for overlay status
osd_messages=yes
@@ -68,6 +66,5 @@ aniskip_button_key=y-k
# OSD hint duration in seconds (shown during first 3s of intro).
aniskip_button_duration=3
# MPV keybindings provided by plugin/subminer.lua:
# MPV keybindings provided by plugin/subminer/main.lua:
# y-s start, y-S stop, y-t toggle visible overlay
# y-i toggle invisible overlay, y-I show invisible overlay, y-u hide invisible overlay

View File

@@ -9,6 +9,10 @@ function M.create(ctx)
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
@@ -21,22 +25,26 @@ function M.create(ctx)
return encoded:gsub(" ", "%%20")
end
local function run_json_curl(url)
local result = mp.command_native({
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,
})
if not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then
return nil, result and result.stderr or "curl failed"
}, 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
return nil, parse_error or "invalid json"
callback(nil, parse_error or "invalid json")
return
end
return parsed, nil
callback(parsed, nil)
end)
end
local function parse_episode_hint(text)
@@ -107,6 +115,18 @@ function M.create(ctx)
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
@@ -117,6 +137,11 @@ function M.create(ctx)
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
@@ -139,34 +164,54 @@ function M.create(ctx)
return best_item
end
local function resolve_mal_id(title, season)
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
return forced_mal_id, "(forced-mal-id)"
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
return numeric, title
callback(numeric, title)
return
end
end
if type(title) ~= "string" or title == "" then
return nil, nil
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)
local mal_json, mal_error = run_json_curl(mal_url)
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))
return nil, lookup
callback(nil, lookup)
return
end
local categories = mal_json.categories
if type(categories) ~= "table" then
return nil, lookup
mal_lookup_cache[cache_key] = false
callback(nil, lookup)
return
end
local all_items = {}
@@ -179,6 +224,8 @@ function M.create(ctx)
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",
@@ -189,9 +236,12 @@ function M.create(ctx)
tostring(season or "-")
)
)
return tonumber(best_item.id), lookup
callback(matched_id, lookup)
return
end
return nil, lookup
mal_lookup_cache[cache_key] = false
callback(nil, lookup)
end)
end
local function set_intro_chapters(intro_start, intro_end)
@@ -238,7 +288,7 @@ function M.create(ctx)
end
end
local function clear_aniskip_state()
local function reset_aniskip_fields()
state.aniskip.prompt_shown = false
state.aniskip.found = false
state.aniskip.mal_id = nil
@@ -249,6 +299,11 @@ function M.create(ctx)
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")
@@ -319,17 +374,53 @@ function M.create(ctx)
return false
end
local function fetch_aniskip_for_current_media()
if not environment.is_subminer_app_running() then
subminer_log("debug", "lifecycle", "Skipping aniskip lookup: SubMiner app not running")
return
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
clear_aniskip_state()
if not opts.aniskip_enabled then
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
local title, episode, season = resolve_title_and_episode()
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 "")
@@ -350,16 +441,91 @@ function M.create(ctx)
seen_titles[key] = true
lookup_titles[#lookup_titles + 1] = trimmed
end
push_lookup_title(title)
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: title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)',
'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 "-"),
@@ -370,35 +536,34 @@ function M.create(ctx)
#lookup_titles
)
)
local mal_id, mal_lookup = nil, nil
for index, lookup_title in ipairs(lookup_titles) do
subminer_log("info", "aniskip", string.format('MAL lookup attempt %d/%d using title="%s"', index, #lookup_titles, lookup_title))
local attempt_mal_id, attempt_lookup = resolve_mal_id(lookup_title, season)
if attempt_mal_id then
mal_id = attempt_mal_id
mal_lookup = attempt_lookup
break
end
mal_lookup = attempt_lookup or mal_lookup
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
local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode)
subminer_log("info", "aniskip", string.format('Resolved MAL id=%d using query="%s"; AniSkip URL=%s', mal_id, tostring(mal_lookup or ""), url))
local payload, fetch_error = run_json_curl(url)
if not payload then
subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error))
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 payload.found ~= true then
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 {

View File

@@ -23,14 +23,47 @@ function M.init()
environment = environment,
}
ctx.log = require("log").create(ctx)
ctx.binary = require("binary").create(ctx)
ctx.aniskip = require("aniskip").create(ctx)
ctx.hover = require("hover").create(ctx)
ctx.process = require("process").create(ctx)
ctx.ui = require("ui").create(ctx)
ctx.messages = require("messages").create(ctx)
ctx.lifecycle = require("lifecycle").create(ctx)
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()

View File

@@ -4,6 +4,11 @@ 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) == "\\"
@@ -29,20 +34,21 @@ function M.create(ctx)
return not is_windows() and not is_macos()
end
local function is_subminer_process_running()
local command = is_windows() and { "tasklist", "/FO", "CSV", "/NH" } or { "ps", "-A", "-o", "args=" }
local result = mp.command_native({
name = "subprocess",
args = command,
playback_only = false,
capture_stdout = true,
capture_stderr = false,
})
if not result or type(result.stdout) ~= "string" or result.status ~= 0 then
return false
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 process_list = result.stdout:lower()
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('^"([^"]+)","')
@@ -80,8 +86,80 @@ function M.create(ctx)
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()
return is_subminer_process_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()
@@ -123,6 +201,8 @@ function M.create(ctx)
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

View File

@@ -33,6 +33,15 @@ function M.create(ctx)
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
@@ -91,7 +100,7 @@ function M.create(ctx)
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 = fix_ass_color(DEFAULT_HOVER_COLOR, DEFAULT_HOVER_COLOR),
hover_color = sanitize_hover_ass_color(nil, DEFAULT_HOVER_COLOR),
}
end
@@ -240,36 +249,22 @@ function M.create(ctx)
raw_close_idx = #raw_ass + 1
end
local open_tag = string.format("{\\1c&H%s&}", hover_color)
local close_tag = string.format("{\\1c&H%s&}", base_color)
local changes = {
{ idx = raw_open_idx, tag = open_tag },
{ idx = raw_close_idx, tag = close_tag },
}
table.sort(changes, function(a, b)
return a.idx < b.idx
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 output = {}
local cursor = 1
for _, change in ipairs(changes) do
if change.idx > #raw_ass + 1 then
change.idx = #raw_ass + 1
end
if change.idx < 1 then
change.idx = 1
end
if change.idx > cursor then
output[#output + 1] = raw_ass:sub(cursor, change.idx - 1)
end
output[#output + 1] = change.tag
cursor = change.idx
end
if cursor <= #raw_ass then
output[#output + 1] = raw_ass:sub(cursor)
end
return table.concat(output)
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)
@@ -294,7 +289,7 @@ function M.create(ctx)
end
local metrics = resolve_metrics()
local hover_color = fix_ass_color(payload.colors and payload.colors.hover or nil, metrics.hover_color)
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

7
plugin/subminer/init.lua Normal file
View File

@@ -0,0 +1,7 @@
local M = {}
function M.init()
require("bootstrap").init()
end
return M

View File

@@ -4,8 +4,6 @@ function M.create(ctx)
local mp = ctx.mp
local opts = ctx.opts
local state = ctx.state
local environment = ctx.environment
local binary = ctx.binary
local options_helper = ctx.options_helper
local process = ctx.process
local aniskip = ctx.aniskip
@@ -13,36 +11,57 @@ function M.create(ctx)
local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd
local function on_file_loaded()
if not environment.is_subminer_app_running() then
aniskip.clear_aniskip_state()
subminer_log("debug", "lifecycle", "Skipping file load hooks: SubMiner app not running")
return true
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()
aniskip.fetch_aniskip_for_current_media()
state.binary_path = binary.find_binary()
if state.binary_path then
state.binary_available = true
subminer_log("info", "lifecycle", "SubMiner ready (binary: " .. state.binary_path .. ")")
local should_auto_start = options_helper.coerce_bool(opts.auto_start, false)
process.disarm_auto_play_ready_gate()
local should_auto_start = resolve_auto_start_enabled()
if should_auto_start then
process.start_overlay()
end
else
state.binary_available = false
subminer_log("warn", "binary", "SubMiner binary not found - overlay features disabled")
if opts.binary_path ~= "" then
subminer_log("warn", "binary", "Configured path '" .. opts.binary_path .. "' does not exist")
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()
if (state.overlay_running or state.texthooker_running) and state.binary_available then
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()
@@ -52,11 +71,22 @@ function M.create(ctx)
local function register_lifecycle_hooks()
mp.register_event("file-loaded", on_file_loaded)
mp.register_event("shutdown", on_shutdown)
mp.register_event("file-loaded", hover.clear_hover_overlay)
mp.register_event("end-file", hover.clear_hover_overlay)
mp.register_event("shutdown", hover.clear_hover_overlay)
mp.register_event("end-file", aniskip.clear_aniskip_state)
mp.register_event("shutdown", aniskip.clear_aniskip_state)
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()

View File

@@ -45,7 +45,14 @@ function M.create(ctx)
local function show_osd(message)
if opts.osd_messages then
mp.osd_message("SubMiner: " .. message, 3)
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

View File

@@ -1,9 +1,25 @@
local mp = require("mp")
local script_dir = mp.get_script_directory() or "."
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("bootstrap").init()
require("init").init()

View File

@@ -8,18 +8,36 @@ function M.create(ctx)
local ui = ctx.ui
local function register_script_messages()
mp.register_script_message("subminer-start", process.start_overlay_from_script_message)
mp.register_script_message("subminer-stop", process.stop_overlay)
mp.register_script_message("subminer-toggle", process.toggle_overlay)
mp.register_script_message("subminer-toggle-invisible", process.toggle_invisible_overlay)
mp.register_script_message("subminer-show-invisible", process.show_invisible_overlay)
mp.register_script_message("subminer-hide-invisible", process.hide_invisible_overlay)
mp.register_script_message("subminer-menu", ui.show_menu)
mp.register_script_message("subminer-options", process.open_options)
mp.register_script_message("subminer-restart", process.restart_overlay)
mp.register_script_message("subminer-status", process.check_status)
mp.register_script_message("subminer-aniskip-refresh", aniskip.fetch_aniskip_for_current_media)
mp.register_script_message("subminer-skip-intro", aniskip.skip_intro_now)
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)

View File

@@ -8,9 +8,8 @@ function M.load(options_lib, default_socket_path)
texthooker_port = 5174,
backend = "auto",
auto_start = true,
auto_start_overlay = false,
auto_start_visible_overlay = false,
auto_start_invisible_overlay = "platform-default",
auto_start_visible_overlay = true,
auto_start_pause_until_ready = true,
osd_messages = true,
log_level = "info",
aniskip_enabled = true,

View File

@@ -1,5 +1,9 @@
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
@@ -11,6 +15,42 @@ function M.create(ctx)
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
@@ -22,6 +62,54 @@ function M.create(ctx)
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 }
@@ -33,8 +121,7 @@ function M.create(ctx)
table.insert(args, log_level)
end
local needs_start_context = action == "start"
if needs_start_context then
if action == "start" then
local backend = resolve_backend(overrides.backend)
if backend and backend ~= "" then
table.insert(args, "--backend")
@@ -44,22 +131,37 @@ function M.create(ctx)
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(action)
local args = build_command_args(action)
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, " "))
local result = mp.command_native({
mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
return result and result.status == 0
}, 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(...)
@@ -91,48 +193,6 @@ function M.create(ctx)
return overrides
end
local function resolve_visible_overlay_startup()
local visible = options_helper.coerce_bool(opts.auto_start_visible_overlay, false)
if options_helper.coerce_bool(opts.auto_start_overlay, false) then
visible = true
end
return visible
end
local function resolve_invisible_overlay_startup()
local raw = opts.auto_start_invisible_overlay
if type(raw) == "boolean" then
return raw
end
local mode = type(raw) == "string" and raw:lower() or "platform-default"
if mode == "visible" or mode == "show" or mode == "yes" or mode == "true" or mode == "on" then
return true
end
if mode == "hidden" or mode == "hide" or mode == "no" or mode == "false" or mode == "off" then
return false
end
return not environment.is_linux()
end
local function apply_startup_overlay_preferences()
local should_show_visible = resolve_visible_overlay_startup()
local should_show_invisible = resolve_invisible_overlay_startup()
local visible_action = should_show_visible and "show-visible-overlay" or "hide-visible-overlay"
if not run_control_command(visible_action) then
subminer_log("warn", "process", "Failed to apply visible startup action: " .. visible_action)
end
local invisible_action = should_show_invisible and "show-invisible-overlay" or "hide-invisible-overlay"
if not run_control_command(invisible_action) then
subminer_log("warn", "process", "Failed to apply invisible startup action: " .. invisible_action)
end
state.invisible_overlay_visible = should_show_invisible
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)
@@ -148,6 +208,7 @@ function M.create(ctx)
callback()
return
end
if state.texthooker_running then
callback()
return
@@ -166,35 +227,69 @@ function M.create(ctx)
}, 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"))
subminer_log(
"warn",
"texthooker",
"Texthooker process exited unexpectedly: " .. (error or (result and result.stderr) or "unknown error")
)
end
end)
mp.add_timeout(0.35, callback)
-- 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
overrides = overrides or {}
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()
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({
@@ -205,21 +300,39 @@ function M.create(ctx)
capture_stderr = true,
}, function(success, result, error)
if not success or (result and result.status ~= 0) then
state.overlay_running = false
subminer_log("error", "process", "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error"))
show_osd("Overlay start failed")
end
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
mp.add_timeout(0.6, function()
apply_startup_overlay_preferences()
end)
end
if texthooker_enabled then
ensure_texthooker_running(launch_overlay)
ensure_texthooker_running(function()
launch_overlay_with_retry(1)
end)
else
launch_overlay()
launch_overlay_with_retry(1)
end
end
@@ -235,23 +348,21 @@ function M.create(ctx)
return
end
local args = build_command_args("stop")
subminer_log("info", "process", "Stopping overlay: " .. table.concat(args, " "))
local result = mp.command_native({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
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
if result.status == 0 then
subminer_log("info", "process", "Overlay stopped")
else
subminer_log("warn", "process", "Stop command returned non-zero status: " .. tostring(result.status))
end
disarm_auto_play_ready_gate()
show_osd("Stopped")
end
@@ -261,115 +372,31 @@ function M.create(ctx)
show_osd("Error: binary not found")
return
end
local args = build_command_args("toggle")
subminer_log("info", "process", "Toggling overlay: " .. table.concat(args, " "))
local result = mp.command_native({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
if result and result.status ~= 0 then
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
local function toggle_invisible_overlay()
if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found")
show_osd("Error: binary not found")
return
end
local args = build_command_args("toggle-invisible-overlay")
subminer_log("info", "process", "Toggling invisible overlay: " .. table.concat(args, " "))
local result = mp.command_native({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
if result and result.status ~= 0 then
subminer_log("warn", "process", "Invisible toggle command failed")
show_osd("Invisible toggle failed")
return
end
state.invisible_overlay_visible = not state.invisible_overlay_visible
show_osd("Invisible overlay: " .. (state.invisible_overlay_visible and "visible" or "hidden"))
end
local function show_invisible_overlay()
if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found")
show_osd("Error: binary not found")
return
end
local args = build_command_args("show-invisible-overlay")
subminer_log("info", "process", "Showing invisible overlay: " .. table.concat(args, " "))
local result = mp.command_native({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
if result and result.status ~= 0 then
subminer_log("warn", "process", "Show invisible command failed")
show_osd("Show invisible failed")
return
end
state.invisible_overlay_visible = true
show_osd("Invisible overlay: visible")
end
local function hide_invisible_overlay()
if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found")
show_osd("Error: binary not found")
return
end
local args = build_command_args("hide-invisible-overlay")
subminer_log("info", "process", "Hiding invisible overlay: " .. table.concat(args, " "))
local result = mp.command_native({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
if result and result.status ~= 0 then
subminer_log("warn", "process", "Hide invisible command failed")
show_osd("Hide invisible failed")
return
end
state.invisible_overlay_visible = false
show_osd("Invisible overlay: hidden")
end)
end
local function open_options()
if not state.binary_available then
if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found")
show_osd("Error: binary not found")
return
end
local args = build_command_args("settings")
subminer_log("info", "process", "Opening options: " .. table.concat(args, " "))
local result = mp.command_native({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
if result.status == 0 then
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()
@@ -382,17 +409,10 @@ function M.create(ctx)
subminer_log("info", "process", "Restarting overlay...")
show_osd("Restarting...")
local stop_args = build_command_args("stop")
mp.command_native({
name = "subprocess",
args = stop_args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
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")
@@ -408,41 +428,51 @@ function M.create(ctx)
}, 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"))
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 state.binary_available then
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,
run_control_command = run_control_command,
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,
apply_startup_overlay_preferences = apply_startup_overlay_preferences,
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,
toggle_invisible_overlay = toggle_invisible_overlay,
show_invisible_overlay = show_invisible_overlay,
hide_invisible_overlay = hide_invisible_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

View File

@@ -7,7 +7,7 @@ function M.new()
overlay_process = nil,
binary_available = false,
binary_path = nil,
invisible_overlay_visible = false,
detected_backend = nil,
hover_highlight = {
revision = -1,
payload = nil,
@@ -27,6 +27,8 @@ function M.new()
found = false,
prompt_shown = false,
},
auto_play_ready_gate_armed = false,
auto_play_ready_timeout = nil,
}
end

View File

@@ -4,16 +4,22 @@ function M.create(ctx)
local mp = ctx.mp
local input = ctx.input
local opts = ctx.opts
local state = ctx.state
local process = ctx.process
local aniskip = ctx.aniskip
local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd
local function show_menu()
if not state.binary_available then
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
@@ -21,20 +27,30 @@ function M.create(ctx)
"Start overlay",
"Stop overlay",
"Toggle overlay",
"Toggle invisible overlay",
"Open options",
"Restart overlay",
"Check status",
}
local actions = {
process.start_overlay,
process.stop_overlay,
process.toggle_overlay,
process.toggle_invisible_overlay,
process.open_options,
process.restart_overlay,
process.check_status,
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({
@@ -49,21 +65,34 @@ function M.create(ctx)
end
local function register_keybindings()
mp.add_key_binding("y-s", "subminer-start", process.start_overlay)
mp.add_key_binding("y-S", "subminer-stop", process.stop_overlay)
mp.add_key_binding("y-t", "subminer-toggle", process.toggle_overlay)
mp.add_key_binding("y-i", "subminer-toggle-invisible", process.toggle_invisible_overlay)
mp.add_key_binding("y-I", "subminer-show-invisible", process.show_invisible_overlay)
mp.add_key_binding("y-u", "subminer-hide-invisible", process.hide_invisible_overlay)
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", process.open_options)
mp.add_key_binding("y-r", "subminer-restart", process.restart_overlay)
mp.add_key_binding("y-c", "subminer-status", process.check_status)
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", aniskip.skip_intro_now)
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", aniskip.skip_intro_now)
mp.add_key_binding("y-k", "subminer-skip-intro-fallback", function()
aniskip.skip_intro_now()
end)
end
end

63
scripts/dev-watch.sh Executable file
View 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[@]}"

View File

@@ -33,7 +33,7 @@ interface CliOptions {
function parseCliArgs(argv: string[]): CliOptions {
const args = [...argv];
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 emitDiagnostics = false;
let mecabCommand: string | undefined;
@@ -394,7 +394,7 @@ function printUsage(): void {
--color-band-5 <#hex> Frequency band-5 color.
--color-known <#hex> Known-word color (default: #a6da95).
--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-dictionary <path> Optional MeCab dictionary directory (default: system default)
-h, --help Show usage.

View File

@@ -11,30 +11,36 @@ Description:
Generates two browser-friendly files next to the input file:
- <name>.mp4 (H.264 + AAC, prefers NVIDIA GPU if available)
- <name>.webm (AV1/VP9 + Opus, prefers NVIDIA GPU if available)
- <name>.gif (palette-optimised, 15 fps)
- <name>-poster.jpg (single frame for video poster fallback)
- <name>.webp (animated, only when --webp is provided)
Options:
-f, --force Overwrite existing output files
-w, --webp Generate animated WebP preview
Encoding profile:
- Crop: 1920x1080 at x=760 y=180
- Crop: 1920x1080 at x=760 y=200
- MP4: H.264 + AAC
- WebM: AV1/VP9 + Opus at 30 fps
USAGE
}
force=0
generate_webp=0
input=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
-h | --help)
usage
exit 0
;;
-f|--force)
-f | --force)
force=1
;;
-w | --webp)
generate_webp=1
;;
-*)
echo "Error: unknown option: $1" >&2
usage
@@ -73,7 +79,8 @@ base="${filename%.*}"
mp4_out="$dir/$base.mp4"
webm_out="$dir/$base.webm"
gif_out="$dir/$base.gif"
webp_out="$dir/$base.webp"
poster_out="$dir/$base-poster.jpg"
overwrite_flag="-n"
if [[ "$force" -eq 1 ]]; then
@@ -81,7 +88,11 @@ if [[ "$force" -eq 1 ]]; then
fi
if [[ "$force" -eq 0 ]]; then
for output in "$mp4_out" "$webm_out" "$gif_out"; do
outputs=("$mp4_out" "$webm_out" "$poster_out")
if [[ "$generate_webp" -eq 1 ]]; then
outputs+=("$webp_out")
fi
for output in "${outputs[@]}"; do
if [[ -e "$output" ]]; then
echo "Error: output exists: $output (use --force to overwrite)" >&2
exit 1
@@ -94,9 +105,8 @@ has_encoder() {
ffmpeg -hide_banner -encoders 2> /dev/null | grep -qE "[[:space:]]${encoder}[[:space:]]"
}
crop_vf="crop=1920:1080:760:180"
crop_vf="crop=1920:1080:760:205"
webm_vf="${crop_vf},fps=30"
gif_vf="${crop_vf},fps=15,scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3"
echo "Generating MP4: $mp4_out"
if has_encoder "h264_nvenc"; then
@@ -157,12 +167,32 @@ else
"$webm_out"
fi
echo "Generating GIF: $gif_out"
ffmpeg "$overwrite_flag" -i "$input" \
-vf "$gif_vf" \
"$gif_out"
if [[ "$generate_webp" -eq 1 ]]; then
if ! has_encoder "libwebp"; then
echo "Error: encoder not found: libwebp" >&2
exit 1
fi
echo "Generating animated WebP: $webp_out"
ffmpeg "$overwrite_flag" -i "$input" \
-vf "${crop_vf},fps=24,scale=960:-1:flags=lanczos" \
-c:v libwebp \
-q:v 80 \
-loop 0 \
-an \
"$webp_out"
fi
echo "Generating poster: $poster_out"
ffmpeg "$overwrite_flag" -ss 00:00:05 -i "$input" \
-vf "$crop_vf" \
-vframes 1 \
-q:v 2 \
"$poster_out"
echo "Done."
echo "MP4: $mp4_out"
echo "WebM: $webm_out"
echo "GIF: $gif_out"
if [[ "$generate_webp" -eq 1 ]]; then
echo "WebP: $webp_out"
fi
echo "Poster: $poster_out"

8
scripts/subminer-dev.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
exec bun run electron . "$@"

Some files were not shown because too many files have changed in this diff Show More