Compare commits

...

10 Commits

Author SHA1 Message Date
sudacode 7442e4266c fix: suppress N+1 for kana-only candidates and fix minSentenceWords coun
- Treat kana-only tokens with surrounding subtitle punctuation (…, ―, etc.) as kana-only so they are not promoted to N+1 targets
- Exclude unknown tokens filtered from N+1 targeting from the minSentenceWords count so filtered kana-only unknowns cannot satisfy sentence length threshold
- Add regression tests for kana-only candidate suppression and filtered-unknown padding cases
2026-04-28 00:09:02 -07:00
sudacode 490f693361 Cancel pending Linux MPV fullscreen overlay refresh bursts
- return a cancel handle from the Linux refresh burst scheduler
- clear pending refresh bursts when overlays hide or windows close
- tighten the burst test polling to wait for the async refresh
2026-04-27 20:31:00 -07:00
sudacode bacc90cd24 fix: accept modified digits for multi-line sentence mining 2026-04-27 20:14:09 -07:00
sudacode 2fbc90cf3a fix: address CodeRabbit review comments 2026-04-27 20:10:33 -07:00
sudacode c150fce782 fix: address fullscreen and n-plus-one review notes 2026-04-27 01:34:41 -07:00
sudacode ab41837d3d fix: refresh overlay on Hyprland fullscreen 2026-04-27 01:34:41 -07:00
sudacode 9e4ad907fe fix: exclude kana-only n+1 targets 2026-04-27 01:34:41 -07:00
sudacode af86ce2341 fix: restore jlpt subtitle underlines 2026-04-27 01:34:41 -07:00
sudacode b10a7b3e98 fix(tokenizer): preserve annotation and enrichment behavior 2026-04-27 01:34:41 -07:00
sudacode 96894ff85c feat(tokenizer): use Yomitan word classes for subtitle POS filtering
- Carry matched headword wordClasses from termsFind into YomitanScanToken
- Map recognized Yomitan wordClasses to SubMiner coarse POS before annotation
- MeCab enrichment now fills only missing POS fields, preserving existing coarse pos1
- Exclude standalone grammar particles, して helper fragments, and single-kana surfaces from annotations
- Respect source-text punctuation gaps when counting N+1 sentence words
- Preserve known-word highlight on excluded kanji-containing tokens
- Add backlog tasks 304 (N+1 boundary bug) and 305 (wordClasses POS, done)
2026-04-27 01:34:41 -07:00
53 changed files with 2214 additions and 87 deletions
@@ -0,0 +1,27 @@
---
id: TASK-304
title: Fix N+1 sentence boundary counting across Yomitan punctuation gaps
status: In Progress
assignee: []
created_date: '2026-04-26 05:33'
labels:
- bug
- tokenizer
- annotations
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
N+1 target selection should respect sentence-ending punctuation from the original subtitle text even when Yomitan token output omits punctuation tokens. Current behavior can treat multiple subtitle sentences as one token span and incorrectly satisfy the minimum content-token threshold.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 A subtitle like `てんめ!ふざけんなよ!` does not mark `ふざけん`/similar single-content-token second sentence as N+1 when the minimum sentence word count is 3.
- [ ] #2 N+1 sentence segmentation uses original subtitle text offsets or equivalent source-boundary data, not only punctuation tokens returned by Yomitan.
- [ ] #3 Existing annotation exclusion behavior for particles/grammar tokens remains unchanged.
- [ ] #4 Regression tests cover Yomitan-style token streams where punctuation is absent from the token list.
<!-- AC:END -->
@@ -0,0 +1,55 @@
---
id: TASK-305
title: Use Yomitan word classes for subtitle token POS filtering
status: Done
assignee: []
created_date: '2026-04-26 05:56'
updated_date: '2026-04-26 05:59'
labels:
- tokenizer
- yomitan
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Subtitle annotation filtering currently uses Yomitan token spans, then enriches those spans by running MeCab over the full normalized subtitle line. Add support for carrying Yomitan headword wordClasses from termsFind into SubMiner tokens so dictionary-backed tokens can provide coarse POS/tag metadata without vendored Yomitan changes. MeCab whole-line enrichment should remain a fallback/source of detailed POS data when Yomitan classes are absent.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Yomitan scanner tokens preserve matched headword wordClasses when termsFind returns them.
- [x] #2 Subtitle tokenization maps recognized Yomitan wordClasses to coarse PartOfSpeech/POS metadata before annotation filtering.
- [x] #3 Whole-line MeCab enrichment remains available for missing or more detailed POS metadata and does not break existing subtitle annotation behavior.
- [x] #4 Focused tokenizer tests cover wordClasses extraction and POS mapping.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add focused regression coverage for Yomitan scanner wordClasses payload and subtitle POS mapping.
2. Extend the app-owned Yomitan scanner payload to carry matched headword wordClasses when present.
3. Map recognized Yomitan wordClasses to SubMiner coarse PartOfSpeech/POS metadata before annotation filtering.
4. Keep MeCab whole-line enrichment as fallback/detail-fill for missing POS fields.
5. Run focused tokenizer tests and typecheck.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented app-only wordClasses extraction from termsFind results; no vendored Yomitan changes required. Recognized classes currently map prt, aux, v*, adj-i/adj-ix, adj-na, and noun-like classes to SubMiner POS metadata. MeCab enrichment now skips only tokens with complete pos1/pos2/pos3 and otherwise fills missing fields while preserving existing coarse pos1. Verification: bun test src/core/services/tokenizer/yomitan-parser-runtime.test.ts src/core/services/tokenizer.test.ts; bun run typecheck.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented app-only Yomitan wordClasses support for subtitle token annotation filtering. The scanner now carries matched headword wordClasses from termsFind results, tokenizer maps recognized classes into SubMiner coarse POS metadata before annotation, and MeCab whole-line enrichment continues to fill missing detailed POS fields without requiring vendored Yomitan changes.
Tests run:
- bun test src/core/services/tokenizer/yomitan-parser-runtime.test.ts src/core/services/tokenizer.test.ts
- bun run typecheck
Note: the working tree already had unrelated tokenizer/annotation edits and task-304 before this work; those were left intact.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,33 @@
---
id: TASK-306
title: Fix Hyprland fullscreen overlay geometry and hover pause
status: Done
assignee: []
created_date: '2026-04-27 01:44'
labels:
- linux
- hyprland
- overlay
- bug
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Overlay should track mpv geometry through Hyprland fullscreen transitions, stay above fullscreen video, and keep primary subtitle hover pause working after fullscreen/toggle cycles.
Implemented by observing mpv fullscreen property changes in addition to Hyprland geometry events, then refreshing visible overlay bounds/layering on Linux.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Hyprland tracker reacts to fullscreen/window state changes with updated geometry.
- [x] #2 Visible overlay is re-layered above mpv after Hyprland fullscreen geometry updates.
- [x] #3 Primary subtitle hover pause remains active after overlay geometry changes or visible overlay toggle cycles.
<!-- AC:END -->
@@ -0,0 +1,58 @@
---
id: TASK-307
title: Exclude kana-only words from N+1 subtitle targets
status: Done
assignee:
- codex
created_date: '2026-04-27 01:52'
updated_date: '2026-04-27 01:57'
labels:
- tokenizer
- annotations
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Subtitle N+1 annotation is over-targeting kana-only or hiragana/katakana tokens that collapse to dictionary words. Adjust targeting so kana-only tokens are not selected as N+1 candidates, while preserving tokenization/hover behavior and other annotation metadata where existing filters allow it.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Kana-only subtitle tokens are not marked as N+1 targets.
- [x] #2 Kanji or mixed lexical tokens can still be marked as N+1 targets when they are the single unknown candidate in a sentence.
- [x] #3 Regression coverage demonstrates the kana-only N+1 exclusion.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a failing regression in `src/core/services/tokenizer.test.ts` showing a kana-only Yomitan token is not selected as the single N+1 target, while a mixed lexical token in the same style still can be targeted.
2. Implement the smallest filter in `src/token-merger.ts`: N+1 candidate selection rejects tokens whose surface is entirely kana; word-count behavior remains governed by existing annotation/POS filters.
3. Run the focused tokenizer tests, then update task acceptance criteria/final summary.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented a surface-level kana-only guard in N+1 candidate selection. Kept existing word-count/POS filtering behavior intact; updated tokenizer and annotation-stage expectations where old tests intentionally allowed kana-only N+1 targets.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Added kana-only surface detection to `isNPlusOneCandidateToken` so hiragana/katakana-only subtitle tokens are not selected as N+1 targets.
- Added/updated tokenizer and annotation-stage regressions for kana-only targets while preserving non-kana N+1 behavior.
- Added changelog fragment `changes/307-kana-nplusone-targets.md`.
Verification:
- `bun test src/core/services/tokenizer.test.ts --test-name-pattern "kana-only N\+1"` failed before the fix with `true !== false`.
- `bun test src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer.test.ts` passed.
- `bun run typecheck` passed.
- `bun run test:fast` passed.
- `bun run changelog:lint` passed.
- `bunx prettier --check src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/token-merger.ts changes/307-kana-nplusone-targets.md` passed.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,54 @@
---
id: TASK-308
title: Restore persistent JLPT subtitle underlines
status: Done
assignee:
- Codex
created_date: '2026-04-27 02:03'
updated_date: '2026-04-27 02:07'
labels:
- overlay
- jlpt
- renderer
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
JLPT tagging currently exposes the JLPT level on hover, but the persistent subtitle underline is missing. When JLPT annotation is enabled and a rendered subtitle token has a JLPT level, users should see the configured JLPT color underline without needing to hover.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 JLPT-tagged subtitle tokens render a persistent underline for N1-N5 levels when JLPT tagging is enabled.
- [x] #2 Hover and keyboard-selected JLPT labels continue to appear for tagged tokens.
- [x] #3 Higher-priority annotation colors such as known words, N+1, names, and frequency styling are not overridden by JLPT text color.
- [x] #4 Regression coverage verifies the CSS contract for persistent JLPT underlines.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a focused renderer CSS regression asserting each `word-jlpt-n*` class provides persistent underline decoration while preserving existing typography constraints.
2. Run the focused renderer test to confirm the regression fails before production changes.
3. Restore underline CSS for JLPT classes without broadening JLPT text-color precedence over known/N+1/name/frequency tokens.
4. Re-run the focused renderer test and update acceptance criteria/task notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Verified red/green regression: tightened `src/renderer/subtitle-render.test.ts` first failed because base `word-jlpt-n*` selectors had no underline decoration, then passed after moving JLPT underline decoration to unconditional base selectors while leaving JLPT text color priority-scoped.
Checks: `bun test src/renderer/subtitle-render.test.ts`; `bun run changelog:lint`; `bun run typecheck`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Restored persistent JLPT subtitle underlines by adding underline decoration to each base `word-jlpt-n*` renderer CSS class. JLPT text color remains in the existing priority-scoped selectors, so known/N+1/name/frequency coloring is not overridden while the underline still appears on any JLPT-tagged token.
Updated renderer CSS regression coverage to assert underline decoration for N1-N5 and added a fixed changelog fragment. Verified with `bun test src/renderer/subtitle-render.test.ts`, `bun run changelog:lint`, and `bun run typecheck`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,57 @@
---
id: TASK-309
title: Accept modified follow-up digits for multi-line sentence mining
status: Done
assignee:
- '@codex'
created_date: '2026-04-27 20:06'
updated_date: '2026-04-27 20:15'
labels:
- bug
- linux
- shortcuts
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
On Linux, `Ctrl+Shift+S` starts multi-line sentence-card mining, but the follow-up digit is not accepted and the prompt times out. Restore reliable digit capture for the multi-mine flow, including the common case where the original shortcut modifiers are still held briefly while pressing the digit.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `Ctrl+Shift+S` followed by a number-row digit creates a counted `mineSentenceMultiple` request instead of timing out.
- [x] #2 Follow-up digit capture works when the user has not fully released `Ctrl`/`Shift` after the starter shortcut.
- [x] #3 Regression coverage includes renderer session bindings and mpv plugin numeric selection.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Backlog MCP unavailable in this session, so this task is tracked via repo-local backlog files.
Implemented renderer digit extraction from `KeyboardEvent.code` for pending numeric selection, so shifted number-row events such as `Ctrl+Shift+Digit3` still dispatch count `3`. Updated the mpv plugin session-binding numeric selector to register bare digits plus the starter shortcut modifier combinations, so plugin-owned `Ctrl+Shift+S` can accept a follow-up digit before the modifiers are fully released.
Verification:
- `bun test src/renderer/handlers/keyboard.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/overlay-window.test.ts`
- `bun run test:plugin:src`
- `bun run changelog:lint`
- `bun x prettier --check src/renderer/handlers/keyboard.ts src/renderer/handlers/keyboard.test.ts package.json 'changes/309-multi-mine-modified-digits.md' 'backlog/tasks/task-309 - Accept-modified-follow-up-digits-for-multi-line-sentence-mining.md'`
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Restored multi-line sentence-card digit capture for the case where `Ctrl`/`Shift` are still held after `Ctrl+Shift+S`. The renderer now accepts digits by physical `Digit1`-`Digit9`/`Numpad1`-`Numpad9` code during pending numeric selection, and the mpv plugin registers the matching modified digit bindings for session-binding numeric prompts.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,58 @@
---
id: TASK-310
title: Suppress N+1 highlight for kana-only candidate sentences
status: Done
assignee:
- Codex
created_date: '2026-04-28 06:55'
updated_date: '2026-04-28 07:04'
labels:
- tokenizer
- n+1
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Reduce noisy N+1 subtitle annotations when the only unknown candidates in a sentence are kana-only hiragana or katakana words, such as mostly-kana subtitle lines where highlighting a particle/helper-like token is low value.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 N+1 annotation does not mark a kana-only unknown target when all N+1 candidates in the sentence are kana-only.
- [x] #2 N+1 annotation continues to mark kanji or mixed-script unknown targets in otherwise eligible sentences.
- [x] #3 A focused regression test covers the kana-only candidate case.
- [x] #4 N+1 minimum sentence word count excludes tokens stripped by the subtitle annotation filter, so filtered grammar/noise tokens cannot satisfy minSentenceWords.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Keep the existing N+1 target eligibility guard: kana-only subtitle surfaces do not become N+1 targets.
2. Add a focused regression in src/core/services/tokenizer/annotation-stage.test.ts proving annotation-filtered tokens do not count toward ankiConnect.nPlusOne.minSentenceWords.
3. Verify the new regression fails before code changes.
4. Patch src/token-merger.ts so the N+1 minimum sentence word count uses the same subtitle-annotation eligibility filter as annotation rendering, excluding filtered particles/auxiliaries/noise from the count.
5. Re-run focused tokenizer tests, then update TASK-310 acceptance criteria and final notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Initial context: current token-merger has an existing surface-level kana-only guard in isNPlusOneCandidateToken, added in commit 9e4ad907. Need decide whether to broaden behavior to lookup/headword forms or verify current behavior only.
Implemented by treating kana-only N+1 candidates as kana-only even when their token surface includes surrounding subtitle punctuation such as ellipsis or dashes. Focused regression was red before the token-merger change: スイッチ… was marked true, then passed after the guard update. test:env initially hit an unrelated immersion-tracker active_days timing/order failure and Bun follow-on loader error; the failing test passed in isolation and the full test:env rerun passed.
Reopened for follow-up scope: minSentenceWords must count annotation-eligible tokens only, not tokens stripped from annotation metadata.
Implemented follow-up minSentenceWords behavior: unknown tokens filtered from N+1 targeting no longer contribute to sentence length; known eligible tokens and true N+1 candidates still count.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Changed N+1 sentence-length counting so minSentenceWords only counts known eligible words and actual N+1 target candidates. Unknown tokens filtered from N+1 targeting, including kana-only unknowns, no longer pad a sentence into eligibility. Existing annotation-filtered particles/auxiliaries remain excluded. Added regression coverage for the filtered unknown padding case while preserving kanji/mixed-script target behavior.
Verification: new regression failed before implementation; `bun test src/core/services/tokenizer/annotation-stage.test.ts -t "N\\+1"` pass; full `bun test src/core/services/tokenizer/annotation-stage.test.ts` pass; `bun test src/core/services/tokenizer.test.ts -t "N\\+1"` pass; `bun run typecheck` pass.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,6 @@
type: fixed
area: tokenizer
- Use Yomitan `wordClasses` metadata for subtitle POS filtering.
- Backfill blank MeCab POS detail fields during parser enrichment.
- Keep subtitle annotation metadata stripped from token results.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed Hyprland fullscreen transitions so mpv fullscreen changes refresh visible overlay geometry, reassert topmost stacking, and keep primary subtitle hover pause working after resize/toggle cycles.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: tokenizer
- Stopped kana-only subtitle tokens from being selected as N+1 targets.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Overlay: Restored persistent JLPT subtitle underlines while keeping hover JLPT labels and annotation color priority intact.
@@ -0,0 +1,4 @@
type: fixed
area: shortcuts
- Accept follow-up number-row digits for multi-line subtitle mining even when the original shortcut modifiers are still held.
+4
View File
@@ -324,6 +324,10 @@ Add a `pass` rule for each global shortcut you configure. The defaults are `Alt+
Without these rules, Hyprland intercepts the keypresses before they reach SubMiner, and the shortcuts silently do nothing.
**Overlay stays behind mpv after fullscreen**
SubMiner watches mpv's `fullscreen` property and refreshes the overlay geometry when it changes. If the overlay still does not move or rise above fullscreen mpv, confirm that the mpv IPC socket is connected and that `hyprctl -j clients` and `hyprctl -j monitors` work from the same environment that launched SubMiner.
For more details, see the Hyprland docs on [global keybinds](https://wiki.hypr.land/Configuring/Binds/#global-keybinds) and [window rules](https://wiki.hypr.land/Configuring/Window-Rules/).
### macOS
+1 -1
View File
@@ -45,7 +45,7 @@
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/integrations.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts",
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/integrations.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js",
"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-binary-windows.lua",
"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/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",
+31 -8
View File
@@ -225,16 +225,39 @@ function M.create(ctx)
end
end
local function start_numeric_selection(action_id, timeout_ms)
local function build_modifier_prefixes(modifiers)
local prefixes = { "" }
if type(modifiers) ~= "table" then
return prefixes
end
for _, modifier in ipairs(modifiers) do
local mapped = MODIFIER_MAP[modifier]
if mapped then
local existing_count = #prefixes
for index = 1, existing_count do
prefixes[#prefixes + 1] = prefixes[index] .. mapped .. "+"
end
end
end
return prefixes
end
local function start_numeric_selection(action_id, timeout_ms, starter_modifiers)
clear_numeric_selection(false)
local modifier_prefixes = build_modifier_prefixes(starter_modifiers)
for digit = 1, 9 do
local digit_string = tostring(digit)
local name = "subminer-session-digit-" .. digit_string
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = name
mp.add_forced_key_binding(digit_string, name, function()
clear_numeric_selection(false)
invoke_cli_action(action_id, { count = digit })
end)
for _, prefix in ipairs(modifier_prefixes) do
local key_name = prefix .. digit_string
local modifier_name = prefix:gsub("[^%w]", "-")
local name = "subminer-session-digit-" .. modifier_name .. digit_string
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = name
mp.add_forced_key_binding(key_name, name, function()
clear_numeric_selection(false)
invoke_cli_action(action_id, { count = digit })
end)
end
end
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] =
@@ -272,7 +295,7 @@ function M.create(ctx)
end
if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms)
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers)
return
end
+134
View File
@@ -0,0 +1,134 @@
package.path = "plugin/subminer/?.lua;" .. package.path
local session_bindings = require("session_bindings")
local function assert_true(condition, message)
if condition then
return
end
error(message)
end
local artifact_path = ".tmp/test-plugin-session-bindings.json"
os.execute("mkdir -p .tmp")
local handle = assert(io.open(artifact_path, "w"))
handle:write("__SESSION_BINDINGS__")
handle:close()
local recorded = {
bindings = {},
removed = {},
async_calls = {},
osd = {},
}
local mp = {}
function mp.add_forced_key_binding(keys, name, fn)
recorded.bindings[#recorded.bindings + 1] = {
keys = keys,
name = name,
fn = fn,
}
end
function mp.remove_key_binding(name)
recorded.removed[#recorded.removed + 1] = name
end
function mp.add_timeout(seconds, callback)
return {
seconds = seconds,
callback = callback,
killed = false,
kill = function(self)
self.killed = true
end,
}
end
function mp.osd_message(message)
recorded.osd[#recorded.osd + 1] = message
end
local ctx = {
mp = mp,
utils = {
parse_json = function(raw)
if raw ~= "__SESSION_BINDINGS__" then
return nil, "unexpected artifact"
end
return {
numericSelectionTimeoutMs = 3000,
bindings = {
{
key = {
code = "KeyS",
modifiers = { "ctrl", "shift" },
},
actionType = "session-action",
actionId = "mineSentenceMultiple",
},
},
}, nil
end,
},
state = {
binary_path = "/tmp/subminer",
session_binding_names = {},
session_numeric_binding_names = {},
session_numeric_selection = nil,
},
process = {
check_binary_available = function()
return true
end,
run_binary_command_async = function(args)
recorded.async_calls[#recorded.async_calls + 1] = args
end,
},
environment = {
resolve_session_bindings_artifact_path = function()
return artifact_path
end,
},
log = {
subminer_log = function() end,
show_osd = function(message)
recorded.osd[#recorded.osd + 1] = message
end,
},
}
local bindings = session_bindings.create(ctx)
assert_true(bindings.register_bindings(), "session bindings should register")
local starter = nil
for _, binding in ipairs(recorded.bindings) do
if binding.keys == "Ctrl+Shift+s" then
starter = binding
break
end
end
assert_true(starter ~= nil, "multi-mine starter binding should be registered")
starter.fn()
local modified_digit = nil
for _, binding in ipairs(recorded.bindings) do
if binding.keys == "Ctrl+Shift+3" then
modified_digit = binding
break
end
end
assert_true(modified_digit ~= nil, "numeric selection should bind Ctrl+Shift+3")
modified_digit.fn()
local call = recorded.async_calls[#recorded.async_calls]
assert_true(call ~= nil, "modified digit should invoke CLI action")
assert_true(call[1] == "/tmp/subminer", "CLI action should use configured binary")
assert_true(call[2] == "--mine-sentence-count", "CLI action should mine sentence count")
assert_true(call[3] == "3", "CLI action should pass selected count")
print("plugin session binding regression tests: OK")
+1
View File
@@ -59,6 +59,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'sub-ass-override',
'sub-use-margins',
'pause',
'fullscreen',
'duration',
'media-title',
'secondary-sub-visibility',
+12
View File
@@ -93,6 +93,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
emitTimePosChange: () => {},
emitDurationChange: () => {},
emitPauseChange: () => {},
emitFullscreenChange: (payload) => state.events.push(payload),
autoLoadSecondarySubTrack: () => {},
setCurrentVideoPath: () => {},
emitSecondarySubtitleVisibility: (payload) => state.events.push(payload),
@@ -160,6 +161,17 @@ test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay sup
]);
});
test('dispatchMpvProtocolMessage emits fullscreen changes', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'fullscreen', data: true },
deps,
);
assert.deepEqual(state.events, [{ fullscreen: true }]);
});
test('dispatchMpvProtocolMessage skips sub-visibility suppression when overlay is hidden', async () => {
const { deps, state } = createDeps({
isVisibleOverlayVisible: () => false,
+3
View File
@@ -65,6 +65,7 @@ export interface MpvProtocolHandleMessageDeps {
emitTimePosChange: (payload: { time: number }) => void;
emitDurationChange: (payload: { duration: number }) => void;
emitPauseChange: (payload: { paused: boolean }) => void;
emitFullscreenChange: (payload: { fullscreen: boolean }) => void;
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void;
setCurrentSecondarySubText: (text: string) => void;
resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean;
@@ -291,6 +292,8 @@ export async function dispatchMpvProtocolMessage(
}
} else if (msg.name === 'pause') {
deps.emitPauseChange({ paused: asBoolean(msg.data, false) });
} else if (msg.name === 'fullscreen') {
deps.emitFullscreenChange({ fullscreen: asBoolean(msg.data, false) });
} else if (msg.name === 'media-title') {
deps.emitMediaTitleChange({
title: typeof msg.data === 'string' ? msg.data.trim() : null,
+16
View File
@@ -57,6 +57,22 @@ test('MpvIpcClient handles sub-text property change and broadcasts tokenized sub
assert.equal(events[0]!.isOverlayVisible, false);
});
test('MpvIpcClient emits fullscreen property changes', async () => {
const events: Array<{ fullscreen: boolean }> = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
client.on('fullscreen-change', (payload) => {
events.push(payload);
});
await invokeHandleMessage(client, {
event: 'property-change',
name: 'fullscreen',
data: true,
});
assert.deepEqual(events, [{ fullscreen: true }]);
});
test('MpvIpcClient clears cached media title when media path changes', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
+4
View File
@@ -119,6 +119,7 @@ export interface MpvIpcClientEventMap {
'time-pos-change': { time: number };
'duration-change': { duration: number };
'pause-change': { paused: boolean };
'fullscreen-change': { fullscreen: boolean };
'secondary-subtitle-change': { text: string };
'subtitle-track-change': { sid: number | null };
'subtitle-track-list-change': { trackList: unknown[] | null };
@@ -330,6 +331,9 @@ export class MpvIpcClient implements MpvClient {
this.playbackPaused = payload.paused;
this.emit('pause-change', payload);
},
emitFullscreenChange: (payload) => {
this.emit('fullscreen-change', payload);
},
emitSecondarySubtitleChange: (payload) => {
this.emit('secondary-subtitle-change', payload);
},
+2
View File
@@ -67,6 +67,8 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
return;
}
window.setAlwaysOnTop(true);
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
window.moveTop();
}
export function enforceOverlayLayerOrder(options: {
+163 -5
View File
@@ -25,6 +25,7 @@ interface YomitanTokenInput {
reading?: string;
headword?: string;
isNameMatch?: boolean;
wordClasses?: string[];
}
function makeDepsFromYomitanTokens(
@@ -55,6 +56,7 @@ function makeDepsFromYomitanTokens(
startPos,
endPos,
isNameMatch: token.isNameMatch ?? false,
wordClasses: token.wordClasses,
};
});
},
@@ -1552,7 +1554,7 @@ test('tokenizeSubtitle assigns JLPT level to Yomitan tokens', async () => {
assert.equal(result.tokens?.[0]?.jlptLevel, 'N4');
});
test('tokenizeSubtitle can assign JLPT level to Yomitan particle token', async () => {
test('tokenizeSubtitle clears JLPT level from standalone Yomitan particle token', async () => {
const result = await tokenizeSubtitle(
'は',
makeDepsFromYomitanTokens([{ surface: 'は', reading: 'は', headword: 'は' }], {
@@ -1561,7 +1563,7 @@ test('tokenizeSubtitle can assign JLPT level to Yomitan particle token', async (
);
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.jlptLevel, 'N5');
assert.equal(result.tokens?.[0]?.jlptLevel, undefined);
});
test('tokenizeSubtitle returns null tokens for empty normalized text', async () => {
@@ -2304,6 +2306,29 @@ test('tokenizeSubtitle selects one N+1 target token', async () => {
assert.equal(targets[0]?.surface, '犬');
});
test('tokenizeSubtitle does not select kana-only N+1 target tokens', async () => {
const result = await tokenizeSubtitle(
'私のばあい',
makeDepsFromYomitanTokens(
[
{ surface: '私', reading: 'わたし', headword: '私' },
{ surface: 'の', reading: 'の', headword: 'の' },
{ surface: 'ばあい', reading: 'ばあい', headword: '場合' },
],
{
getMinSentenceWordsForNPlusOne: () => 2,
isKnownWord: (text) => text === '私',
},
),
);
assert.equal(result.tokens?.length, 3);
assert.equal(
result.tokens?.some((token) => token.isNPlusOneTarget),
false,
);
});
test('tokenizeSubtitle does not mark target when sentence has multiple candidates', async () => {
const result = await tokenizeSubtitle(
'猫犬',
@@ -3034,6 +3059,85 @@ test('tokenizeSubtitle skips all enrichment stages when disabled', async () => {
assert.equal(frequencyCalls, 0);
});
test('tokenizeSubtitle uses Yomitan word classes to classify standalone particles', async () => {
let mecabCalls = 0;
const result = await tokenizeSubtitle(
'は',
makeDepsFromYomitanTokens(
[{ surface: 'は', reading: 'は', headword: 'は', wordClasses: ['prt'] }],
{
getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: (text) => (text === 'は' ? 10 : null),
getJlptLevel: (text) => (text === 'は' ? 'N5' : null),
tokenizeWithMecab: async () => {
mecabCalls += 1;
return null;
},
},
),
);
assert.equal(mecabCalls, 1);
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.partOfSpeech, PartOfSpeech.particle);
assert.equal(result.tokens?.[0]?.pos1, '助詞');
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
assert.equal(result.tokens?.[0]?.jlptLevel, undefined);
});
test('tokenizeSubtitle uses Yomitan word classes to classify auxiliary subclasses', async () => {
const result = await tokenizeSubtitle(
'です',
makeDepsFromYomitanTokens(
[{ surface: 'です', reading: 'です', headword: 'です', wordClasses: ['aux-v'] }],
{
getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: () => 10,
getJlptLevel: () => 'N5',
tokenizeWithMecab: async () => null,
},
),
);
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.partOfSpeech, PartOfSpeech.bound_auxiliary);
assert.equal(result.tokens?.[0]?.pos1, '助動詞');
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
assert.equal(result.tokens?.[0]?.jlptLevel, undefined);
});
test('tokenizeSubtitle fills detailed MeCab POS when Yomitan word class supplies coarse POS', async () => {
const result = await tokenizeSubtitle(
'は',
makeDepsFromYomitanTokens(
[{ surface: 'は', reading: 'は', headword: 'は', wordClasses: ['prt'] }],
{
tokenizeWithMecab: async () => [
{
headword: 'は',
surface: 'は',
reading: 'ハ',
startPos: 0,
endPos: 1,
partOfSpeech: PartOfSpeech.particle,
pos1: '助詞',
pos2: '係助詞',
pos3: '*',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
],
},
),
);
assert.equal(result.tokens?.[0]?.partOfSpeech, PartOfSpeech.particle);
assert.equal(result.tokens?.[0]?.pos1, '助詞');
assert.equal(result.tokens?.[0]?.pos2, '係助詞');
});
test('tokenizeSubtitle keeps frequency enrichment while n+1 is disabled', async () => {
let knownCalls = 0;
let mecabCalls = 0;
@@ -3110,6 +3214,60 @@ test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and freque
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
});
test('tokenizeSubtitle preserves known-word highlight for exact non-independent kanji noun tokens', async () => {
const result = await tokenizeSubtitle(
'その点',
makeDepsFromYomitanTokens(
[
{ surface: 'その', reading: 'その', headword: 'その' },
{ surface: '点', reading: 'てん', headword: '点' },
],
{
isKnownWord: (text) => text === '点' || text === 'てん',
getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: (text) => (text === '点' ? 1384 : null),
getJlptLevel: (text) => (text === '点' ? 'N3' : null),
tokenizeWithMecab: async () => [
{
headword: 'その',
surface: 'その',
reading: 'ソノ',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.other,
pos1: '連体詞',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: '点',
surface: '点',
reading: 'テン',
startPos: 2,
endPos: 3,
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '非自立',
pos3: '一般',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
],
},
),
);
assert.equal(result.tokens?.length, 2);
assert.equal(result.tokens?.[0]?.isKnown, false);
assert.equal(result.tokens?.[1]?.surface, '点');
assert.equal(result.tokens?.[1]?.isKnown, true);
assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false);
assert.equal(result.tokens?.[1]?.frequencyRank, undefined);
assert.equal(result.tokens?.[1]?.jlptLevel, undefined);
});
test('tokenizeSubtitle keeps mecab-tagged interjections tokenized while clearing annotation metadata', async () => {
const result = await tokenizeSubtitle(
'ぐはっ',
@@ -3574,7 +3732,7 @@ test('tokenizeSubtitle excludes single-kana merged tokens from frequency highlig
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
});
test('tokenizeSubtitle excludes merged function/content token from frequency highlighting but keeps N+1', async () => {
test('tokenizeSubtitle excludes merged kana-only function/content token from frequency and N+1', async () => {
const result = await tokenizeSubtitle(
'になれば',
makeDepsFromYomitanTokens([{ surface: 'になれば', reading: 'になれば', headword: 'なる' }], {
@@ -3628,7 +3786,7 @@ test('tokenizeSubtitle excludes merged function/content token from frequency hig
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.pos1, '助詞|動詞');
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, true);
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
});
test('tokenizeSubtitle clears all annotations for kana-only demonstrative helper merges', async () => {
@@ -3827,7 +3985,7 @@ test('tokenizeSubtitle clears all annotations for explanatory pondering endings'
surface: 'どうかしちゃった',
headword: 'どうかしちゃう',
isKnown: false,
isNPlusOneTarget: true,
isNPlusOneTarget: false,
frequencyRank: 3200,
jlptLevel: 'N3',
},
+81 -17
View File
@@ -96,6 +96,7 @@ interface TokenizerAnnotationOptions {
minSentenceWordsForNPlusOne: number | undefined;
pos1Exclusions: ReadonlySet<string>;
pos2Exclusions: ReadonlySet<string>;
sourceText?: string;
}
let parserEnrichmentWorkerRuntimeModulePromise: Promise<
@@ -159,7 +160,7 @@ async function applyAnnotationStage(
options: TokenizerAnnotationOptions,
): Promise<MergedToken[]> {
if (!hasAnyAnnotationEnabled(options)) {
return tokens;
return stripSubtitleAnnotationMetadata(tokens);
}
if (!annotationStageModulePromise) {
@@ -333,6 +334,66 @@ function normalizeSelectedYomitanTokens(tokens: MergedToken[]): MergedToken[] {
}));
}
function normalizeYomitanWordClasses(wordClasses: unknown): string[] {
if (!Array.isArray(wordClasses)) {
return [];
}
const normalized: string[] = [];
for (const wordClass of wordClasses) {
if (typeof wordClass !== 'string') {
continue;
}
const trimmed = wordClass.trim();
if (trimmed && !normalized.includes(trimmed)) {
normalized.push(trimmed);
}
}
return normalized;
}
function resolvePartOfSpeechFromYomitanWordClasses(wordClasses: string[]): {
partOfSpeech: PartOfSpeech;
pos1?: string;
} {
if (wordClasses.includes('prt')) {
return { partOfSpeech: PartOfSpeech.particle, pos1: '助詞' };
}
if (wordClasses.some((wordClass) => wordClass === 'aux' || wordClass.startsWith('aux-'))) {
return { partOfSpeech: PartOfSpeech.bound_auxiliary, pos1: '助動詞' };
}
if (wordClasses.some((wordClass) => wordClass.startsWith('v'))) {
return { partOfSpeech: PartOfSpeech.verb, pos1: '動詞' };
}
if (wordClasses.includes('adj-i') || wordClasses.includes('adj-ix')) {
return { partOfSpeech: PartOfSpeech.i_adjective, pos1: '形容詞' };
}
if (wordClasses.includes('adj-na')) {
return { partOfSpeech: PartOfSpeech.na_adjective, pos1: '名詞' };
}
if (
wordClasses.some(
(wordClass) =>
wordClass === 'n' ||
wordClass === 'num' ||
wordClass === 'ctr' ||
wordClass === 'pn' ||
wordClass.startsWith('n-'),
)
) {
return { partOfSpeech: PartOfSpeech.noun, pos1: '名詞' };
}
return { partOfSpeech: PartOfSpeech.other };
}
function getYomitanWordClassPosMetadata(wordClasses: unknown): {
partOfSpeech: PartOfSpeech;
pos1?: string;
} {
return resolvePartOfSpeechFromYomitanWordClasses(normalizeYomitanWordClasses(wordClasses));
}
function resolveFrequencyLookupText(
token: MergedToken,
matchMode: FrequencyDictionaryMatchMode,
@@ -623,19 +684,23 @@ async function parseWithYomitanInternalParser(
}
const normalizedSelectedTokens = normalizeSelectedYomitanTokens(
selectedTokens.map(
(token): MergedToken => ({
surface: token.surface,
reading: token.reading,
headword: token.headword,
startPos: token.startPos,
endPos: token.endPos,
partOfSpeech: PartOfSpeech.other,
isMerged: true,
isKnown: false,
isNPlusOneTarget: false,
isNameMatch: token.isNameMatch ?? false,
frequencyRank: token.frequencyRank,
}),
(token): MergedToken => {
const posMetadata = getYomitanWordClassPosMetadata(token.wordClasses);
return {
surface: token.surface,
reading: token.reading,
headword: token.headword,
startPos: token.startPos,
endPos: token.endPos,
partOfSpeech: posMetadata.partOfSpeech,
pos1: posMetadata.pos1,
isMerged: true,
isKnown: false,
isNPlusOneTarget: false,
isNameMatch: token.isNameMatch ?? false,
frequencyRank: token.frequencyRank,
};
},
),
);
@@ -716,12 +781,11 @@ export async function tokenizeSubtitle(
.replace(/\s+/g, ' ')
.trim();
const annotationOptions = getAnnotationOptions(deps);
annotationOptions.sourceText = tokenizeText;
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps, annotationOptions);
if (yomitanTokens && yomitanTokens.length > 0) {
const annotatedTokens = await stripSubtitleAnnotationMetadata(
await applyAnnotationStage(yomitanTokens, deps, annotationOptions),
);
const annotatedTokens = await applyAnnotationStage(yomitanTokens, deps, annotationOptions);
return {
text: displayText,
tokens: annotatedTokens.length > 0 ? annotatedTokens : null,
@@ -366,6 +366,132 @@ test('shouldExcludeTokenFromSubtitleAnnotations excludes kana-only non-independe
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone して grammar helper fragments', () => {
const token = makeToken({
surface: 'して',
headword: 'する',
reading: 'シテ',
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞|助詞',
pos2: '自立|接続助詞',
});
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes inflected standalone して grammar helper fragments', () => {
const token = makeToken({
surface: 'してる',
headword: 'する',
reading: 'シテル',
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞|助動詞',
pos2: '自立|非自立',
});
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone particle fragments without POS tags', () => {
const token = makeToken({
surface: 'と',
headword: 'と',
reading: 'ト',
partOfSpeech: PartOfSpeech.other,
pos1: '',
pos2: '',
});
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone connective particle fragments without POS tags', () => {
const token = makeToken({
surface: 'たって',
headword: 'たって',
reading: 'タッテ',
partOfSpeech: PartOfSpeech.other,
pos1: '',
pos2: '',
});
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes rhetorical もんか grammar particle phrases', () => {
for (const surface of ['もんか', 'ものか']) {
const token = makeToken({
surface,
headword: surface,
reading: surface === 'もんか' ? 'モンカ' : 'モノカ',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞|助詞',
pos2: '非自立|副助詞/並立助詞/終助詞',
});
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, surface);
}
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes bare くれ auxiliary fragments', () => {
const token = makeToken({
surface: 'くれ',
headword: '暮れ',
reading: 'クレ',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '一般',
});
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone quote particle and auxiliary grammar terms', () => {
for (const token of [
makeToken({
surface: 'って',
headword: 'って',
reading: 'ッテ',
partOfSpeech: PartOfSpeech.other,
pos1: '',
pos2: '',
}),
makeToken({
surface: 'べき',
headword: 'べき',
reading: 'ベキ',
partOfSpeech: PartOfSpeech.other,
pos1: '',
pos2: '',
}),
]) {
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, token.surface);
}
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes single-kana surface fragments', () => {
for (const token of [
makeToken({
surface: 'ふ',
headword: '不',
reading: 'フ',
partOfSpeech: PartOfSpeech.other,
pos1: '接頭詞',
pos2: '',
}),
makeToken({
surface: 'フ',
headword: '負',
reading: 'フ',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '一般',
}),
]) {
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, token.surface);
}
});
test('stripSubtitleAnnotationMetadata keeps token hover data while clearing annotation fields', () => {
const token = makeToken({
surface: 'は',
@@ -444,13 +570,13 @@ test('annotateTokens keeps other annotations for name matches when name highligh
let jlptLookupCalls = 0;
const tokens = [
makeToken({
surface: 'オリヴィア',
reading: 'オリヴィア',
headword: 'オリヴィア',
surface: '山田',
reading: 'ヤマダ',
headword: '山田',
isNameMatch: true,
frequencyRank: 42,
startPos: 0,
endPos: 5,
endPos: 2,
}),
];
@@ -501,6 +627,63 @@ test('annotateTokens N+1 handoff marks expected target when threshold is satisfi
assert.equal(result[2]?.isNPlusOneTarget, false);
});
test('annotateTokens does not mark kana-only unknown target with subtitle punctuation as N+1', () => {
const tokens = [
makeToken({
surface: '何やら',
headword: '何やら',
reading: 'ナニヤラ',
pos1: '副詞',
startPos: 0,
endPos: 3,
}),
makeToken({
surface: 'ボタン',
headword: 'ボタン',
reading: 'ボタン',
pos1: '名詞',
startPos: 3,
endPos: 6,
}),
makeToken({
surface: 'スイッチ…',
headword: 'スイッチ',
reading: 'スイッチ',
pos1: '名詞',
startPos: 6,
endPos: 11,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '何やら' || text === 'ボタン',
}),
{ minSentenceWordsForNPlusOne: 3 },
);
assert.equal(result[2]?.isNPlusOneTarget, false);
});
test('annotateTokens still marks kanji unknown target in otherwise eligible sentence as N+1', () => {
const tokens = [
makeToken({ surface: '私', headword: '私', pos1: '名詞', startPos: 0, endPos: 1 }),
makeToken({ surface: '猫', headword: '猫', pos1: '名詞', startPos: 1, endPos: 2 }),
makeToken({ surface: '装置…', headword: '装置', pos1: '名詞', startPos: 2, endPos: 5 }),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '私' || text === '猫',
}),
{ minSentenceWordsForNPlusOne: 3 },
);
assert.equal(result[2]?.isNPlusOneTarget, true);
});
test('annotateTokens N+1 minimum sentence words counts only eligible word tokens', () => {
const tokens = [
makeToken({ surface: '猫', headword: '猫', startPos: 0, endPos: 1 }),
@@ -536,6 +719,134 @@ test('annotateTokens N+1 minimum sentence words counts only eligible word tokens
assert.equal(result[0]?.isNPlusOneTarget, false);
});
test('annotateTokens N+1 minimum sentence words excludes unknown tokens filtered from N+1 targeting', () => {
const tokens = [
makeToken({ surface: '私', headword: '私', pos1: '名詞', startPos: 0, endPos: 1 }),
makeToken({ surface: '猫', headword: '猫', pos1: '名詞', startPos: 1, endPos: 2 }),
makeToken({
surface: 'スイッチ',
headword: 'スイッチ',
reading: 'スイッチ',
pos1: '名詞',
startPos: 2,
endPos: 6,
}),
makeToken({ surface: '装置', headword: '装置', pos1: '名詞', startPos: 6, endPos: 8 }),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '私' || text === '猫',
}),
{ minSentenceWordsForNPlusOne: 4 },
);
assert.equal(result[3]?.isNPlusOneTarget, false);
});
test('annotateTokens N+1 sentence word count respects source punctuation gaps omitted by Yomitan', () => {
const tokens = [
makeToken({
surface: '私',
headword: '私',
pos1: '名詞',
startPos: 0,
endPos: 1,
}),
makeToken({
surface: '猫',
headword: '猫',
pos1: '名詞',
startPos: 1,
endPos: 2,
}),
makeToken({
surface: '犬',
headword: '犬',
pos1: '名詞',
startPos: 2,
endPos: 3,
}),
makeToken({
surface: 'ふざけん',
headword: 'ふざける',
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞',
pos2: '自立',
startPos: 4,
endPos: 8,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '私' || text === '猫' || text === '犬',
}),
{
minSentenceWordsForNPlusOne: 3,
sourceText: '私猫犬!ふざけんなよ!',
},
);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[1]?.isNPlusOneTarget, false);
assert.equal(result[2]?.isNPlusOneTarget, false);
assert.equal(result[3]?.isNPlusOneTarget, false);
});
test('annotateTokens N+1 sentence word count normalizes line breaks before gap detection', () => {
const tokens = [
makeToken({
surface: '私',
headword: '私',
pos1: '名詞',
startPos: 0,
endPos: 1,
}),
makeToken({
surface: '猫',
headword: '猫',
pos1: '名詞',
startPos: 2,
endPos: 3,
}),
makeToken({
surface: '犬',
headword: '犬',
pos1: '名詞',
startPos: 3,
endPos: 4,
}),
makeToken({
surface: 'ふざけん',
headword: 'ふざける',
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞',
pos2: '自立',
startPos: 5,
endPos: 9,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '私' || text === '猫' || text === '犬',
}),
{
minSentenceWordsForNPlusOne: 3,
sourceText: '私\r\n猫犬!ふざけんなよ!',
},
);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[1]?.isNPlusOneTarget, false);
assert.equal(result[2]?.isNPlusOneTarget, false);
assert.equal(result[3]?.isNPlusOneTarget, false);
});
test('annotateTokens applies configured pos1 exclusions to both frequency and N+1', () => {
const tokens = [
makeToken({
@@ -593,7 +904,7 @@ test('annotateTokens allows previously default-excluded pos1 when removed from e
});
assert.equal(result[0]?.frequencyRank, 8);
assert.equal(result[0]?.isNPlusOneTarget, true);
assert.equal(result[0]?.isNPlusOneTarget, false);
});
test('annotateTokens excludes default non-independent pos2 from frequency and N+1', () => {
@@ -618,6 +929,37 @@ test('annotateTokens excludes default non-independent pos2 from frequency and N+
assert.equal(result[0]?.isNPlusOneTarget, false);
});
test('annotateTokens preserves exact known-word status for non-independent kanji noun tokens', () => {
const tokens = [
makeToken({
surface: '点',
reading: 'てん',
headword: '点',
partOfSpeech: PartOfSpeech.other,
pos1: '名詞',
pos2: '非自立',
pos3: '一般',
startPos: 2,
endPos: 3,
frequencyRank: 1384,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '点' || text === 'てん',
getJlptLevel: (text) => (text === '点' ? 'N3' : null),
}),
{ minSentenceWordsForNPlusOne: 1 },
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined);
});
test('annotateTokens clears all annotations for non-independent kanji noun tokens under unified gate', () => {
const tokens = [
makeToken({
@@ -665,7 +1007,7 @@ test('annotateTokens excludes likely kana SFX tokens from frequency when POS tag
assert.equal(result[0]?.frequencyRank, undefined);
});
test('annotateTokens excludes single hiragana and katakana tokens from frequency when POS tags are missing', () => {
test('annotateTokens clears all annotations from single hiragana and katakana surface fragments', () => {
const tokens = [
makeToken({
surface: 'た',
@@ -679,12 +1021,12 @@ test('annotateTokens excludes single hiragana and katakana tokens from frequency
endPos: 1,
}),
makeToken({
surface: '',
reading: '',
headword: '',
pos1: '',
surface: '',
reading: '',
headword: '',
pos1: '名詞',
pos2: '',
partOfSpeech: PartOfSpeech.other,
partOfSpeech: PartOfSpeech.noun,
frequencyRank: 22,
startPos: 1,
endPos: 2,
@@ -706,8 +1048,14 @@ test('annotateTokens excludes single hiragana and katakana tokens from frequency
minSentenceWordsForNPlusOne: 1,
});
assert.equal(result[0]?.isKnown, false);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined);
assert.equal(result[1]?.isKnown, false);
assert.equal(result[1]?.isNPlusOneTarget, false);
assert.equal(result[1]?.frequencyRank, undefined);
assert.equal(result[1]?.jlptLevel, undefined);
assert.equal(result[2]?.frequencyRank, 23);
});
@@ -751,10 +1099,10 @@ test('annotateTokens allows previously default-excluded pos2 when removed from e
});
assert.equal(result[0]?.frequencyRank, 9);
assert.equal(result[0]?.isNPlusOneTarget, true);
assert.equal(result[0]?.isNPlusOneTarget, false);
});
test('annotateTokens excludes composite function/content tokens from frequency but keeps N+1 eligible', () => {
test('annotateTokens excludes kana-only composite function/content tokens from frequency and N+1', () => {
const tokens = [
makeToken({
surface: 'になれば',
@@ -772,7 +1120,7 @@ test('annotateTokens excludes composite function/content tokens from frequency b
});
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.isNPlusOneTarget, true);
assert.equal(result[0]?.isNPlusOneTarget, false);
});
test('annotateTokens excludes composite tokens when all component pos tags are excluded', () => {
@@ -856,6 +1204,219 @@ test('annotateTokens clears all annotations for kana-only non-independent noun h
assert.equal(result[0]?.jlptLevel, undefined);
});
test('annotateTokens clears all annotations for standalone して helper fragments', () => {
const tokens = [
makeToken({
surface: 'してる',
headword: 'する',
reading: 'シテル',
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞|助動詞',
pos2: '自立|非自立',
startPos: 0,
endPos: 3,
frequencyRank: 22,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === 'する',
getJlptLevel: (text) => (text === 'する' ? 'N5' : null),
}),
{ minSentenceWordsForNPlusOne: 1 },
);
assert.equal(result[0]?.isKnown, false);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined);
});
test('annotateTokens clears all annotations for standalone particle fragments without POS tags', () => {
const tokens = [
makeToken({
surface: 'と',
headword: 'と',
reading: 'ト',
partOfSpeech: PartOfSpeech.other,
pos1: '',
pos2: '',
startPos: 0,
endPos: 1,
frequencyRank: 4,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === 'と',
getJlptLevel: (text) => (text === 'と' ? 'N5' : null),
}),
{ minSentenceWordsForNPlusOne: 1 },
);
assert.equal(result[0]?.isKnown, false);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined);
});
test('annotateTokens does not mark standalone connective particles as N+1', () => {
const tokens = [
makeToken({
surface: '逃げる',
headword: '逃げる',
reading: 'ニゲル',
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞',
pos2: '自立',
startPos: 0,
endPos: 3,
}),
makeToken({
surface: 'たって',
headword: 'たって',
reading: 'タッテ',
partOfSpeech: PartOfSpeech.other,
pos1: '',
pos2: '',
startPos: 3,
endPos: 6,
frequencyRank: 28,
}),
makeToken({
surface: '無駄',
headword: '無駄',
reading: 'ムダ',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '形容動詞語幹',
startPos: 6,
endPos: 8,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '逃げる' || text === '無駄',
getJlptLevel: (text) => (text === 'たって' ? 'N3' : null),
}),
{ minSentenceWordsForNPlusOne: 1 },
);
assert.equal(result[1]?.isKnown, false);
assert.equal(result[1]?.isNPlusOneTarget, false);
assert.equal(result[1]?.frequencyRank, undefined);
assert.equal(result[1]?.jlptLevel, undefined);
});
test('annotateTokens clears all annotations for rhetorical もんか grammar particle phrases', () => {
const tokens = [
makeToken({
surface: 'もんか',
headword: 'もんか',
reading: 'モンカ',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞|助詞',
pos2: '非自立|副助詞/並立助詞/終助詞',
startPos: 0,
endPos: 3,
frequencyRank: 69629,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === 'もんか',
getJlptLevel: (text) => (text === 'もんか' ? 'N2' : null),
}),
{ minSentenceWordsForNPlusOne: 1 },
);
assert.equal(result[0]?.isKnown, false);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined);
});
test('annotateTokens clears all annotations for bare くれ auxiliary fragments', () => {
const tokens = [
makeToken({
surface: 'くれ',
headword: '暮れ',
reading: 'クレ',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '一般',
startPos: 0,
endPos: 2,
frequencyRank: 12877,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '暮れ',
getJlptLevel: (text) => (text === '暮れ' ? 'N3' : null),
}),
{ minSentenceWordsForNPlusOne: 1 },
);
assert.equal(result[0]?.isKnown, false);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined);
});
test('annotateTokens clears all annotations for standalone quote particle and auxiliary grammar terms', () => {
const tokens = [
makeToken({
surface: 'って',
headword: 'って',
reading: 'ッテ',
partOfSpeech: PartOfSpeech.other,
pos1: '',
pos2: '',
startPos: 0,
endPos: 2,
frequencyRank: 28,
}),
makeToken({
surface: 'べき',
headword: 'べき',
reading: 'ベキ',
partOfSpeech: PartOfSpeech.other,
pos1: '',
pos2: '',
startPos: 2,
endPos: 4,
frequencyRank: 268,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === 'って' || text === 'べき',
getJlptLevel: (text) => (text === 'って' || text === 'べき' ? 'N3' : null),
}),
{ minSentenceWordsForNPlusOne: 1 },
);
for (const token of result) {
assert.equal(token.isKnown, false, token.surface);
assert.equal(token.isNPlusOneTarget, false, token.surface);
assert.equal(token.frequencyRank, undefined, token.surface);
assert.equal(token.jlptLevel, undefined, token.surface);
}
});
test('annotateTokens clears all annotations from standalone あ interjections without POS tags', () => {
const tokens = [
makeToken({
@@ -89,6 +89,7 @@ export interface AnnotationStageOptions {
minSentenceWordsForNPlusOne?: number;
pos1Exclusions?: ReadonlySet<string>;
pos2Exclusions?: ReadonlySet<string>;
sourceText?: string;
}
function resolveKnownWordText(
@@ -670,6 +671,36 @@ function computeTokenKnownStatus(
return normalizedReading !== matchText.trim() && isKnownWord(normalizedReading);
}
function computeExcludedTokenKnownStatus(
token: MergedToken,
isKnownWord: (text: string) => boolean,
): boolean {
const normalizedSurface = token.surface.trim();
if (!hasKanjiChar(normalizedSurface)) {
return false;
}
if (normalizedSurface && isKnownWord(normalizedSurface)) {
return true;
}
const normalizedReading = token.reading.trim();
if (
normalizedReading &&
normalizedReading !== normalizedSurface &&
isKnownWord(normalizedReading)
) {
return true;
}
const normalizedHeadword = token.headword.trim();
return (
normalizedHeadword.length > 0 &&
normalizedHeadword === normalizedSurface &&
isKnownWord(normalizedHeadword)
);
}
function filterTokenFrequencyRank(
token: MergedToken,
pos1Exclusions: ReadonlySet<string>,
@@ -732,10 +763,16 @@ export function annotateTokens(
pos2Exclusions,
})
) {
return sharedStripSubtitleAnnotationMetadata(token, {
const strippedToken = sharedStripSubtitleAnnotationMetadata(token, {
pos1Exclusions,
pos2Exclusions,
});
return {
...strippedToken,
isKnown:
nPlusOneEnabled &&
computeExcludedTokenKnownStatus(token, deps.isKnownWord),
};
}
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
@@ -779,6 +816,7 @@ export function annotateTokens(
sanitizedMinSentenceWordsForNPlusOne,
pos1Exclusions,
pos2Exclusions,
options.sourceText,
);
if (!nameMatchEnabled) {
@@ -39,6 +39,33 @@ test('enrichTokensWithMecabPos1 fills missing pos1 using surface-sequence fallba
assert.equal(enriched[0]?.pos1, '助詞');
});
test('enrichTokensWithMecabPos1 backfills blank pos2 and pos3 fields', () => {
const tokens = [
makeToken({
surface: 'は',
startPos: 0,
endPos: 1,
pos1: '助詞',
pos2: '',
pos3: ' ',
}),
];
const mecabTokens = [
makeToken({
surface: 'は',
startPos: 0,
endPos: 1,
pos1: '助詞',
pos2: '係助詞',
pos3: '一般',
}),
];
const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens);
assert.equal(enriched[0]?.pos2, '係助詞');
assert.equal(enriched[0]?.pos3, '一般');
});
test('enrichTokensWithMecabPos1 keeps partOfSpeech unchanged and only enriches POS tags', () => {
const tokens = [makeToken({ surface: 'これは', startPos: 0, endPos: 3 })];
const mecabTokens = [
@@ -120,6 +120,13 @@ function lowerBoundByIndex(candidates: IndexedMecabToken[], targetIndex: number)
return low;
}
function coalesceMissingPosField(
current: string | undefined,
fallback: string | undefined,
): string | undefined {
return typeof current === 'string' && current.trim().length > 0 ? current : fallback;
}
function joinUniqueTags(values: Array<string | undefined>): string | undefined {
const unique: string[] = [];
for (const value of values) {
@@ -303,7 +310,9 @@ function fillMissingPos1BySurfaceSequence(
let cursor = 0;
return tokens.map((token) => {
if (token.pos1 && token.pos1.trim().length > 0) {
const hasCompletePosMetadata =
token.pos1?.trim() && token.pos2?.trim() && token.pos3?.trim();
if (hasCompletePosMetadata) {
return token;
}
@@ -327,9 +336,9 @@ function fillMissingPos1BySurfaceSequence(
cursor = best.index + 1;
return {
...token,
pos1: best.pos1,
pos2: best.pos2,
pos3: best.pos3,
pos1: coalesceMissingPosField(token.pos1, best.pos1),
pos2: coalesceMissingPosField(token.pos2, best.pos2),
pos3: coalesceMissingPosField(token.pos3, best.pos3),
};
});
}
@@ -382,7 +391,7 @@ export function enrichTokensWithMecabPos1(
const metadataByTokenIndex = new Map<number, MecabPosMetadata>();
for (const [index, token] of tokens.entries()) {
if (token.pos1) {
if (token.pos1?.trim() && token.pos2?.trim() && token.pos3?.trim()) {
continue;
}
@@ -410,9 +419,9 @@ export function enrichTokensWithMecabPos1(
return {
...token,
pos1: metadata.pos1,
pos2: metadata.pos2,
pos3: metadata.pos3,
pos1: coalesceMissingPosField(token.pos1, metadata.pos1),
pos2: coalesceMissingPosField(token.pos2, metadata.pos2),
pos3: coalesceMissingPosField(token.pos3, metadata.pos3),
};
});
@@ -13,17 +13,28 @@ const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
const KATAKANA_CODEPOINT_START = 0x30a1;
const KATAKANA_CODEPOINT_END = 0x30f6;
const STANDALONE_GRAMMAR_PARTICLE_PHRASES = ['たって', 'だって'] as const;
const STANDALONE_GRAMMAR_PARTICLE_PHRASES_SET: ReadonlySet<string> = new Set(
STANDALONE_GRAMMAR_PARTICLE_PHRASES,
);
const SUBTITLE_ANNOTATION_EXCLUDED_TERMS = new Set([
'あ',
'ああ',
'ええ',
'うう',
'おお',
'くれ',
'って',
'はあ',
'はは',
'べき',
'へえ',
'ふう',
'ほう',
'もんか',
'ものか',
...STANDALONE_GRAMMAR_PARTICLE_PHRASES,
]);
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES = ['ん', 'の', 'なん', 'なの'];
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES = [
@@ -72,7 +83,25 @@ const SUBTITLE_ANNOTATION_EXCLUDED_TRAILING_PARTICLE_SUFFIXES = new Set([
]);
const AUXILIARY_STEM_GRAMMAR_TAIL_POS1 = new Set(['名詞', '助動詞', '助詞']);
const NON_INDEPENDENT_NOUN_HELPER_TAIL_POS1 = new Set(['助詞', '助動詞']);
const STANDALONE_GRAMMAR_PARTICLE_SURFACES = new Set([
'か',
'が',
'さ',
'し',
'ぞ',
'ぜ',
'と',
'な',
'に',
'ね',
'の',
'は',
'へ',
'も',
'や',
'よ',
'を',
]);
export interface SubtitleAnnotationFilterOptions {
pos1Exclusions?: ReadonlySet<string>;
pos2Exclusions?: ReadonlySet<string>;
@@ -278,6 +307,38 @@ function isKanaOnlyNonIndependentNounHelperMerge(token: MergedToken): boolean {
return pos1Parts.slice(1).every((part) => NON_INDEPENDENT_NOUN_HELPER_TAIL_POS1.has(part));
}
function isKanaOnlyText(text: string): boolean {
const normalized = normalizeKana(text);
return normalized.length > 0 && [...normalized].every(isKanaChar);
}
function isStandaloneSuruTeGrammarHelper(token: MergedToken): boolean {
const normalizedSurface = normalizeKana(token.surface);
const normalizedHeadword = normalizeKana(token.headword);
if (!normalizedSurface.startsWith('して') || normalizedHeadword !== 'する') {
return false;
}
const pos1Parts = splitNormalizedTagParts(normalizePosTag(token.pos1));
return isKanaOnlyText(normalizedSurface) && (pos1Parts.length === 0 || pos1Parts.includes('動詞'));
}
function isStandaloneGrammarParticle(token: MergedToken): boolean {
const normalizedSurface = normalizeKana(token.surface);
const normalizedHeadword = normalizeKana(token.headword);
return (
normalizedSurface === normalizedHeadword &&
(STANDALONE_GRAMMAR_PARTICLE_SURFACES.has(normalizedSurface) ||
STANDALONE_GRAMMAR_PARTICLE_PHRASES_SET.has(normalizedSurface))
);
}
function isSingleKanaSurfaceFragment(token: MergedToken): boolean {
const normalizedSurface = normalizeKana(token.surface);
const chars = [...normalizedSurface];
return chars.length === 1 && chars.every(isKanaChar);
}
function isExcludedByTerm(token: MergedToken): boolean {
const candidates = [token.surface, token.reading, token.headword].filter(
(candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0,
@@ -365,6 +426,18 @@ export function shouldExcludeTokenFromSubtitleAnnotations(
return true;
}
if (isStandaloneSuruTeGrammarHelper(token)) {
return true;
}
if (isStandaloneGrammarParticle(token)) {
return true;
}
if (isSingleKanaSurfaceFragment(token)) {
return true;
}
if (isExcludedTrailingParticleMergedToken(token)) {
return true;
}
@@ -1049,6 +1049,60 @@ test('requestYomitanScanTokens marks grouped entries when SubMiner dictionary al
assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true);
});
test('requestYomitanScanTokens preserves matched headword word classes', async () => {
let scannerScript = '';
const deps = createDeps(async (script) => {
if (script.includes('termsFind')) {
scannerScript = script;
return [];
}
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
},
},
],
};
}
return null;
});
await requestYomitanScanTokens('は', deps, { error: () => undefined });
const result = await runInjectedYomitanScript(scannerScript, (action, params) => {
if (action !== 'termsFind') {
throw new Error(`unexpected action: ${action}`);
}
const text = (params as { text?: string } | undefined)?.text;
if (text !== 'は') {
return { originalTextLength: 0, dictionaryEntries: [] };
}
return {
originalTextLength: 1,
dictionaryEntries: [
{
headwords: [
{
term: 'は',
reading: 'は',
wordClasses: ['prt'],
sources: [{ originalText: 'は', isPrimary: true, matchType: 'exact' }],
},
],
},
],
};
});
assert.deepEqual((result as Array<{ wordClasses?: string[] }>)[0]?.wordClasses, ['prt']);
});
test('requestYomitanScanTokens skips fallback fragments without exact primary source matches', async () => {
const deps = createDeps(async (script) => {
if (script.includes('optionsGetFull')) {
@@ -53,6 +53,7 @@ export interface YomitanScanToken {
endPos: number;
isNameMatch?: boolean;
frequencyRank?: number;
wordClasses?: string[];
}
interface YomitanProfileMetadata {
@@ -91,7 +92,10 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
typeof entry.startPos === 'number' &&
typeof entry.endPos === 'number' &&
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
(entry.frequencyRank === undefined || typeof entry.frequencyRank === 'number'),
(entry.frequencyRank === undefined || typeof entry.frequencyRank === 'number') &&
(entry.wordClasses === undefined ||
(Array.isArray(entry.wordClasses) &&
entry.wordClasses.every((wordClass) => typeof wordClass === 'string'))),
)
);
}
@@ -975,6 +979,11 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
return best;
}
function getPreferredHeadword(dictionaryEntries, token, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
function normalizeWordClasses(headword) {
if (!Array.isArray(headword?.wordClasses)) { return undefined; }
const classes = headword.wordClasses.filter((wordClass) => typeof wordClass === "string" && wordClass.trim().length > 0);
return classes.length > 0 ? classes : undefined;
}
function appendDictionaryNames(target, value) {
if (!value || typeof value !== 'object') {
return;
@@ -1033,6 +1042,7 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
return {
term: preferredMatch.headword.term,
reading: preferredMatch.headword.reading,
wordClasses: normalizeWordClasses(preferredMatch.headword),
isNameMatch: matchedNameDictionary || isNameDictionaryEntry(preferredMatch.dictionaryEntry),
frequencyRank: getBestFrequencyRankForMatches(
exactFrequencyMatches.length > 0 ? exactFrequencyMatches : exactPrimaryMatches,
@@ -1099,7 +1109,7 @@ ${YOMITAN_SCANNING_HELPERS}
if (preferredHeadword && typeof preferredHeadword.term === "string") {
const reading = typeof preferredHeadword.reading === "string" ? preferredHeadword.reading : "";
const segments = distributeFuriganaInflected(preferredHeadword.term, reading, source);
tokens.push({
const tokenPayload = {
surface: segments.map((segment) => segment.text).join("") || source,
reading: segments.map((segment) => typeof segment.reading === "string" ? segment.reading : "").join(""),
headword: preferredHeadword.term,
@@ -1110,7 +1120,11 @@ ${YOMITAN_SCANNING_HELPERS}
typeof preferredHeadword.frequencyRank === "number" && Number.isFinite(preferredHeadword.frequencyRank)
? Math.max(1, Math.floor(preferredHeadword.frequencyRank))
: undefined,
});
};
if (Array.isArray(preferredHeadword.wordClasses) && preferredHeadword.wordClasses.length > 0) {
tokenPayload.wordClasses = preferredHeadword.wordClasses;
}
tokens.push(tokenPayload);
i += originalTextLength;
continue;
}
+48 -3
View File
@@ -33,6 +33,11 @@ import {
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import {
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
scheduleLinuxVisibleOverlayFullscreenRefreshBurst,
} from './main/runtime/linux-mpv-fullscreen-overlay-refresh';
import { mergeAiConfig } from './ai/config';
function getPasswordStoreArg(argv: string[]): string | null {
@@ -1395,6 +1400,9 @@ const subtitleProcessingController = createSubtitleProcessingController(
let subtitlePrefetchService: SubtitlePrefetchService | null = null;
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let lastObservedTimePos = 0;
let cancelLinuxMpvFullscreenOverlayRefreshBurst:
| CancelLinuxMpvFullscreenOverlayRefreshBurst
| null = null;
const SEEK_THRESHOLD_SECONDS = 3;
function clearScheduledSubtitlePrefetchRefresh(): void {
@@ -1404,6 +1412,11 @@ function clearScheduledSubtitlePrefetchRefresh(): void {
}
}
function cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(): void {
cancelLinuxMpvFullscreenOverlayRefreshBurst?.();
cancelLinuxMpvFullscreenOverlayRefreshBurst = null;
}
const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
getCurrentService: () => subtitlePrefetchService,
setCurrentService: (service) => {
@@ -3102,6 +3115,10 @@ const {
stopTexthookerService: () => texthookerService.stop(),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
cancelLinuxMpvFullscreenOverlayRefreshBurst = null;
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
},
getMainOverlayWindow: () => overlayManager.getMainWindow(),
clearMainOverlayWindow: () => overlayManager.setMainWindow(null),
getModalOverlayWindow: () => overlayManager.getModalWindow(),
@@ -3806,6 +3823,17 @@ const {
}
lastObservedTimePos = time;
},
onFullscreenChange: () => {
cancelLinuxMpvFullscreenOverlayRefreshBurst =
scheduleLinuxVisibleOverlayFullscreenRefreshBurst({
overlayManager: {
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
},
overlayVisibilityRuntime,
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
});
},
onSubtitleTrackChange: (sid) => {
scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
@@ -4046,10 +4074,18 @@ const buildUpdateVisibleOverlayBoundsMainDepsHandler =
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
afterSetOverlayWindowBounds: () => {
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
if (!overlayManager.getVisibleOverlayVisible()) {
return;
}
scheduleWindowsVisibleOverlayZOrderSyncBurst();
if (process.platform === 'win32') {
scheduleWindowsVisibleOverlayZOrderSyncBurst();
return;
}
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
ensureOverlayWindowLevel(mainWindow);
},
});
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
@@ -5125,6 +5161,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
onWindowClosed: (windowKind) => {
if (windowKind === 'visible') {
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
overlayManager.setMainWindow(null);
} else {
overlayManager.setModalWindow(null);
@@ -5372,6 +5409,9 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions();
if (!visible) {
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
}
if (visible) {
void ensureOverlayMpvSubtitlesHidden();
}
@@ -5381,13 +5421,18 @@ function setVisibleOverlayVisible(visible: boolean): void {
function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions();
if (!overlayManager.getVisibleOverlayVisible()) {
if (overlayManager.getVisibleOverlayVisible()) {
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} else {
void ensureOverlayMpvSubtitlesHidden();
}
toggleVisibleOverlayHandler();
syncOverlayMpvSubtitleSuppression();
}
function setOverlayVisible(visible: boolean): void {
if (!visible) {
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
}
if (visible) {
void ensureOverlayMpvSubtitlesHidden();
}
@@ -18,6 +18,8 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
stopTexthookerService: () => calls.push('stop-texthooker'),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-poll'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'),
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
@@ -42,10 +44,11 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
});
cleanup();
assert.equal(calls.length, 29);
assert.equal(calls.length, 30);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
});
@@ -7,6 +7,7 @@ export function createOnWillQuitCleanupHandler(deps: {
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
destroyMainOverlayWindow: () => void;
destroyModalOverlayWindow: () => void;
destroyYomitanParserWindow: () => void;
@@ -38,6 +39,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow();
deps.destroyModalOverlayWindow();
deps.destroyYomitanParserWindow();
@@ -20,6 +20,8 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
stopTexthookerService: () => calls.push('stop-texthooker'),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'),
getMainOverlayWindow: () => ({
isDestroyed: () => false,
destroy: () => calls.push('destroy-main-overlay-window'),
@@ -88,6 +90,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('stop-discord-presence'));
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.equal(reconnectTimer, null);
assert.equal(immersionTracker, null);
});
@@ -103,6 +106,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => ({
isDestroyed: () => true,
destroy: () => calls.push('destroy-main-overlay-window'),
@@ -26,6 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
getMainOverlayWindow: () => DestroyableWindow | null;
clearMainOverlayWindow: () => void;
getModalOverlayWindow: () => DestroyableWindow | null;
@@ -67,6 +68,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
stopTexthookerService: () => deps.stopTexthookerService(),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts(),
destroyMainOverlayWindow: () => {
const window = deps.getMainOverlayWindow();
if (!window) return;
@@ -22,6 +22,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
clearMainOverlayWindow: () => {},
getModalOverlayWindow: () => null,
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
scheduleLinuxVisibleOverlayFullscreenRefreshBurst,
} from './linux-mpv-fullscreen-overlay-refresh';
test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work on linux', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
const calls: string[] = [];
try {
scheduleLinuxVisibleOverlayFullscreenRefreshBurst({
overlayManager: {
getMainWindow: () =>
({
hide: () => calls.push('hide'),
isDestroyed: () => false,
isVisible: () => true,
showInactive: () => calls.push('showInactive'),
}) as never,
getVisibleOverlayVisible: () => true,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
},
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
});
const deadline = Date.now() + 200;
while (!calls.includes('updateVisibleOverlayVisibility') && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.ok(calls.includes('updateVisibleOverlayVisibility'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('showInactive'));
assert.ok(calls.includes('ensureOverlayWindowLevel'));
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
@@ -0,0 +1,70 @@
type LinuxMpvFullscreenOverlayWindow = {
hide: () => void;
isDestroyed: () => boolean;
isVisible: () => boolean;
showInactive: () => void;
};
export type LinuxMpvFullscreenOverlayRefreshDeps = {
overlayManager: {
getMainWindow: () => LinuxMpvFullscreenOverlayWindow | null;
getVisibleOverlayVisible: () => boolean;
};
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => void;
};
ensureOverlayWindowLevel: (window: LinuxMpvFullscreenOverlayWindow) => void;
};
export type CancelLinuxMpvFullscreenOverlayRefreshBurst = () => void;
const LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS = [0, 50, 150, 300, 600] as const;
let linuxMpvFullscreenOverlayRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
function clearLinuxMpvFullscreenOverlayRefreshTimeouts(): void {
for (const timeout of linuxMpvFullscreenOverlayRefreshTimeouts) {
clearTimeout(timeout);
}
linuxMpvFullscreenOverlayRefreshTimeouts = [];
}
function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(
deps: LinuxMpvFullscreenOverlayRefreshDeps,
): void {
if (process.platform !== 'linux' || !deps.overlayManager.getVisibleOverlayVisible()) {
return;
}
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility();
const mainWindow = deps.overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return;
}
mainWindow.hide();
mainWindow.showInactive();
deps.ensureOverlayWindowLevel(mainWindow);
}
export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
deps: LinuxMpvFullscreenOverlayRefreshDeps,
): CancelLinuxMpvFullscreenOverlayRefreshBurst {
if (process.platform !== 'linux') {
return () => {};
}
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
for (const delayMs of LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => {
linuxMpvFullscreenOverlayRefreshTimeouts = linuxMpvFullscreenOverlayRefreshTimeouts.filter(
(timeout) => timeout !== refreshTimeout,
);
refreshLinuxVisibleOverlayAfterMpvFullscreenChange(deps);
}, delayMs);
refreshTimeout.unref?.();
linuxMpvFullscreenOverlayRefreshTimeouts.push(refreshTimeout);
}
return clearLinuxMpvFullscreenOverlayRefreshTimeouts;
}
export { clearLinuxMpvFullscreenOverlayRefreshTimeouts };
@@ -128,6 +128,7 @@ test('mpv event bindings register all expected events', () => {
onTimePosChange: () => {},
onDurationChange: () => {},
onPauseChange: () => {},
onFullscreenChange: () => {},
onSubtitleMetricsChange: () => {},
onSecondarySubtitleVisibility: () => {},
});
@@ -151,6 +152,7 @@ test('mpv event bindings register all expected events', () => {
'time-pos-change',
'duration-change',
'pause-change',
'fullscreen-change',
'subtitle-metrics-change',
'secondary-subtitle-visibility',
]);
@@ -11,6 +11,7 @@ type MpvBindingEventName =
| 'time-pos-change'
| 'duration-change'
| 'pause-change'
| 'fullscreen-change'
| 'subtitle-metrics-change'
| 'secondary-subtitle-visibility';
@@ -83,6 +84,7 @@ export function createBindMpvClientEventHandlers(deps: {
onTimePosChange: (payload: { time: number }) => void;
onDurationChange: (payload: { duration: number }) => void;
onPauseChange: (payload: { paused: boolean }) => void;
onFullscreenChange: (payload: { fullscreen: boolean }) => void;
onSubtitleMetricsChange: (payload: { patch: Record<string, unknown> }) => void;
onSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
}) {
@@ -99,6 +101,7 @@ export function createBindMpvClientEventHandlers(deps: {
mpvClient.on('time-pos-change', deps.onTimePosChange);
mpvClient.on('duration-change', deps.onDurationChange);
mpvClient.on('pause-change', deps.onPauseChange);
mpvClient.on('fullscreen-change', deps.onFullscreenChange);
mpvClient.on('subtitle-metrics-change', deps.onSubtitleMetricsChange);
mpvClient.on('secondary-subtitle-visibility', deps.onSecondarySubtitleVisibility);
};
@@ -68,6 +68,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
recordMediaDuration: (durationSec: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
onFullscreenChange?: (fullscreen: boolean) => void;
recordPauseState: (paused: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
@@ -177,6 +178,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
onTimePosChange: handleMpvTimePosChange,
onDurationChange: ({ duration }) => deps.recordMediaDuration(duration),
onPauseChange: handleMpvPauseChange,
onFullscreenChange: ({ fullscreen }) => deps.onFullscreenChange?.(fullscreen),
onSubtitleMetricsChange: handleMpvSubtitleMetricsChange,
onSecondarySubtitleVisibility: handleMpvSecondarySubtitleVisibility,
})(mpvClient);
@@ -57,6 +57,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`),
onFullscreenChange: (fullscreen) => calls.push(`fullscreen:${fullscreen}`),
updateSubtitleRenderMetrics: () => calls.push('metrics'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
})();
@@ -95,6 +96,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.notifyImmersionTitleUpdate('title');
deps.recordPlaybackPosition(10);
deps.reportJellyfinRemoteProgress(true);
deps.onFullscreenChange?.(true);
deps.recordPauseState(true);
deps.updateSubtitleRenderMetrics({});
deps.setPreviousSecondarySubVisibility(true);
@@ -112,6 +114,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('autoplay:/tmp/video'));
assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('fullscreen:true'));
assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('reset-sidebar-layout'));
@@ -60,6 +60,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resetAnilistMediaGuessState: () => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
onFullscreenChange?: (fullscreen: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
refreshDiscordPresence: () => void;
ensureImmersionTrackerInitialized: () => void;
@@ -176,6 +177,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
onTimePosUpdate: deps.onTimePosUpdate
? (time: number) => deps.onTimePosUpdate!(time)
: undefined,
onFullscreenChange: deps.onFullscreenChange
? (fullscreen: boolean) => deps.onFullscreenChange!(fullscreen)
: undefined,
recordPauseState: (paused: boolean) => {
deps.appState.playbackPaused = paused;
deps.ensureImmersionTrackerInitialized();
+26
View File
@@ -1168,6 +1168,32 @@ test('session binding: copy subtitle multiple captures follow-up digit locally',
}
});
test('session binding: mine sentence multiple captures modified follow-up digit locally', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.mineSentenceMultiple',
originalKey: 'Ctrl+Shift+S',
key: { code: 'KeyS', modifiers: ['ctrl', 'shift'] },
actionType: 'session-action',
actionId: 'mineSentenceMultiple',
},
] as never);
testGlobals.dispatchKeydown({ key: 'S', code: 'KeyS', ctrlKey: true, shiftKey: true });
testGlobals.dispatchKeydown({ key: '#', code: 'Digit3', ctrlKey: true, shiftKey: true });
assert.deepEqual(testGlobals.sessionActions, [
{ actionId: 'mineSentenceMultiple', payload: { count: 3 } },
]);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: h moves left when popup is closed', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
+6 -2
View File
@@ -176,13 +176,17 @@ export function createKeyboardHandlers(
return true;
}
if (!/^[1-9]$/.test(e.key) || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
const digit = /^[1-9]$/.test(e.key)
? e.key
: (e.code.match(/^(?:Digit|Numpad)([1-9])$/)?.[1] ?? null);
if (!digit) {
e.preventDefault();
return true;
}
e.preventDefault();
const count = Number(e.key);
const count = Number(digit);
const actionId = pendingNumericSelection.actionId;
cancelPendingNumericSelection(false);
void window.electronAPI.dispatchSessionAction(actionId, { count });
+68
View File
@@ -1315,6 +1315,74 @@ test('window resize ignores synthetic subtitle enter until the pointer moves aga
}
});
test('window resize allows primary hover pause from a real mouseenter over subtitles', async () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const mpvCommands: Array<(string | number)[]> = [];
const windowListeners = new Map<string, Array<() => void>>();
ctx.platform.shouldToggleMouseIgnore = true;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
setIgnoreMouseEvents: () => {},
},
addEventListener: (type: string, listener: () => void) => {
const bucket = windowListeners.get(type) ?? [];
bucket.push(listener);
windowListeners.set(type, bucket);
},
getComputedStyle: () => ({
visibility: 'hidden',
display: 'none',
opacity: '0',
}),
focus: () => {},
innerHeight: 1000,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
addEventListener: () => {},
elementFromPoint: () => ctx.dom.subtitleContainer,
querySelectorAll: () => [],
body: {},
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => true,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: (command) => {
mpvCommands.push(command);
},
});
handlers.setupResizeHandler();
for (const listener of windowListeners.get('resize') ?? []) {
listener();
}
await handlers.handlePrimaryMouseEnter({ clientX: 120, clientY: 240 } as MouseEvent);
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});
test('visibility recovery keeps overlay click-through when pointer is not over subtitles', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
+5 -2
View File
@@ -300,12 +300,15 @@ export function createMouseHandlers(
}
async function handleMouseEnter(
_event?: MouseEvent,
event?: MouseEvent,
showSecondaryHover = false,
source: 'direct' | 'tracked-pointer' = 'direct',
): Promise<void> {
if (source === 'direct' && suppressDirectHoverEnterSource !== null) {
return;
if (!event || !syncHoverStateFromPoint(event.clientX, event.clientY).isOverSubtitle) {
return;
}
suppressDirectHoverEnterSource = null;
}
ctx.state.isOverSubtitle = true;
+40
View File
@@ -793,6 +793,14 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
color: var(--subtitle-name-match-color, #f5bde6);
}
#subtitleRoot .word.word-jlpt-n1 {
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
text-decoration-color: var(--subtitle-jlpt-n1-color, #ed8796);
text-decoration-style: solid;
}
#subtitleRoot
.word.word-jlpt-n1:not(
:is(
@@ -814,6 +822,14 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
color: var(--subtitle-jlpt-n1-color, #ed8796);
}
#subtitleRoot .word.word-jlpt-n2 {
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
text-decoration-color: var(--subtitle-jlpt-n2-color, #f5a97f);
text-decoration-style: solid;
}
#subtitleRoot
.word.word-jlpt-n2:not(
:is(
@@ -835,6 +851,14 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
color: var(--subtitle-jlpt-n2-color, #f5a97f);
}
#subtitleRoot .word.word-jlpt-n3 {
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
text-decoration-color: var(--subtitle-jlpt-n3-color, #f9e2af);
text-decoration-style: solid;
}
#subtitleRoot
.word.word-jlpt-n3:not(
:is(
@@ -856,6 +880,14 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
color: var(--subtitle-jlpt-n3-color, #f9e2af);
}
#subtitleRoot .word.word-jlpt-n4 {
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
text-decoration-color: var(--subtitle-jlpt-n4-color, #a6e3a1);
text-decoration-style: solid;
}
#subtitleRoot
.word.word-jlpt-n4:not(
:is(
@@ -877,6 +909,14 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
color: var(--subtitle-jlpt-n4-color, #a6e3a1);
}
#subtitleRoot .word.word-jlpt-n5 {
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
text-decoration-color: var(--subtitle-jlpt-n5-color, #8aadf4);
text-decoration-style: solid;
}
#subtitleRoot
.word.word-jlpt-n5:not(
:is(
+8
View File
@@ -901,6 +901,14 @@ test('subtitle annotation CSS changes token color without overriding typography'
for (let level = 1; level <= 5; level += 1) {
const plainJlptBlock = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`);
assert.doesNotMatch(plainJlptBlock, /(?:^|\n)\s*color\s*:/m);
assert.match(plainJlptBlock, /text-decoration-line:\s*underline;/);
assert.match(plainJlptBlock, /text-decoration-thickness:\s*2px;/);
assert.match(plainJlptBlock, /text-underline-offset:\s*4px;/);
assert.match(
plainJlptBlock,
new RegExp(`text-decoration-color:\\s*var\\(--subtitle-jlpt-n${level}-color,`),
);
assert.match(plainJlptBlock, /text-decoration-style:\s*solid;/);
const block = extractClassBlock(cssText, buildJlptColorSelector(level));
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
+85 -4
View File
@@ -177,8 +177,7 @@ export function mergeTokens(
}
const result: MergedToken[] = [];
const normalizedSourceText =
typeof sourceText === 'string' ? sourceText.replace(/\r?\n/g, ' ').trim() : null;
const normalizedSourceText = normalizeSourceTextForTokenOffsets(sourceText);
let charOffset = 0;
let sourceCursor = 0;
let lastStandaloneToken: Token | null = null;
@@ -191,7 +190,9 @@ export function mergeTokens(
for (const token of tokens) {
const matchedStart =
normalizedSourceText !== null ? normalizedSourceText.indexOf(token.word, sourceCursor) : -1;
typeof normalizedSourceText === 'string'
? normalizedSourceText.indexOf(token.word, sourceCursor)
: -1;
const start = matchedStart >= sourceCursor ? matchedStart : charOffset;
const end = start + token.word.length;
charOffset = end;
@@ -282,6 +283,49 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet<strin
return parts.every((part) => exclusions.has(part));
}
function isKanaChar(char: string): boolean {
const code = char.codePointAt(0);
if (code === undefined) {
return false;
}
return (
(code >= 0x3041 && code <= 0x3096) ||
(code >= 0x309b && code <= 0x309f) ||
code === 0x30fc ||
(code >= 0x30a0 && code <= 0x30fa) ||
(code >= 0x30fd && code <= 0x30ff)
);
}
function isKanaCandidateIgnorableChar(char: string): boolean {
return /^[\s.,!?;:()[\]{}"'`-]$/u.test(char);
}
function isKanaOnlyText(text: string): boolean {
const normalized = text.trim();
if (normalized.length === 0) {
return false;
}
let hasKana = false;
for (const char of normalized) {
if (isKanaChar(char)) {
hasKana = true;
continue;
}
if (!isKanaCandidateIgnorableChar(char)) {
return false;
}
}
return hasKana;
}
function normalizeSourceTextForTokenOffsets(sourceText: string | undefined): string | undefined {
return typeof sourceText === 'string' ? sourceText.replace(/\r?\n/g, ' ').trim() : undefined;
}
export function isNPlusOneCandidateToken(
token: MergedToken,
pos1Exclusions: ReadonlySet<string> = N_PLUS_ONE_IGNORED_POS1,
@@ -290,6 +334,9 @@ export function isNPlusOneCandidateToken(
if (token.isKnown) {
return false;
}
if (isKanaOnlyText(token.surface)) {
return false;
}
return isNPlusOneWordCountToken(token, pos1Exclusions, pos2Exclusions);
}
@@ -339,6 +386,18 @@ function isNPlusOneWordCountToken(
return true;
}
function isNPlusOneSentenceLengthToken(
token: MergedToken,
pos1Exclusions: ReadonlySet<string> = N_PLUS_ONE_IGNORED_POS1,
pos2Exclusions: ReadonlySet<string> = N_PLUS_ONE_IGNORED_POS2,
): boolean {
if (!isNPlusOneWordCountToken(token, pos1Exclusions, pos2Exclusions)) {
return false;
}
return token.isKnown || isNPlusOneCandidateToken(token, pos1Exclusions, pos2Exclusions);
}
function isSentenceBoundaryToken(token: MergedToken): boolean {
if (token.partOfSpeech !== PartOfSpeech.symbol) {
return false;
@@ -347,22 +406,39 @@ function isSentenceBoundaryToken(token: MergedToken): boolean {
return SENTENCE_BOUNDARY_SURFACES.has(token.surface);
}
function hasSentenceBoundaryInSourceGap(
sourceText: string | undefined,
previousEnd: number | null,
nextStart: number,
): boolean {
if (typeof sourceText !== 'string' || previousEnd === null || nextStart <= previousEnd) {
return false;
}
const gap = sourceText.slice(previousEnd, nextStart);
return [...gap].some((char) => SENTENCE_BOUNDARY_SURFACES.has(char));
}
export function markNPlusOneTargets(
tokens: MergedToken[],
minSentenceWords = 3,
pos1Exclusions: ReadonlySet<string> = N_PLUS_ONE_IGNORED_POS1,
pos2Exclusions: ReadonlySet<string> = N_PLUS_ONE_IGNORED_POS2,
sourceText?: string,
): MergedToken[] {
if (tokens.length === 0) {
return [];
}
const normalizedSourceText = normalizeSourceTextForTokenOffsets(sourceText);
const markedTokens = tokens.map((token) => ({
...token,
isNPlusOneTarget: false,
}));
let sentenceStart = 0;
let previousTokenEnd: number | null = null;
const minimumSentenceWords = Number.isInteger(minSentenceWords)
? Math.max(1, minSentenceWords)
: 3;
@@ -373,7 +449,7 @@ export function markNPlusOneTargets(
for (let i = start; i < endExclusive; i++) {
const token = markedTokens[i];
if (!token) continue;
if (isNPlusOneWordCountToken(token, pos1Exclusions, pos2Exclusions)) {
if (isNPlusOneSentenceLengthToken(token, pos1Exclusions, pos2Exclusions)) {
sentenceWordCount += 1;
}
@@ -393,10 +469,15 @@ export function markNPlusOneTargets(
for (let i = 0; i < markedTokens.length; i++) {
const token = markedTokens[i];
if (!token) continue;
if (hasSentenceBoundaryInSourceGap(normalizedSourceText, previousTokenEnd, token.startPos)) {
markSentence(sentenceStart, i);
sentenceStart = i;
}
if (isSentenceBoundaryToken(token)) {
markSentence(sentenceStart, i);
sentenceStart = i + 1;
}
previousTokenEnd = token.endPos;
}
if (sentenceStart < markedTokens.length) {
@@ -1,9 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
isHyprlandGeometryEvent,
parseHyprctlClients,
resolveHyprlandWindowGeometry,
selectHyprlandMpvWindow,
type HyprlandClient,
type HyprlandMonitor,
} from './hyprland-tracker';
function makeClient(overrides: Partial<HyprlandClient> = {}): HyprlandClient {
@@ -19,6 +22,17 @@ function makeClient(overrides: Partial<HyprlandClient> = {}): HyprlandClient {
};
}
function makeMonitor(overrides: Partial<HyprlandMonitor> = {}): HyprlandMonitor {
return {
id: 0,
x: 0,
y: 0,
width: 1920,
height: 1080,
...overrides,
};
}
test('selectHyprlandMpvWindow ignores hidden and unmapped mpv clients', () => {
const selected = selectHyprlandMpvWindow(
[
@@ -106,3 +120,32 @@ test('parseHyprctlClients tolerates non-json prefix output', () => {
},
]);
});
test('isHyprlandGeometryEvent treats fullscreenv2 as a geometry-changing event', () => {
assert.equal(isHyprlandGeometryEvent('fullscreenv2'), true);
assert.equal(isHyprlandGeometryEvent('workspacev2'), true);
assert.equal(isHyprlandGeometryEvent('activewindowv2'), false);
});
test('resolveHyprlandWindowGeometry uses monitor bounds for fullscreen clients', () => {
const geometry = resolveHyprlandWindowGeometry(
makeClient({
at: [60, 80],
size: [1280, 720],
monitor: 1,
fullscreen: 2,
fullscreenClient: 2,
}),
[
makeMonitor({ id: 0, x: 0, y: 0, width: 1920, height: 1080 }),
makeMonitor({ id: 1, x: 1920, y: 0, width: 2560, height: 1440 }),
],
);
assert.deepEqual(geometry, {
x: 1920,
y: 0,
width: 2560,
height: 1440,
});
});
+118 -17
View File
@@ -20,6 +20,7 @@ import * as net from 'net';
import { execSync } from 'child_process';
import { BaseWindowTracker } from './base-tracker';
import { createLogger } from '../logger';
import type { WindowGeometry } from '../types';
const log = createLogger('tracker').child('hyprland');
@@ -29,11 +30,22 @@ export interface HyprlandClient {
initialClass?: string;
at: [number, number];
size: [number, number];
monitor?: number;
fullscreen?: number;
fullscreenClient?: number;
pid?: number;
mapped?: boolean;
hidden?: boolean;
}
export interface HyprlandMonitor {
id: number;
x: number;
y: number;
width: number;
height: number;
}
interface SelectHyprlandMpvWindowOptions {
targetMpvSocketPath: string | null;
activeWindowAddress: string | null;
@@ -132,8 +144,73 @@ export function parseHyprctlClients(output: string): HyprlandClient[] | null {
return parsed as HyprlandClient[];
}
export function parseHyprctlMonitors(output: string): HyprlandMonitor[] | null {
const jsonPayload = extractHyprctlJsonPayload(output);
if (!jsonPayload) {
return null;
}
const parsed = JSON.parse(jsonPayload) as unknown;
if (!Array.isArray(parsed)) {
return null;
}
return parsed as HyprlandMonitor[];
}
function isHyprlandFullscreenClient(client: HyprlandClient): boolean {
return (client.fullscreen ?? 0) > 0;
}
export function resolveHyprlandWindowGeometry(
client: HyprlandClient,
monitors: HyprlandMonitor[] | null,
): WindowGeometry {
if (isHyprlandFullscreenClient(client) && typeof client.monitor === 'number') {
const monitor = monitors?.find((candidate) => candidate.id === client.monitor);
if (monitor) {
return {
x: monitor.x,
y: monitor.y,
width: monitor.width,
height: monitor.height,
};
}
}
return {
x: client.at[0],
y: client.at[1],
width: client.size[0],
height: client.size[1],
};
}
export function isHyprlandGeometryEvent(name: string): boolean {
return (
name === 'movewindow' ||
name === 'movewindowv2' ||
name === 'resizewindow' ||
name === 'resizewindowv2' ||
name === 'windowtitle' ||
name === 'windowtitlev2' ||
name === 'openwindow' ||
name === 'closewindow' ||
name === 'fullscreen' ||
name === 'fullscreenv2' ||
name === 'changefloatingmode' ||
name === 'workspace' ||
name === 'workspacev2' ||
name === 'focusedmon' ||
name === 'monitoradded' ||
name === 'monitoraddedv2' ||
name === 'monitorremoved'
);
}
export class HyprlandWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private pollTimeouts: Array<ReturnType<typeof setTimeout>> = [];
private eventSocket: net.Socket | null = null;
private readonly targetMpvSocketPath: string | null;
private activeWindowAddress: string | null = null;
@@ -154,6 +231,10 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
for (const timeout of this.pollTimeouts) {
clearTimeout(timeout);
}
this.pollTimeouts = [];
if (this.eventSocket) {
this.eventSocket.destroy();
this.eventSocket = null;
@@ -200,6 +281,9 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
}
const [name, rawData = ''] = trimmedEvent.split('>>', 2);
if (!name) {
return;
}
const data = rawData.trim();
if (name === 'activewindowv2') {
@@ -212,17 +296,24 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
this.activeWindowAddress = null;
}
if (
name === 'movewindow' ||
name === 'movewindowv2' ||
name === 'windowtitle' ||
name === 'windowtitlev2' ||
name === 'openwindow' ||
name === 'closewindow' ||
name === 'fullscreen' ||
name === 'changefloatingmode'
) {
this.pollGeometry();
if (isHyprlandGeometryEvent(name)) {
this.scheduleGeometryPollBurst();
}
}
private scheduleGeometryPollBurst(): void {
for (const timeout of this.pollTimeouts) {
clearTimeout(timeout);
}
this.pollTimeouts = [0, 50, 150, 300].map((delayMs) => {
const pollTimeout = setTimeout(() => {
this.pollTimeouts = this.pollTimeouts.filter((timeout) => timeout !== pollTimeout);
this.pollGeometry();
}, delayMs);
return pollTimeout;
});
for (const pollTimeout of this.pollTimeouts) {
pollTimeout.unref?.();
}
}
@@ -237,12 +328,9 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
const mpvWindow = this.findTargetWindow(clients);
if (mpvWindow) {
this.updateGeometry({
x: mpvWindow.at[0],
y: mpvWindow.at[1],
width: mpvWindow.size[0],
height: mpvWindow.size[1],
});
this.updateGeometry(
resolveHyprlandWindowGeometry(mpvWindow, this.getHyprlandMonitors(mpvWindow)),
);
} else {
this.updateGeometry(null);
}
@@ -259,6 +347,19 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
});
}
private getHyprlandMonitors(client: HyprlandClient): HyprlandMonitor[] | null {
if (!isHyprlandFullscreenClient(client)) {
return null;
}
try {
const output = execSync('hyprctl -j monitors', { encoding: 'utf-8' });
return parseHyprctlMonitors(output);
} catch {
return null;
}
}
private getWindowCommandLine(pid: number): string | null {
const commandLine = execSync(`ps -p ${pid} -o args=`, {
encoding: 'utf-8',