Compare commits

...

19 Commits

Author SHA1 Message Date
sudacode f044877c83 fix(macos): drop target after grace period on repeated tracking misses
- registerTrackingMiss was resetting grace state on every miss, so focus was never released; now starts timer on first miss and drops after grace elapses
- update two tests to assert focus is dropped (not preserved) once grace expires
- add IPC test for setIgnoreMouseEvents → onOverlayMouseInteractionChanged mapping
2026-05-16 19:04:16 -07:00
sudacode fe201a2d2f fix(macos): keep overlay interactive when mpv loses foreground
- Track overlay mouse interaction state via IPC setIgnoreMouseEvents hook
- Skip macOS hide/passthrough when overlayInteractionActive is set
- Focus overlay window so lookup keys reach it during interaction
- Record mpv duration events into AniList media state for threshold checks
2026-05-16 18:48:45 -07:00
sudacode 215e0f804b fix(anilist): pass fresh time-pos to post-watch threshold check
- Thread live mpv time-position through to AniList watched-seconds check
- Prevents missed progress updates when the cached value lags behind playback
2026-05-16 17:48:55 -07:00
sudacode a36e628512 fix(macos): release overlay when mpv loses focus 2026-05-16 17:41:58 -07:00
sudacode b6272b229e fix(character-dictionary): cache AniList media resolution to skip repeat
- Add anilist-resolution-cache.json to persist seriesKey→mediaId mappings
- Skip AniList search when a cached resolution or matching snapshot exists
- Expose readCachedSnapshots and readCachedMediaResolution from cache module
2026-05-16 16:19:22 -07:00
sudacode 3a7d650a70 fix(macos): hide overlay when mpv loses foreground and open stats inacti
- Add "active" Swift helper output when mpv is frontmost but geometry is temporarily unavailable, preserving overlay through transient tracker misses
- Use showInactive for overlay and stats window on macOS to avoid switching Spaces over fullscreen mpv
- Disable autoInstallOnAppQuit to prevent premature Squirrel install before user confirms restart
2026-05-16 15:36:44 -07:00
sudacode 89723e2ccb fix(updater): handle unsupported macOS app updates 2026-05-16 02:05:28 -07:00
sudacode d05e2bd8ec ci(prerelease): use committed release-notes, drop CI claude regen
CI runners don't have Claude Code installed, so the regen step in the
prerelease workflow died on 'claude CLI not found'. Mirror the stable
release model: generate prerelease-notes.md locally (claude -p), commit
it, and have CI just verify and reference the committed file.
2026-05-16 00:54:49 -07:00
sudacode 7484d3c102 ci(prerelease): keep Install Lua before test:fast for workflow assertion 2026-05-16 00:33:19 -07:00
sudacode f78a875ba3 ci(prerelease): install Lua before env suite 2026-05-16 00:23:39 -07:00
sudacode a025652542 chore(release): prepare v0.15.0-beta.1 2026-05-16 00:11:21 -07:00
sudacode 91a01b86a9 feat: add auto update support (#65) 2026-05-16 00:09:14 -07:00
sudacode 105713361e fix(macos): preserve overlay on transient tracker loss; fix subsync modal open (#66) 2026-05-15 20:46:00 -07:00
sudacode 4cb0dbfaad Remove vendor source-inspection tests from yomitan-settings (#67) 2026-05-15 03:06:05 -07:00
sudacode 801cdcafca fix(docs): update Plausible proxy hostname to worker.sudacode.com
- Change `PLAUSIBLE_PROXY_HOSTNAME` from `worker.subminer.moe` to `worker.sudacode.com`
- Update test assertion to match new hostname
2026-05-15 02:13:20 -07:00
sudacode 094bcce0dc feat: add auto update support 2026-05-15 01:47:56 -07:00
sudacode d1ec678d7a fix(docs): proxy Plausible tracking script 2026-05-15 01:26:26 -07:00
sudacode f0324cd93a docs: simplify Windows setup instructions and remove experimental warnin
- Remove "Windows support is experimental" warning across README and docs
- Replace `bun run subminer` references with SubMiner mpv shortcut as recommended Windows launch method
- Remove `subminer doctor` step from README quickstart
- Clarify launcher script is primarily for Linux/macOS
2026-05-13 00:30:55 -07:00
sudacode 1b2ee03678 docs: add v0.14.0 bundled plugin migration note to changelog 2026-05-13 00:11:18 -07:00
180 changed files with 9424 additions and 473 deletions
+24 -4
View File
@@ -47,6 +47,13 @@ jobs:
- name: Build (TypeScript check)
run: bun run typecheck
- name: Install Lua
run: |
sudo apt-get update
sudo apt-get install -y lua5.4
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
lua -v
- name: Test suite (source)
run: bun run test:fast
@@ -139,7 +146,10 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: appimage
path: release/*.AppImage
path: |
release/*.AppImage
release/*.yml
release/*.blockmap
if-no-files-found: error
build-macos:
@@ -216,6 +226,8 @@ jobs:
path: |
release/*.dmg
release/*.zip
release/*.yml
release/*.blockmap
if-no-files-found: error
build-windows:
@@ -267,6 +279,8 @@ jobs:
path: |
release/*.exe
release/*.zip
release/*.yml
release/*.blockmap
if-no-files-found: error
release:
@@ -339,7 +353,7 @@ jobs:
- name: Generate checksums
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz dist/launcher/subminer)
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/*.yml release/*.blockmap dist/launcher/subminer)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
@@ -355,8 +369,12 @@ jobs:
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Generate prerelease notes from pending fragments
run: bun run changelog:prerelease-notes --version "${{ steps.version.outputs.VERSION }}"
- name: Verify committed prerelease notes
run: |
if [ ! -s release/prerelease-notes.md ]; then
echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
exit 1
fi
- name: Publish Prerelease
env:
@@ -371,6 +389,8 @@ jobs:
release/*.exe
release/*.zip
release/*.tar.gz
release/*.yml
release/*.blockmap
release/SHA256SUMS.txt
dist/launcher/subminer
)
+11 -2
View File
@@ -137,7 +137,10 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: appimage
path: release/*.AppImage
path: |
release/*.AppImage
release/*.yml
release/*.blockmap
build-macos:
needs: [quality-gate]
@@ -213,6 +216,8 @@ jobs:
path: |
release/*.dmg
release/*.zip
release/*.yml
release/*.blockmap
build-windows:
needs: [quality-gate]
@@ -263,6 +268,8 @@ jobs:
path: |
release/*.exe
release/*.zip
release/*.yml
release/*.blockmap
if-no-files-found: error
release:
@@ -335,7 +342,7 @@ jobs:
- name: Generate checksums
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz dist/launcher/subminer)
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/*.yml release/*.blockmap dist/launcher/subminer)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
@@ -389,6 +396,8 @@ jobs:
release/*.exe
release/*.zip
release/*.tar.gz
release/*.yml
release/*.blockmap
release/SHA256SUMS.txt
dist/launcher/subminer
)
+1
View File
@@ -10,6 +10,7 @@ dist/
release/*
!release/
!release/release-notes.md
!release/prerelease-notes.md
build/yomitan/
coverage/
+3 -3
View File
@@ -20,9 +20,9 @@ MACOS_APP_DIR ?= $(HOME)/Applications
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
# If building from source, the AppImage will typically land in release/.
APPIMAGE_SRC := $(firstword $(wildcard release/SubMiner-*.AppImage))
MACOS_APP_SRC := $(firstword $(wildcard release/*.app release/*/*.app))
MACOS_ZIP_SRC := $(firstword $(wildcard release/SubMiner-*.zip))
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
ifeq ($(OS),Windows_NT)
+10 -22
View File
@@ -205,7 +205,7 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~
```
> [!NOTE]
> The `subminer` wrapper uses a [Bun](https://bun.sh) shebang. Make sure `bun` is on your `PATH`.
> The `subminer` wrapper uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory.
</details>
@@ -217,23 +217,22 @@ Download the latest DMG or ZIP from [GitHub Releases](https://github.com/ksyasud
Also download the `subminer` launcher (recommended):
```bash
sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer \
&& sudo chmod +x /usr/local/bin/subminer
mkdir -p ~/.local/bin
curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer
```
> [!NOTE]
> The `subminer` launcher uses a [Bun](https://bun.sh) shebang. Make sure `bun` is on your `PATH`.
> The `subminer` launcher uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory. Make sure `~/.local/bin` is on your PATH before installing there.
</details>
<details>
<summary><b>Windows</b></summary>
Download the latest installer or portable `.zip` from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Make sure `mpv` is on your `PATH`.
Download the latest installer (`.exe`) [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Make sure `mpv` is on your `PATH`.
**Windows support is experimental.** Core features such as mining, annotations, and dictionary lookups work, but some functionality may be missing or unstable. Bug reports welcome.
**Note:** On Windows the `subminer` launcher requires [`bun`](https://bun.sh) and must be invoked with `bun run subminer` instead of running the script directly. The recommended alternative is the **SubMiner mpv** shortcut created during first-run setup — double-click it, drag files onto it, or run `SubMiner.exe --launch-mpv` from a terminal. See the [Windows mpv Shortcut](https://docs.subminer.moe/usage#windows-mpv-shortcut) section for details.
**Note:** On Windows the recommended way to run playback is with the **SubMiner mpv** shortcut created during first-run setup. First-run setup can also optionally install Bun and a `subminer.cmd` command shim to your user PATH, so new terminals can run `subminer` without adding `SubMiner.exe` to PATH.
</details>
@@ -250,23 +249,12 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so
subminer app --setup # launch the first-run setup wizard
```
SubMiner creates a default config, starts in the system tray, and opens a setup popup that walks you through installing the mpv plugin and configuring Yomitan dictionaries. Follow the on-screen steps to complete setup.
Jellyfin setup is available from the tray or `subminer jellyfin`; once Jellyfin is enabled with a server URL, the tray can toggle Jellyfin Discovery for the current app session.
SubMiner creates a default config, starts in the system tray, and opens a setup popup that walks you through installing Yomitan dictionaries. The setup popup can also optionally install Bun and the `subminer` command-line launcher; those choices do not block setup completion.
> [!NOTE]
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
### 3. Verify Setup
```bash
subminer doctor # verify mpv, ffmpeg, config, and socket
```
> [!NOTE]
> On Windows, use `bun run subminer doctor` or run `SubMiner.exe` directly — first-run setup will guide you through dependency checks.
### 4. Mine
### 3. Mine
```bash
subminer video.mkv # play video with overlay
@@ -276,7 +264,7 @@ subminer stats -b # stats daemon in background
subminer stats -s # stop background stats daemon
```
On **Windows**, the `subminer` script must be run with `bun run subminer` (e.g. `bun run subminer video.mkv`). The recommended alternative is the **SubMiner mpv** shortcut (created during setup) or `SubMiner.exe --launch-mpv`. Drag a video file onto the shortcut to play it, or double-click it to open mpv with SubMiner's defaults.
On **Windows**, use the **SubMiner mpv** shortcut created during first-run setup — double-click it to open mpv, or drag a video file onto it. You can also run `SubMiner.exe --launch-mpv` from a terminal.
## Documentation
+12 -17
View File
@@ -10,6 +10,7 @@
"@xhayper/discord-rpc": "^1.3.3",
"axios": "^1.13.5",
"commander": "^14.0.3",
"electron-updater": "^6.8.3",
"hono": "^4.12.7",
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
@@ -333,6 +334,8 @@
"electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
"electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="],
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -487,6 +490,10 @@
"lodash": ["lodash@4.18.0", "", {}, "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
"lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
@@ -627,7 +634,7 @@
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
@@ -681,6 +688,8 @@
"tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
"tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
@@ -745,12 +754,12 @@
"@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
"@electron/rebuild/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@electron/universal/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
"@electron/windows-sign/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
@@ -765,14 +774,10 @@
"@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@npmcli/fs/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -787,8 +792,6 @@
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"global-agent/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -797,18 +800,10 @@
"minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"node-api-version/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"node-gyp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"simple-update-notifier/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
+4
View File
@@ -0,0 +1,4 @@
type: added
area: updater
- Added tray and `subminer -u` update checks for SubMiner releases, including app update prompts, launcher updates, Linux rofi theme updates, checksum verification, configurable update notifications, and an opt-in prerelease update channel for beta/RC testing.
@@ -0,0 +1,4 @@
type: fixed
area: character-dictionary
- Reused cached character-dictionary media matches so loading a title with an existing snapshot no longer sends another AniList search request.
@@ -0,0 +1,5 @@
type: added
area: setup
- Added optional first-run setup controls to install Bun and the `subminer` command-line launcher on Linux, macOS, and Windows.
- Added a Windows `subminer.cmd` user PATH shim so users can type `subminer` without adding `SubMiner.exe` to PATH.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anilist
- Used fresh mpv time-position and duration events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold.
+10
View File
@@ -0,0 +1,10 @@
type: fixed
area: overlay
- Hid the macOS visible overlay when mpv is no longer the foreground target so other apps and Spaces are not covered by SubMiner subtitles.
- Kept the macOS overlay layered above active mpv while stats mouse passthrough is enabled, and treated the frontmost mpv app as the focus signal.
- Opened the stats overlay inactive on macOS so it appears over fullscreen mpv instead of switching back to SubMiner's original desktop.
- Preserved the active mpv focus state through transient macOS helper misses so subtitles do not flicker while mpv remains foreground.
- Kept fullscreen macOS overlays stable when mpv remains frontmost but window geometry temporarily disappears from the macOS window APIs.
- Released the macOS overlay when the helper reports mpv is no longer foreground so other apps are no longer covered.
- Reduced macOS window-tracker background work by preferring the compiled helper and slowing polls while mpv is stably focused.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed macOS overlay tracking so transient mpv window misses no longer hide the overlay; minimizing mpv still hides it.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed macOS overlay passthrough so mpv controls remain clickable before hovering subtitle bars.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed subtitle sync modal opens so macOS no longer flashes and hides the first modal attempt or leaves stale modal state after syncing.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: updater
- Made Linux `subminer -u` perform release updates from the launcher, independent of any running tray app instance, while reporting `up to date` without downloading assets when the latest release is not newer.
- Limited support asset updates to the Linux rofi theme.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Fixed Linux first-run launcher installs by building the packaged launcher with a valid Bun shebang.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Stopped Linux tray update checks from invoking the native Electron updater, using GitHub release metadata/assets instead so checks do not crash the tray app.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: setup
- First-run setup now recognizes installed macOS launchers in Homebrew or user PATH dirs, while manual setup installs avoid Homebrew-owned directories.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Fixed tray update checks for builds that cannot install native app updates, showing a manual install message instead of a restart prompt that cannot apply the update.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Bring macOS update dialogs to the front when `subminer --update` is run from the launcher.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: build
- Fixed one-shot `make clean build install` flows so install picks up the AppImage built earlier in the same make invocation.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updates
- Kept signed macOS app updates on the native updater path while preventing eager Squirrel install checks before the user confirms restart.
@@ -0,0 +1,4 @@
type: internal
area: tests
- Removed stale Yomitan vendor source-inspection assertions for changes that were not shipped.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Fixed `subminer app --setup` so it opens the setup flow when SubMiner is already running in the background.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: setup
- Quit standalone setup app launches after first-run setup finishes, returning the terminal instead of leaving the app process open.
+10
View File
@@ -0,0 +1,10 @@
type: fixed
area: tray
- Kept the tray app running when closing tray-launched Yomitan settings.
- Kept tray-launched Yomitan settings loading from blocking other tray actions.
- Replaced the default native Yomitan settings menu with a close-only menu so closing settings does not quit the tray app.
- Added an in-page close button for Yomitan settings on Hyprland, where native window controls are not available.
- Disabled Yomitan's embedded popup preview in the tray-launched settings window to avoid renderer hangs during normal sidebar navigation.
- Serialized copied Yomitan extension refreshes so startup cannot race itself and leave extension loading in an error state.
- Fixed tray-launched session help focus handling so the modal can close without mpv running.
+12
View File
@@ -155,6 +155,18 @@
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ==========================================
// Updates
// Automatic update check behavior.
// Manual checks from the tray or launcher are always allowed.
// ==========================================
"updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
+16
View File
@@ -1,4 +1,11 @@
const DOCS_HOSTNAME = 'https://docs.subminer.moe';
const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com';
const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js';
const PLAUSIBLE_ENDPOINT = `${PLAUSIBLE_PROXY_HOSTNAME}/api/event`;
const PLAUSIBLE_INIT_SCRIPT = [
'window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};',
`plausible.init({ endpoint: '${PLAUSIBLE_ENDPOINT}' });`,
].join('\n');
function pageToCanonicalHref(page: string): string | null {
if (page === '404.md') return null;
@@ -15,6 +22,15 @@ export default {
description:
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
head: [
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
[
'script',
{
async: '',
src: `${PLAUSIBLE_PROXY_HOSTNAME}${PLAUSIBLE_SITE_SCRIPT_PATH}`,
},
],
['script', {}, PLAUSIBLE_INIT_SCRIPT],
['link', { rel: 'icon', href: '/favicon.ico', sizes: 'any' }],
[
'link',
-28
View File
@@ -7,32 +7,7 @@ import './mermaid-modal.css';
import TuiLayout from './TuiLayout.vue';
let mermaidLoader: Promise<any> | null = null;
let plausibleTrackerInitialized = false;
const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
const PLAUSIBLE_DOMAIN = 'subminer.moe';
const PLAUSIBLE_ENABLED_HOSTNAMES = new Set(['docs.subminer.moe']);
const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/capture';
async function initPlausibleTracker() {
if (typeof window === 'undefined' || plausibleTrackerInitialized) {
return;
}
if (!PLAUSIBLE_ENABLED_HOSTNAMES.has(window.location.hostname)) {
return;
}
const { init } = await import('@plausible-analytics/tracker');
init({
domain: PLAUSIBLE_DOMAIN,
endpoint: PLAUSIBLE_ENDPOINT,
outboundLinks: true,
fileDownloads: true,
formSubmissions: true,
captureOnLocalhost: false,
});
plausibleTrackerInitialized = true;
}
function closeMermaidModal() {
if (typeof document === 'undefined') {
@@ -222,9 +197,6 @@ export default {
};
onMounted(() => {
initPlausibleTracker().catch((error) => {
console.error('Failed to initialize Plausible tracker:', error);
});
render();
});
watch(() => route.path, render);
-3
View File
@@ -8,7 +8,6 @@
"@catppuccin/vitepress": "^0.1.2",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/manrope": "^5.2.8",
"@plausible-analytics/tracker": "^0.4.4",
"mermaid": "^11.12.3",
},
"devDependencies": {
@@ -143,8 +142,6 @@
"@mermaid-js/parser": ["@mermaid-js/parser@1.0.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw=="],
"@plausible-analytics/tracker": ["@plausible-analytics/tracker@0.4.4", "", {}, "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
+2
View File
@@ -2,6 +2,8 @@
## v0.14.0 (2026-05-12)
SubMiner no longer requires a globally-installed mpv plugin. The bundled plugin is injected at runtime only when SubMiner launches mpv — through the `subminer` launcher, the app's managed launch, or the packaged Windows SubMiner mpv shortcut. When you open mpv on its own, SubMiner is not involved and the plugin is never loaded. If you have a legacy global SubMiner plugin under mpv's `scripts` directory, first-run setup detects it and prompts you to remove it before playback starts.
**Added**
- **Character Dictionary:** Added AniList-based character dictionary selection for resolving title mismatches — open it in-app with the new `Ctrl+Alt+A` shortcut or from the CLI with `subminer dictionary --candidates` / `--select`. Series-scoped overrides replace stale entries in the merged dictionary.
+2 -2
View File
@@ -35,7 +35,7 @@ Character dictionary sync is disabled by default. To turn it on:
```
::: tip
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot.
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup.
:::
::: warning
@@ -139,7 +139,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine
5. **importing** — Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
6. **ready** — Dictionary is live. Character names will match on the next subtitle line.
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`:
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search.
```jsonc
{
+81 -58
View File
@@ -129,6 +129,7 @@ The configuration file includes several main sections:
- [**Stats Dashboard**](#stats-dashboard) - Local dashboard and overlay for immersion progress
- [**MPV Launcher**](#mpv-launcher) - mpv executable path and window launch mode
- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
- [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing
## Core Settings
@@ -148,6 +149,28 @@ Control the minimum log level for runtime output:
| ------- | ---------------------------------------- | --------------------------------------------------------- |
| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) |
### Updates
Configure automatic update checks and update notifications:
```json
{
"updates": {
"enabled": true,
"checkIntervalHours": 24,
"notificationType": "system",
"channel": "stable"
}
}
```
| Option | Values | Description |
| -------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
| `notificationType` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. |
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
### Auto-Start Overlay
Control whether the overlay automatically becomes visible when it connects to mpv:
@@ -218,10 +241,10 @@ See `config.example.jsonc` for detailed configuration options.
}
```
| Option | Values | Description |
| --------- | ------------------------- | --------------------------------------------- |
| Option | Values | Description |
| --------- | ------------------------- | --------------------------------------------------- |
| `enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) |
| `port` | number | WebSocket server port (default: 6677) |
| `port` | number | WebSocket server port (default: 6677) |
### Annotation WebSocket
@@ -258,10 +281,10 @@ See `config.example.jsonc` for detailed configuration options.
}
```
| Option | Values | Description |
| ----------------- | --------------- | ---------------------------------------------------------------------- |
| Option | Values | Description |
| ----------------- | --------------- | ----------------------------------------------------------------------- |
| `launchAtStartup` | `true`, `false` | Start texthooker automatically with SubMiner startup (default: `false`) |
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) |
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) |
## Subtitle Display
@@ -859,59 +882,59 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `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). |
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `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). |
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode`; manual clipboard updates always replace generated sentence audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `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`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `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`) |
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
+38 -11
View File
@@ -155,15 +155,29 @@ chmod +x ~/.local/bin/SubMiner.AppImage
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
chmod +x ~/.local/bin/subminer
# Download launcher support assets used for bundled runtime plugin injection
# Download the optional Linux rofi theme
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 ~/.local/share/SubMiner/plugin/subminer
cp -R /tmp/plugin/subminer/. ~/.local/share/SubMiner/plugin/subminer/
mkdir -p ~/.local/share/SubMiner/themes
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
```
The `subminer` launcher is the recommended way to use SubMiner on Linux. It ensures mpv is launched with the correct IPC socket, SubMiner defaults, and the bundled runtime plugin so you don't need to configure `mpv.conf` or install a global mpv plugin.
The first-run setup window can also install Bun and the packaged `subminer` launcher into an existing writable PATH directory. Both steps are optional.
To check for updates later:
```bash
subminer -u
# or
subminer --update
```
SubMiner verifies launcher and Linux rofi theme downloads against `SHA256SUMS.txt`. If the launcher is installed in a protected path such as `/usr/local/bin/subminer`, SubMiner does not elevate itself; it shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead.
On Linux, `subminer -u` performs this update from the launcher process, so it does not need to start or IPC into the tray app.
### From Source
```bash
@@ -204,6 +218,8 @@ brew install mecab mecab-ipadic
The `subminer` launcher is the recommended way to use SubMiner on macOS. It launches mpv with the correct IPC socket and SubMiner defaults so you don't need to set up an `mpv.conf` profile manually.
First-run setup can install Bun and the packaged launcher into a writable directory that is already on PATH. It does not edit shell profiles.
Download it from the same [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) page:
```bash
@@ -218,6 +234,16 @@ sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/sub
sudo chmod +x /usr/local/bin/subminer
```
To check for updates later:
```bash
subminer -u
# or
subminer --update
```
SubMiner verifies launcher downloads against `SHA256SUMS.txt`. If `/usr/local/bin/subminer` is protected, SubMiner shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead of elevating itself.
::: warning Bun required for the launcher
The `subminer` launcher uses a Bun shebang (`#!/usr/bin/env bun`), so [Bun](https://bun.sh) must be installed and available on `PATH`. Install Bun if you haven't already: `curl -fsSL https://bun.sh/install | bash`.
:::
@@ -245,7 +271,7 @@ Build and install the launcher alongside the app:
make install-macos
```
This builds the `subminer` launcher into `dist/launcher/subminer` and installs it to `~/.local/bin/subminer` along with the app bundle and rofi theme. To install to `/usr/local/bin` instead (already on the default macOS `PATH`):
This builds the `subminer` launcher into `dist/launcher/subminer` and installs it to `~/.local/bin/subminer` along with the app bundle. To install to `/usr/local/bin` instead (already on the default macOS `PATH`):
```bash
sudo make install-macos PREFIX=/usr/local
@@ -301,9 +327,6 @@ binary_path=/Applications/SubMiner.app/Contents/MacOS/subminer
## Windows
> [!WARNING]
> **Windows support is experimental.** Core features — mining, annotations, and dictionary lookups — work, but some functionality may be missing or unstable. Bug reports welcome.
### Prerequisites
1. Install [`mpv`](https://mpv.io/installation/) and ensure `mpv.exe` is on `PATH`. If mpv is installed elsewhere, you can set `mpv.executablePath` in `config.jsonc` or use the first-run setup field to point at the executable.
@@ -323,7 +346,8 @@ Download the latest Windows installer from [GitHub Releases](https://github.com/
1. **Run `SubMiner.exe` once** — first-run setup creates `%APPDATA%\SubMiner\config.jsonc` and opens Yomitan settings for dictionary import. The global mpv plugin install is optional for compatibility; the SubMiner mpv shortcut injects the bundled runtime plugin.
2. **Create the SubMiner mpv shortcut** _(recommended)_ — the setup popup offers to create a `SubMiner mpv` Start Menu and/or Desktop shortcut. This is the recommended way to launch playback on Windows.
3. **Play a video** — double-click the shortcut, drag a video file onto it, or run from a terminal:
3. **Optional: install the command-line launcher** — first-run setup can install Bun with winget/Scoop/the official installer and add `%LOCALAPPDATA%\SubMiner\bin\subminer.cmd` to your user PATH. Open a new terminal and type `subminer`.
4. **Play a video** — double-click the shortcut, drag a video file onto it, or run from a terminal:
```powershell
& "C:\Program Files\SubMiner\SubMiner.exe" --launch-mpv "C:\Videos\episode 01.mkv"
@@ -333,7 +357,9 @@ The shortcut and `--launch-mpv` pass SubMiner's default IPC socket, subtitle arg
### Windows-Specific Notes
- The `subminer` launcher script requires [Bun](https://bun.sh) and must be invoked with `bun run subminer` on Windows since the shebang is not supported. The **SubMiner mpv** shortcut or `SubMiner.exe --launch-mpv` is the simpler alternative.
- The **SubMiner mpv** shortcut created during first-run setup is the recommended way to launch playback on Windows.
- The optional command-line launcher installs a `subminer.cmd` shim, but users type `subminer`; Windows resolves `.cmd` through `PATHEXT`.
- First-run setup adds only `%LOCALAPPDATA%\SubMiner\bin` to the HKCU user PATH. It does not add `SubMiner.exe` or the app install directory to PATH.
- First-run plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is in a non-standard location.
- Plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket` — do not keep `/tmp/subminer-socket` on Windows.
- Config is stored at `%APPDATA%\SubMiner\config.jsonc`.
@@ -397,8 +423,9 @@ The setup popup walks you through:
- **mpv plugin**: install the bundled Lua plugin for in-player keybindings
- **Yomitan dictionaries**: import at least one dictionary so lookups work
- **Windows shortcut** _(Windows only)_: optionally create a `SubMiner mpv` Start Menu/Desktop shortcut
- **Command line launcher**: optionally install Bun and the `subminer` launcher to your command-line PATH
The `Finish setup` button stays disabled until the plugin is installed and at least one dictionary is imported. Once you finish, SubMiner will not show the popup again.
The `Finish setup` button follows the normal config/Yomitan readiness checks. Bun and the command-line launcher are optional and never block setup completion.
> [!TIP]
> You can re-open the setup popup at any time with `subminer app --setup` or `SubMiner.AppImage --setup`.
@@ -442,7 +469,7 @@ subminer doctor
This checks for the app binary, mpv, ffmpeg, config file, and socket path. Fix any failures before continuing.
> [!NOTE]
> On Windows, use `bun run subminer doctor` or run `SubMiner.exe` directly. Replace `SubMiner.AppImage` with `SubMiner.exe` in the direct app commands below.
> On Windows, run `SubMiner.exe` directly. Replace `SubMiner.AppImage` with `SubMiner.exe` in the direct app commands below.
## Optional Extras
+5 -1
View File
@@ -3,7 +3,7 @@
The `subminer` launcher is an all-in-one script that handles video selection, mpv startup, and overlay management. It is the recommended way to use SubMiner on Linux and macOS because it guarantees mpv is launched with the correct IPC socket and SubMiner defaults. It's a Bun script distributed as a release asset alongside the AppImage and DMG.
::: tip Windows users
On Windows the `subminer` script cannot run directly via shebang — use `bun run subminer` instead (e.g. `bun run subminer video.mkv`). The recommended alternative is the **SubMiner mpv** shortcut created during first-run setup, or `SubMiner.exe --launch-mpv`. See [Windows mpv Shortcut](/usage#windows-mpv-shortcut) for details.
On Windows, the recommended way to launch playback is the **SubMiner mpv** shortcut created during first-run setup — double-click it, drag a file onto it, or run `SubMiner.exe --launch-mpv` from a terminal. See [Windows mpv Shortcut](/usage#windows-mpv-shortcut) for details.
:::
## Video Picker
@@ -63,6 +63,7 @@ SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
subminer video.mkv # play a specific file (default plugin config auto-starts visible overlay)
subminer https://youtu.be/... # YouTube playback (requires yt-dlp)
subminer --backend x11 video.mkv # Force x11 backend for a specific file
subminer -u # check for SubMiner updates
subminer stats # open immersion dashboard
subminer stats -b # start background stats daemon
```
@@ -98,6 +99,7 @@ Use `subminer <subcommand> -h` for command-specific help.
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
| `--setup` | Open first-run setup popup manually |
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
| `--start` | Explicitly start overlay after mpv launches |
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
| `-T, --no-texthooker` | Disable texthooker server |
@@ -107,6 +109,8 @@ Use `subminer <subcommand> -h` for command-specific help.
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, and rofi theme even when SubMiner is already running in the tray.
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
-1
View File
@@ -14,7 +14,6 @@
"@catppuccin/vitepress": "^0.1.2",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/manrope": "^5.2.8",
"@plausible-analytics/tracker": "^0.4.4",
"mermaid": "^11.12.3"
},
"devDependencies": {
+25 -16
View File
@@ -3,25 +3,34 @@ import { readFileSync } from 'node:fs';
const docsConfigPath = new URL('./.vitepress/config.ts', import.meta.url);
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
const docsPackagePath = new URL('./package.json', import.meta.url);
const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
const docsPackageContents = readFileSync(docsPackagePath, 'utf8');
test('docs site keeps docs hostname while sending plausible events to subminer.moe via worker.subminer.moe capture endpoint', () => {
test('docs site loads the docs.subminer.moe Plausible script through the analytics proxy', () => {
expect(docsConfigContents).toContain("const DOCS_HOSTNAME = 'https://docs.subminer.moe'");
expect(docsConfigContents).toContain('hostname: DOCS_HOSTNAME');
expect(docsThemeContents).toContain("const PLAUSIBLE_DOMAIN = 'subminer.moe'");
expect(docsThemeContents).toContain('const PLAUSIBLE_ENABLED_HOSTNAMES = new Set([');
expect(docsThemeContents).toContain("'docs.subminer.moe'");
expect(docsThemeContents).toContain(
"const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/capture'",
expect(docsConfigContents).toContain(
"const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com'",
);
expect(docsThemeContents).toContain('@plausible-analytics/tracker');
expect(docsThemeContents).toContain('const { init } = await import');
expect(docsThemeContents).toContain('!PLAUSIBLE_ENABLED_HOSTNAMES.has(window.location.hostname)');
expect(docsThemeContents).toContain('domain: PLAUSIBLE_DOMAIN');
expect(docsThemeContents).toContain('endpoint: PLAUSIBLE_ENDPOINT');
expect(docsThemeContents).toContain('outboundLinks: true');
expect(docsThemeContents).toContain('fileDownloads: true');
expect(docsThemeContents).toContain('formSubmissions: true');
expect(docsThemeContents).toContain('captureOnLocalhost: false');
expect(docsConfigContents).toContain(
"const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js'",
);
expect(docsConfigContents).toContain(
'const PLAUSIBLE_ENDPOINT = `${PLAUSIBLE_PROXY_HOSTNAME}/api/event`',
);
expect(docsConfigContents).toContain('hostname: DOCS_HOSTNAME');
expect(docsConfigContents).toContain("rel: 'preconnect'");
expect(docsConfigContents).toContain('href: PLAUSIBLE_PROXY_HOSTNAME');
expect(docsConfigContents).toContain("async: ''");
expect(docsConfigContents).toContain(
'src: `${PLAUSIBLE_PROXY_HOSTNAME}${PLAUSIBLE_SITE_SCRIPT_PATH}`',
);
expect(docsConfigContents).toContain('plausible.init({ endpoint:');
expect(docsConfigContents).toContain('PLAUSIBLE_ENDPOINT');
expect(docsConfigContents).not.toContain("'data-domain'");
expect(docsConfigContents).not.toContain("'data-api'");
expect(docsThemeContents).not.toContain('@plausible-analytics/tracker');
expect(docsThemeContents).not.toContain('initPlausibleTracker');
expect(docsPackageContents).not.toContain('@plausible-analytics/tracker');
});
+12
View File
@@ -155,6 +155,18 @@
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ==========================================
// Updates
// Automatic update check behavior.
// Manual checks from the tray or launcher are always allowed.
// ==========================================
"updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
+24
View File
@@ -100,6 +100,30 @@ SubMiner retries the connection automatically with increasing delays (200 ms, 50
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless — SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
## Updates
**"Update check failed"**
Manual update checks show this when GitHub Releases or updater metadata cannot be reached. Check your network connection, then try again from the tray menu or:
```bash
subminer -u
```
Automatic checks log failures quietly so playback is not interrupted.
**"SubMiner is up to date" but a prerelease exists**
SubMiner defaults to stable GitHub releases. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases.
**Launcher update shows a sudo command**
The detected launcher is installed in a protected path such as `/usr/local/bin/subminer` or `/usr/bin/subminer`. SubMiner does not elevate itself. Run the command shown in the popup to replace the launcher after checksum verification.
**OSD update notification did not appear**
`updates.notificationType: "osd"` uses the existing mpv/overlay notification path. If mpv is disconnected, SubMiner logs the update and does not force-start the overlay. Use `"system"` or `"both"` if you want OS notifications outside playback.
## AnkiConnect
**"AnkiConnect: unable to connect"**
+2 -2
View File
@@ -37,7 +37,7 @@ Field names must match your Anki note type exactly (case-sensitive). See [Anki I
There are several ways to use SubMiner:
> [!TIP]
> **New users: start with the `subminer` wrapper script** (or the **SubMiner mpv** shortcut on Windows). It handles mpv launch, IPC socket setup, and overlay lifecycle automatically so you don't need to configure anything in `mpv.conf`.
> **New users on Linux/macOS: start with the `subminer` wrapper script.** On Windows, use the **SubMiner mpv** shortcut created during first-run setup. Both handle mpv launch, IPC socket setup, and overlay lifecycle automatically so you don't need to configure anything in `mpv.conf`.
| Approach | Use when | How |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
@@ -45,7 +45,7 @@ There are several ways to use SubMiner:
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults directly. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
| **MPV plugin** (all platforms) | You launch mpv yourself or from another tool (file manager, Jellyfin, etc.). Requires `--input-ipc-server=/tmp/subminer-socket` in your mpv config. | Use `y` chord keybindings inside mpv |
You can use both — the plugin provides in-player controls, while the `subminer` script (or the Windows shortcut) is convenient for direct playback. The `subminer` script runs directly via shebang on Linux and macOS (no `bun run` needed); on Windows it must be invoked with `bun run subminer` since the shebang is not supported.
You can use both — the plugin provides in-player controls, while the `subminer` script (Linux/macOS) or the SubMiner mpv shortcut (Windows) is convenient for direct playback.
## Live Config Reload
+12 -1
View File
@@ -31,6 +31,9 @@
`bun run test:fast`
`bun run test:env`
`bun run build`
When validating auto-update metadata, also run the relevant platform package
build and confirm `release/` contains the generated updater metadata
(`*.yml`) and blockmaps (`*.blockmap`).
8. If `docs-site/` changed, also run:
`bun run docs:test`
`bun run docs:build`
@@ -50,7 +53,12 @@
`bun run test:fast`
`bun run test:env`
`bun run build`
5. Commit the prerelease prep. Do not run `bun run changelog:build`.
When validating packaged updater output, confirm the platform build writes
`*.yml` and `*.blockmap` files under `release/`.
5. Commit the prerelease prep (package.json version bump + the generated
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
committed file — so review it before committing. Do not run
`bun run changelog:build`.
6. Tag the commit: `git tag v<version>`.
7. Push commit + tag.
@@ -72,3 +80,6 @@ Notes:
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
+33
View File
@@ -8,6 +8,7 @@ import { runDictionaryCommand } from './dictionary-command.js';
import { runDoctorCommand } from './doctor-command.js';
import { runMpvPreAppCommand } from './mpv-command.js';
import { runStatsCommand } from './stats-command.js';
import { runUpdateCommand } from './update-command.js';
class ExitSignal extends Error {
code: number;
@@ -240,6 +241,38 @@ test('dictionary command returns after app handoff starts', () => {
assert.equal(handled, true);
});
test('update command runs direct Linux release update without launching Electron', async () => {
const context = createContext();
context.args.update = true;
const calls: string[] = [];
const handled = await runUpdateCommand(context, {
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
runDirectReleaseUpdate: async (request) => {
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
return {
appImage: { status: 'not-found' },
launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }],
};
},
readMainConfig: () => null,
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, [
'direct:/tmp/subminer.app:/tmp/subminer:stable',
'info:AppImage update: not-found',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
]);
});
test('stats command launches attached app command with response path', async () => {
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
const handled = await runStatsCommand(harness.context, harness.commandDeps);
+140
View File
@@ -0,0 +1,140 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runUpdateCommand } from './update-command';
import type { LauncherCommandContext } from './context';
function makeContext(overrides: Partial<LauncherCommandContext> = {}): LauncherCommandContext {
return {
args: {
update: true,
logLevel: 'warn',
} as LauncherCommandContext['args'],
scriptPath: '/home/kyle/.local/bin/subminer',
scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {} as LauncherCommandContext['pluginRuntimeConfig'],
appPath: '/home/kyle/.local/bin/SubMiner.AppImage',
launcherJellyfinConfig: {} as LauncherCommandContext['launcherJellyfinConfig'],
processAdapter: {
platform: () => 'linux',
} as LauncherCommandContext['processAdapter'],
...overrides,
};
}
test('runUpdateCommand updates directly on Linux without launching Electron', async () => {
const calls: string[] = [];
const handled = await runUpdateCommand(makeContext(), {
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
runDirectReleaseUpdate: async (request) => {
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
return {
appImage: { status: 'updated' },
launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }],
};
},
readMainConfig: () => ({ updates: { channel: 'prerelease' } }),
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, [
'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease',
'info:AppImage update: updated',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
]);
});
test('runUpdateCommand skips Linux asset replacement when release is not newer', async () => {
const calls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string) => {
calls.push(`fetch:${url}`);
if (!url.endsWith('/releases')) {
throw new Error(`unexpected asset fetch: ${url}`);
}
return {
ok: true,
status: 200,
json: async () => [
{
tag_name: 'v0.14.0',
prerelease: false,
draft: false,
assets: [
{
name: 'SHA256SUMS.txt',
browser_download_url: 'https://example.test/SHA256SUMS.txt',
},
{
name: 'SubMiner.AppImage',
browser_download_url: 'https://example.test/SubMiner.AppImage',
},
],
},
],
text: async () => '',
arrayBuffer: async () => new ArrayBuffer(0),
};
}) as typeof fetch;
try {
const handled = await runUpdateCommand(makeContext(), {
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
readMainConfig: () => null,
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, [
'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases',
'info:AppImage update: up to date',
'info:Launcher update: up to date',
'info:Rofi theme update: up to date',
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('runUpdateCommand keeps app-mediated update path on non-Linux', async () => {
const calls: string[] = [];
const handled = await runUpdateCommand(
makeContext({
processAdapter: {
platform: () => 'darwin',
} as LauncherCommandContext['processAdapter'],
appPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
}),
{
createTempDir: () => '/tmp/subminer-update-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandCaptureOutput: (appPath, appArgs) => {
calls.push(`app:${appPath}:${appArgs.join(' ')}`);
return { status: 0, stdout: '', stderr: '' };
},
waitForUpdateResponse: async () => ({ ok: true, status: 'up-to-date' }),
removeDir: (targetPath) => {
calls.push(`remove:${targetPath}`);
},
},
);
assert.equal(handled, true);
assert.deepEqual(calls, [
'app:/Applications/SubMiner.app/Contents/MacOS/SubMiner:--update --update-launcher-path /home/kyle/.local/bin/subminer --update-response-path /tmp/subminer-update-test/response.json',
'remove:/tmp/subminer-update-test',
]);
});
+220
View File
@@ -0,0 +1,220 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import packageJson from '../../package.json';
import { runAppCommandCaptureOutput } from '../mpv.js';
import { log as launcherLog } from '../log.js';
import { nowMs } from '../time.js';
import { sleep } from '../util.js';
import type { LauncherCommandContext } from './context.js';
import { readLauncherMainConfigObject } from '../config/shared-config-reader.js';
import type { UpdateChannel } from '../../src/types/config.js';
import { updateAppImageFromRelease } from '../../src/main/runtime/update/appimage-updater.js';
import { updateLauncherFromRelease } from '../../src/main/runtime/update/launcher-updater.js';
import {
compareSemverLike,
fetchLatestStableRelease,
fetchReleaseAssetBuffer,
fetchReleaseAssetText,
findReleaseAsset,
parseReleaseVersion,
parseSha256Sums,
type FetchLike,
} from '../../src/main/runtime/update/release-assets.js';
import { updateSupportAssetsFromRelease } from '../../src/main/runtime/update/support-assets.js';
type UpdateCommandResponse = {
ok: boolean;
status?: string;
version?: string;
error?: string;
};
type DirectReleaseUpdateRequest = {
appPath: string;
launcherPath: string;
channel: UpdateChannel;
};
type DirectReleaseUpdateResult = {
appImage: { status: string; command?: string; message?: string };
launcher: { status: string; command?: string; message?: string };
supportAssets: Array<{ status: string; command?: string; message?: string }>;
};
type UpdateCommandDeps = {
createTempDir: (prefix: string) => string;
joinPath: (...parts: string[]) => string;
runAppCommandCaptureOutput: (
appPath: string,
appArgs: string[],
) => { status: number; stdout: string; stderr: string; error?: Error };
waitForUpdateResponse: (responsePath: string) => Promise<UpdateCommandResponse>;
removeDir: (targetPath: string) => void;
runDirectReleaseUpdate: (
request: DirectReleaseUpdateRequest,
) => Promise<DirectReleaseUpdateResult>;
readMainConfig: () => Record<string, unknown> | null;
log: typeof launcherLog;
};
const UPDATE_RESPONSE_TIMEOUT_MS = 10 * 60 * 1000;
const CURRENT_VERSION = packageJson.version;
function getFetchForLauncherUpdater(): FetchLike {
return globalThis.fetch.bind(globalThis) as FetchLike;
}
async function runDirectReleaseUpdate(
request: DirectReleaseUpdateRequest,
): Promise<DirectReleaseUpdateResult> {
const fetchForUpdater = getFetchForLauncherUpdater();
const release = await fetchLatestStableRelease({
fetch: fetchForUpdater,
channel: request.channel,
});
const releaseVersion = parseReleaseVersion(release);
if (releaseVersion && compareSemverLike(releaseVersion, CURRENT_VERSION) <= 0) {
return {
appImage: { status: 'up-to-date' },
launcher: { status: 'up-to-date' },
supportAssets: [{ status: 'up-to-date' }],
};
}
const sumsAsset = release ? findReleaseAsset(release, 'SHA256SUMS.txt') : null;
const sha256Sums =
sumsAsset && release
? parseSha256Sums(
await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url),
)
: new Map<string, string>();
const downloadAsset = (url: string) => fetchReleaseAssetBuffer(fetchForUpdater, url);
const [appImage, launcher, supportAssets] = await Promise.all([
updateAppImageFromRelease({
release,
sha256Sums,
appImagePath: request.appPath,
downloadAsset,
}),
updateLauncherFromRelease({
release,
sha256Sums,
launcherPath: request.launcherPath,
downloadAsset,
}),
updateSupportAssetsFromRelease({
release,
sha256Sums,
downloadAsset,
}),
]);
return { appImage, launcher, supportAssets };
}
function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel {
const updates =
root?.updates && typeof root.updates === 'object' && !Array.isArray(root.updates)
? (root.updates as Record<string, unknown>)
: null;
return updates?.channel === 'prerelease' ? 'prerelease' : 'stable';
}
function logUpdateResult(
label: string,
result: { status: string; command?: string; message?: string },
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
deps: Pick<UpdateCommandDeps, 'log'>,
): void {
const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status;
deps.log('info', configuredLogLevel, `${label} update: ${displayStatus}`);
if (result.command) {
deps.log(
'warn',
configuredLogLevel,
`${label} update requires manual command: ${result.command}`,
);
} else if (result.message) {
deps.log('warn', configuredLogLevel, `${label} update note: ${result.message}`);
}
}
const defaultDeps: UpdateCommandDeps = {
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
joinPath: (...parts) => path.join(...parts),
runAppCommandCaptureOutput: (appPath, appArgs) => runAppCommandCaptureOutput(appPath, appArgs),
waitForUpdateResponse: async (responsePath) => {
const deadline = nowMs() + UPDATE_RESPONSE_TIMEOUT_MS;
while (nowMs() < deadline) {
try {
if (fs.existsSync(responsePath)) {
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as UpdateCommandResponse;
}
} catch {
// retry until timeout
}
await sleep(100);
}
return { ok: false, error: 'Timed out waiting for SubMiner update response.' };
},
removeDir: (targetPath) => {
fs.rmSync(targetPath, { recursive: true, force: true });
},
runDirectReleaseUpdate,
readMainConfig: readLauncherMainConfigObject,
log: launcherLog,
};
export async function runUpdateCommand(
context: LauncherCommandContext,
deps: Partial<UpdateCommandDeps> = {},
): Promise<boolean> {
const resolvedDeps: UpdateCommandDeps = { ...defaultDeps, ...deps };
const { args, appPath, scriptPath } = context;
if (!args.update || !appPath) {
return false;
}
if (context.processAdapter.platform() === 'linux') {
const result = await resolvedDeps.runDirectReleaseUpdate({
appPath,
launcherPath: scriptPath,
channel: readUpdateChannel(resolvedDeps.readMainConfig()),
});
const logLevel = args.logLevel ?? 'warn';
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
for (const supportResult of result.supportAssets) {
logUpdateResult('Rofi theme', supportResult, logLevel, resolvedDeps);
}
return true;
}
const tempDir = resolvedDeps.createTempDir('subminer-update-');
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');
try {
const result = resolvedDeps.runAppCommandCaptureOutput(appPath, [
'--update',
'--update-launcher-path',
scriptPath,
'--update-response-path',
responsePath,
]);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`SubMiner update command exited with status ${result.status}.`);
}
const response = await resolvedDeps.waitForUpdateResponse(responsePath);
if (!response.ok) {
throw new Error(response.error || 'SubMiner update check failed.');
}
return true;
} finally {
resolvedDeps.removeDir(tempDir);
}
}
+2
View File
@@ -156,6 +156,7 @@ export function createDefaultArgs(
statsCleanupLifetime: false,
doctor: false,
doctorRefreshKnownWords: false,
update: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -217,6 +218,7 @@ export function applyRootOptionsToArgs(
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
if (options.rofi === true) parsed.useRofi = true;
if (options.update === true) parsed.update = true;
if (options.startOverlay === true) parsed.autoStartOverlay = true;
if (options.texthooker === false) parsed.useTexthooker = false;
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
+1
View File
@@ -57,6 +57,7 @@ function applyRootOptions(program: Command): void {
.option('-p, --profile <profile>', 'MPV profile')
.option('--start', 'Explicitly start overlay')
.option('--log-level <level>', 'Log level')
.option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker')
.option('-S, --start-overlay', 'Auto-start overlay')
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
+5
View File
@@ -18,6 +18,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
import { runStatsCommand } from './commands/stats-command.js';
import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runPlaybackCommand } from './commands/playback-command.js';
import { runUpdateCommand } from './commands/update-command.js';
function createCommandContext(
args: ReturnType<typeof parseArgs>,
@@ -86,6 +87,10 @@ async function main(): Promise<void> {
return;
}
if (await runUpdateCommand(appContext)) {
return;
}
if (await runMpvPostAppCommand(appContext)) {
return;
}
+19
View File
@@ -1315,6 +1315,25 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo
});
}
export function runAppCommandSilently(appPath: string, appArgs: string[]): void {
if (maybeCaptureAppArgs(appArgs)) {
process.exit(0);
}
const target = resolveAppSpawnTarget(appPath, appArgs);
const proc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(),
});
attachAppProcessLogging(proc);
proc.once('error', (error) => {
fail(`Failed to run app command: ${error.message}`);
});
proc.once('close', (code) => {
process.exit(code ?? 0);
});
}
export function runAppCommandCaptureOutput(
appPath: string,
appArgs: string[],
+12
View File
@@ -57,6 +57,18 @@ test('parseArgs captures mpv args string', () => {
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
});
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
const shortParsed = parseArgs(['-u'], 'subminer', {});
const longParsed = parseArgs(['--update'], 'subminer', {});
const jellyfinParsed = parseArgs(['jellyfin', 'setup', '-u', 'kyle'], 'subminer', {});
assert.equal(shortParsed.update, true);
assert.equal(longParsed.update, true);
assert.equal(jellyfinParsed.update, false);
assert.equal(jellyfinParsed.jellyfin, true);
assert.equal(jellyfinParsed.jellyfinUsername, 'kyle');
});
test('parseArgs maps jellyfin play action and log-level override', () => {
const parsed = parseArgs(['jellyfin', 'play', '--log-level', 'debug'], 'subminer', {});
+26 -10
View File
@@ -3,11 +3,17 @@ import assert from 'node:assert/strict';
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
import type { SetupState } from '../src/shared/setup-state';
const commandLineSetupDefaults = {
bunInstallStatus: 'unknown',
launcherInstallStatus: 'unknown',
launcherInstallPath: null,
} satisfies Pick<SetupState, 'bunInstallStatus' | 'launcherInstallStatus' | 'launcherInstallPath'>;
test('waitForSetupCompletion resolves completed and cancelled states', async () => {
const sequence: Array<SetupState | null> = [
null,
{
version: 3,
version: 4,
status: 'in_progress',
completedAt: null,
completionSource: null,
@@ -17,9 +23,10 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
},
{
version: 3,
version: 4,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
@@ -29,6 +36,7 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'skipped',
...commandLineSetupDefaults,
},
];
@@ -56,7 +64,7 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
if (reads === 1) return null;
if (reads === 2) {
return {
version: 3,
version: 4,
status: 'in_progress',
completedAt: null,
completionSource: null,
@@ -66,10 +74,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
}
return {
version: 3,
version: 4,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
@@ -79,6 +88,7 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'installed',
...commandLineSetupDefaults,
};
},
launchSetupApp: () => {
@@ -125,7 +135,7 @@ test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal'
readSetupState: () => {
reads += 1;
return {
version: 3,
version: 4,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z',
completionSource: 'user',
@@ -135,6 +145,7 @@ test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal'
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
},
hasLegacyMpvPlugin: () => legacyPluginInstalled,
@@ -164,7 +175,7 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
readSetupState: () => {
reads += 1;
return {
version: 3,
version: 4,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:30:00.000Z',
completionSource: 'user',
@@ -174,6 +185,7 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
},
hasLegacyMpvPlugin: () => true,
@@ -196,7 +208,7 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
const result = await ensureLauncherSetupReady({
readSetupState: () => ({
version: 3,
version: 4,
status: 'cancelled',
completedAt: null,
completionSource: null,
@@ -206,6 +218,7 @@ test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
}),
launchSetupApp: () => undefined,
sleep: async () => undefined,
@@ -228,7 +241,7 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
reads += 1;
if (reads <= 2) {
return {
version: 3,
version: 4,
status: 'cancelled',
completedAt: null,
completionSource: null,
@@ -238,11 +251,12 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
}
if (reads === 3) {
return {
version: 3,
version: 4,
status: 'in_progress',
completedAt: null,
completionSource: null,
@@ -252,10 +266,11 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
}
return {
version: 3,
version: 4,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'legacy_auto_detected',
@@ -265,6 +280,7 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
},
launchSetupApp: () => undefined,
+1
View File
@@ -134,6 +134,7 @@ export interface Args {
dictionaryTarget?: string;
doctor: boolean;
doctorRefreshKnownWords: boolean;
update?: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;
+18 -5
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.14.0",
"version": "0.15.0-beta.2",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -15,7 +15,7 @@
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
"build:yomitan": "bun scripts/build-yomitan.mjs",
"build:assets": "bun scripts/prepare-build-assets.mjs",
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=dist/launcher/subminer",
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
"build:stats": "cd stats && bun run build",
"dev:stats": "cd stats && bun run dev",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
@@ -47,8 +47,8 @@
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.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/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.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/shared/setup-state.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/yomitan-extension-paths.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/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.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/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.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/shared/setup-state.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/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.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/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.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/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.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/yomitan-extension-paths.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/jimaku-download-path.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/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-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",
@@ -70,7 +70,7 @@
"test:launcher": "bun run test:launcher:src",
"test:core": "bun run test:core:src",
"test:subtitle": "bun run test:subtitle:src",
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"generate:config-example": "bun run src/generate-config-example.ts",
"verify:config-example": "bun run src/verify-config-example.ts",
"start": "bun run build && electron . --start",
@@ -111,6 +111,7 @@
"@xhayper/discord-rpc": "^1.3.3",
"axios": "^1.13.5",
"commander": "^14.0.3",
"electron-updater": "^6.8.3",
"hono": "^4.12.7",
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
@@ -136,6 +137,14 @@
"directories": {
"output": "release"
},
"publish": [
{
"provider": "github",
"owner": "ksyasuda",
"repo": "SubMiner"
}
],
"electronUpdaterCompatibility": ">=2.16",
"linux": {
"target": [
"AppImage"
@@ -236,6 +245,10 @@
"from": "plugin/subminer.conf",
"to": "plugin/subminer.conf"
},
{
"from": "dist/launcher/subminer",
"to": "launcher/subminer"
},
{
"from": "dist/scripts/get-mpv-window-windows.ps1",
"to": "scripts/get-mpv-window-windows.ps1"
-50
View File
@@ -1,50 +0,0 @@
## Highlights
### Added
- **Character Dictionary:** Added AniList-based selection to resolve character dictionary mismatches, with series-scoped overrides that replace stale entries. Available via `subminer dictionary --candidates` / `--select` and a default `Ctrl+Alt+A` in-app shortcut.
- **Subtitle Bar Toggle:** Added a `V` shortcut and mpv binding to toggle the primary subtitle bar independently of mpv's native subtitle display.
- **Texthooker:** Added `subminer texthooker -o` and a tray menu item to open the local texthooker page in the default browser.
### Changed
- **mpv Plugin Setup:** Managed launches now inject the bundled plugin automatically. The setup flow can trash detected legacy global plugin files before launch, and legacy global install entrypoints have been removed so regular mpv playback is unaffected.
- **Tray Menu:** Replaced "Open Overlay" with "Open Help," which opens the session help modal.
- **Stats Exclusions:** Vocabulary exclusions now persist in the immersion database and migrate existing browser-local exclusions on first load.
- **Config Defaults:** Disabled texthooker startup, subtitle, and annotation websocket servers by default. Fresh installs now use a Japanese font stack, transparent subtitle backgrounds, stronger text shadows, and teal N4/fourth-band coloring for primary subtitles. Yomitan popup auto-pause remains enabled.
- **Config Example:** The generated example config now lists every built-in keybinding default.
### Fixed
- **Subtitle Annotations — Grammar Filtering:** Suppressed N+1, JLPT, frequency, and name styling on grammar-only tokens: standalone interjections (`あ`, katakana variants), kana grammar helpers (`ことに`), auxiliary inflection fragments (`れる`, `れた`), polite copula tails (`です`, `じゃないですか`), standalone particles matched by known-word decks, and existence verbs (`ある`/`有る`). Known-word highlighting is preserved where applicable.
- **Subtitle Annotations — Color Priority:** Fixed token color priority so typography settings are preserved, JLPT colors no longer override higher-priority known-word or frequency colors, JLPT underlines persist at their correct color after dictionary lookups and when a token also carries known-word or frequency annotations, and frequency highlighting works correctly for ordinal prefix-noun tokens like `第二`.
- **Subtitle Annotations — Other:** Stopped kana-only tokens from being selected as N+1 targets; preserved Yomitan compound tokens so known component words no longer color a larger unknown word green; kept annotation prefetch running after immediate cache-hit renders; added a brightness lift for annotated token hover states when hover backgrounds are transparent; accepted `subtitleStyle.hoverBackground` as an alias for `subtitleStyle.hoverTokenBackgroundColor`; refreshed the current subtitle after successful card mining so newly known words recolor immediately.
- **Subtitle Bar:** Changed `v` to cycle the primary subtitle bar through visible, hover, and hidden modes with OSD feedback. Added `subtitleStyle.primaryDefaultMode` to set the startup visibility default independently from secondary subtitles.
- **Tokenizer:** Now uses Yomitan `wordClasses` metadata for part-of-speech filtering, and backfills blank MeCab POS fields during parser enrichment.
- **Overlay (Linux):** Fixed multi-line subtitle copy timing out after the prompt; follow-up number-row digits are now accepted for multi-line mining even when the original shortcut modifiers are still held.
- **Overlay (Hyprland):** Fixed fullscreen transitions so overlay geometry refreshes on mpv fullscreen changes, topmost stacking is reasserted, and hover pause works correctly after resize/toggle cycles. Overlay windows now align precisely to mpv bounds with floating decoration disabled; the stats overlay is opaque to prevent mpv bleed-through at the top edge; overlay windows no longer pin across workspaces.
- **Overlay (macOS):** Kept the overlay visible and interactive during transient tracker refreshes while mpv is the active tracked window, and kept it behind unrelated foreground windows while remaining above mpv.
- **Overlay:** Keyboard-only Yomitan popup shortcuts now take precedence over overlay keybindings like `j`; the browser focus outline is hidden so focused overlays no longer show a yellow/orange viewport border.
- **Default Keybindings:** Fixed replay/next subtitle keybindings — session help moved to `Ctrl/Cmd+/`, freeing `Ctrl+Shift+H` and `Ctrl+Shift+L` for subtitle playback controls. `Ctrl+Shift+L` now correctly reaches play-next-subtitle, and play-next resumes from a paused state before pausing again at the subtitle end.
- **Anki:** Manual clipboard subtitle updates preserve existing word audio while replacing sentence audio, animated-image media, and expression fields — even when audio overwrite is configured off.
- **AniList:** Post-watch progress checks now run on time-position updates using the fresh mpv position; manual mark-watched forces a progress sync; missing episode metadata is filled from the filename parser. Duplicate writes during concurrent checks are prevented, and manual watched marks are preserved when sync fails.
- **AniList (Linux):** Retried safeStorage availability after transient keyring failures so tokens can load and save once the keyring becomes available. Prevented config reload from opening the setup window during playback when token storage cannot be resolved, and stopped the setup flow from reporting success when token persistence fails.
- **mpv:** Stopped mpv from holding SubMiner subprocesses during shutdown, preventing desktop crash notifications on video close. Kept the overlay alive across same-media buffering reloads to avoid duplicate startup gates and AniSkip lookups; playlist navigation now reuses the running overlay without repeating the pause-until-ready warmup gate.
- **Launcher:** Managed playback now exits the background SubMiner app when the video closes; explicit background launches remain persistent.
- **Stats:** Background mode routes through the isolated stats daemon; app startup defers to an already-running daemon instead of failing when the port is already in use. Fixed recent session detail pages showing "Media not found" before lifetime media summaries are available.
- **Jellyfin:** Improved setup with recent server selection and inline authentication feedback. Added a tray toggle for runtime-only cast discovery.
### Docs
- Improved the docs homepage with canonical URLs and a cleaner sitemap.
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+102 -19
View File
@@ -7,7 +7,7 @@
// It works with both bundled and unbundled mpv installations.
//
// Usage: swift get-mpv-window-macos.swift
// Output: "x,y,width,height" or "not-found"
// Output: "x,y,width,height,focused", "minimized", "active", "inactive", or "not-found"
//
import Cocoa
@@ -25,6 +25,18 @@ private struct WindowState {
let focused: Bool
}
private struct FrontmostApplicationState {
let pid: pid_t
let isMpv: Bool
}
private enum WindowLookupResult {
case visible(WindowState)
case minimized
case active
case inactive
}
private let targetMpvSocketPath: String? = {
guard CommandLine.arguments.count > 1 else {
return nil
@@ -141,11 +153,44 @@ private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? {
return geometry
}
private func frontmostApplicationPid() -> pid_t? {
NSWorkspace.shared.frontmostApplication?.processIdentifier
private func frontmostApplicationState() -> FrontmostApplicationState? {
guard let app = NSWorkspace.shared.frontmostApplication else {
return nil
}
return FrontmostApplicationState(
pid: app.processIdentifier,
isMpv: app.localizedName.map(normalizedMpvName) ?? false
)
}
private func windowStateFromAccessibilityAPI() -> WindowState? {
private func isFocusedMpvWindow(ownerPid: pid_t, frontmost: FrontmostApplicationState?) -> Bool {
guard let frontmost = frontmost else {
return false
}
if frontmost.pid == ownerPid {
return true
}
return frontmost.isMpv && windowHasTargetSocket(ownerPid)
}
private func isFrontmostTargetMpv(_ frontmost: FrontmostApplicationState?) -> Bool {
guard let frontmost = frontmost, frontmost.isMpv else {
return false
}
if windowHasTargetSocket(frontmost.pid) {
return true
}
// When macOS says mpv is frontmost but geometry APIs miss, keep the
// overlay stable even if ps cannot expose the socket argument.
return targetMpvSocketPath != nil
}
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
let runningApps = NSWorkspace.shared.runningApplications.filter { app in
guard let name = app.localizedName else {
return false
@@ -153,7 +198,8 @@ private func windowStateFromAccessibilityAPI() -> WindowState? {
return normalizedMpvName(name)
}
let frontmostPid = frontmostApplicationPid()
let frontmost = frontmostApplicationState()
var foundMinimizedTargetWindow = false
for app in runningApps {
let appElement = AXUIElementCreateApplication(app.processIdentifier)
@@ -168,14 +214,12 @@ private func windowStateFromAccessibilityAPI() -> WindowState? {
}
for window in windows {
var minimizedRef: CFTypeRef?
let minimizedStatus = AXUIElementCopyAttributeValue(window, kAXMinimizedAttribute as CFString, &minimizedRef)
if minimizedStatus == .success, let minimized = minimizedRef as? Bool, minimized {
var windowPid: pid_t = 0
if AXUIElementGetPid(window, &windowPid) != .success {
continue
}
var windowPid: pid_t = 0
if AXUIElementGetPid(window, &windowPid) != .success {
if windowPid != app.processIdentifier {
continue
}
@@ -183,15 +227,28 @@ private func windowStateFromAccessibilityAPI() -> WindowState? {
continue
}
var minimizedRef: CFTypeRef?
let minimizedStatus = AXUIElementCopyAttributeValue(window, kAXMinimizedAttribute as CFString, &minimizedRef)
if minimizedStatus == .success, let minimized = minimizedRef as? Bool, minimized {
foundMinimizedTargetWindow = true
continue
}
if let geometry = geometryFromAXWindow(window) {
return WindowState(
geometry: geometry,
focused: frontmostPid == windowPid
return .visible(
WindowState(
geometry: geometry,
focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)
)
)
}
}
}
if foundMinimizedTargetWindow {
return .minimized
}
return nil
}
@@ -200,7 +257,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
// Use on-screen layer-0 windows to avoid off-screen helpers/shadows.
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
let frontmostPid = frontmostApplicationPid()
let frontmost = frontmostApplicationState()
for window in windowList {
guard let ownerName = window[kCGWindowOwnerName as String] as? String,
@@ -243,17 +300,43 @@ private func windowStateFromCoreGraphics() -> WindowState? {
return WindowState(
geometry: geometry,
focused: frontmostPid == ownerPid
focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)
)
}
return nil
}
if let window = windowStateFromAccessibilityAPI() ?? windowStateFromCoreGraphics() {
print(
"\(window.geometry.x),\(window.geometry.y),\(window.geometry.width),\(window.geometry.height),\(window.focused ? 1 : 0)"
)
private let lookupResult: WindowLookupResult? = {
if let axResult = windowStateFromAccessibilityAPI() {
return axResult
}
if let cgWindow = windowStateFromCoreGraphics() {
return .visible(cgWindow)
}
let frontmost = frontmostApplicationState()
if isFrontmostTargetMpv(frontmost) {
return .active
}
if frontmost != nil {
return .inactive
}
return nil
}()
if let result = lookupResult {
switch result {
case .visible(let window):
print(
"\(window.geometry.x),\(window.geometry.y),\(window.geometry.width),\(window.geometry.height),\(window.focused ? 1 : 0)"
)
case .minimized:
print("minimized")
case .active:
print("active")
case .inactive:
print("inactive")
}
} else {
print("not-found")
}
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync('scripts/get-mpv-window-macos.swift', 'utf8');
test('minimized Accessibility windows are validated by PID and socket before reporting minimized', () => {
const minimizedAssignmentIndex = source.indexOf('foundMinimizedTargetWindow = true');
assert.notEqual(minimizedAssignmentIndex, -1);
const loopStartIndex = source.lastIndexOf('for window in windows', minimizedAssignmentIndex);
assert.notEqual(loopStartIndex, -1);
const pidExtractionIndex = source.indexOf(
'AXUIElementGetPid(window, &windowPid)',
loopStartIndex,
);
const appPidMatchIndex = source.indexOf('windowPid != app.processIdentifier', loopStartIndex);
const socketCheckIndex = source.indexOf('if !windowHasTargetSocket(windowPid)', loopStartIndex);
assert.ok(
pidExtractionIndex > loopStartIndex && pidExtractionIndex < minimizedAssignmentIndex,
'window PID must be extracted before accepting a minimized window',
);
assert.ok(
appPidMatchIndex > pidExtractionIndex && appPidMatchIndex < minimizedAssignmentIndex,
'window PID must match the owning app before accepting a minimized window',
);
assert.ok(
socketCheckIndex > appPidMatchIndex && socketCheckIndex < minimizedAssignmentIndex,
'target socket must be validated before accepting a minimized window',
);
});
test('focused mpv window follows the frontmost mpv app signal', () => {
const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow');
assert.notEqual(focusHelperIndex, -1);
const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1);
const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex);
assert.ok(
focusHelperBody.includes('frontmost.pid == ownerPid'),
'matching frontmost PID should mark the mpv window focused',
);
assert.ok(
focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'),
'frontmost mpv app should mark the target mpv window focused even when PIDs differ',
);
assert.ok(
source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'),
'Accessibility path should use the shared focused mpv helper',
);
assert.ok(
source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'),
'CoreGraphics path should use the shared focused mpv helper',
);
});
test('frontmost mpv app emits active state when geometry lookup misses', () => {
assert.ok(
/case\s+\.active:/.test(source),
'helper should expose an active state without window geometry',
);
assert.ok(
source.includes('if windowHasTargetSocket(frontmost.pid)'),
'active state should still accept a matching target socket when available',
);
assert.ok(
source.includes('return targetMpvSocketPath != nil'),
'active state should preserve frontmost mpv even if command-line socket detection fails',
);
assert.ok(
source.includes('return .active'),
'lookup should preserve active mpv state after geometry lookup misses',
);
assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker');
});
test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => {
assert.ok(
/case\s+\.inactive:/.test(source),
'helper should expose an inactive state without window geometry',
);
assert.ok(
source.includes('if frontmost != nil'),
'helper should distinguish a known non-mpv frontmost app from an unknown miss',
);
assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive');
assert.ok(
source.includes('print("inactive")'),
'inactive state should be printed for the tracker',
);
});
+24
View File
@@ -51,6 +51,24 @@ test('parseArgs ignores missing value after --log-level', () => {
assert.equal(args.start, true);
});
test('parseArgs captures update command and internal launcher paths', () => {
const args = parseArgs([
'--update',
'--update-launcher-path',
'/home/kyle/.local/bin/subminer',
'--update-response-path',
'/tmp/subminer-update-response.json',
]);
assert.equal(args.update, true);
assert.equal(args.updateLauncherPath, '/home/kyle/.local/bin/subminer');
assert.equal(args.updateResponsePath, '/tmp/subminer-update-response.json');
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
assert.equal(commandNeedsOverlayRuntime(args), false);
assert.equal(shouldRunSettingsOnlyStartup(args), false);
});
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
const args = parseArgs(['--launch-mpv', 'C:\\a.mkv', 'C:\\b.mkv']);
assert.equal(args.launchMpv, true);
@@ -182,6 +200,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(refreshKnownWords), true);
assert.equal(isHeadlessInitialCommand(refreshKnownWords), true);
const update = parseArgs(['--update']);
assert.equal(update.update, true);
assert.equal(hasExplicitCommand(update), true);
assert.equal(shouldStartApp(update), true);
assert.equal(isHeadlessInitialCommand(update), true);
const settings = parseArgs(['--settings']);
assert.equal(settings.settings, true);
assert.equal(hasExplicitCommand(settings), true);
+26 -3
View File
@@ -73,6 +73,9 @@ export interface CliArgs {
texthooker: boolean;
texthookerOpenBrowser: boolean;
help: boolean;
update?: boolean;
updateLauncherPath?: string;
updateResponsePath?: string;
autoStartOverlay: boolean;
generateConfig: boolean;
configPath?: string;
@@ -167,6 +170,9 @@ export function parseArgs(argv: string[]): CliArgs {
texthooker: false,
texthookerOpenBrowser: false,
help: false,
update: false,
updateLauncherPath: undefined,
updateResponsePath: undefined,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
@@ -330,7 +336,20 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
else if (arg === '--texthooker') args.texthooker = true;
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
else if (arg === '--update') args.update = true;
else if (arg.startsWith('--update-launcher-path=')) {
const value = arg.split('=', 2)[1];
if (value) args.updateLauncherPath = value;
} else if (arg === '--update-launcher-path') {
const value = readValue(argv[i + 1]);
if (value) args.updateLauncherPath = value;
} else if (arg.startsWith('--update-response-path=')) {
const value = arg.split('=', 2)[1];
if (value) args.updateResponsePath = value;
} else if (arg === '--update-response-path') {
const value = readValue(argv[i + 1]);
if (value) args.updateResponsePath = value;
} else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
else if (arg === '--generate-config') args.generateConfig = true;
else if (arg === '--backup-overwrite') args.backupOverwrite = true;
else if (arg === '--help') args.help = true;
@@ -517,13 +536,14 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.jellyfinRemoteAnnounce ||
args.jellyfinPreviewAuth ||
args.texthooker ||
args.update ||
args.generateConfig ||
args.help
);
}
export function isHeadlessInitialCommand(args: CliArgs): boolean {
return args.refreshKnownWords;
return args.refreshKnownWords || args.update === true;
}
export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
@@ -587,6 +607,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.jellyfinPlay &&
!args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.update &&
!args.help &&
!args.autoStartOverlay &&
!args.generateConfig
@@ -638,7 +659,8 @@ export function shouldStartApp(args: CliArgs): boolean {
args.stats ||
args.jellyfin ||
args.jellyfinPlay ||
args.texthooker
args.texthooker ||
args.update
) {
if (args.launchMpv) {
return false;
@@ -708,6 +730,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.texthooker &&
!args.update &&
!args.help &&
!args.autoStartOverlay &&
!args.generateConfig &&
+1
View File
@@ -17,6 +17,7 @@ ${B}Session${R}
--stats Open the stats dashboard in your browser
--texthooker Start texthooker server only ${D}(no overlay)${R}
--open-browser Open texthooker in your default browser
--update Check for updates
${B}Overlay${R}
--toggle-visible-overlay Toggle subtitle overlay
+61
View File
@@ -109,6 +109,58 @@ test('loads defaults when config is missing', () => {
assert.equal(config.immersionTracking.lifetimeSummaries?.anime, true);
assert.equal(config.immersionTracking.lifetimeSummaries?.media, true);
assert.equal(config.stats.autoOpenBrowser, false);
assert.equal(config.updates.enabled, true);
assert.equal(config.updates.checkIntervalHours, 24);
assert.equal(config.updates.notificationType, 'system');
assert.equal(config.updates.channel, 'stable');
});
test('parses updates config and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"updates": {
"enabled": false,
"checkIntervalHours": 6,
"notificationType": "both",
"channel": "prerelease"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().updates.enabled, false);
assert.equal(validService.getConfig().updates.checkIntervalHours, 6);
assert.equal(validService.getConfig().updates.notificationType, 'both');
assert.equal(validService.getConfig().updates.channel, 'prerelease');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"updates": {
"enabled": "yes",
"checkIntervalHours": 0,
"notificationType": "toast",
"channel": "nightly"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
const config = invalidService.getConfig();
const warnings = invalidService.getWarnings();
assert.equal(config.updates.enabled, DEFAULT_CONFIG.updates.enabled);
assert.equal(config.updates.checkIntervalHours, DEFAULT_CONFIG.updates.checkIntervalHours);
assert.equal(config.updates.notificationType, DEFAULT_CONFIG.updates.notificationType);
assert.equal(config.updates.channel, DEFAULT_CONFIG.updates.channel);
assert.ok(warnings.some((warning) => warning.path === 'updates.enabled'));
assert.ok(warnings.some((warning) => warning.path === 'updates.checkIntervalHours'));
assert.ok(warnings.some((warning) => warning.path === 'updates.notificationType'));
assert.ok(warnings.some((warning) => warning.path === 'updates.channel'));
});
test('throws actionable startup parse error for malformed config at construction time', () => {
@@ -2124,6 +2176,7 @@ test('template generator includes known keys', () => {
assert.match(output, /"websocket":/);
assert.match(output, /"discordPresence":/);
assert.match(output, /"startupWarmups":/);
assert.match(output, /"updates":/);
assert.match(output, /"youtube":/);
assert.doesNotMatch(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/);
@@ -2210,6 +2263,14 @@ test('template generator includes known keys', () => {
output,
/"autoOpenBrowser": false,? \/\/ Automatically open the stats dashboard in a browser when the server starts\. Values: true \| false/,
);
assert.match(
output,
/"notificationType": "system",? \/\/ How SubMiner announces available updates\. Values: system \| osd \| both \| none/,
);
assert.match(
output,
/"channel": "stable",? \/\/ Release channel used for update checks\. Values: stable \| prerelease/,
);
assert.match(
output,
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for managed subtitle auto-selection\./,
+2
View File
@@ -33,6 +33,7 @@ const {
youtube,
subsync,
startupWarmups,
updates,
auto_start_overlay,
} = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
@@ -55,6 +56,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
youtube,
subsync,
startupWarmups,
updates,
subtitleStyle,
subtitleSidebar,
auto_start_overlay,
+7
View File
@@ -14,6 +14,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'youtube'
| 'subsync'
| 'startupWarmups'
| 'updates'
| 'auto_start_overlay'
> = {
subtitlePosition: { yPercent: 10 },
@@ -116,5 +117,11 @@ export const CORE_DEFAULT_CONFIG: Pick<
subtitleDictionaries: true,
jellyfinRemoteSession: true,
},
updates: {
enabled: true,
checkIntervalHours: 24,
notificationType: 'system',
channel: 'stable',
},
auto_start_overlay: false,
};
@@ -22,6 +22,7 @@ test('config option registry includes critical paths and has unique entries', ()
'controller.enabled',
'controller.scrollPixelsPerSecond',
'startupWarmups.lowPowerMode',
'updates.channel',
'youtube.primarySubLanguages',
'subtitleStyle.enableJlpt',
'subtitleStyle.autoPauseVideoOnYomitanPopup',
+26
View File
@@ -383,6 +383,32 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.startupWarmups.jellyfinRemoteSession,
description: 'Warm up Jellyfin remote session at startup.',
},
{
path: 'updates.enabled',
kind: 'boolean',
defaultValue: defaultConfig.updates.enabled,
description: 'Run automatic update checks in the background.',
},
{
path: 'updates.checkIntervalHours',
kind: 'number',
defaultValue: defaultConfig.updates.checkIntervalHours,
description: 'Minimum hours between automatic update checks.',
},
{
path: 'updates.notificationType',
kind: 'enum',
enumValues: ['system', 'osd', 'both', 'none'],
defaultValue: defaultConfig.updates.notificationType,
description: 'How SubMiner announces available updates.',
},
{
path: 'updates.channel',
kind: 'enum',
enumValues: ['stable', 'prerelease'],
defaultValue: defaultConfig.updates.channel,
description: 'Release channel used for update checks.',
},
{
path: 'shortcuts.multiCopyTimeoutMs',
kind: 'number',
@@ -53,6 +53,14 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
],
key: 'startupWarmups',
},
{
title: 'Updates',
description: [
'Automatic update check behavior.',
'Manual checks from the tray or launcher are always allowed.',
],
key: 'updates',
},
{
title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
+56
View File
@@ -478,6 +478,62 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.updates)) {
const enabled = asBoolean(src.updates.enabled);
if (enabled !== undefined) {
resolved.updates.enabled = enabled;
} else if (src.updates.enabled !== undefined) {
warn('updates.enabled', src.updates.enabled, resolved.updates.enabled, 'Expected boolean.');
}
const checkIntervalHours = asNumber(src.updates.checkIntervalHours);
if (
checkIntervalHours !== undefined &&
Number.isFinite(checkIntervalHours) &&
checkIntervalHours > 0
) {
resolved.updates.checkIntervalHours = checkIntervalHours;
} else if (src.updates.checkIntervalHours !== undefined) {
warn(
'updates.checkIntervalHours',
src.updates.checkIntervalHours,
resolved.updates.checkIntervalHours,
'Expected positive number.',
);
}
const notificationType = asString(src.updates.notificationType);
if (
notificationType === 'system' ||
notificationType === 'osd' ||
notificationType === 'both' ||
notificationType === 'none'
) {
resolved.updates.notificationType = notificationType;
} else if (src.updates.notificationType !== undefined) {
warn(
'updates.notificationType',
src.updates.notificationType,
resolved.updates.notificationType,
'Expected system, osd, both, or none.',
);
}
const channel = asString(src.updates.channel);
if (channel === 'stable' || channel === 'prerelease') {
resolved.updates.channel = channel;
} else if (src.updates.channel !== undefined) {
warn(
'updates.channel',
src.updates.channel,
resolved.updates.channel,
'Expected stable or prerelease.',
);
}
} else if (src.updates !== undefined) {
warn('updates', src.updates, resolved.updates, 'Expected object.');
}
if (isObject(src.shortcuts)) {
const shortcutKeys = [
'toggleVisibleOverlayGlobal',
+50 -4
View File
@@ -70,6 +70,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
texthooker: false,
texthookerOpenBrowser: false,
help: false,
update: false,
updateLauncherPath: undefined,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
@@ -128,8 +130,8 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
},
openFirstRunSetup: () => {
calls.push('openFirstRunSetup');
openFirstRunSetup: (force?: boolean) => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
},
setVisibleOverlayVisible: (visible) => {
calls.push(`setVisibleOverlayVisible:${visible}`);
@@ -231,6 +233,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
runYoutubePlaybackFlow: async (request) => {
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`);
},
runUpdateCommand: async (args) => {
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
},
printHelp: () => {
calls.push('printHelp');
},
@@ -242,6 +247,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
log: (message) => {
calls.push(`log:${message}`);
},
logDebug: (message) => {
calls.push(`debug:${message}`);
},
warn: (message) => {
calls.push(`warn:${message}`);
},
@@ -353,16 +361,54 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
);
});
test('handleCliCommand forces setup open for second-instance setup command', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ setup: true }), 'second-instance', deps);
assert.ok(calls.includes('openFirstRunSetup:force'));
assert.ok(calls.includes('debug:Opened first-run setup flow.'));
});
test('handleCliCommand opens first-run setup window for --setup', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ setup: true }), 'initial', deps);
assert.ok(calls.includes('openFirstRunSetup'));
assert.ok(calls.includes('log:Opened first-run setup flow.'));
assert.ok(calls.includes('openFirstRunSetup:force'));
assert.ok(calls.includes('debug:Opened first-run setup flow.'));
assert.equal(calls.includes('log:Opened first-run setup flow.'), false);
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
});
test('handleCliCommand runs update command without overlay startup', async () => {
const { deps, calls } = createDeps();
handleCliCommand(
makeArgs({ update: true, updateLauncherPath: '/home/kyle/.local/bin/subminer' }),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ['runUpdateCommand:/home/kyle/.local/bin/subminer']);
});
test('handleCliCommand stops app after headless initial update completes', async () => {
const { deps, calls } = createDeps({
hasMainWindow: () => false,
});
handleCliCommand(
makeArgs({ update: true, updateLauncherPath: '/home/kyle/.local/bin/subminer' }),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ['runUpdateCommand:/home/kyle/.local/bin/subminer', 'stopApp']);
});
test('handleCliCommand dispatches stats command without overlay startup', async () => {
const { deps, calls } = createDeps({
runStatsCommand: async () => {
+23 -4
View File
@@ -41,7 +41,7 @@ export interface CliCommandServiceDeps {
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void;
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
@@ -95,6 +95,7 @@ export interface CliCommandServiceDeps {
}) => Promise<CharacterDictionarySelectionResult>;
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
@@ -105,6 +106,7 @@ export interface CliCommandServiceDeps {
getMultiCopyTimeoutMs: () => number;
showMpvOsd: (text: string) => void;
log: (message: string) => void;
logDebug: (message: string) => void;
warn: (message: string) => void;
error: (message: string, err: unknown) => void;
}
@@ -156,7 +158,7 @@ interface MiningCliRuntime {
}
interface UiCliRuntime {
openFirstRunSetup: () => void;
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
@@ -174,6 +176,7 @@ interface AnilistCliRuntime {
interface AppCliRuntime {
stop: () => void;
hasMainWindow: () => boolean;
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
}
@@ -209,6 +212,7 @@ export interface CliCommandDepsRuntimeOptions {
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => unknown;
log: (message: string) => void;
logDebug: (message: string) => void;
warn: (message: string) => void;
error: (message: string, err: unknown) => void;
}
@@ -277,12 +281,14 @@ export function createCliCommandDepsRuntime(
setCharacterDictionarySelection: options.dictionary.setSelection,
runStatsCommand: options.jellyfin.runStatsCommand,
runJellyfinCommand: options.jellyfin.runCommand,
runUpdateCommand: options.app.runUpdateCommand,
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow,
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
showMpvOsd: options.mpv.showOsd,
log: options.log,
logDebug: options.logDebug,
warn: options.warn,
error: options.error,
};
@@ -375,8 +381,8 @@ export function handleCliCommand(
} else if (args.togglePrimarySubtitleBar) {
deps.togglePrimarySubtitleBar();
} else if (args.setup) {
deps.openFirstRunSetup();
deps.log('Opened first-run setup flow.');
deps.openFirstRunSetup(true);
deps.logDebug('Opened first-run setup flow.');
} else if (args.settings) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.show || args.showVisibleOverlay) {
@@ -416,6 +422,19 @@ export function handleCliCommand(
deps.stopApp();
}
});
} else if (args.update) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps
.runUpdateCommand(args, source)
.catch((err) => {
deps.error('runUpdateCommand failed:', err);
deps.showMpvOsd(`Update failed: ${(err as Error).message}`);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.toggleSecondarySub) {
deps.cycleSecondarySubMode();
} else if (args.triggerFieldGrouping) {
+1 -1
View File
@@ -27,7 +27,7 @@ export {
isAutoUpdateEnabledRuntime,
shouldAutoInitializeOverlayRuntimeFromConfig,
} from './startup';
export { openYomitanSettingsWindow } from './yomitan-settings';
export { destroyYomitanSettingsWindow, openYomitanSettingsWindow } from './yomitan-settings';
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
export {
addYomitanNoteViaSearch,
+32 -1
View File
@@ -218,6 +218,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
onOverlayModalClosed: () => {},
onOverlayMouseInteractionChanged: (active) => {
calls.push(`overlay-interaction:${active}`);
},
openYomitanSettings: () => {},
quitApp: () => {},
toggleVisibleOverlay: () => {},
@@ -281,6 +284,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
deps.clearAnilistToken();
deps.openAnilistSetup();
deps.onOverlayMouseInteractionChanged?.(true, null);
assert.deepEqual(deps.getAnilistQueueStatus(), {
pending: 1,
ready: 0,
@@ -298,10 +302,37 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
assert.deepEqual(calls, [
'clearAnilistToken',
'openAnilistSetup',
'overlay-interaction:true',
'retryAnilistQueueNow',
]);
assert.equal(deps.getPlaybackPaused(), true);
});
test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction active state', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
onOverlayMouseInteractionChanged: (active) => {
calls.push(`overlay-interaction:${active}`);
},
}),
registrar,
);
const handler = handlers.on.get(IPC_CHANNELS.command.setIgnoreMouseEvents);
assert.equal(typeof handler, 'function');
handler?.({}, true, { forward: true });
handler?.({}, false, {});
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
});
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
+10
View File
@@ -44,6 +44,10 @@ export interface IpcServiceDeps {
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayMouseInteractionChanged?: (
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleDevTools: () => void;
@@ -175,6 +179,10 @@ export interface IpcDepsRuntimeOptions {
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayMouseInteractionChanged?: (
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleVisibleOverlay: () => void;
@@ -233,6 +241,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
return {
onOverlayModalClosed: options.onOverlayModalClosed,
onOverlayModalOpened: options.onOverlayModalOpened,
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
openYomitanSettings: options.openYomitanSettings,
quitApp: options.quitApp,
toggleDevTools: () => {
@@ -349,6 +358,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
}
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
},
);
@@ -547,7 +547,7 @@ test('initializeOverlayRuntime hides overlay windows when tracker loses the targ
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
});
test('initializeOverlayRuntime hides visible overlay on Windows tracker loss when target is not minimized', () => {
test('initializeOverlayRuntime refreshes visible overlay on tracker loss when target is not minimized', () => {
const calls: string[] = [];
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
@@ -600,7 +600,7 @@ test('initializeOverlayRuntime hides visible overlay on Windows tracker loss whe
calls.length = 0;
tracker.onWindowLost?.();
assert.deepEqual(calls, ['hide-visible', 'sync-shortcuts']);
assert.deepEqual(calls, ['update-visible']);
});
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {
+7 -3
View File
@@ -105,10 +105,14 @@ export function initializeOverlayRuntime(options: {
};
windowTracker.onWindowLost = () => {
options.releaseOverlayOwner?.();
for (const window of options.getOverlayWindows()) {
window.hide();
if (windowTracker.isTargetWindowMinimized()) {
for (const window of options.getOverlayWindows()) {
window.hide();
}
options.syncOverlayShortcuts();
return;
}
options.syncOverlayShortcuts();
options.updateVisibleOverlayVisibility();
};
windowTracker.onWindowFocusChange = () => {
if (options.isVisibleOverlayVisible()) {
+445 -11
View File
@@ -42,6 +42,11 @@ function createMainWindowRecorder() {
setAlwaysOnTop: (flag: boolean) => {
calls.push(`always-on-top:${flag}`);
},
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
calls.push(
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
);
},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
},
@@ -538,11 +543,12 @@ test('forced passthrough still shows tracked overlay while bound to mpv on Windo
assert.ok(calls.includes('sync-windows-z-order'));
});
test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => {
test('forced mouse passthrough keeps macOS tracked overlay above active mpv', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
updateVisibleOverlayVisibility({
@@ -571,8 +577,53 @@ test('forced mouse passthrough drops macOS tracked overlay below higher-priority
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(!calls.includes('always-on-top:false'));
});
test('forced mouse passthrough still hides macOS tracked overlay when mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
@@ -883,7 +934,7 @@ test('visible overlay stays hidden while a modal window is active', () => {
assert.ok(!calls.includes('update-bounds'));
});
test('macOS tracked visible overlay stays interactive without passively stealing focus', () => {
test('macOS tracked visible overlay starts click-through without passively stealing focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
@@ -915,12 +966,53 @@ test('macOS tracked visible overlay stays interactive without passively stealing
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('macOS keeps active mpv overlay visible and interactive during tracker refresh', () => {
test('macOS tracked visible overlay remains click-through even if the overlay had focus', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
setFocused(true);
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(!calls.includes('focus'));
});
test('macOS keeps active mpv overlay visible and click-through during tracker refresh', () => {
const { window, calls } = createMainWindowRecorder();
const osdMessages: string[] = [];
const tracker: WindowTrackerStub = {
@@ -961,7 +1053,7 @@ test('macOS keeps active mpv overlay visible and interactive during tracker refr
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
@@ -969,7 +1061,7 @@ test('macOS keeps active mpv overlay visible and interactive during tracker refr
assert.deepEqual(osdMessages, []);
});
test('macOS tracked overlay releases topmost level when mpv loses foreground', () => {
test('macOS tracked overlay hides when mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
@@ -977,6 +1069,9 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
@@ -1006,14 +1101,202 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('show'));
});
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('hide'));
});
test('macOS lets an active overlay receive mouse input instead of forcing passthrough', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(!calls.includes('mouse-ignore:true:forward'));
assert.ok(!calls.includes('hide'));
});
test('macOS focuses an active overlay so lookup trigger keys reach it', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('focus'));
assert.ok(!calls.includes('hide'));
});
test('macOS tracked overlay passively reappears when mpv regains foreground', () => {
const { window, calls } = createMainWindowRecorder();
let targetFocused = false;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => targetFocused,
};
window.show();
calls.length = 0;
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
run();
assert.ok(calls.includes('hide'));
calls.length = 0;
targetFocused = true;
run();
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('enforce-order'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => {
const { window, calls } = createMainWindowRecorder();
const osdMessages: string[] = [];
@@ -1060,7 +1343,7 @@ test('macOS preserves an already visible active mpv overlay while tracker is tem
assert.equal(trackerWarning, false);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('hide'));
@@ -1101,7 +1384,8 @@ test('forced mouse passthrough keeps macOS tracked overlay passive while visible
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
@@ -1390,7 +1674,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
assert.deepEqual(osdMessages, []);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
@@ -1398,6 +1682,156 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
assert.ok(!calls.includes('show'));
});
test('macOS hides visible overlay during tracker loss after mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: () => {
calls.push('loading-osd');
},
} as never);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('loading-osd'));
});
test('macOS keeps a focused overlay visible during tracker loss', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
window.show();
setFocused(true);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: () => {
calls.push('loading-osd');
},
} as never);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('loading-osd'));
});
test('macOS keeps an interactive overlay visible during tracker loss even when Electron focus drops', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: () => {
calls.push('loading-osd');
},
} as never);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('loading-osd'));
});
test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => {
const { window } = createMainWindowRecorder();
const osdMessages: string[] = [];
+84 -16
View File
@@ -15,6 +15,17 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
opacityCapableWindow.setOpacity?.(opacity);
}
function releaseOverlayWindowLevel(window: BrowserWindow): void {
window.setAlwaysOnTop(false);
const allWorkspacesWindow = window as BrowserWindow & {
setVisibleOnAllWorkspaces?: (
visible: boolean,
options?: { visibleOnFullScreen?: boolean },
) => void;
};
allWorkspacesWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
}
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
if (!pendingTimeout) {
@@ -52,6 +63,7 @@ export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
modalActive?: boolean;
forceMousePassthrough?: boolean;
overlayInteractionActive?: boolean;
mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null;
lastKnownWindowsForegroundProcessName?: string | null;
@@ -78,6 +90,7 @@ export function updateVisibleOverlayVisibility(args: {
}
const mainWindow = args.mainWindow;
const overlayInteractionActive = args.overlayInteractionActive === true;
if (args.modalActive) {
if (args.isWindowsPlatform) {
@@ -93,16 +106,34 @@ export function updateVisibleOverlayVisibility(args: {
const forceMousePassthrough = args.forceMousePassthrough === true;
const wasVisible = mainWindow.isVisible();
const isVisibleOverlayFocused =
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
overlayInteractionActive ||
(typeof mainWindow.isFocused === 'function' && mainWindow.isFocused());
const windowTracker = args.windowTracker;
const canReportMacOSTargetMinimized =
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
const isTrackedMacOSTargetMinimized =
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
const hasTransientMacOSTrackerLoss =
args.isMacOSPlatform &&
canReportMacOSTargetMinimized &&
!!windowTracker &&
!windowTracker.isTracking() &&
!isTrackedMacOSTargetMinimized &&
trackedMacOSTargetFocused !== false &&
mainWindow.isVisible();
const isTrackedMacOSTargetFocused =
!args.isMacOSPlatform || !args.windowTracker
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
? true
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
: (trackedMacOSTargetFocused ?? true);
const shouldReleaseMacOSOverlayLevel =
args.isMacOSPlatform &&
!!args.windowTracker &&
!hasTransientMacOSTrackerLoss &&
!isVisibleOverlayFocused &&
!isTrackedMacOSTargetFocused;
// Renderer hover tracking temporarily disables this for subtitle and popup interaction.
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform && !overlayInteractionActive;
const shouldDefaultToPassthrough =
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
const windowsForegroundProcessName =
@@ -127,6 +158,7 @@ export function updateVisibleOverlayVisibility(args: {
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
const shouldIgnoreMouseEvents =
shouldUseMacOSMousePassthrough ||
forceMousePassthrough ||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
@@ -143,14 +175,22 @@ export function updateVisibleOverlayVisibility(args: {
mainWindow.setIgnoreMouseEvents(false);
}
if (shouldReleaseMacOSOverlayLevel) {
releaseOverlayWindowLevel(mainWindow);
if (wasVisible) {
mainWindow.hide();
}
return false;
}
if (shouldBindTrackedWindowsOverlay) {
// On Windows, z-order is enforced by the OS via the owner window mechanism
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
// without any manual z-order management.
} else if (!forceMousePassthrough && !shouldReleaseMacOSOverlayLevel) {
} else if (!forceMousePassthrough || args.isMacOSPlatform) {
args.ensureOverlayWindowLevel(mainWindow);
} else {
mainWindow.setAlwaysOnTop(false);
releaseOverlayWindowLevel(mainWindow);
}
if (!wasVisible) {
const hasWebContents =
@@ -163,16 +203,20 @@ export function updateVisibleOverlayVisibility(args: {
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
// callback will trigger another visibility update when the renderer
// has painted its first frame.
} else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) {
setOverlayWindowOpacity(mainWindow, 0);
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
scheduleWindowsOverlayReveal(
mainWindow,
shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined,
);
if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal(
mainWindow,
shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined,
);
}
} else {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
@@ -193,6 +237,16 @@ export function updateVisibleOverlayVisibility(args: {
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
}
if (
args.isMacOSPlatform &&
overlayInteractionActive &&
!forceMousePassthrough &&
typeof mainWindow.isFocused === 'function' &&
!mainWindow.isFocused()
) {
mainWindow.focus();
}
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
mainWindow.focus();
}
@@ -200,6 +254,11 @@ export function updateVisibleOverlayVisibility(args: {
return !shouldReleaseMacOSOverlayLevel;
};
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
shouldEnforceLayerOrder &&
!args.isWindowsPlatform &&
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
const maybeShowOverlayLoadingOsd = (): void => {
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
return;
@@ -242,7 +301,7 @@ export function updateVisibleOverlayVisibility(args: {
}
args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
args.enforceOverlayLayerOrder();
}
args.syncOverlayShortcuts();
@@ -274,9 +333,18 @@ export function updateVisibleOverlayVisibility(args: {
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
const hasActiveMacOSTargetSignal =
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
const hasActiveMacOSOverlaySignal = args.isMacOSPlatform && overlayInteractionActive;
const canReportMacOSTargetMinimized =
args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function';
const isTrackedMacOSTargetMinimized =
canReportMacOSTargetMinimized && args.windowTracker.isTargetWindowMinimized();
const shouldPreserveTransientTrackedOverlay =
(args.isMacOSPlatform &&
(hasRetainedTrackedGeometry || (mainWindow.isVisible() && hasActiveMacOSTargetSignal))) ||
!isTrackedMacOSTargetMinimized &&
(hasRetainedTrackedGeometry ||
(mainWindow.isVisible() && hasActiveMacOSOverlaySignal) ||
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
(args.isWindowsPlatform &&
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
!args.windowTracker.isTargetWindowMinimized());
@@ -292,7 +360,7 @@ export function updateVisibleOverlayVisibility(args: {
}
args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
args.enforceOverlayLayerOrder();
}
args.syncOverlayShortcuts();
+3 -2
View File
@@ -66,15 +66,16 @@ export function handleOverlayWindowBlurred(options: {
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
ensureOverlayWindowLevel: () => void;
moveWindowTop: () => void;
onWindowsVisibleOverlayBlur?: () => void;
onVisibleOverlayBlur?: () => void;
platform?: NodeJS.Platform;
}): boolean {
const platform = options.platform ?? process.platform;
if (platform === 'win32' && options.kind === 'visible') {
options.onWindowsVisibleOverlayBlur?.();
options.onVisibleOverlayBlur?.();
return false;
}
if (platform === 'darwin' && options.kind === 'visible') {
options.onVisibleOverlayBlur?.();
return false;
}
+7 -7
View File
@@ -136,14 +136,14 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
moveWindowTop: () => {
calls.push('move-top');
},
onWindowsVisibleOverlayBlur: () => {
calls.push('windows-visible-blur');
onVisibleOverlayBlur: () => {
calls.push('visible-blur');
},
platform: 'win32',
});
assert.equal(handled, false);
assert.deepEqual(calls, ['windows-visible-blur']);
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
@@ -166,7 +166,7 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visible overlay blur', () => {
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
@@ -179,14 +179,14 @@ test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visib
moveWindowTop: () => {
calls.push('move-top');
},
onWindowsVisibleOverlayBlur: () => {
calls.push('windows-visible-blur');
onVisibleOverlayBlur: () => {
calls.push('visible-blur');
},
platform: 'darwin',
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
+1 -1
View File
@@ -180,7 +180,7 @@ export function createOverlayWindow(
moveWindowTop: () => {
window.moveTop();
},
onWindowsVisibleOverlayBlur:
onVisibleOverlayBlur:
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
});
});
@@ -223,3 +223,23 @@ test('runStartupBootstrapRuntime enables quiet background mode by default', () =
assert.equal(result.backgroundMode, true);
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
});
test('runStartupBootstrapRuntime enables quiet update mode by default', () => {
const calls: string[] = [];
const args = makeArgs({ update: true });
const result = runStartupBootstrapRuntime({
argv: ['node', 'main.ts', '--update'],
parseArgs: () => args,
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.equal(result.backgroundMode, false);
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
});
+86
View File
@@ -358,3 +358,89 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime'
assert.ok(calls.indexOf('init-overlay') !== -1);
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
});
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
const calls: string[] = [];
await runAppReadyRuntime({
ensureDefaultConfigBootstrap: () => {
calls.push('bootstrap');
},
loadSubtitlePosition: () => {
calls.push('load-subtitle-position');
},
resolveKeybindings: () => {
calls.push('resolve-keybindings');
},
createMpvClient: () => {
calls.push('create-mpv');
},
reloadConfig: () => {
calls.push('reload-config');
},
getResolvedConfig: () => ({
websocket: { enabled: false },
annotationWebsocket: { enabled: false },
texthooker: { launchAtStartup: false },
}),
getConfigWarnings: () => [],
logConfigWarning: () => {},
setLogLevel: () => {
calls.push('set-log-level');
},
initRuntimeOptionsManager: () => {
calls.push('init-runtime-options');
},
setSecondarySubMode: () => {
calls.push('set-secondary-sub-mode');
},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 0,
defaultAnnotationWebsocketPort: 0,
defaultTexthookerPort: 0,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
startAnnotationWebsocket: () => {},
startTexthooker: () => {},
log: () => {
calls.push('log');
},
createMecabTokenizerAndCheck: async () => {},
createSubtitleTimingTracker: () => {
calls.push('subtitle-timing');
},
createImmersionTracker: () => {
calls.push('immersion');
},
startJellyfinRemoteSession: async () => {},
loadYomitanExtension: async () => {
calls.push('load-yomitan-direct');
},
ensureYomitanExtensionLoaded: async () => {
calls.push('load-yomitan-guarded');
},
handleFirstRunSetup: async () => {
calls.push('first-run');
},
prewarmSubtitleDictionaries: async () => {},
startBackgroundWarmups: () => {
calls.push('warmups');
},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {
calls.push('visible-overlay');
},
initializeOverlayRuntime: () => {
calls.push('init-overlay');
},
handleInitialArgs: () => {
calls.push('handle-initial-args');
},
shouldUseMinimalStartup: () => false,
shouldSkipHeavyStartup: () => false,
});
assert.equal(calls.includes('load-yomitan-direct'), false);
assert.equal(calls.includes('load-yomitan-guarded'), true);
});
+8 -13
View File
@@ -44,7 +44,7 @@ export function runStartupBootstrapRuntime(
if (initialArgs.logLevel) {
deps.setLogLevel(initialArgs.logLevel, 'cli');
} else if (initialArgs.background) {
} else if (initialArgs.background || initialArgs.update) {
deps.setLogLevel('warn', 'cli');
}
@@ -131,6 +131,7 @@ export interface AppReadyRuntimeDeps {
createImmersionTracker?: () => void;
startJellyfinRemoteSession?: () => Promise<void>;
loadYomitanExtension: () => Promise<void>;
ensureYomitanExtensionLoaded?: () => Promise<void>;
handleFirstRunSetup: () => Promise<void>;
prewarmSubtitleDictionaries?: () => Promise<void>;
startBackgroundWarmups: () => void;
@@ -215,6 +216,8 @@ export function isAutoUpdateEnabledRuntime(
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
const now = deps.now ?? (() => Date.now());
const startupStartedAtMs = now();
const ensureYomitanExtensionReady =
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
deps.ensureDefaultConfigBootstrap();
if (deps.shouldRunHeadlessInitialCommand?.()) {
deps.reloadConfig();
@@ -224,7 +227,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
} else {
deps.createMpvClient();
deps.createSubtitleTimingTracker();
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
deps.initializeOverlayRuntime();
deps.handleInitialArgs();
}
@@ -237,18 +240,10 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
return;
}
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
return;
}
deps.logDebug?.('App-ready critical path started.');
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
@@ -319,12 +314,12 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.texthookerOnlyMode) {
deps.log('Texthooker-only mode enabled; skipping overlay window.');
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
deps.setVisibleOverlayVisible(true);
deps.initializeOverlayRuntime();
} else {
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
}
await deps.handleFirstRunSetup();
+19
View File
@@ -9,6 +9,8 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
Partial<Pick<BrowserWindow, 'showInactive'>>;
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
return (
@@ -104,6 +106,23 @@ export function promoteStatsWindowLevel(
window.moveTop();
}
export function presentStatsWindow(
window: StatsWindowPresentationController,
platform: NodeJS.Platform = process.platform,
): void {
if (platform === 'darwin') {
if (window.showInactive) {
window.showInactive();
} else {
window.show();
}
return;
}
window.show();
window.focus();
}
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
query: Record<string, string>;
} {
+43
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowOptions,
presentStatsWindow,
promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput,
@@ -230,3 +231,45 @@ test('promoteStatsWindowLevel raises stats above overlay level on Windows', () =
assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']);
});
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
const calls: string[] = [];
presentStatsWindow(
{
show: () => {
calls.push('show');
},
showInactive: () => {
calls.push('show-inactive');
},
focus: () => {
calls.push('focus');
},
} as never,
'darwin',
);
assert.deepEqual(calls, ['show-inactive']);
});
test('presentStatsWindow shows and focuses on non-macOS platforms', () => {
const calls: string[] = [];
presentStatsWindow(
{
show: () => {
calls.push('show');
},
showInactive: () => {
calls.push('show-inactive');
},
focus: () => {
calls.push('focus');
},
} as never,
'linux',
);
assert.deepEqual(calls, ['show', 'focus']);
});
+2 -2
View File
@@ -5,6 +5,7 @@ import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowOptions,
presentStatsWindow,
promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput,
@@ -49,14 +50,13 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
const bounds = options.resolveBounds();
let placementBounds = syncStatsWindowBounds(window, bounds);
promoteStatsWindowLevel(window);
window.show();
presentStatsWindow(window);
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
if (
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
) {
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
}
window.focus();
options.onVisibilityChanged?.(true);
promoteStatsWindowLevel(window);
}
+103 -4
View File
@@ -9,6 +9,13 @@ const YOMITAN_SYNC_SCRIPT_PATHS = [
path.join('js', 'display', 'display-audio.js'),
];
type ExtensionCopyResult = {
targetDir: string;
copied: boolean;
};
const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>();
function readManifestVersion(manifestPath: string): string | null {
try {
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { version?: unknown };
@@ -18,6 +25,15 @@ function readManifestVersion(manifestPath: string): string | null {
}
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath);
return true;
} catch {
return false;
}
}
export function hashDirectoryContents(dirPath: string): string | null {
try {
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
@@ -53,6 +69,42 @@ export function hashDirectoryContents(dirPath: string): string | null {
}
}
export async function hashDirectoryContentsAsync(dirPath: string): Promise<string | null> {
try {
const dirStat = await fs.promises.stat(dirPath);
if (!dirStat.isDirectory()) {
return null;
}
const hash = createHash('sha256');
const queue = [''];
while (queue.length > 0) {
const relativeDir = queue.shift()!;
const absoluteDir = path.join(dirPath, relativeDir);
const entries = await fs.promises.readdir(absoluteDir, { withFileTypes: true });
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const relativePath = path.join(relativeDir, entry.name);
const normalizedRelativePath = relativePath.split(path.sep).join('/');
hash.update(normalizedRelativePath);
if (entry.isDirectory()) {
queue.push(relativePath);
continue;
}
if (!entry.isFile()) {
continue;
}
hash.update(await fs.promises.readFile(path.join(dirPath, relativePath)));
}
}
return hash.digest('hex');
} catch {
return null;
}
}
function areFilesEqual(sourcePath: string, targetPath: string): boolean {
if (!fs.existsSync(sourcePath) || !fs.existsSync(targetPath)) return false;
try {
@@ -93,10 +145,7 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
export function ensureExtensionCopy(
sourceDir: string,
userDataPath: string,
): {
targetDir: string;
copied: boolean;
} {
): ExtensionCopyResult {
if (process.platform === 'win32') {
return { targetDir: sourceDir, copied: false };
}
@@ -117,3 +166,53 @@ export function ensureExtensionCopy(
return { targetDir, copied: shouldCopy };
}
export async function ensureExtensionCopyAsync(
sourceDir: string,
userDataPath: string,
): Promise<ExtensionCopyResult> {
if (process.platform === 'win32') {
return { targetDir: sourceDir, copied: false };
}
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
const inFlightKey = path.resolve(targetDir);
const inFlight = asyncExtensionCopyInFlight.get(inFlightKey);
if (inFlight) {
return await inFlight;
}
const copyPromise = ensureExtensionCopyAsyncInternal(sourceDir, extensionsRoot, targetDir);
asyncExtensionCopyInFlight.set(inFlightKey, copyPromise);
try {
return await copyPromise;
} finally {
if (asyncExtensionCopyInFlight.get(inFlightKey) === copyPromise) {
asyncExtensionCopyInFlight.delete(inFlightKey);
}
}
}
async function ensureExtensionCopyAsyncInternal(
sourceDir: string,
extensionsRoot: string,
targetDir: string,
): Promise<ExtensionCopyResult> {
let shouldCopy = !(await pathExists(targetDir));
if (!shouldCopy) {
const [sourceHash, targetHash] = await Promise.all([
hashDirectoryContentsAsync(sourceDir),
hashDirectoryContentsAsync(targetDir),
]);
shouldCopy = sourceHash !== targetHash;
}
if (shouldCopy) {
await fs.promises.mkdir(extensionsRoot, { recursive: true });
await fs.promises.rm(targetDir, { recursive: true, force: true });
await fs.promises.cp(sourceDir, targetDir, { recursive: true });
}
return { targetDir, copied: shouldCopy };
}
@@ -4,7 +4,12 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { ensureExtensionCopy, shouldCopyYomitanExtension } from './yomitan-extension-copy';
import {
ensureExtensionCopy,
ensureExtensionCopyAsync,
shouldCopyYomitanExtension,
} from './yomitan-extension-copy';
import { withSuppressedYomitanExtensionWarnings } from './yomitan-extension-loader';
function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -15,6 +20,66 @@ function writeFile(filePath: string, content: string): void {
fs.writeFileSync(filePath, content, 'utf-8');
}
test('suppresses Yomitan contextMenus extension load warnings only while loading', async () => {
const emitted: string[] = [];
const warningProcess = {
emitWarning: (warning: string | Error, options?: { type?: string }) => {
const message = warning instanceof Error ? warning.message : warning;
emitted.push(`${options?.type ?? ''}:${message}`);
},
} as Pick<NodeJS.Process, 'emitWarning'>;
await withSuppressedYomitanExtensionWarnings(async () => {
warningProcess.emitWarning(
"Warnings loading extension:\nPermission 'contextMenus' is unknown.",
{
type: 'ExtensionLoadWarning',
},
);
warningProcess.emitWarning('Other extension warning', { type: 'ExtensionLoadWarning' });
return null;
}, warningProcess);
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
type: 'ExtensionLoadWarning',
});
assert.deepEqual(emitted, [
'ExtensionLoadWarning:Other extension warning',
"ExtensionLoadWarning:Permission 'contextMenus' is unknown.",
]);
});
test('suppressed Yomitan warning wrapper is re-entrant safe', async () => {
const emitted: string[] = [];
const warningProcess = {
emitWarning: (warning: string | Error, options?: { type?: string }) => {
const message = warning instanceof Error ? warning.message : warning;
emitted.push(`${options?.type ?? ''}:${message}`);
},
} as Pick<NodeJS.Process, 'emitWarning'>;
const originalEmitWarning = warningProcess.emitWarning;
await withSuppressedYomitanExtensionWarnings(async () => {
await withSuppressedYomitanExtensionWarnings(async () => {
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
type: 'ExtensionLoadWarning',
});
warningProcess.emitWarning('Nested warning', { type: 'ExtensionLoadWarning' });
}, warningProcess);
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
type: 'ExtensionLoadWarning',
});
warningProcess.emitWarning('Outer warning', { type: 'ExtensionLoadWarning' });
}, warningProcess);
assert.equal(warningProcess.emitWarning, originalEmitWarning);
assert.deepEqual(emitted, [
'ExtensionLoadWarning:Nested warning',
'ExtensionLoadWarning:Outer warning',
]);
});
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
const tempRoot = makeTempDir('subminer-yomitan-copy-');
const sourceDir = path.join(tempRoot, 'source');
@@ -82,3 +147,112 @@ test('ensureExtensionCopy refreshes copied extension when display files change',
'new display code',
);
});
test('ensureExtensionCopyAsync refreshes copied extension without completing synchronously', async () => {
const sourceRoot = makeTempDir('subminer-yomitan-src-');
const userDataRoot = makeTempDir('subminer-yomitan-user-');
const sourceDir = path.join(sourceRoot, 'yomitan');
const targetDir = path.join(userDataRoot, 'extensions', 'yomitan');
fs.mkdirSync(path.join(sourceDir, 'js', 'display'), { recursive: true });
fs.mkdirSync(path.join(targetDir, 'js', 'display'), { recursive: true });
fs.writeFileSync(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(
path.join(sourceDir, 'js', 'display', 'structured-content-generator.js'),
'new display code',
);
fs.writeFileSync(
path.join(targetDir, 'js', 'display', 'structured-content-generator.js'),
'old display code',
);
let completed = false;
const resultPromise = ensureExtensionCopyAsync(sourceDir, userDataRoot).then((result) => {
completed = true;
return result;
});
assert.equal(completed, false);
const result = await resultPromise;
assert.equal(result.targetDir, targetDir);
assert.equal(result.copied, true);
assert.equal(
fs.readFileSync(
path.join(targetDir, 'js', 'display', 'structured-content-generator.js'),
'utf8',
),
'new display code',
);
});
test('ensureExtensionCopyAsync shares an in-flight refresh for the same copied extension', async () => {
const sourceRoot = makeTempDir('subminer-yomitan-src-');
const userDataRoot = makeTempDir('subminer-yomitan-user-');
const sourceDir = path.join(sourceRoot, 'yomitan');
const targetDir = path.join(userDataRoot, 'extensions', 'yomitan');
fs.mkdirSync(path.join(sourceDir, 'js', 'pages', 'settings'), { recursive: true });
fs.mkdirSync(path.join(targetDir, 'js', 'pages', 'settings'), { recursive: true });
fs.writeFileSync(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(
path.join(sourceDir, 'js', 'pages', 'settings', 'settings-main.js'),
'new settings code',
);
fs.writeFileSync(
path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'),
'old settings code',
);
const originalCp = fs.promises.cp;
let cpCalls = 0;
let firstCopyStarted = false;
let releaseFirstCopy: () => void = () => {};
const firstCopyStartedPromise = new Promise<void>((resolve) => {
const fsPromises = fs.promises as typeof fs.promises & {
cp: typeof fs.promises.cp;
};
fsPromises.cp = async (...args: Parameters<typeof fs.promises.cp>) => {
cpCalls++;
if (!firstCopyStarted) {
firstCopyStarted = true;
resolve();
await new Promise<void>((release) => {
releaseFirstCopy = release;
});
}
return await originalCp(...args);
};
});
try {
const first = ensureExtensionCopyAsync(sourceDir, userDataRoot);
await firstCopyStartedPromise;
const second = ensureExtensionCopyAsync(sourceDir, userDataRoot);
releaseFirstCopy();
const results = await Promise.all([first, second]);
assert.equal(cpCalls, 1);
assert.equal(results[0].targetDir, targetDir);
assert.equal(results[1].targetDir, targetDir);
assert.equal(results[0].copied, true);
assert.equal(results[1].copied, true);
assert.equal(
fs.readFileSync(path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'), 'utf8'),
'new settings code',
);
} finally {
const fsPromises = fs.promises as typeof fs.promises & {
cp: typeof fs.promises.cp;
};
fsPromises.cp = originalCp;
}
});
+102 -10
View File
@@ -3,7 +3,7 @@ import type { BrowserWindow, Extension, Session } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger';
import { ensureExtensionCopy } from './yomitan-extension-copy';
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
import {
getYomitanExtensionSearchPaths,
resolveExternalYomitanExtensionPath,
@@ -29,6 +29,85 @@ export interface YomitanExtensionLoaderDeps {
setYomitanSession: (session: Session | null) => void;
}
type WarningProcess = Pick<NodeJS.Process, 'emitWarning'>;
const suppressedWarningState = new WeakMap<
WarningProcess,
{
count: number;
originalEmitWarning: WarningProcess['emitWarning'];
}
>();
function getWarningType(warning: string | Error, args: unknown[]): string | undefined {
if (typeof warning !== 'string') {
return warning.name;
}
const firstArg = args[0];
if (typeof firstArg === 'string') {
return firstArg;
}
if (firstArg && typeof firstArg === 'object' && 'type' in firstArg) {
const type = (firstArg as { type?: unknown }).type;
return typeof type === 'string' ? type : undefined;
}
return undefined;
}
function shouldSuppressYomitanExtensionWarning(warning: string | Error, args: unknown[]): boolean {
const message = warning instanceof Error ? warning.message : warning;
return (
getWarningType(warning, args) === 'ExtensionLoadWarning' &&
message.includes("Permission 'contextMenus' is unknown.")
);
}
export async function withSuppressedYomitanExtensionWarnings<T>(
run: () => Promise<T>,
warningProcess: WarningProcess = process,
): Promise<T> {
const existingState = suppressedWarningState.get(warningProcess);
if (existingState) {
existingState.count++;
try {
return await run();
} finally {
existingState.count--;
if (existingState.count === 0) {
warningProcess.emitWarning = existingState.originalEmitWarning;
suppressedWarningState.delete(warningProcess);
}
}
}
const originalEmitWarning = warningProcess.emitWarning;
const state = {
count: 1,
originalEmitWarning,
};
suppressedWarningState.set(warningProcess, state);
warningProcess.emitWarning = ((warning: string | Error, ...args: unknown[]) => {
if (shouldSuppressYomitanExtensionWarning(warning, args)) {
return;
}
return (originalEmitWarning as (...emitArgs: unknown[]) => void).call(
warningProcess,
warning,
...args,
);
}) as typeof process.emitWarning;
try {
return await run();
} finally {
state.count--;
if (state.count === 0) {
warningProcess.emitWarning = originalEmitWarning;
suppressedWarningState.delete(warningProcess);
}
}
}
export async function loadYomitanExtension(
deps: YomitanExtensionLoaderDeps,
): Promise<Extension | null> {
@@ -79,9 +158,20 @@ export async function loadYomitanExtension(
return null;
}
const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath);
let extensionCopy: { copied: boolean; targetDir: string };
try {
extensionCopy = await ensureExtensionCopyAsync(extPath, deps.userDataPath);
} catch (error) {
logger.error('Failed to copy Yomitan extension:', {
error,
extensionPath: extPath,
userDataPath: deps.userDataPath,
});
clearRuntimeState();
return null;
}
if (extensionCopy.copied) {
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
logger.debug(`Copied yomitan extension to ${extensionCopy.targetDir}`);
}
extPath = extensionCopy.targetDir;
}
@@ -91,13 +181,15 @@ export async function loadYomitanExtension(
try {
const extensions = targetSession.extensions;
const extension = extensions
? await extensions.loadExtension(extPath, {
allowFileAccess: true,
})
: await targetSession.loadExtension(extPath, {
allowFileAccess: true,
});
const extension = await withSuppressedYomitanExtensionWarnings(() =>
extensions
? extensions.loadExtension(extPath, {
allowFileAccess: true,
})
: targetSession.loadExtension(extPath, {
allowFileAccess: true,
}),
);
deps.setYomitanExtension(extension);
return extension;
} catch (err) {
+145
View File
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildYomitanSettingsCloseButtonScript,
buildYomitanSettingsWindowMenuTemplate,
buildYomitanSettingsUrl,
configureYomitanSettingsWindowChrome,
destroyYomitanSettingsWindow,
installYomitanSettingsCloseButton,
showYomitanSettingsWindow,
shouldInstallYomitanSettingsCloseButton,
} from './yomitan-settings';
test('yomitan settings window uses a close-only menu without app quit', () => {
const calls: string[] = [];
configureYomitanSettingsWindowChrome({
isDestroyed: () => false,
close: () => calls.push('close'),
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
} as never, (template) => {
calls.push(`menu-label:${template[0]?.label ?? ''}`);
const submenu = template[0]?.submenu;
assert.ok(Array.isArray(submenu));
const closeItem = submenu[0];
assert.equal(closeItem?.label, 'Close');
assert.notEqual(closeItem?.role, 'quit');
closeItem?.click?.({} as never, {} as never, {} as never);
return { id: 'settings-menu' } as never;
});
assert.deepEqual(calls, ['auto-hide:false', 'menu-label:File', 'close', 'menu:custom']);
});
test('yomitan settings close menu skips destroyed windows', () => {
const calls: string[] = [];
const template = buildYomitanSettingsWindowMenuTemplate({
isDestroyed: () => true,
close: () => calls.push('close'),
} as never);
const submenu = template[0]?.submenu;
assert.ok(Array.isArray(submenu));
submenu[0]?.click?.({} as never, {} as never, {} as never);
assert.deepEqual(calls, []);
});
test('yomitan settings close button script installs an idempotent in-page close control', () => {
const script = buildYomitanSettingsCloseButtonScript();
assert.match(script, /subminer-yomitan-settings-close/);
assert.match(script, /aria-label', 'Close Yomitan settings'/);
assert.match(script, /window\.close\(\)/);
assert.match(script, /getElementById\(buttonId\)/);
});
test('yomitan settings close button only installs for Hyprland sessions', () => {
assert.equal(
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
true,
);
assert.equal(
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: '' }),
false,
);
assert.equal(
shouldInstallYomitanSettingsCloseButton('darwin', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
false,
);
assert.equal(
shouldInstallYomitanSettingsCloseButton('win32', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
false,
);
});
test('yomitan settings close button injection skips non-Hyprland windows', () => {
const calls: string[] = [];
installYomitanSettingsCloseButton(
{
isDestroyed: () => false,
webContents: {
executeJavaScript: () => {
calls.push('execute');
return Promise.resolve();
},
},
} as never,
{ platform: 'darwin', env: { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' } },
);
assert.deepEqual(calls, []);
});
test('yomitan settings URL disables the embedded popup preview', () => {
assert.equal(
buildYomitanSettingsUrl('abc123'),
'chrome-extension://abc123/settings.html?popup-preview=false',
);
});
test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existing window', () => {
const calls: string[] = [];
showYomitanSettingsWindow({
isDestroyed: () => false,
isMinimized: () => true,
restore: () => calls.push('restore'),
getSize: () => [1200, 800],
setSize: (width: number, height: number) => calls.push(`set-size:${width}x${height}`),
webContents: {
invalidate: () => calls.push('invalidate'),
},
show: () => calls.push('show'),
focus: () => calls.push('focus'),
} as never);
assert.deepEqual(calls, ['restore', 'set-size:1200x800', 'invalidate', 'show', 'focus']);
});
test('destroyYomitanSettingsWindow destroys a live settings window before app quit', () => {
const calls: string[] = [];
const destroyed = destroyYomitanSettingsWindow({
isDestroyed: () => false,
destroy: () => calls.push('destroy'),
} as never);
assert.equal(destroyed, true);
assert.deepEqual(calls, ['destroy']);
});
test('destroyYomitanSettingsWindow skips missing or already destroyed settings windows', () => {
assert.equal(destroyYomitanSettingsWindow(null), false);
assert.equal(
destroyYomitanSettingsWindow({
isDestroyed: () => true,
destroy: () => {
throw new Error('should not destroy twice');
},
} as never),
false,
);
});
+154 -11
View File
@@ -1,8 +1,8 @@
import electron from 'electron';
import type { BrowserWindow, Extension, Session } from 'electron';
import type { BrowserWindow, Extension, Menu, MenuItemConstructorOptions, Session } from 'electron';
import { createLogger } from '../../logger';
const { BrowserWindow: ElectronBrowserWindow, session } = electron;
const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = electron;
const logger = createLogger('main:yomitan-settings');
export interface OpenYomitanSettingsWindowOptions {
@@ -13,6 +13,151 @@ export interface OpenYomitanSettingsWindowOptions {
onWindowClosed?: () => void;
}
type YomitanSettingsWindowMenuOwner = Pick<BrowserWindow, 'close' | 'isDestroyed'>;
type HyprlandSessionEnv = {
HYPRLAND_INSTANCE_SIGNATURE?: string;
};
export interface InstallYomitanSettingsCloseButtonOptions {
platform?: NodeJS.Platform;
env?: HyprlandSessionEnv;
}
export function shouldInstallYomitanSettingsCloseButton(
platform: NodeJS.Platform = process.platform,
env: HyprlandSessionEnv = process.env,
): boolean {
return platform === 'linux' && Boolean(env.HYPRLAND_INSTANCE_SIGNATURE);
}
export function buildYomitanSettingsWindowMenuTemplate(
settingsWindow: YomitanSettingsWindowMenuOwner,
): MenuItemConstructorOptions[] {
return [
{
label: 'File',
submenu: [
{
label: 'Close',
accelerator: process.platform === 'darwin' ? 'Command+W' : 'Ctrl+W',
click: () => {
if (!settingsWindow.isDestroyed()) {
settingsWindow.close();
}
},
},
],
},
];
}
export function buildYomitanSettingsCloseButtonScript(): string {
return `
(() => {
const buttonId = 'subminer-yomitan-settings-close';
const styleId = 'subminer-yomitan-settings-close-style';
if (document.getElementById(buttonId)) {
return;
}
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = \`
#\${buttonId} {
position: fixed;
top: 10px;
left: 10px;
z-index: 2147483647;
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 4px;
background: rgba(24, 24, 24, 0.92);
color: #f2f2f2;
font: 22px/1 system-ui, sans-serif;
cursor: pointer;
}
#\${buttonId}:hover {
background: rgba(54, 54, 54, 0.96);
border-color: rgba(255, 255, 255, 0.5);
}
#\${buttonId}:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: 2px;
}
\`;
document.head.appendChild(style);
}
const button = document.createElement('button');
button.id = buttonId;
button.type = 'button';
button.title = 'Close';
button.setAttribute('aria-label', 'Close Yomitan settings');
button.textContent = '\\u00d7';
button.addEventListener('click', () => {
window.close();
});
document.body.appendChild(button);
})();
`;
}
export function installYomitanSettingsCloseButton(
settingsWindow: Pick<BrowserWindow, 'isDestroyed' | 'webContents'>,
options: InstallYomitanSettingsCloseButtonOptions = {},
): void {
if (settingsWindow.isDestroyed()) {
return;
}
if (!shouldInstallYomitanSettingsCloseButton(options.platform, options.env)) {
return;
}
settingsWindow.webContents
.executeJavaScript(buildYomitanSettingsCloseButtonScript())
.catch((error: Error) => {
logger.warn('Failed to install Yomitan settings close button:', error.message);
});
}
export function configureYomitanSettingsWindowChrome(
settingsWindow: Pick<BrowserWindow, 'close' | 'isDestroyed' | 'setAutoHideMenuBar' | 'setMenu'>,
buildMenu: (template: MenuItemConstructorOptions[]) => Menu = (template) =>
ElectronMenu.buildFromTemplate(template),
): void {
settingsWindow.setAutoHideMenuBar(false);
settingsWindow.setMenu(buildMenu(buildYomitanSettingsWindowMenuTemplate(settingsWindow)));
}
export function buildYomitanSettingsUrl(extensionId: string): string {
return `chrome-extension://${extensionId}/settings.html?popup-preview=false`;
}
export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
if (settingsWindow.isDestroyed()) {
return;
}
if (settingsWindow.isMinimized()) {
settingsWindow.restore();
}
const [width = 0, height = 0] = settingsWindow.getSize();
settingsWindow.setSize(width, height);
settingsWindow.webContents.invalidate();
settingsWindow.show();
settingsWindow.focus();
}
export function destroyYomitanSettingsWindow(settingsWindow: BrowserWindow | null): boolean {
if (!settingsWindow || settingsWindow.isDestroyed()) {
return false;
}
settingsWindow.destroy();
return true;
}
export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOptions): void {
logger.info('openYomitanSettings called');
@@ -24,8 +169,8 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
const existingWindow = options.getExistingWindow();
if (existingWindow && !existingWindow.isDestroyed()) {
logger.info('Settings window already exists, focusing');
existingWindow.focus();
logger.info('Settings window already exists, showing and focusing');
showYomitanSettingsWindow(existingWindow);
return;
}
@@ -35,15 +180,17 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
width: 1200,
height: 800,
show: false,
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
session: options.yomitanSession ?? session.defaultSession,
},
});
configureYomitanSettingsWindowChrome(settingsWindow);
options.setWindow(settingsWindow);
const settingsUrl = `chrome-extension://${options.yomitanExt.id}/settings.html`;
const settingsUrl = buildYomitanSettingsUrl(options.yomitanExt.id);
logger.info('Loading settings URL:', settingsUrl);
let loadAttempts = 0;
@@ -73,15 +220,11 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
settingsWindow.webContents.on('did-finish-load', () => {
logger.info('Settings page loaded successfully');
installYomitanSettingsCloseButton(settingsWindow);
});
setTimeout(() => {
if (!settingsWindow.isDestroyed()) {
const [width = 0, height = 0] = settingsWindow.getSize();
settingsWindow.setSize(width, height);
settingsWindow.webContents.invalidate();
settingsWindow.show();
}
showYomitanSettingsWindow(settingsWindow);
}, 500);
settingsWindow.on('closed', () => {
+271 -19
View File
@@ -82,6 +82,7 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
return {
shouldUseMinimalStartup: Boolean(
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
initialArgs?.update ||
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
),
@@ -90,6 +91,7 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
(shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.stats ||
initialArgs.dictionary ||
initialArgs.update ||
initialArgs.setup),
),
};
@@ -124,6 +126,7 @@ import type {
SecondarySubMode,
SubtitleData,
SubtitlePosition,
UpdateChannel,
WindowGeometry,
} from './types';
import { AnkiIntegration } from './anki-integration';
@@ -313,6 +316,7 @@ import {
createTokenizerDepsRuntime,
cycleSecondarySubMode as cycleSecondarySubModeCore,
deleteYomitanDictionaryByTitle,
destroyYomitanSettingsWindow,
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
getYomitanDictionaryInfo,
@@ -363,6 +367,7 @@ import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/
import {
createFirstRunSetupService,
getFirstRunSetupCompletionMessage,
isStandaloneFirstRunSetupCommand,
shouldAutoOpenFirstRunSetup,
} from './main/runtime/first-run-setup-service';
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
@@ -393,6 +398,11 @@ import {
detectWindowsMpvShortcuts,
resolveWindowsMpvShortcutPaths,
} from './main/runtime/windows-mpv-shortcuts';
import {
detectCommandLineLauncher,
installBun as installCommandLineBun,
installLauncher as installCommandLineLauncher,
} from './main/runtime/command-line-launcher';
import {
createWindowsMpvLaunchDeps,
getConfiguredWindowsMpvPathStatus,
@@ -405,7 +415,10 @@ import {
toggleJellyfinDiscoveryFromTray as toggleJellyfinDiscoveryFromTrayRuntime,
} from './main/runtime/jellyfin-tray-discovery';
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
import {
shouldEnsureTrayOnStartupForInitialArgs,
shouldQuitOnWindowAllClosedForTrayState,
} from './main/runtime/startup-tray-policy';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
import {
@@ -473,13 +486,13 @@ import { createOverlayModalInputState } from './main/runtime/overlay-modal-input
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
import { openCharacterDictionaryModal as openCharacterDictionaryModalRuntime } from './main/runtime/character-dictionary-open';
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
import { openOverlayHostedModal } from './main/runtime/overlay-hosted-modal-open';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import {
createFrequencyDictionaryRuntimeService,
@@ -498,6 +511,30 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import {
createElectronAppUpdater,
isNativeUpdaterSupported,
} from './main/runtime/update/app-updater';
import {
fetchLatestStableRelease,
fetchReleaseAssetBuffer,
fetchReleaseAssetText,
findReleaseAsset,
parseSha256Sums,
type GitHubRelease,
} from './main/runtime/update/release-assets';
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs';
import {
runUpdateCliCommand,
writeUpdateCliCommandResponse,
} from './main/runtime/update/update-cli-command';
import {
createFileUpdateStateStore,
createUpdateService,
} from './main/runtime/update/update-service';
import { updateSupportAssetsFromRelease } from './main/runtime/update/support-assets';
import {
createRefreshSubtitlePrefetchFromActiveTrackHandler,
createResolveActiveSubtitleSidebarSourceHandler,
@@ -812,6 +849,9 @@ const appLogger = {
logInfo: (message: string) => {
logger.info(message);
},
logDebug: (message: string) => {
logger.debug(message);
},
logWarning: (message: string) => {
logger.warn(message);
},
@@ -875,6 +915,8 @@ function stopStatsServer(): void {
}
function requestAppQuit(): void {
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
appState.yomitanSettingsWindow = null;
destroyStatsWindow();
stopStatsServer();
if (!forceQuitTimer) {
@@ -1199,6 +1241,16 @@ const resolveWindowsMpvShortcutRuntimePaths = () =>
appDataDir: app.getPath('appData'),
desktopDir: app.getPath('desktop'),
});
const createCommandLineLauncherRuntimeOptions = () => ({
platform: process.platform,
env: process.env,
homeDir: os.homedir(),
localAppData: process.env.LOCALAPPDATA,
userProfile: process.env.USERPROFILE,
cwd: process.cwd(),
resourcesPath: process.resourcesPath,
appExePath: process.execPath,
});
syncInstalledFirstRunPluginBinaryPath({
platform: process.platform,
homeDir: os.homedir(),
@@ -1274,6 +1326,32 @@ const firstRunSetupService = createFirstRunSetupService({
shell.writeShortcutLink(shortcutPath, operation, details),
});
},
detectCommandLineLauncher: () =>
detectCommandLineLauncher(createCommandLineLauncherRuntimeOptions()),
installBun: async () => {
const snapshot = await installCommandLineBun(createCommandLineLauncherRuntimeOptions());
return {
ok: snapshot.status === 'ready',
message:
snapshot.message ??
(snapshot.status === 'ready'
? 'Bun is ready. Open a new terminal.'
: 'Bun installation failed.'),
};
},
installCommandLineLauncher: async () => {
const snapshot = await installCommandLineLauncher(createCommandLineLauncherRuntimeOptions());
const ok = snapshot.status === 'ready' || snapshot.status === 'installed_bun_missing';
return {
ok,
installPath: snapshot.installPath,
message:
snapshot.message ??
(ok
? 'Command-line launcher installed. Open a new terminal.'
: 'Command-line launcher installation failed.'),
};
},
onStateChanged: (state) => {
appState.firstRunSetupCompleted = state.status === 'completed';
if (appTray) {
@@ -1406,9 +1484,11 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
},
showMpvOsd: (text) => showMpvOsd(text),
openManualPicker: (payload) => {
sendToActiveOverlayWindow('subsync:open-manual', payload, {
restoreOnModalClose: 'subsync',
});
openOverlayHostedModalWithOsd(
(deps) => openSubsyncManualModalRuntime(deps, payload),
'Subsync overlay unavailable.',
'Failed to open subsync overlay.',
);
},
});
const immersionMediaRuntime = createImmersionMediaRuntime(
@@ -1989,6 +2069,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
getModalActive: () => overlayModalInputState.getModalInputExclusive(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible,
getOverlayInteractionActive: () => visibleOverlayInteractionActive,
getWindowTracker: () => appState.windowTracker,
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
@@ -2032,23 +2113,24 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
})(),
);
const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderSyncInFlight = false;
let windowsVisibleOverlayZOrderSyncQueued = false;
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0;
let visibleOverlayInteractionActive = false;
function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) {
function clearVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
clearTimeout(timeout);
}
windowsVisibleOverlayBlurRefreshTimeouts = [];
visibleOverlayBlurRefreshTimeouts = [];
}
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
@@ -2249,20 +2331,22 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
}
function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform !== 'win32') {
if (process.platform !== 'win32' && process.platform !== 'darwin') {
return;
}
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
clearWindowsVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
if (process.platform === 'win32') {
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
}
clearVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => {
windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter(
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
(timeout) => timeout !== refreshTimeout,
);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, delayMs);
windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
}
}
@@ -2749,6 +2833,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
mpvExecutablePath,
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
commandLineLauncher: snapshot.commandLineLauncher,
message: firstRunSetupMessage,
};
},
@@ -2784,6 +2869,16 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'install-bun') {
const snapshot = await firstRunSetupService.installBun();
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'install-command-line-launcher') {
const snapshot = await firstRunSetupService.installCommandLineLauncher();
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'open-yomitan-settings') {
firstRunSetupMessage = openYomitanSettings()
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
@@ -2816,6 +2911,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
},
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode,
shouldQuitWhenClosedCompleted: () =>
Boolean(appState.initialArgs && isStandaloneFirstRunSetupCommand(appState.initialArgs)),
quitApp: () => requestAppQuit(),
clearSetupWindow: () => {
appState.firstRunSetupWindow = null;
@@ -2950,6 +3047,7 @@ const {
resetAnilistMediaTracking,
getAnilistMediaGuessRuntimeState,
setAnilistMediaGuessRuntimeState,
recordAnilistMediaDuration,
resetAnilistMediaGuessState,
maybeProbeAnilistDuration,
ensureAnilistMediaGuess,
@@ -3053,6 +3151,13 @@ const {
);
},
},
recordMediaDurationMainDeps: {
getCurrentMediaKey: () => getCurrentAnilistMediaKey(),
getState: () => getAnilistMediaGuessRuntimeState(),
setState: (state) => {
setAnilistMediaGuessRuntimeState(state);
},
},
resetMediaGuessStateMainDeps: {
setMediaGuess: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
@@ -3647,9 +3752,12 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
reloadConfigMainDeps: {
reloadConfigStrict: () => configService.reloadConfigStrict(),
logInfo: (message) => appLogger.logInfo(message),
logDebug: (message) => appLogger.logDebug(message),
logWarning: (message) => appLogger.logWarning(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
startConfigHotReload: () => configHotReloadRuntime.start(),
shouldRefreshAnilistClientSecretState: () =>
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretStateIfEnabled(options),
failHandlers: {
logError: (details) => logger.error(details),
@@ -3768,6 +3876,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
loadYomitanExtension: async () => {
await loadYomitanExtension();
},
ensureYomitanExtensionLoaded: async () => {
await ensureYomitanExtensionLoaded();
},
handleFirstRunSetup: async () => {
const snapshot = await firstRunSetupService.ensureSetupStateInitialized();
appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
@@ -3885,7 +3996,10 @@ const {
reportJellyfinRemoteStopped: () => {
void reportJellyfinRemoteStopped();
},
maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(),
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
recordAnilistMediaDuration: (durationSec) => {
recordAnilistMediaDuration(durationSec);
},
logSubtitleTimingError: (message, error) => logger.error(message, error),
broadcastToOverlayWindows: (channel, payload) => {
broadcastToOverlayWindows(channel, payload);
@@ -4518,6 +4632,105 @@ flushPendingMpvLogWrites = () => {
void flushMpvLog();
};
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
let updateService: ReturnType<typeof createUpdateService> | null = null;
function getFetchForUpdater() {
return globalThis.fetch.bind(globalThis);
}
async function updateLauncherFromSelectedRelease(
launcherPath?: string,
channel: UpdateChannel = getResolvedConfig().updates.channel,
release: GitHubRelease | null = null,
) {
const fetchForUpdater = getFetchForUpdater();
if (!release) {
return { status: 'missing-asset', message: `No ${channel} GitHub release found.` };
}
const sumsAsset = findReleaseAsset(release, 'SHA256SUMS.txt');
if (!sumsAsset) {
return { status: 'missing-asset', message: 'Release has no SHA256SUMS.txt asset.' };
}
const sums = parseSha256Sums(
await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url),
);
const launcherResult = await updateLauncherFromRelease({
release,
sha256Sums: sums,
launcherPath,
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
});
const supportResults = await updateSupportAssetsFromRelease({
release,
sha256Sums: sums,
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
});
for (const result of supportResults) {
if (result.status === 'protected' && result.command) {
logger.warn(`Rofi theme update requires manual command: ${result.command}`);
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
logger.warn(`Rofi theme update skipped: ${result.message ?? result.status}`);
}
}
return launcherResult;
}
function getUpdateService() {
if (updateService) return updateService;
const appUpdater = createElectronAppUpdater({
currentVersion: app.getVersion(),
isPackaged: app.isPackaged,
log: (message) => logger.info(message),
getChannel: () => getResolvedConfig().updates.channel,
isNativeUpdaterSupported: () =>
isNativeUpdaterSupported({
platform: process.platform,
isPackaged: app.isPackaged,
execPath: process.execPath,
env: process.env,
log: (message) => logger.warn(message),
}),
});
const updateDialogPresenter = createUpdateDialogPresenter({
platform: process.platform,
focusApp: () => app.focus({ steal: true }),
showMessageBox: (options) => dialog.showMessageBox(options),
});
updateService = createUpdateService({
getConfig: () => getResolvedConfig().updates,
getCurrentVersion: () => app.getVersion(),
now: () => Date.now(),
readState: () => updateStateStore.readState(),
writeState: (state) => updateStateStore.writeState(state),
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
fetchLatestStableRelease: (channel) =>
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
updateLauncher: (launcherPath, channel, release) =>
updateLauncherFromSelectedRelease(launcherPath, channel, release),
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version),
showUpdateAvailableDialog: (version) =>
updateDialogPresenter.showUpdateAvailableDialog(version),
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
showManualUpdateRequiredDialog: (version) =>
updateDialogPresenter.showManualUpdateRequiredDialog(version),
downloadAppUpdate: () => appUpdater.downloadUpdate(),
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
quitAndInstall: () => appUpdater.quitAndInstall(),
notifyUpdateAvailable: (version) =>
notifyUpdateAvailable(
{ notificationType: getResolvedConfig().updates.notificationType, version },
{
showSystemNotification: (title, body) => showDesktopNotification(title, { body }),
showOsdNotification: (message) => showMpvOsd(message),
log: (message) => logger.warn(message),
},
),
log: (message) => logger.warn(message),
});
return updateService;
}
const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubModeMainDeps: {
getSecondarySubMode: () => appState.secondarySubMode,
@@ -4928,6 +5141,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
onOverlayModalOpened: (modal) => {
overlayModalRuntime.notifyOverlayModalOpened(modal);
},
onOverlayMouseInteractionChanged: (active, senderWindow) => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || senderWindow !== mainWindow) {
return;
}
if (visibleOverlayInteractionActive === active) {
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}
return;
}
visibleOverlayInteractionActive = active;
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
},
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => requestAppQuit(),
@@ -5135,7 +5362,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
initializeOverlayRuntime: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force),
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
@@ -5173,6 +5400,14 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
runStatsCliCommand(argsFromCommand, source),
runUpdateCommand: async (argsFromCommand: CliArgs, source: CliCommandSource) => {
await runUpdateCliCommand(argsFromCommand, source, {
checkForUpdates: (request) => getUpdateService().checkForUpdates(request),
writeResponse: (responsePath, payload) =>
writeUpdateCliCommandResponse(responsePath, payload),
logWarn: (message, error) => logger.warn(message, error),
});
},
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
@@ -5185,6 +5420,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
logInfo: (message: string) => logger.info(message),
logDebug: (message: string) => logger.debug(message),
logWarn: (message: string) => logger.warn(message),
logError: (message: string, err: unknown) => logger.error(message, err),
},
@@ -5240,7 +5476,11 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
shouldQuitOnWindowAllClosed: () => !appState.backgroundMode,
shouldQuitOnWindowAllClosed: () =>
shouldQuitOnWindowAllClosedForTrayState({
backgroundMode: appState.backgroundMode,
hasTray: Boolean(appTray),
}),
},
createAppLifecycleRuntimeRunner: (params) => createAppLifecycleRuntimeRunner(params),
buildStartupBootstrapMainDeps: (startAppLifecycle) => ({
@@ -5283,6 +5523,12 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
});
runAndApplyStartupState();
void app.whenReady().then(() => {
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
return;
}
getUpdateService().startAutomaticChecks();
});
const startupModeFlags = getStartupModeFlags(appState.initialArgs);
const shouldUseMinimalStartup = startupModeFlags.shouldUseMinimalStartup;
const shouldSkipHeavyStartup = startupModeFlags.shouldSkipHeavyStartup;
@@ -5367,6 +5613,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
openSessionHelpModal: () => openSessionHelpOverlay(),
openTexthookerInBrowser: () =>
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
showTexthookerPage: () => getResolvedConfig().texthooker.launchAtStartup !== false,
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
@@ -5379,6 +5626,9 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
toggleJellyfinDiscovery: () =>
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()),
openAnilistSetupWindow: () => openAnilistSetupWindow(),
checkForUpdates: () => {
void getUpdateService().checkForUpdates({ source: 'manual' });
},
quitApp: () => requestAppQuit(),
},
ensureTrayDeps: {
@@ -5534,6 +5784,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
});
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
getYomitanExtension: () => appState.yomitanExt,
getYomitanExtensionLoadInFlight: () => yomitanLoadInFlight,
getYomitanSession: () => appState.yomitanSession,
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {
openYomitanSettingsWindow({
+2
View File
@@ -44,6 +44,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
ensureYomitanExtensionLoaded?: AppReadyRuntimeDeps['ensureYomitanExtensionLoaded'];
handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup'];
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
@@ -109,6 +110,7 @@ export function createAppReadyRuntimeDeps(
createImmersionTracker: params.createImmersionTracker,
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
loadYomitanExtension: params.loadYomitanExtension,
ensureYomitanExtensionLoaded: params.ensureYomitanExtensionLoaded,
handleFirstRunSetup: params.handleFirstRunSetup,
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
startBackgroundWarmups: params.startBackgroundWarmups,
@@ -1459,7 +1459,7 @@ test('generateForCurrentMedia preserves duplicate surface forms across different
}
});
test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => {
test('getOrCreateCurrentSnapshot reuses cached media resolution without AniList requests', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
let searchQueryCount = 0;
@@ -1567,11 +1567,18 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data',
});
const first = await runtime.getOrCreateCurrentSnapshot();
assert.equal(searchQueryCount, 1);
assert.equal(characterQueryCount, 1);
fs.rmSync(path.join(userDataPath, 'character-dictionaries', 'anilist-resolution-cache.json'), {
force: true,
});
const second = await runtime.getOrCreateCurrentSnapshot();
assert.equal(first.fromCache, false);
assert.equal(second.fromCache, true);
assert.equal(searchQueryCount, 2);
assert.equal(searchQueryCount, 1);
assert.equal(characterQueryCount, 1);
assert.equal(
fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')),
+60
View File
@@ -15,7 +15,10 @@ import {
getMergedZipPath,
getSnapshotPath,
normalizeMergedMediaIds,
readCachedMediaResolution,
readCachedSnapshots,
readSnapshot,
writeCachedMediaResolution,
writeSnapshot,
} from './character-dictionary-runtime/cache';
import {
@@ -41,6 +44,7 @@ import type {
CharacterDictionaryManualSelectionResult,
CharacterDictionaryManualSelectionSnapshot,
CharacterDictionaryRuntimeDeps,
CharacterDictionarySnapshot,
CharacterDictionarySnapshotImage,
CharacterDictionarySnapshotProgress,
CharacterDictionarySnapshotProgressCallbacks,
@@ -204,6 +208,26 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
};
};
const findCachedSnapshotForSeriesKey = (
seriesKey: string,
): CharacterDictionarySnapshot | null => {
return (
readCachedSnapshots(outputDir).find((snapshot) => {
const snapshotSeriesKey = buildCharacterDictionarySeriesKey({
mediaPath: null,
mediaTitle: snapshot.mediaTitle,
guess: {
title: snapshot.mediaTitle,
season: null,
episode: null,
source: 'fallback',
},
});
return snapshotSeriesKey === seriesKey;
}) ?? null
);
};
const resolveCurrentMedia = async (
targetPath?: string,
beforeRequest?: () => Promise<void>,
@@ -228,7 +252,43 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
staleMediaIds: override.staleMediaIds,
};
}
const cachedResolution = readCachedMediaResolution(outputDir, seriesKey);
if (cachedResolution) {
const cachedSnapshot = readSnapshot(getSnapshotPath(outputDir, cachedResolution.mediaId));
if (cachedSnapshot) {
deps.logInfo?.(
`[dictionary] cached AniList match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`,
);
return {
id: cachedSnapshot.mediaId,
title: cachedSnapshot.mediaTitle,
};
}
}
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey);
if (cachedSnapshot) {
writeCachedMediaResolution(outputDir, {
seriesKey,
mediaId: cachedSnapshot.mediaId,
mediaTitle: cachedSnapshot.mediaTitle,
});
deps.logInfo?.(
`[dictionary] cached snapshot match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`,
);
return {
id: cachedSnapshot.mediaId,
title: cachedSnapshot.mediaTitle,
};
}
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
writeCachedMediaResolution(outputDir, {
seriesKey,
mediaId: resolved.id,
mediaTitle: resolved.title,
});
deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
return resolved;
};
@@ -21,6 +21,102 @@ export function getMergedZipPath(outputDir: string): string {
return path.join(outputDir, 'merged.zip');
}
type MediaResolutionCacheEntry = {
seriesKey: string;
mediaId: number;
mediaTitle: string;
};
type MediaResolutionCacheFile = {
entries?: MediaResolutionCacheEntry[];
};
function getMediaResolutionCachePath(outputDir: string): string {
return path.join(outputDir, 'anilist-resolution-cache.json');
}
function normalizeMediaResolutionEntry(value: unknown): MediaResolutionCacheEntry | null {
if (!value || typeof value !== 'object') return null;
const raw = value as Partial<MediaResolutionCacheEntry>;
const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : '';
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
if (typeof raw.mediaId !== 'number' || !Number.isFinite(raw.mediaId)) return null;
const mediaId = Math.floor(raw.mediaId);
if (!seriesKey || mediaId <= 0 || !mediaTitle) return null;
return {
seriesKey,
mediaId,
mediaTitle,
};
}
function readMediaResolutionEntries(outputDir: string): MediaResolutionCacheEntry[] {
try {
const parsed = JSON.parse(
fs.readFileSync(getMediaResolutionCachePath(outputDir), 'utf8'),
) as MediaResolutionCacheFile;
if (!Array.isArray(parsed.entries)) return [];
const byKey = new Map<string, MediaResolutionCacheEntry>();
for (const value of parsed.entries) {
const normalized = normalizeMediaResolutionEntry(value);
if (normalized) byKey.set(normalized.seriesKey, normalized);
}
return [...byKey.values()];
} catch {
return [];
}
}
function writeMediaResolutionEntries(
outputDir: string,
entries: MediaResolutionCacheEntry[],
): void {
ensureDir(outputDir);
fs.writeFileSync(
getMediaResolutionCachePath(outputDir),
JSON.stringify({ entries }, null, 2),
'utf8',
);
}
export function readCachedMediaResolution(
outputDir: string,
seriesKey: string,
): MediaResolutionCacheEntry | null {
const normalizedKey = seriesKey.trim();
if (!normalizedKey) return null;
return (
readMediaResolutionEntries(outputDir).find((entry) => entry.seriesKey === normalizedKey) ?? null
);
}
export function writeCachedMediaResolution(
outputDir: string,
entry: MediaResolutionCacheEntry,
): void {
const normalized = normalizeMediaResolutionEntry(entry);
if (!normalized) return;
const remaining = readMediaResolutionEntries(outputDir).filter(
(existing) => existing.seriesKey !== normalized.seriesKey,
);
writeMediaResolutionEntries(outputDir, [...remaining, normalized]);
}
export function readCachedSnapshots(outputDir: string): CharacterDictionarySnapshot[] {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((entry) => entry.isFile() && /^anilist-\d+\.json$/.test(entry.name))
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) => readSnapshot(path.join(getSnapshotsDir(outputDir), entry.name)))
.filter((snapshot): snapshot is CharacterDictionarySnapshot => snapshot !== null);
}
export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot | null {
try {
const raw = fs.readFileSync(snapshotPath, 'utf8');
+5 -1
View File
@@ -20,7 +20,7 @@ export interface CliCommandRuntimeServiceContext {
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void;
openFirstRunSetup: (force?: boolean) => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -43,6 +43,7 @@ export interface CliCommandRuntimeServiceContext {
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
@@ -53,6 +54,7 @@ export interface CliCommandRuntimeServiceContext {
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
log: (message: string) => void;
logDebug: (message: string) => void;
warn: (message: string) => void;
error: (message: string, err: unknown) => void;
}
@@ -118,6 +120,7 @@ function createCliCommandDepsFromContext(
app: {
stop: context.stopApp,
hasMainWindow: context.hasMainWindow,
runUpdateCommand: context.runUpdateCommand,
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
},
dispatchSessionAction: context.dispatchSessionAction,
@@ -131,6 +134,7 @@ function createCliCommandDepsFromContext(
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
schedule: context.schedule,
log: context.log,
logDebug: context.logDebug,
warn: context.warn,
error: context.error,
};
+6
View File
@@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp'];
@@ -184,6 +185,7 @@ export interface CliCommandRuntimeServiceDepsParams {
app: {
stop: CliCommandDepsRuntimeOptions['app']['stop'];
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
};
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
@@ -197,6 +199,7 @@ export interface CliCommandRuntimeServiceDepsParams {
getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs'];
schedule: CliCommandDepsRuntimeOptions['schedule'];
log: CliCommandDepsRuntimeOptions['log'];
logDebug: CliCommandDepsRuntimeOptions['logDebug'];
warn: CliCommandDepsRuntimeOptions['warn'];
error: CliCommandDepsRuntimeOptions['error'];
}
@@ -227,6 +230,7 @@ export function createMainIpcRuntimeServiceDeps(
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
onOverlayModalClosed: params.onOverlayModalClosed,
onOverlayModalOpened: params.onOverlayModalOpened,
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
onYoutubePickerResolve: params.onYoutubePickerResolve,
openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp,
@@ -362,6 +366,7 @@ export function createCliCommandRuntimeServiceDeps(
app: {
stop: params.app.stop,
hasMainWindow: params.app.hasMainWindow,
runUpdateCommand: params.app.runUpdateCommand,
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
},
dispatchSessionAction: params.dispatchSessionAction,
@@ -375,6 +380,7 @@ export function createCliCommandRuntimeServiceDeps(
getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs,
schedule: params.schedule,
log: params.log,
logDebug: params.logDebug,
warn: params.warn,
error: params.error,
};
+2
View File
@@ -11,6 +11,7 @@ export interface OverlayVisibilityRuntimeDeps {
getModalActive: () => boolean;
getVisibleOverlayVisible: () => boolean;
getForceMousePassthrough: () => boolean;
getOverlayInteractionActive?: () => boolean;
getWindowTracker: () => BaseWindowTracker | null;
getLastKnownWindowsForegroundProcessName?: () => string | null;
getWindowsOverlayProcessName?: () => string | null;
@@ -49,6 +50,7 @@ export function createOverlayVisibilityRuntimeService(
visibleOverlayVisible,
modalActive: deps.getModalActive(),
forceMousePassthrough,
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
mainWindow,
windowTracker,
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
createBuildRecordAnilistMediaDurationMainDepsHandler,
createBuildResetAnilistMediaGuessStateMainDepsHandler,
createBuildResetAnilistMediaTrackingMainDepsHandler,
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
@@ -70,3 +71,32 @@ test('reset anilist media guess state main deps builder maps callbacks', () => {
deps.setMediaGuessPromise(null);
assert.deepEqual(calls, ['guess', 'promise']);
});
test('record anilist media duration main deps builder maps callbacks', () => {
const calls: string[] = [];
const state = {
mediaKey: '/tmp/video.mkv',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
};
const deps = createBuildRecordAnilistMediaDurationMainDepsHandler({
getCurrentMediaKey: () => {
calls.push('key');
return '/tmp/video.mkv';
},
getState: () => {
calls.push('get');
return state;
},
setState: () => {
calls.push('set');
},
})();
assert.equal(deps.getCurrentMediaKey(), '/tmp/video.mkv');
deps.getState();
deps.setState(state);
assert.deepEqual(calls, ['key', 'get', 'set']);
});
@@ -1,6 +1,7 @@
import type {
createGetAnilistMediaGuessRuntimeStateHandler,
createGetCurrentAnilistMediaKeyHandler,
createRecordAnilistMediaDurationHandler,
createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler,
createSetAnilistMediaGuessRuntimeStateHandler,
@@ -18,6 +19,9 @@ type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
typeof createSetAnilistMediaGuessRuntimeStateHandler
>[0];
type RecordAnilistMediaDurationMainDeps = Parameters<
typeof createRecordAnilistMediaDurationHandler
>[0];
type ResetAnilistMediaGuessStateMainDeps = Parameters<
typeof createResetAnilistMediaGuessStateHandler
>[0];
@@ -66,6 +70,16 @@ export function createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler(
});
}
export function createBuildRecordAnilistMediaDurationMainDepsHandler(
deps: RecordAnilistMediaDurationMainDeps,
) {
return (): RecordAnilistMediaDurationMainDeps => ({
getCurrentMediaKey: () => deps.getCurrentMediaKey(),
getState: () => deps.getState(),
setState: (state) => deps.setState(state),
});
}
export function createBuildResetAnilistMediaGuessStateMainDepsHandler(
deps: ResetAnilistMediaGuessStateMainDeps,
) {
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
createGetAnilistMediaGuessRuntimeStateHandler,
createGetCurrentAnilistMediaKeyHandler,
createRecordAnilistMediaDurationHandler,
createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler,
createSetAnilistMediaGuessRuntimeStateHandler,
@@ -176,3 +177,57 @@ test('reset anilist media guess state clears guess and in-flight promise', () =>
assert.equal(state.mediaDurationSec, 240);
assert.equal(state.lastDurationProbeAtMs, 321);
});
test('record anilist media duration stores observed mpv duration for current media', () => {
const existingPromise = Promise.resolve(null);
let state = {
mediaKey: '/tmp/video.mkv' as string | null,
mediaDurationSec: null as number | null,
mediaGuess: { title: 'guess' } as { title: string } | null,
mediaGuessPromise: existingPromise as Promise<unknown> | null,
lastDurationProbeAtMs: 321,
};
const recordDuration = createRecordAnilistMediaDurationHandler({
getCurrentMediaKey: () => '/tmp/video.mkv',
getState: () => state as never,
setState: (nextState) => {
state = nextState as never;
},
});
recordDuration(1440);
assert.equal(state.mediaDurationSec, 1440);
assert.deepEqual(state.mediaGuess, { title: 'guess' });
assert.equal(state.mediaGuessPromise, existingPromise);
assert.equal(state.lastDurationProbeAtMs, 321);
});
test('record anilist media duration resets stale media state when media key changes', () => {
let state = {
mediaKey: '/tmp/old.mkv' as string | null,
mediaDurationSec: 120 as number | null,
mediaGuess: { title: 'old' } as { title: string } | null,
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
lastDurationProbeAtMs: 321,
};
const recordDuration = createRecordAnilistMediaDurationHandler({
getCurrentMediaKey: () => '/tmp/new.mkv',
getState: () => state as never,
setState: (nextState) => {
state = nextState as never;
},
});
recordDuration(1440);
assert.deepEqual(state, {
mediaKey: '/tmp/new.mkv',
mediaDurationSec: 1440,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
});
});
+31
View File
@@ -61,6 +61,37 @@ export function createSetAnilistMediaGuessRuntimeStateHandler(deps: {
};
}
export function createRecordAnilistMediaDurationHandler(deps: {
getCurrentMediaKey: () => string | null;
getState: () => AnilistMediaGuessRuntimeState;
setState: (state: AnilistMediaGuessRuntimeState) => void;
}) {
return (durationSec: number): void => {
if (!Number.isFinite(durationSec) || durationSec <= 0) {
return;
}
const mediaKey = deps.getCurrentMediaKey();
if (!mediaKey) {
return;
}
const state = deps.getState();
if (state.mediaKey === mediaKey) {
deps.setState({
...state,
mediaDurationSec: durationSec,
});
return;
}
deps.setState({
mediaKey,
mediaDurationSec: durationSec,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
});
};
}
export function createResetAnilistMediaGuessStateHandler(deps: {
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;

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