Compare commits

..

5 Commits

Author SHA1 Message Date
sudacode e8f10fe8a9 fix: macOS visible-overlay blur no longer invokes Windows-only blur call
- Split win32/darwin branches in handleOverlayWindowBlurred so darwin visible blur returns early without calling onWindowsVisibleOverlayBlur
- Add regression test asserting Windows callback stays inactive on macOS visible overlay blur
- Close TASK-347
2026-05-12 02:50:05 -07:00
sudacode ca796bfe6a fix: macOS overlay z-order and Yomitan compound token known highlighting
- Release always-on-top when tracked mpv loses foreground on macOS
- Skip visible overlay blur restacking on macOS to avoid covering unrelated windows
- Prefer Yomitan internal parse tokens over fragmented scanner output for known-word decisions
- Add regression tests for both behaviors
2026-05-12 02:34:28 -07:00
sudacode 6bf905140c fix: address PR-57 CodeRabbit findings and CI failures
- use filtered word counts in media detail session token aggregation
- cancel fullscreen refresh burst on exit via updateLinuxMpvFullscreenOverlayRefreshBurst
- guard Hyprland JSON.parse in try/catch; exclude windowtitle from geometry events
- narrow focus suppression from :focus to :focus-visible
- apply JLPT lock selectors to word-name-match tokens (N1–N5)
2026-05-12 00:28:48 -07:00
sudacode 6e666d7ca5 fix: resolve media detail from sessions when lifetime summary is absent
- Change `getMediaDetail` JOIN to LEFT JOIN on `imm_lifetime_media` and fall back to aggregated session metrics when no lifetime row exists
- Add filter `AND (lm.video_id IS NOT NULL OR s.session_id IS NOT NULL)` to keep results valid
- Add regression test covering the session-visible / media-detail-missing mismatch
2026-05-11 23:52:56 -07:00
sudacode 27be0e6fd7 fix: address coderabbit subtitle follow-ups 2026-05-11 23:48:32 -07:00
31 changed files with 1042 additions and 37 deletions
@@ -0,0 +1,70 @@
---
id: TASK-345
title: Address PR 57 latest CodeRabbit review comments
status: Done
assignee:
- codex
created_date: '2026-05-12 06:35'
updated_date: '2026-05-12 06:38'
labels:
- pr-review
- coderabbit
dependencies: []
references:
- 'https://github.com/ksyasuda/SubMiner/pull/57'
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Assess the 2026-05-11 CodeRabbit review on PR #57 and address still-valid actionable comments with minimal changes. Current comments cover mpv subtitle playback unpause behavior, parser-selection empty reading handling, and overlay focus selector accessibility.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Still-valid CodeRabbit comments from the latest PR #57 review are fixed or explicitly documented as skipped with rationale.
- [x] #2 Regression coverage is added for behavior-affecting fixes where practical before production code changes.
- [x] #3 Relevant targeted checks pass locally.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect current code and nearby tests for the three latest CodeRabbit comments on PR #57.
2. Add regression tests first for behavior-impacting findings: mpv playNextSubtitle unpauses when pause state is unknown, and parser selection preserves combined reading for empty segment readings.
3. Apply minimal production fixes plus the CSS :focus-visible selector change.
4. Run targeted test commands for touched areas and update task notes/final status.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added red tests for the two behavior comments. `bun test src/core/services/mpv.test.ts` fails because playNextSubtitle skips unpause when pause state is null. `bun test src/core/services/tokenizer/parser-selection-stage.test.ts` fails because empty grammar-ending reading clears preceding combined reading.
Implemented all three latest CodeRabbit findings. Added regressions for mpv unknown pause state and parser-selection empty reading handling; changed overlay focus selectors to :focus-visible. Also fixed existing Prettier failure in src/core/services/stats-window.ts. Verification passed: targeted tests, typecheck, test:fast, test:env, format:check:src, build, test:smoke:dist, changelog:lint, changelog:pr-check.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Addressed the latest CodeRabbit comments on PR #57.
Changes:
- `playNextSubtitle` now sends `pause=false` unless mpv is explicitly known to be playing, covering startup/reconnect unknown pause state.
- Parser selection no longer slices combined readings for empty grammar-ending readings, preserving preceding token readings.
- Overlay focus suppression now targets `:focus-visible` selectors.
- Applied Prettier to `src/core/services/stats-window.ts` to clear the existing formatting gate failure.
Verification:
- `bun test src/core/services/mpv.test.ts`
- `bun test src/core/services/tokenizer/parser-selection-stage.test.ts`
- `bun run typecheck`
- `bun run test:fast`
- `bun run test:env`
- `bun run format:check:src`
- `bun run build`
- `bun run test:smoke:dist`
- `bun run changelog:lint`
- `bun run changelog:pr-check`
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,57 @@
---
id: TASK-346
title: Fix stats session detail when recent media is missing
status: Done
assignee:
- Codex
created_date: '2026-05-12 06:41'
updated_date: '2026-05-12 06:44'
labels:
- bug
- stats
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Stats overview can show a completed session, but clicking it opens a detail view that says "Media not found". The details view should resolve the session/media consistently for recently completed local playback so users can inspect session cards, words, timeline, and media stats after a video finishes.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 A completed session listed on the stats overview opens a usable details view instead of "Media not found" when its media is still present in stats data.
- [x] #2 Regression coverage reproduces the overview-to-detail lookup mismatch and verifies the corrected behavior.
- [x] #3 Relevant stats/detail documentation is updated if behavior or APIs change.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a focused regression test in `src/core/services/immersion-tracker/__tests__/query.test.ts` covering a video/session visible from session summaries before `imm_lifetime_media` exists.
2. Update `getMediaDetail` in `src/core/services/immersion-tracker/query-library.ts` so detail rows can resolve from `imm_videos` plus session metrics when lifetime summary is absent.
3. Run the focused query test lane and update task notes/acceptance criteria.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented root-cause fix in `getMediaDetail`: media detail now resolves from `imm_videos` plus session metrics when `imm_lifetime_media` is not populated yet, while still preferring lifetime summary totals when available. Added regression test for session-visible/media-detail-missing mismatch. No docs update required because the API shape is unchanged; added changelog fragment `changes/346-stats-session-detail.md`. Verification: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts`, `bun run typecheck`, `bun run format:check:src`, `bun run test:fast`, `bun run changelog:lint`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Fixed stats media detail lookup so sessions visible in overview can open detail even before lifetime media summaries exist.
- Preserved lifetime-summary totals when available and added a regression test for the missing-lifetime case.
- Added `changes/346-stats-session-detail.md` for the user-visible fix.
Tests:
- `bun test src/core/services/immersion-tracker/__tests__/query.test.ts`
- `bun run typecheck`
- `bun run format:check:src`
- `bun run test:fast`
- `bun run changelog:lint`
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,61 @@
---
id: TASK-347
title: Address PR 57 CodeRabbit review round after stats session fix
status: Done
assignee:
- codex
created_date: '2026-05-12 07:02'
updated_date: '2026-05-12 09:48'
labels:
- pr-review
- coderabbit
- ci
dependencies: []
references:
- 'https://github.com/ksyasuda/SubMiner/pull/57'
modified_files:
- src/core/services/overlay-window-input.ts
- src/core/services/overlay-window.test.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Assess and address the 2026-05-12 CodeRabbit review on PR #57 plus the current red GitHub Actions check. Latest comments cover stats session detail token aggregation, Linux fullscreen overlay refresh scheduling, Hyprland title-event polling, malformed Hyprland monitor JSON handling, and JLPT-lock test coverage for name matches.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Still-valid latest CodeRabbit findings on PR #57 are fixed or documented as skipped with rationale.
- [x] #2 CI failure context is inspected and any repo-relevant failing tests or formatting issues are fixed.
- [x] #3 Regression coverage is added for behavior changes where practical before production edits.
- [x] #4 Relevant local verification passes.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect failing GitHub Actions log and current code around each latest CodeRabbit finding.
2. Add or update focused regression tests first for behavior changes: stats token aggregation, fullscreen refresh exit cancellation, Hyprland monitor parse failure, and title-only event filtering.
3. Apply minimal production fixes for still-valid findings, plus the subtitle-render duplicate test coverage item.
4. Run targeted tests first, then format/typecheck and broader relevant gates; update the task with results.
Address latest CodeRabbit callback-scope finding: add regression coverage that macOS visible-overlay blur does not invoke onWindowsVisibleOverlayBlur, then split win32/darwin blur handling in src/core/services/overlay-window-input.ts.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-05-12 09:47: Assessed latest PR #57 CodeRabbit comments via gh. Prior review findings in comments are marked addressed by CodeRabbit; latest still-actionable item was macOS visible-overlay blur invoking the Windows-only blur callback in overlay-window-input.ts.
Added regression coverage showing macOS visible-overlay blur leaves onWindowsVisibleOverlayBlur inactive; test failed before production change and passed after splitting win32/darwin handling.
Verification: bun test src/core/services/overlay-window.test.ts; bun run typecheck; git diff --check. PR checks: build-test-audit pass, GitGuardian pass, CodeRabbit pass/skipped, Claude skipped.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Addressed the latest CodeRabbit callback-scope review on PR #57. Windows visible-overlay blur still invokes onWindowsVisibleOverlayBlur and returns without restacking; macOS visible-overlay blur now returns without restacking and without invoking the Windows-only callback. Added focused regression coverage and verified targeted tests, typecheck, diff whitespace, and current PR checks.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,56 @@
---
id: TASK-348
title: Fix PR 57 coverage CI focus chrome failure
status: Done
assignee:
- '@codex'
created_date: '2026-05-12 07:02'
updated_date: '2026-05-12 07:11'
labels:
- ci
- bug
dependencies: []
references:
- 'https://github.com/ksyasuda/SubMiner/pull/57'
- 'https://github.com/ksyasuda/SubMiner/actions/runs/25718536412'
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix current GitHub Actions `build-test-audit` failure on PR #57 (`tokenizer-updates`). CI fails during `bun run test:coverage:src` in the maintained source lane: `renderer stylesheet hides focus chrome on top-level overlay focus targets`.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Root cause of the focus chrome coverage failure is identified from CI/local test output.
- [x] #2 A focused fix is applied without broad unrelated changes.
- [x] #3 Relevant local coverage/test command passes.
- [x] #4 Remote PR check status is rechecked or next CI action is documented.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce the CI failure locally with `bun test src/renderer/overlay-legacy-cleanup.test.ts`.
2. Update the stale legacy cleanup assertion to expect top-level `:focus-visible` suppression and reject broad `:focus` suppression.
3. Run the targeted test and `bun run test:coverage:src` to match CI's failing lane.
4. Recheck PR checks or document that CI needs a push/rerun.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
CI/local root cause: `src/renderer/style.css` was intentionally changed to `html/body/#overlay:focus-visible`, but `src/renderer/overlay-legacy-cleanup.test.ts` still required broad `:focus` selectors. The stale assertion fails in `test:coverage:src`.
Additional coverage-lane failure after first fix: `src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts` imported `updateLinuxMpvFullscreenOverlayRefreshBurst`, but `src/main/runtime/linux-mpv-fullscreen-overlay-refresh.ts` did not export/implement it. Added the helper to cancel existing bursts and schedule only while fullscreen is true.
Verification passed: `bun test src/renderer/overlay-legacy-cleanup.test.ts`; `bun test src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts`; `bun run test:coverage:src`; `bun run format:check:src`. `gh pr checks 57` still reports the old failed `build-test-audit` run at run 25718536412; branch needs push/rerun for remote green.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed current PR #57 `build-test-audit` CI blockers. Updated the stale overlay legacy cleanup assertion to expect `:focus-visible` top-level focus suppression and guard against reintroducing broad `:focus` suppression. Added the missing `updateLinuxMpvFullscreenOverlayRefreshBurst` export used by the Linux fullscreen overlay refresh tests. Verification passed locally: focused overlay legacy cleanup test, focused Linux fullscreen refresh test, `bun run test:coverage:src`, and `bun run format:check:src`. Remote PR checks still show the old failed `build-test-audit` run until these local changes are pushed and CI reruns.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,74 @@
---
id: TASK-349
title: Fix macOS overlay window ordering behind foreground apps
status: Done
assignee: []
created_date: '2026-05-12 08:50'
updated_date: '2026-05-12 08:58'
labels:
- bug
- macos
- overlay
dependencies: []
references:
- src/core/services/overlay-visibility.ts
- src/window-trackers
- TASK-344
modified_files:
- src/core/services/overlay-visibility.ts
- src/core/services/overlay-visibility.test.ts
- src/core/services/overlay-window-input.ts
- src/core/services/overlay-window.test.ts
- changes/349-macos-overlay-z-order.md
priority: high
ordinal: 183500
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
macOS overlay should stay visually above mpv, but remain grouped with mpv in normal desktop stacking. When another app/window is brought in front of mpv, that window must also appear in front of the overlay, matching Windows behavior. This follows the earlier active-mpv fix that stopped the overlay from hiding while mpv remained foremost.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 When mpv is the foreground playback window on macOS, the overlay remains visible above mpv.
- [x] #2 When another application or window is brought in front of mpv on macOS, that foreground window appears above both mpv and the overlay.
- [x] #3 Restoring mpv to the foreground restores the overlay above mpv without requiring a restart.
- [x] #4 Regression coverage documents the macOS stacking relationship and does not regress the prior active-mpv overlay preservation behavior.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add focused regression coverage for macOS mpv focus loss: the overlay must release its topmost level, remain visible/click-through, and stop enforcing layer order while mpv is behind another window.
2. Add focused blur-handler coverage so the macOS visible overlay does not restack itself when it loses focus.
3. Update overlay visibility and blur handling to use tracker focus as the macOS stacking boundary: focused mpv raises overlay; unfocused mpv releases topmost and skips restack.
4. Run focused overlay tests, formatting, typecheck, changelog lint, env/build/smoke checks; document any blocked broad gate separately.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented macOS stacking boundary using tracker focus. When tracked mpv is unfocused and the overlay itself is not focused, the visible overlay now releases Electron always-on-top, remains visible/click-through, and skips layer-order enforcement. Visible overlay blur restacking is also skipped on macOS, matching the Windows no-restack path for focus loss. `test:fast` remains blocked by existing cross-file pollution: `keyboard.test.ts` leaves `window.electronAPI` undefined for a later `subsync.test.ts`, causing Bun nested `node:test` errors in subsequent files.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Updated macOS overlay visibility so tracked mpv focus controls stacking: focused mpv keeps the overlay raised; unfocused mpv releases topmost while keeping the overlay visible and click-through.
- Stopped macOS visible overlay blur handling from immediately restacking the overlay above unrelated foreground windows.
- Added regression tests for macOS mpv focus loss and macOS blur restacking behavior.
- Added a changelog fragment for the user-visible overlay z-order fix.
Verification:
- Passed: `bun test src/core/services/overlay-visibility.test.ts src/core/services/overlay-window.test.ts`
- Passed: `bunx prettier --check src/core/services/overlay-visibility.ts src/core/services/overlay-visibility.test.ts src/core/services/overlay-window-input.ts src/core/services/overlay-window.test.ts changes/349-macos-overlay-z-order.md`
- Passed: `bun run typecheck`
- Passed: `bun run changelog:lint`
- Passed: `bun run test:env`
- Passed: `bun run build`
- Passed: `bun run test:smoke:dist`
- Blocked: `bun run test:fast` by existing keyboard/subsync cross-file global pollution; focused and environment tests pass.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,62 @@
---
id: TASK-350
title: Fix known highlighting for Yomitan compound tokens
status: Done
assignee:
- codex
created_date: '2026-05-12 09:08'
updated_date: '2026-05-12 09:29'
labels:
- bug
- tokenizer
dependencies: []
modified_files:
- src/core/services/tokenizer/yomitan-parser-runtime.ts
- src/core/services/tokenizer/yomitan-parser-runtime.test.ts
- src/core/services/tokenizer.test.ts
- changes/350-known-yomitan-token-highlighting.md
priority: high
ordinal: 184500
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Subtitle known-word coloring should respect the lexical token selected by Yomitan. If Yomitan emits a compound or inflected expression as one token, SubMiner must not mark that displayed token known solely because MeCab/POS enrichment can decompose it into known component words.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 A Yomitan token such as `取り組んで` with headword `取り組む` remains not-known when only component words like `取る` or `組む` are known.
- [x] #2 Frequency/JLPT/POS enrichment still works for the selected Yomitan token without leaking component known-word status into `isKnown`.
- [x] #3 Regression coverage demonstrates the compound-token case and fails on current behavior before the fix.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a regression in `src/core/services/tokenizer.test.ts` for a Yomitan-selected compound token: Yomitan emits `取り組んで` with headword `取り組む`; MeCab splits the same span into component tokens whose headwords include known component words such as `組む`; expected result is one displayed token with `isKnown === false` when only the components are known.
2. Verify the regression fails on current code.
3. Patch MeCab enrichment so it only contributes POS metadata used by annotation filters/exclusions. It must preserve the Yomitan token's `surface`, `headword`, `reading`, offsets, and existing lexical annotation state, especially `isKnown`.
4. Re-run the targeted tokenizer test, then a relevant fast test lane if practical.
After inspecting code, MeCab enrichment currently only writes POS metadata. The observed component coloring can also come from SubMiner's custom Yomitan scanning path fragmenting a phrase differently than Yomitan's internal parser. Regression should exercise `requestYomitanScanTokens` fallback/parser behavior as seen by `tokenizeSubtitle`, and the fix should prefer Yomitan internal parse token identity while keeping MeCab limited to filtering/POS metadata.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
User clarified MeCab is intended only to help filter unwanted characters/particles/sound effects/etc., not to alter lexical tokenization or known-word decisions.
Implementation settled on parse-first token identity: `requestYomitanScanTokens` now reads Yomitan internal parse tokens first. It still runs the scanner to keep scanner metadata when spans agree, but returns parse tokens when the scanner fragments the parse token. MeCab remains POS/filter enrichment only.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed known-word highlighting for Yomitan compound tokens by preferring Yomitan internal parse token spans over fragmented scanner output. When scanner output agrees with parse spans, scanner metadata such as name-match and word classes is preserved; when it fragments a Yomitan token, the parse token identity wins so known component words do not color the larger unknown token green.
Added regressions for `取り組んで` with known component words (`取る`, `組む`, `もらう`) and for parser-runtime token selection/metadata behavior. Added a changelog fragment.
Validation run: `bun test src/core/services/tokenizer.test.ts src/core/services/tokenizer/yomitan-parser-runtime.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts`; `bun run typecheck`; `bun x prettier --check src/core/services/tokenizer.test.ts src/core/services/tokenizer/yomitan-parser-runtime.ts src/core/services/tokenizer/yomitan-parser-runtime.test.ts changes/350-known-yomitan-token-highlighting.md`; `bun run changelog:lint`; `git diff --check`.
<!-- SECTION:FINAL_SUMMARY:END -->
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Fixed recent session detail pages showing "Media not found" before lifetime media summaries are available.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Overlay: Kept the macOS overlay behind unrelated foreground windows while preserving its position above mpv.
@@ -0,0 +1,4 @@
type: fixed
area: tokenizer
- Tokenizer: Preserve Yomitan compound tokens for known-word highlighting so known component words no longer color a larger unknown word green.
@@ -3050,6 +3050,59 @@ test('anime and media detail prefer lifetime totals over partial retained sessio
}
});
test('media detail resolves retained sessions before lifetime summary exists', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/recent-session.mkv', {
canonicalTitle: 'Recent Session Episode',
sourcePath: '/tmp/recent-session.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const startedAtMs = 1_700_000_000_000;
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
db.prepare(
`
UPDATE imm_sessions
SET ended_at_ms = ?, status = 2, active_watched_ms = ?, lines_seen = ?, tokens_seen = ?, cards_mined = ?
WHERE session_id = ?
`,
).run(startedAtMs + 600_000, 600_000, 100, 990, 1, sessionId);
insertFilteredWordOccurrence(db, {
sessionId,
videoId,
occurrenceCount: 4,
startedAtMs,
});
assert.equal(getSessionSummaries(db, 1)[0]?.videoId, videoId);
assert.equal(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media WHERE video_id = ?')
.get(videoId) as { total: number }
).total,
0,
);
const detail = getMediaDetail(db, videoId);
assert.ok(detail);
assert.equal(detail.canonicalTitle, 'Recent Session Episode');
assert.equal(detail.totalSessions, 1);
assert.equal(detail.totalActiveMs, 600_000);
assert.equal(detail.totalLinesSeen, 100);
assert.equal(detail.totalTokensSeen, 4);
assert.equal(detail.totalCards, 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('media library and detail queries read lifetime totals', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
@@ -243,6 +243,7 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
}
export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRow | null {
const wordsExpr = sessionDisplayWordsExpr('s', 'swc', 'COALESCE(asm.tokensSeen, s.tokens_seen)');
return db
.prepare(
`
@@ -251,11 +252,26 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo
v.video_id AS videoId,
v.canonical_title AS canonicalTitle,
v.anime_id AS animeId,
COALESCE(lm.total_sessions, 0) AS totalSessions,
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
COALESCE(lm.total_cards, 0) AS totalCards,
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
COALESCE(lm.total_lines_seen, 0) AS totalLinesSeen,
CASE
WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_sessions, 0)
ELSE COUNT(DISTINCT s.session_id)
END AS totalSessions,
CASE
WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_active_ms, 0)
ELSE COALESCE(SUM(COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0)), 0)
END AS totalActiveMs,
CASE
WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_cards, 0)
ELSE COALESCE(SUM(COALESCE(asm.cardsMined, s.cards_mined, 0)), 0)
END AS totalCards,
CASE
WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_tokens_seen, 0)
ELSE COALESCE(SUM(${wordsExpr}), 0)
END AS totalTokensSeen,
CASE
WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_lines_seen, 0)
ELSE COALESCE(SUM(COALESCE(asm.linesSeen, s.lines_seen, 0)), 0)
END AS totalLinesSeen,
COALESCE(SUM(COALESCE(asm.lookupCount, s.lookup_count, 0)), 0) AS totalLookupCount,
COALESCE(SUM(COALESCE(asm.lookupHits, s.lookup_hits, 0)), 0) AS totalLookupHits,
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount,
@@ -271,11 +287,13 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo
yv.uploader_url AS uploaderUrl,
yv.description AS description
FROM imm_videos v
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
LEFT JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
LEFT JOIN imm_sessions s ON s.video_id = v.video_id
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
LEFT JOIN session_word_counts swc ON swc.sessionId = s.session_id
WHERE v.video_id = ?
AND (lm.video_id IS NOT NULL OR s.session_id IS NOT NULL)
GROUP BY v.video_id
`,
)
+17
View File
@@ -515,6 +515,23 @@ test('MpvIpcClient playNextSubtitle starts playback from paused state and auto-p
]);
});
test('MpvIpcClient playNextSubtitle starts playback when pause state is unknown', () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).send = (payload: unknown) => {
commands.push(payload);
return true;
};
client.playNextSubtitle();
assert.equal((client as any).pendingPauseAtSubEnd, true);
assert.deepEqual(commands, [
{ command: ['sub-seek', 1] },
{ command: ['set_property', 'pause', false] },
]);
});
test('MpvIpcClient playNextSubtitle still auto-pauses at end while already playing', async () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
+1 -1
View File
@@ -525,7 +525,7 @@ export class MpvIpcClient implements MpvClient {
this.pendingPauseAtSubEnd = true;
this.pauseAtTime = null;
this.send({ command: ['sub-seek', 1] });
if (this.playbackPaused === true) {
if (this.playbackPaused !== false) {
this.send({ command: ['set_property', 'pause', false] });
}
}
@@ -969,6 +969,51 @@ test('macOS keeps active mpv overlay visible and interactive during tracker refr
assert.deepEqual(osdMessages, []);
});
test('macOS tracked overlay releases topmost level when mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('hide'));
});
test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => {
const { window, calls } = createMainWindowRecorder();
const osdMessages: string[] = [];
+18 -11
View File
@@ -89,17 +89,22 @@ export function updateVisibleOverlayVisibility(args: {
return;
}
const showPassiveVisibleOverlay = (): void => {
const showPassiveVisibleOverlay = (): boolean => {
const forceMousePassthrough = args.forceMousePassthrough === true;
const wasVisible = mainWindow.isVisible();
const isVisibleOverlayFocused =
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
const isTrackedMacOSTargetFocused =
!args.isMacOSPlatform || !args.windowTracker
? true
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
const shouldReleaseMacOSOverlayLevel =
args.isMacOSPlatform &&
!!args.windowTracker &&
!isVisibleOverlayFocused &&
!isTrackedMacOSTargetFocused;
const shouldDefaultToPassthrough =
args.isWindowsPlatform ||
forceMousePassthrough ||
(args.isMacOSPlatform &&
!isVisibleOverlayFocused &&
!(args.windowTracker?.isTargetWindowFocused?.() ?? true));
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
const windowsForegroundProcessName =
args.lastKnownWindowsForegroundProcessName?.trim().toLowerCase() ?? null;
const windowsOverlayProcessName = args.windowsOverlayProcessName?.trim().toLowerCase() ?? null;
@@ -142,7 +147,7 @@ export function updateVisibleOverlayVisibility(args: {
// On Windows, z-order is enforced by the OS via the owner window mechanism
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
// without any manual z-order management.
} else if (!forceMousePassthrough) {
} else if (!forceMousePassthrough && !shouldReleaseMacOSOverlayLevel) {
args.ensureOverlayWindowLevel(mainWindow);
} else {
mainWindow.setAlwaysOnTop(false);
@@ -191,6 +196,8 @@ export function updateVisibleOverlayVisibility(args: {
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
mainWindow.focus();
}
return !shouldReleaseMacOSOverlayLevel;
};
const maybeShowOverlayLoadingOsd = (): void => {
@@ -234,8 +241,8 @@ export function updateVisibleOverlayVisibility(args: {
args.updateVisibleOverlayBounds(geometry);
}
args.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay();
if (!args.forceMousePassthrough && !args.isWindowsPlatform) {
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
args.enforceOverlayLayerOrder();
}
args.syncOverlayShortcuts();
@@ -284,8 +291,8 @@ export function updateVisibleOverlayVisibility(args: {
args.updateVisibleOverlayBounds(geometry);
}
args.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay();
if (!args.forceMousePassthrough && !args.isWindowsPlatform) {
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
args.enforceOverlayLayerOrder();
}
args.syncOverlayShortcuts();
+5 -1
View File
@@ -69,10 +69,14 @@ export function handleOverlayWindowBlurred(options: {
onWindowsVisibleOverlayBlur?: () => void;
platform?: NodeJS.Platform;
}): boolean {
if ((options.platform ?? process.platform) === 'win32' && options.kind === 'visible') {
const platform = options.platform ?? process.platform;
if (platform === 'win32' && options.kind === 'visible') {
options.onWindowsVisibleOverlayBlur?.();
return false;
}
if (platform === 'darwin' && options.kind === 'visible') {
return false;
}
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
return false;
+43
View File
@@ -146,6 +146,49 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
assert.deepEqual(calls, ['windows-visible-blur']);
});
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
moveWindowTop: () => {
calls.push('move-top');
},
platform: 'darwin',
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visible overlay blur', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
moveWindowTop: () => {
calls.push('move-top');
},
onWindowsVisibleOverlayBlur: () => {
calls.push('windows-visible-blur');
},
platform: 'darwin',
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
const calls: string[] = [];
+3 -1
View File
@@ -51,7 +51,9 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
promoteStatsWindowLevel(window);
window.show();
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
if (!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })) {
if (
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
) {
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
}
window.focus();
+135
View File
@@ -2846,6 +2846,141 @@ test('tokenizeSubtitle checks known words by surface when configured', async ()
assert.equal(result.tokens?.[0]?.isKnown, true);
});
test('tokenizeSubtitle preserves Yomitan compound token when MeCab components are known', async () => {
const text = '取り組んでもらいます';
const result = await tokenizeSubtitle(
text,
makeDeps({
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
getYomitanParserWindow: () =>
({
isDestroyed: () => false,
webContents: {
executeJavaScript: async (script: string) => {
if (script.includes('getTermFrequencies')) {
return [];
}
if (script.includes('parseText')) {
return [
{
source: 'scanning-parser',
index: 0,
content: [
[
{
text: '取り組んで',
reading: 'とりくんで',
headwords: [[{ term: '取り組む' }]],
},
],
[
{
text: 'もらいます',
reading: 'もらいます',
headwords: [[{ term: 'もらう' }]],
},
],
],
},
];
}
return [
{
surface: '取り',
reading: 'とり',
headword: '取る',
startPos: 0,
endPos: 2,
},
{
surface: '組んで',
reading: 'くんで',
headword: '組む',
startPos: 2,
endPos: 5,
},
{
surface: 'もらいます',
reading: 'もらいます',
headword: 'もらう',
startPos: 5,
endPos: 10,
},
];
},
},
}) as unknown as Electron.BrowserWindow,
isKnownWord: (word) => word === '取る' || word === '組む' || word === 'もらう',
tokenizeWithMecab: async () => [
{
headword: '取り組む',
surface: '取り組ん',
reading: 'トリクン',
startPos: 0,
endPos: 4,
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞',
pos2: '自立',
pos3: '*',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'で',
surface: 'で',
reading: 'デ',
startPos: 4,
endPos: 5,
partOfSpeech: PartOfSpeech.particle,
pos1: '助詞',
pos2: '接続助詞',
pos3: '*',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'もらう',
surface: 'もらい',
reading: 'モライ',
startPos: 5,
endPos: 8,
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞',
pos2: '非自立',
pos3: '*',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'ます',
surface: 'ます',
reading: 'マス',
startPos: 8,
endPos: 10,
partOfSpeech: PartOfSpeech.bound_auxiliary,
pos1: '助動詞',
pos2: '*',
pos3: '*',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
],
}),
);
assert.equal(result.text, text);
assert.equal(result.tokens?.[0]?.surface, '取り組んで');
assert.equal(result.tokens?.[0]?.headword, '取り組む');
assert.equal(result.tokens?.[0]?.isKnown, false);
assert.equal(result.tokens?.[0]?.pos1, '動詞|助詞');
});
test('tokenizeSubtitle uses frequency surface match mode when configured', async () => {
const result = await tokenizeSubtitle(
'鍛えた',
@@ -187,6 +187,38 @@ test('splits trailing grammar endings when later segments are standalone words',
);
});
test('keeps preceding reading when standalone grammar ending has empty reading', () => {
const parseResults = [
makeParseItem('scanning-parser', [
[
{ text: '猫', reading: 'ねこ', headword: '猫' },
{ text: 'です', reading: '', headword: 'です' },
],
]),
];
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
assert.deepEqual(
tokens?.map((token) => ({
surface: token.surface,
reading: token.reading,
headword: token.headword,
})),
[
{
surface: '猫',
reading: 'ねこ',
headword: '猫',
},
{
surface: 'です',
reading: '',
headword: 'です',
},
],
);
});
test('splits trailing ja-nai grammar endings from preceding content', () => {
const parseResults = [
makeParseItem('scanning-parser', [
@@ -270,7 +270,7 @@ export function mapYomitanParseResultItemToMergedTokens(
const segmentHeadword = extractYomitanHeadword(segment);
if (isStandaloneGrammarEndingSegment(segment)) {
combinedSurface = combinedSurface.slice(0, -segmentText.length);
if (typeof segment.reading === 'string') {
if (typeof segment.reading === 'string' && segment.reading.length > 0) {
combinedReading = combinedReading.slice(0, -segment.reading.length);
}
flushCombinedToken(segmentStart);
@@ -533,7 +533,7 @@ test('requestYomitanTermFrequencies caches repeated term+reading lookups', async
assert.equal(frequencyCalls, 1);
});
test('requestYomitanScanTokens uses left-to-right termsFind scanning instead of parseText', async () => {
test('requestYomitanScanTokens prefers parseText tokenization over termsFind fragments', async () => {
const scripts: string[] = [];
const deps = createDeps(async (script) => {
scripts.push(script);
@@ -549,6 +549,138 @@ test('requestYomitanScanTokens uses left-to-right termsFind scanning instead of
],
};
}
if (script.includes('parseText')) {
return [
{
source: 'scanning-parser',
index: 0,
content: [
[
{
text: '取り組んで',
reading: 'とりくんで',
headwords: [[{ term: '取り組む' }]],
},
],
],
},
];
}
return [
{
surface: '取り',
reading: 'とり',
headword: '取る',
startPos: 0,
endPos: 2,
},
{
surface: '組んで',
reading: 'くんで',
headword: '組む',
startPos: 2,
endPos: 5,
},
];
});
const result = await requestYomitanScanTokens('取り組んで', deps, {
error: () => undefined,
});
assert.deepEqual(result, [
{
surface: '取り組んで',
reading: 'とりくんで',
headword: '取り組む',
startPos: 0,
endPos: 5,
},
]);
assert.ok(scripts.some((script) => script.includes('parseText')));
assert.ok(scripts.some((script) => script.includes('termsFind')));
});
test('requestYomitanScanTokens keeps scanner metadata when parse spans agree', async () => {
const deps = createDeps(async (script) => {
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
},
},
],
};
}
if (script.includes('parseText')) {
return [
{
source: 'scanning-parser',
index: 0,
content: [
[
{
text: 'アクア',
reading: 'あくあ',
headwords: [[{ term: 'アクア' }]],
},
],
],
},
];
}
return [
{
surface: 'アクア',
reading: 'あくあ',
headword: 'アクア',
startPos: 0,
endPos: 3,
isNameMatch: true,
wordClasses: ['n'],
},
];
});
const result = await requestYomitanScanTokens('アクア', deps, {
error: () => undefined,
});
assert.deepEqual(result, [
{
surface: 'アクア',
reading: 'あくあ',
headword: 'アクア',
startPos: 0,
endPos: 3,
isNameMatch: true,
wordClasses: ['n'],
},
]);
});
test('requestYomitanScanTokens falls back to left-to-right termsFind scanning', async () => {
const scripts: string[] = [];
const deps = createDeps(async (script) => {
scripts.push(script);
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
},
},
],
};
}
if (script.includes('parseText')) {
return [];
}
return [
{
surface: 'カズマ',
@@ -573,6 +705,7 @@ test('requestYomitanScanTokens uses left-to-right termsFind scanning instead of
endPos: 3,
},
]);
assert.ok(scripts.some((script) => script.includes('parseText')));
const scannerScript = scripts.find((script) => script.includes('termsFind'));
assert.ok(scannerScript, 'expected termsFind scanning request script');
assert.doesNotMatch(scannerScript ?? '', /parseText/);
@@ -100,6 +100,22 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
);
}
function hasSameTokenSpans(left: YomitanScanToken[], right: YomitanScanToken[]): boolean {
if (left.length !== right.length) {
return false;
}
return left.every((token, index) => {
const other = right[index];
return (
other !== undefined &&
token.surface === other.surface &&
token.startPos === other.startPos &&
token.endPos === other.endPos
);
});
}
function makeTermReadingCacheKey(term: string, reading: string | null): string {
return `${term}\u0000${reading ?? ''}`;
}
@@ -1252,6 +1268,17 @@ export async function requestYomitanScanTokens(
return null;
}
const parseResults = await requestYomitanParseResults(text, deps, logger);
const selectedParseTokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
const parseScanTokens =
selectedParseTokens?.map((token) => ({
surface: token.surface,
reading: token.reading,
headword: token.headword,
startPos: token.startPos,
endPos: token.endPos,
})) ?? null;
const metadata = await requestYomitanProfileMetadata(parserWindow, logger);
const profileIndex = metadata?.profileIndex ?? 0;
const scanLength = metadata?.scanLength ?? DEFAULT_YOMITAN_SCAN_LENGTH;
@@ -1269,6 +1296,9 @@ export async function requestYomitanScanTokens(
true,
);
if (isScanTokenArray(rawResult)) {
if (parseScanTokens && parseScanTokens.length > 0) {
return hasSameTokenSpans(parseScanTokens, rawResult) ? rawResult : parseScanTokens;
}
return rawResult;
}
if (Array.isArray(rawResult)) {
@@ -1283,8 +1313,14 @@ export async function requestYomitanScanTokens(
})) ?? null
);
}
if (parseScanTokens && parseScanTokens.length > 0) {
return parseScanTokens;
}
return null;
} catch (err) {
if (parseScanTokens && parseScanTokens.length > 0) {
return parseScanTokens;
}
logger.error('Yomitan scanner request failed:', (err as Error).message);
return null;
}
+8 -5
View File
@@ -36,7 +36,7 @@ import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import {
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
scheduleLinuxVisibleOverlayFullscreenRefreshBurst,
updateLinuxMpvFullscreenOverlayRefreshBurst,
} from './main/runtime/linux-mpv-fullscreen-overlay-refresh';
import { mergeAiConfig } from './ai/config';
@@ -3859,16 +3859,19 @@ const {
}
lastObservedTimePos = time;
},
onFullscreenChange: () => {
cancelLinuxMpvFullscreenOverlayRefreshBurst =
scheduleLinuxVisibleOverlayFullscreenRefreshBurst({
onFullscreenChange: (fullscreen) => {
cancelLinuxMpvFullscreenOverlayRefreshBurst = updateLinuxMpvFullscreenOverlayRefreshBurst(
fullscreen,
{
overlayManager: {
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
},
overlayVisibilityRuntime,
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
});
},
cancelLinuxMpvFullscreenOverlayRefreshBurst,
);
},
onSubtitleTrackChange: (sid) => {
scheduleSubtitlePrefetchRefresh();
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
updateLinuxMpvFullscreenOverlayRefreshBurst,
scheduleLinuxVisibleOverlayFullscreenRefreshBurst,
} from './linux-mpv-fullscreen-overlay-refresh';
@@ -48,3 +49,45 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
}
}
});
test('linux mpv fullscreen overlay refresh update cancels burst when fullscreen exits', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
const calls: string[] = [];
try {
const deps = {
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 cancel = updateLinuxMpvFullscreenOverlayRefreshBurst(true, deps, null);
const nextCancel = updateLinuxMpvFullscreenOverlayRefreshBurst(false, deps, cancel);
await new Promise((resolve) => setTimeout(resolve, 80));
assert.equal(nextCancel, null);
assert.deepEqual(calls, []);
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
@@ -67,4 +67,17 @@ export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
return clearLinuxMpvFullscreenOverlayRefreshTimeouts;
}
export function updateLinuxMpvFullscreenOverlayRefreshBurst(
isFullscreen: boolean,
deps: LinuxMpvFullscreenOverlayRefreshDeps,
cancelCurrentBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null,
): CancelLinuxMpvFullscreenOverlayRefreshBurst | null {
cancelCurrentBurst?.();
if (!isFullscreen) {
return null;
}
return scheduleLinuxVisibleOverlayFullscreenRefreshBurst(deps);
}
export { clearLinuxMpvFullscreenOverlayRefreshTimeouts };
+9 -2
View File
@@ -28,9 +28,16 @@ test('renderer stylesheet no longer contains invisible-layer selectors', () => {
assert.doesNotMatch(cssSource, /body\.layer-invisible/);
});
test('renderer stylesheet hides focus chrome on top-level overlay focus targets', () => {
test('renderer stylesheet only hides visible focus chrome on top-level overlay focus targets', () => {
const cssSource = readWorkspaceFile('src/renderer/style.css');
assert.match(cssSource, /html:focus,\s*body:focus,\s*#overlay:focus\s*\{[^}]*outline:\s*none;/s);
assert.match(
cssSource,
/html:focus-visible,\s*body:focus-visible,\s*#overlay:focus-visible\s*\{[^}]*outline:\s*none;/s,
);
assert.doesNotMatch(
cssSource,
/html:focus,\s*body:focus,\s*#overlay:focus\s*\{[^}]*outline:\s*none;/s,
);
});
test('top-level readme avoids stale overlay-layers wording', () => {
+8 -3
View File
@@ -40,9 +40,9 @@ body {
'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Arial Unicode MS', Arial, sans-serif;
}
html:focus,
body:focus,
#overlay:focus {
html:focus-visible,
body:focus-visible,
#overlay:focus-visible {
outline: none;
}
@@ -990,6 +990,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n1.word-known,
#subtitleRoot .word.word-jlpt-n1.word-n-plus-one,
#subtitleRoot .word.word-jlpt-n1.word-name-match,
#subtitleRoot .word.word-jlpt-n1.word-frequency-single,
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-1,
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-2,
@@ -1006,6 +1007,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n2.word-known,
#subtitleRoot .word.word-jlpt-n2.word-n-plus-one,
#subtitleRoot .word.word-jlpt-n2.word-name-match,
#subtitleRoot .word.word-jlpt-n2.word-frequency-single,
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-1,
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-2,
@@ -1022,6 +1024,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n3.word-known,
#subtitleRoot .word.word-jlpt-n3.word-n-plus-one,
#subtitleRoot .word.word-jlpt-n3.word-name-match,
#subtitleRoot .word.word-jlpt-n3.word-frequency-single,
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-1,
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-2,
@@ -1038,6 +1041,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n4.word-known,
#subtitleRoot .word.word-jlpt-n4.word-n-plus-one,
#subtitleRoot .word.word-jlpt-n4.word-name-match,
#subtitleRoot .word.word-jlpt-n4.word-frequency-single,
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-1,
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-2,
@@ -1054,6 +1058,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n5.word-known,
#subtitleRoot .word.word-jlpt-n5.word-n-plus-one,
#subtitleRoot .word.word-jlpt-n5.word-name-match,
#subtitleRoot .word.word-jlpt-n5.word-frequency-single,
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-1,
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-2,
+1
View File
@@ -1105,6 +1105,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
for (const annotationClass of [
'word-known',
'word-n-plus-one',
'word-name-match',
'word-frequency-single',
'word-frequency-band-2',
]) {
+9 -1
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {
isHyprlandGeometryEvent,
parseHyprctlClients,
parseHyprctlMonitors,
resolveHyprlandWindowGeometry,
selectHyprlandMpvWindow,
type HyprlandClient,
@@ -121,9 +122,16 @@ test('parseHyprctlClients tolerates non-json prefix output', () => {
]);
});
test('isHyprlandGeometryEvent treats fullscreenv2 as a geometry-changing event', () => {
test('parseHyprctlMonitors returns null for malformed JSON output', () => {
assert.equal(parseHyprctlMonitors('not-json'), null);
assert.equal(parseHyprctlMonitors('[{"id":0,"x":0,"y":0,"width":1920'), null);
});
test('isHyprlandGeometryEvent treats geometry events as geometry-changing only', () => {
assert.equal(isHyprlandGeometryEvent('fullscreenv2'), true);
assert.equal(isHyprlandGeometryEvent('workspacev2'), true);
assert.equal(isHyprlandGeometryEvent('windowtitle'), false);
assert.equal(isHyprlandGeometryEvent('windowtitlev2'), false);
assert.equal(isHyprlandGeometryEvent('activewindowv2'), false);
});
+12 -4
View File
@@ -136,7 +136,12 @@ export function parseHyprctlClients(output: string): HyprlandClient[] | null {
return null;
}
const parsed = JSON.parse(jsonPayload) as unknown;
let parsed: unknown;
try {
parsed = JSON.parse(jsonPayload) as unknown;
} catch {
return null;
}
if (!Array.isArray(parsed)) {
return null;
}
@@ -150,7 +155,12 @@ export function parseHyprctlMonitors(output: string): HyprlandMonitor[] | null {
return null;
}
const parsed = JSON.parse(jsonPayload) as unknown;
let parsed: unknown;
try {
parsed = JSON.parse(jsonPayload) as unknown;
} catch {
return null;
}
if (!Array.isArray(parsed)) {
return null;
}
@@ -192,8 +202,6 @@ export function isHyprlandGeometryEvent(name: string): boolean {
name === 'movewindowv2' ||
name === 'resizewindow' ||
name === 'resizewindowv2' ||
name === 'windowtitle' ||
name === 'windowtitlev2' ||
name === 'openwindow' ||
name === 'closewindow' ||
name === 'fullscreen' ||