Compare commits

..

4 Commits

127 changed files with 6311 additions and 5250 deletions
-389
View File
@@ -1,389 +0,0 @@
name: Prerelease
on:
push:
tags:
- 'v*-beta.*'
- 'v*-rc.*'
concurrency:
group: prerelease-${{ github.ref }}
cancel-in-progress: false
jobs:
quality-gate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Lint stats (formatting)
run: bun run lint:stats
- name: Build (TypeScript check)
run: bun run typecheck
- name: Test suite (source)
run: bun run test:fast
- name: Coverage suite (maintained source lane)
run: bun run test:coverage:src
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-test-src
path: coverage/test-src/lcov.info
if-no-files-found: error
- name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src
- name: Upload launcher smoke artifacts (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: launcher-smoke
path: .tmp/launcher-smoke/**
if-no-files-found: ignore
- name: Build (bundle)
run: bun run build
- name: Immersion SQLite verification
run: bun run test:immersion:sqlite:dist
- name: Dist smoke suite
run: bun run test:smoke:dist
build-linux:
needs: [quality-gate]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
run: |
cd vendor/texthooker-ui
bun install
bun run build
- name: Build AppImage
run: bun run build:appimage
- name: Build unversioned AppImage
run: |
shopt -s nullglob
appimages=(release/SubMiner-*.AppImage)
if [ "${#appimages[@]}" -eq 0 ]; then
echo "No versioned AppImage found to create unversioned artifact."
ls -la release
exit 1
fi
cp "${appimages[0]}" release/SubMiner.AppImage
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
with:
name: appimage
path: release/*.AppImage
build-macos:
needs: [quality-gate]
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Validate macOS signing/notarization secrets
run: |
missing=0
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
if [ -z "${!name}" ]; then
echo "Missing required secret: $name"
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
echo "Set all required macOS signing/notarization secrets and rerun."
exit 1
fi
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
run: |
cd vendor/texthooker-ui
bun install
bun run build
- name: Build signed + notarized macOS artifacts
run: bun run build:mac
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
with:
name: macos
path: |
release/*.dmg
release/*.zip
build-windows:
needs: [quality-gate]
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
shell: powershell
run: |
Set-Location vendor/texthooker-ui
bun install
bun run build
- name: Build unsigned Windows artifacts
run: bun run build:win:unsigned
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: windows
path: |
release/*.exe
release/*.zip
if-no-files-found: error
release:
needs: [build-linux, build-macos, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download AppImage
uses: actions/download-artifact@v4
with:
name: appimage
path: release
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: macos
path: release
- name: Download Windows artifacts
uses: actions/download-artifact@v4
with:
name: windows
path: release
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build Bun subminer wrapper
run: make build-launcher
- name: Verify Bun subminer wrapper
run: dist/launcher/subminer --help >/dev/null
- name: Enforce generated launcher workflow
run: bash scripts/verify-generated-launcher.sh
- name: Verify generated config examples
run: bun run verify:config-example
- name: Package optional assets bundle
run: |
tar -czf "release/subminer-assets.tar.gz" \
config.example.jsonc \
plugin/subminer \
plugin/subminer.conf \
assets/themes/subminer.rasi
- name: Generate checksums
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz dist/launcher/subminer)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
fi
sha256sum "${files[@]}" > release/SHA256SUMS.txt
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Generate prerelease notes from pending fragments
run: bun run changelog:prerelease-notes --version "${{ steps.version.outputs.VERSION }}"
- name: Publish Prerelease
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
gh release edit "${{ steps.version.outputs.VERSION }}" \
--draft=false \
--prerelease \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release/prerelease-notes.md
else
gh release create "${{ steps.version.outputs.VERSION }}" \
--latest=false \
--prerelease \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release/prerelease-notes.md
fi
shopt -s nullglob
artifacts=(
release/*.AppImage
release/*.dmg
release/*.exe
release/*.zip
release/*.tar.gz
release/SHA256SUMS.txt
dist/launcher/subminer
)
if [ "${#artifacts[@]}" -eq 0 ]; then
echo "No release artifacts found for upload."
exit 1
fi
for asset in "${artifacts[@]}"; do
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
done
-2
View File
@@ -4,8 +4,6 @@ on:
push:
tags:
- 'v*'
- '!v*-beta.*'
- '!v*-rc.*'
concurrency:
group: release-${{ github.ref }}
@@ -0,0 +1,34 @@
---
id: TASK-285
title: Rename anime visibility filter heading to title visibility
status: Done
assignee:
- codex
created_date: '2026-04-10 00:00'
updated_date: '2026-04-10 00:00'
labels:
- stats
- ui
- bug
milestone: m-1
dependencies: []
references:
- stats/src/components/trends/TrendsTab.tsx
- stats/src/components/trends/TrendsTab.test.tsx
priority: low
ordinal: 200000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Align the library cumulative trends filter UI with the new terminology by renaming the hardcoded anime visibility heading to title visibility.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The trends filter heading uses `Title Visibility`
- [x] #2 The component behavior and props stay unchanged
- [x] #3 A regression test covers the rendered heading text
<!-- AC:END -->
@@ -1,54 +0,0 @@
---
id: TASK-285
title: Investigate inconsistent mpv y-t overlay toggle after menu toggle
status: To Do
assignee: []
created_date: '2026-04-07 22:55'
updated_date: '2026-04-07 22:55'
labels:
- bug
- overlay
- keyboard
- mpv
dependencies: []
references:
- plugin/subminer/process.lua
- plugin/subminer/ui.lua
- src/renderer/handlers/keyboard.ts
- src/main/runtime/autoplay-ready-gate.ts
- src/core/services/overlay-window-input.ts
- backlog/tasks/task-248 - Fix-macOS-visible-overlay-toggle-getting-immediately-restored.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
User report: toggling the visible overlay with mpv `y-t` is inconsistent. After manually toggling through the `y-y` menu, `y-t` may allow one hide, but after toggling back on it can stop hiding the overlay again, forcing the user back into the menu path.
Initial assessment:
- no active backlog item currently tracks this exact report
- nearest prior work is `TASK-248`, which fixed a macOS-specific visible-overlay restore bug and is marked done
- current targeted regressions for the old fix surface pass, including plugin ready-signal suppression, focused-overlay `y-t` proxy dispatch, autoplay-ready gate deduplication, and blur-path restacking guards
This should be treated as a fresh investigation unless reproduction proves it is the same closed macOS issue resurfacing on the current build.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Reproduce the reported `y-t` / `y-y` inconsistency on the affected platform and identify the exact event sequence
- [ ] #2 Determine whether the failure is in mpv plugin command dispatch, focused-overlay key forwarding, or main-process visible-overlay state transitions
- [ ] #3 Fix the inconsistency so repeated hide/show/hide cycles work from `y-t` without requiring menu recovery
- [ ] #4 Add regression coverage for the reproduced failing sequence
- [ ] #5 Record whether this is a regression of `TASK-248` or a distinct bug
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce the report with platform/build details and capture whether the failing `y-t` press originates in raw mpv or the focused overlay y-chord proxy path.
2. Trace visible-overlay state mutations across plugin toggle commands, autoplay-ready callbacks, and main-process visibility/window blur handling.
3. Patch the narrowest failing path and add regression coverage for the exact hide/show/hide sequence.
4. Re-run targeted plugin, overlay visibility, overlay window, and renderer keyboard suites before broader verification.
<!-- SECTION:PLAN:END -->
@@ -0,0 +1,44 @@
---
id: TASK-287
title: Restore Lua parser compatibility for mpv plugin modules
status: Done
assignee: []
created_date: '2026-04-11 21:25'
updated_date: '2026-04-11 21:29'
labels:
- bug
- mpv-plugin
- lua
dependencies: []
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Users with Lua runtimes that do not accept the current `goto continue` pattern in the mpv plugin should be able to load the plugin without syntax errors. Remove the parser-incompatible control-flow usage from the affected plugin modules without changing plugin behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The mpv plugin source no longer relies on parser-incompatible `goto continue` labels in the affected Lua modules.
- [x] #2 Automated coverage fails on the old parser-incompatible source and passes once the compatibility fix is in place.
- [x] #3 Existing plugin start/gate verification still passes after the compatibility fix.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Reused existing local cleanups in `plugin/subminer/hover.lua` and `plugin/subminer/environment.lua` to remove `goto continue` / `::continue::` control flow without behavior changes.
Added `scripts/test-plugin-lua-compat.lua` and wired it into `test:plugin:src`; the regression checks reject the legacy pattern structurally and verify parse success with `luajit` when available.
Verification run on 2026-04-11: `lua scripts/test-plugin-lua-compat.lua` ✅, `bun run test:plugin:src` ✅, `bun run changelog:lint` ✅, `bun run typecheck` ✅, `bun run test:env` ✅, `bun run build` ✅, `bun run test:smoke:dist` ✅.
`bun run test:fast` remains red for unrelated existing immersion-tracker assertions in `src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts` and `src/core/services/immersion-tracker/__tests__/query.test.ts` (`tsMs`/`lastWatchedMs` observed as `-2147483648`).
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Removed parser-incompatible `goto continue` usage from the affected mpv Lua plugin modules, added a dedicated Lua compatibility regression script to the plugin test lane, and added a changelog fragment for the user-visible fix. Requested plugin verification is green; unrelated existing `test:fast` immersion-tracker failures remain outside this task.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,42 @@
---
id: TASK-288
title: Stabilize immersion-tracker CI timestamp handling under libsql/Bun
status: Done
assignee: []
created_date: '2026-04-11 21:34'
updated_date: '2026-04-11 21:43'
labels:
- bug
- ci
- immersion-tracker
dependencies: []
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
`bun run test:fast` is currently failing because large millisecond timestamps are not handled safely through the libsql/Bun path. Fix timestamp parsing/storage so lifetime/library and session-event queries return correct wall-clock values in CI and runtime.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Large wall-clock timestamps round-trip correctly through immersion-tracker lifetime/library queries under the repo's Bun/libsql runtime.
- [x] #2 Session-event timestamps round-trip correctly for real wall-clock values used by runtime event inserts.
- [x] #3 Targeted immersion-tracker regression coverage passes, and the previously failing `test:fast` lane no longer fails on these timestamp assertions.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause split in two places: Bun/libsql corrupts large millisecond timestamp strings when coerced through `Number(...)`, and `imm_session_events.ts_ms` being `INTEGER` let runtime event inserts/readbacks return `-2147483648` on CI/runtime.
Fix shipped by parsing timestamp strings without the broken `Number(largeString)` path, migrating `imm_session_events.ts_ms` to `TEXT`, ordering/retention queries via `CAST(ts_ms AS REAL)`, and avoiding `Number(currentMs)` when reusing already-normalized timestamp strings.
Added regression coverage for both real runtime event inserts and schema migration/repair of previously truncated session-event rows.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed immersion-tracker timestamp handling under Bun/libsql so large wall-clock millisecond values survive runtime writes, query reads, and schema migration. `bun run test:fast`, `bun run typecheck`, `bun run test:env`, `bun run build`, `bun run test:smoke:dist`, and `bun run changelog:lint` all pass after the patch.
<!-- SECTION:FINAL_SUMMARY:END -->
-3
View File
@@ -12,7 +12,6 @@
"commander": "^14.0.3",
"hono": "^4.12.7",
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"ws": "^8.19.0",
},
@@ -479,8 +478,6 @@
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"koffi": ["koffi@2.15.6", "", {}, "sha512-WQBpM5uo74UQ17UpsFN+PUOrQQg4/nYdey4SGVluQun2drYYfePziLLWdSmFb4wSdWlJC1aimXQnjhPCheRKuw=="],
"lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
"libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="],
@@ -1,5 +0,0 @@
type: internal
area: release
- Added a dedicated beta/rc prerelease GitHub Actions workflow that publishes GitHub prereleases without consuming pending changelog fragments or updating AUR.
- Added prerelease note generation so beta and release-candidate tags can reuse the current pending `changes/*.md` fragments while leaving stable changelog publication for the final release cut.
-6
View File
@@ -30,9 +30,3 @@ Rules:
- each non-empty body line becomes a bullet
- `README.md` is ignored by the generator
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
Prerelease notes:
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed overlay drag-and-drop routing so dropping external subtitle files like `.ass` onto mpv still loads them when the overlay is visible.
-11
View File
@@ -1,11 +0,0 @@
type: fixed
area: overlay
- Fixed Windows overlay z-order so the visible subtitle overlay stops staying above unrelated apps after mpv loses focus.
- Fixed Windows overlay tracking to use native window polling and owner/z-order binding, which keeps the subtitle overlay aligned to the active mpv window more reliably.
- Fixed Windows overlay hide/restore behavior so minimizing mpv immediately hides the overlay and restoring mpv brings it back on top of the mpv window without requiring a click.
- Fixed stats overlay layering so the in-player stats page now stays above mpv and the subtitle overlay while it is open.
- Fixed Windows subtitle overlay stability so transient tracker misses and restore events keep the current subtitle visible instead of waiting for the next subtitle line.
- Fixed Windows focus handoff from the interactive subtitle overlay back to mpv so the overlay no longer drops behind mpv and briefly disappears.
- Fixed Windows visible-overlay startup so it no longer briefly opens as an interactive or opaque surface before the tracked transparent overlay state settles.
- Fixed spurious auto-pause after overlay visibility recovery and window resize so the overlay no longer pauses mpv until the pointer genuinely re-enters the subtitle area.
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed Windows secondary subtitle hover mode so the expanded hover hit area no longer blocks the native minimize, maximize, and close buttons.
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed Windows Yomitan popup focus loss after closing nested lookups so the original popup stays interactive instead of falling through to mpv.
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Fixed immersion-tracker timestamp handling under Bun/libsql so library rows, session timelines, and lifetime summaries keep real wall-clock millisecond values instead of truncating to invalid negative timestamps.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: mpv-plugin
- Fixed the mpv Lua plugin so hover and environment modules no longer use the `goto continue` pattern that can fail to parse on some user Lua runtimes.
+11
View File
@@ -0,0 +1,11 @@
type: changed
area: stats
- Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group.
- Trends add a 365-day range next to the existing 7d/30d/90d/all options.
- Library detail view gets a delete-episode action that removes the video and all its sessions.
- Vocabulary Top 50 tightens the word/reading column so katakana entries no longer push the scores off screen.
- Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
- Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility.
- Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard.
- Session timeline no longer plots seek-forward/seek-backward markers — they were too noisy on sessions with lots of rewinds.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: stats
- Replaced the "Library — Per Day" section on the Stats → Trends page with a "Library — Summary" section. The new section shows a top-10 watch-time leaderboard chart and a sortable per-title table (watch time, videos, sessions, cards, words, lookups, lookups/100w, date range), all scoped to the current date range selector.
-24
View File
@@ -2,8 +2,6 @@
# Releasing
## Stable Release
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples.
3. Run `bun run changelog:lint`.
@@ -26,37 +24,15 @@
10. Tag the commit: `git tag v<version>`.
11. Push commit + tag.
## Prerelease
1. Confirm release-facing docs and pending `changes/*.md` fragments are current.
2. Run `bun run changelog:lint`.
3. Bump `package.json` to the prerelease version, for example `0.11.3-beta.1` or `0.11.3-rc.1`.
4. Run the prerelease gate locally:
`bun run changelog:prerelease-notes --version <version>`
`bun run verify:config-example`
`bun run typecheck`
`bun run test:fast`
`bun run test:env`
`bun run build`
5. Commit the prerelease prep. Do not run `bun run changelog:build`.
6. Tag the commit: `git tag v<version>`.
7. Push commit + tag.
Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes.
Notes:
- Versioning policy: SubMiner stays 0-ver. Large or breaking release lines still bump the minor number (`0.x.0`), not `1.0.0`. Example: the next major line after `0.6.5` is `0.7.0`.
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
- `changelog:check` now rejects tag/package version mismatches.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
- In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes.
- Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
- If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
# Library Summary Replaces Per-Day Trends — Design
**Status:** Draft
**Date:** 2026-04-09
**Scope:** `stats/` frontend, `src/core/services/immersion-tracker/query-trends.ts` backend
## Problem
The "Library — Per Day" section on the stats Trends tab (`stats/src/components/trends/TrendsTab.tsx:224-254`) renders six stacked-area charts — Videos, Watch Time, Cards, Words, Lookups, and Lookups/100w, each broken down per title per day.
In practice these charts are not useful:
- Most titles only have activity on one or two days in a window, so they render as isolated bumps on a noisy baseline.
- Stacking 7+ titles with mostly-zero days makes individual lines hard to follow.
- The top "Activity" and "Period Trends" sections already answer "what am I doing per day" globally.
- The "Library — Cumulative" section directly below already answers "which titles am I progressing through" with less noise.
The per-day section occupies significant vertical space without carrying its weight, and the user has confirmed it should be replaced.
## Goal
Replace the six per-day stacked charts with a single "Library — Summary" section that surfaces per-title aggregate statistics over the selected date range. The new view should make it trivially easy to answer: "For the selected window, which titles am I spending time on, how much mining output have they produced, and how efficient is my lookup rate on each?"
## Non-goals
- Changing the "Library — Cumulative" section (stays as-is).
- Changing the "Activity", "Period Trends", or "Patterns" sections.
- Adding a new API endpoint — the existing dashboard endpoint is extended in place.
- Renaming internal `anime*` data-model identifiers (`animeId`, `imm_anime`, etc.). Those stay per the convention established in `c5e778d7`; only new fields/types/user-visible strings use generic "title"/"library" wording.
- Supporting a true all-time library view on the Trends tab. If that's ever wanted, it belongs on a different tab.
## Solution Overview
Delete the "Library — Per Day" section. In its place, add "Library — Summary", composed of:
1. A horizontal-bar leaderboard chart of watch time per title (top 10, descending).
2. A sortable table of every title with activity in the selected window, with columns: Title, Watch Time, Videos, Sessions, Cards, Words, Lookups, Lookups/100w, Date Range.
Both controls are scoped to the top-of-page date range selector. The existing shared Anime Visibility filter continues to work — it now gates Summary + Cumulative instead of Per-Day + Cumulative.
## Backend
### New type
Add to `stats/src/types/stats.ts` and the backend query module:
```ts
type LibrarySummaryRow = {
title: string; // display title — anime series, YouTube video title, etc.
watchTimeMin: number; // sum(total_active_min) across the window
videos: number; // distinct video_id count
sessions: number; // session count from imm_sessions
cards: number; // sum(total_cards)
words: number; // sum(total_tokens_seen)
lookups: number; // sum(lookup_count) from imm_sessions
lookupsPerHundred: number | null; // lookups / words * 100, null when words == 0
firstWatched: number; // min(rollup_day) as epoch day, within the window
lastWatched: number; // max(rollup_day) as epoch day, within the window
};
```
### Query changes in `src/core/services/immersion-tracker/query-trends.ts`
- Add `librarySummary: LibrarySummaryRow[]` to `TrendsDashboardQueryResult`.
- Populate it from a single aggregating query over `imm_daily_rollups` joined to `imm_videos``imm_anime`, filtered by `rollup_day` within the selected window. Session count and lookup count come from `imm_sessions` aggregated by `video_id` and then grouped by the parent library entry. Use a single query (or at most two joined/unioned) — no N+1.
- `imm_anime` is the generic library-grouping table; anime series, YouTube videos, and yt-dlp imports all land there. The internal table name stays `imm_anime`; only the new field uses generic naming.
- Return rows pre-sorted by `watchTimeMin` descending so the leaderboard is zero-cost and the table default sort matches.
- Emit `lookupsPerHundred: null` when `words == 0`.
### Removed from API response
Drop the entire `animePerDay` field from `TrendsDashboardQueryResult` (both backend in `src/core/services/immersion-tracker/query-trends.ts` and frontend in `stats/src/types/stats.ts`).
Internally, the existing helpers (`buildPerAnimeFromDailyRollups`, `buildEpisodesPerAnimeFromDailyRollups`) are still used as intermediates to build `animeCumulative.*` via `buildCumulativePerAnime`. Keep those helpers — just scope their output to local variables inside `getTrendsDashboard` instead of exposing them on the response. The `buildPerAnimeFromSessions` call for lookups and the `buildLookupsPerHundredPerAnime` helper become unused and can be deleted.
Before removing `animePerDay` from the frontend type, verify no other file under `stats/src/` references it. Based on current inspection, only `TrendsTab.tsx` and `stats/src/types/stats.ts` touch it.
## Frontend
### New component: `stats/src/components/trends/LibrarySummarySection.tsx`
Owns the header, leaderboard chart, visibility-filtered data, and the table. Keeps `TrendsTab.tsx` from growing. Component props: `{ rows: LibrarySummaryRow[]; hiddenTitles: ReadonlySet<string>; windowStart: Date; windowEnd: Date }`.
Internal state: `useState<{ column: ColumnId; direction: 'asc' | 'desc' }>` for sort, defaulting to `{ column: 'watchTimeMin', direction: 'desc' }`.
### Layout
Replaces `TrendsTab.tsx:224-254`:
```
[SectionHeader: "Library — Summary"]
[AnimeVisibilityFilter — unchanged, shared with Cumulative below]
[Card, col-span-full: Leaderboard — horizontal bar chart, ~260px tall]
[Card, col-span-full: Sortable table, auto height up to ~480px with internal scroll]
```
Both cards use the existing chart/card wrapper styling.
### Leaderboard chart
- Recharts horizontal bar chart (matches the rest of the page — existing charts use `recharts`, not ECharts).
- Top 10 titles by watch time. If fewer titles have activity, render what's there.
- Y-axis: title (category), truncated with ellipsis at container width; full title visible in the Recharts tooltip.
- X-axis: minutes (number).
- Use `layout="vertical"` with `YAxis dataKey="title" type="category"` and `XAxis type="number"`.
- Single series color: `#8aadf4` (matching the existing Watch Time color).
- Reuse `CHART_DEFAULTS`, `CHART_THEME`, `TOOLTIP_CONTENT_STYLE` from `stats/src/lib/chart-theme.ts` so theming matches the rest of the dashboard.
- Chart order is fixed at watch-time desc regardless of table sort — the leaderboard's meaning is fixed.
### Table
- Plain HTML `<table>` with Tailwind classes. No new deps.
- Columns, in order:
1. **Title** — left-aligned, sticky, truncated with ellipsis, full title on hover.
2. **Watch Time** — formatted `Xh Ym` when ≥60 min, else `Xm`.
3. **Videos** — integer.
4. **Sessions** — integer.
5. **Cards** — integer.
6. **Words** — integer.
7. **Lookups** — integer.
8. **Lookups/100w** — one decimal place, `—` when null.
9. **Date Range**`Mon D → Mon D` using the title's `firstWatched` / `lastWatched` within the window.
- Click a column header to sort; click again to reverse. Visual arrow on the active column.
- Numeric columns right-aligned.
- Null `lookupsPerHundred` sorts as the lowest value in both directions (consistent with "no data").
- Row hover highlight; no row click action (read-only view).
- Empty state: "No library activity in the selected window."
### Visibility filter integration
Hiding a title via `AnimeVisibilityFilter` removes it from both the leaderboard and the table. The filter's set of available titles is built from the union of titles that appear in `librarySummary` and the existing `animeCumulative.*` arrays (matches current behavior in `buildAnimeVisibilityOptions`).
### `TrendsTab.tsx` changes
- Remove the `filteredEpisodesPerAnime`, `filteredWatchTimePerAnime`, `filteredCardsPerAnime`, `filteredWordsPerAnime`, `filteredLookupsPerAnime`, `filteredLookupsPerHundredPerAnime` locals.
- Remove the six `<StackedTrendChart>` calls in the "Library — Per Day" section.
- Remove the `<SectionHeader>Library — Per Day</SectionHeader>` and the `<AnimeVisibilityFilter>` from that position.
- Insert `<SectionHeader>Library — Summary</SectionHeader>` + `<AnimeVisibilityFilter>` + `<LibrarySummarySection>` in the same place.
- Update `buildAnimeVisibilityOptions` input to use `librarySummary` titles instead of the six dropped `animePerDay.*` arrays.
## Data flow
1. `useTrends(range, groupBy)` calls `/api/stats/trends/dashboard`.
2. Response now includes `librarySummary` (sorted by watch time desc).
3. `TrendsTab` holds the shared `hiddenAnime` set (unchanged).
4. `LibrarySummarySection` receives `librarySummary` + `hiddenAnime`, filters out hidden rows, renders the leaderboard from the top-10 slice of the filtered list, renders the table from the filtered list with local sort state applied.
5. Date-range selector changes trigger a new fetch; `groupBy` toggle does not affect the summary section (it's always window-total).
## Edge cases
- **No activity in window:** Section renders header + empty-state card. Leaderboard card hidden. Visibility filter hidden.
- **One title only:** Leaderboard renders a single bar; table renders one row. No special-casing.
- **Title with zero words but non-zero lookups:** `lookupsPerHundred` is `null`, rendered as `—`. Sort treats null as lowest.
- **Title with zero cards/lookups/words but non-zero watch time:** Normal zero rendering, still shown.
- **Very long titles:** Ellipsis in chart y-axis labels and table title column; full title in `title` attribute / ECharts tooltip.
- **Mixed sources (anime + YouTube):** No special case — both land in `imm_anime` and are grouped uniformly.
## Testing
### Backend (`query-trends.ts`)
New unit tests, following the existing pattern:
1. Empty window returns `librarySummary: []`.
2. Single title with a few rollups: all aggregates are correct; `firstWatched`/`lastWatched` match the bounding days within the window.
3. Multiple titles: rows returned sorted by watch time desc.
4. Mixed sources (anime-style + YouTube-style entries in `imm_anime`): both appear in the summary with their own aggregates.
5. Title with `words == 0`: `lookupsPerHundred` is `null`.
6. Date range excludes some rollups: excluded rollups are not counted; `firstWatched`/`lastWatched` reflect only within-window activity.
7. `sessions` and `lookups` come from `imm_sessions`, not `imm_daily_rollups`, and are correctly attributed to the parent library entry.
### Frontend
- Existing Trends tab smoke test should continue to pass after wiring.
- Optional: a targeted render test for `LibrarySummarySection` (empty state, single title, sort toggle, visibility filter interaction). Not required for merge if the smoke test exercises the happy path.
## Release / docs
- One fragment in `changes/*.md` summarizing the replacement.
- No user-facing docs (`docs-site/`) changes unless the per-day section was documented there — verify during implementation.
## Open items
None.
@@ -0,0 +1,347 @@
# Stats Dashboard Feedback Pass — Design
Date: 2026-04-09
Scope: Stats dashboard UX follow-ups from user feedback (items 17).
Delivery: **Single PR**, broken into logically scoped commits.
## Goals
Address seven concrete pieces of feedback against the Statistics menu:
1. Library — collapse episodes behind a per-series dropdown.
2. Sessions — roll up multiple sessions of the same episode within a day.
3. Trends — add a 365d range option.
4. Library — delete an episode (video) from its detail view.
5. Vocabulary — tighten spacing between word and reading in the Top 50 table.
6. Episode detail — hide cards whose Anki notes have been deleted.
7. Trend/watch charts — add gridlines, fix tick legibility, unify theming.
Out of scope for this pass: English-token ingestion cleanup and Overview stat-card drill-downs (feedback items 8 and 9). Those require a larger design decision and a migration respectively.
## Files touched (inventory)
Dashboard (`stats/src/`):
- `components/library/LibraryTab.tsx` — collapsible groups (item 1).
- `components/library/MediaDetailView.tsx`, `components/library/MediaHeader.tsx` — delete-episode action (item 4).
- `components/sessions/SessionsTab.tsx`, `components/library/MediaSessionList.tsx` — episode rollup (item 2).
- `components/trends/DateRangeSelector.tsx`, `hooks/useTrends.ts`, `lib/api-client.ts`, `lib/api-client.test.ts` — 365d (item 3).
- `components/vocabulary/FrequencyRankTable.tsx` — word/reading column collapse (item 5).
- `components/anime/EpisodeDetail.tsx` — filter deleted Anki cards (item 6).
- `components/trends/TrendChart.tsx`, `components/trends/StackedTrendChart.tsx`, `components/overview/WatchTimeChart.tsx`, `lib/chart-theme.ts` — chart clarity (item 7).
- New file: `stats/src/lib/session-grouping.ts` + `session-grouping.test.ts`.
Backend (`src/core/services/`):
- `immersion-tracker/query-trends.ts` — extend `TrendRange` and `TREND_DAY_LIMITS` (item 3).
- `immersion-tracker/__tests__/query.test.ts` — 365d coverage (item 3).
- `stats-server.ts` — passthrough if range validation lives here (check before editing).
- `__tests__/stats-server.test.ts` — 365d coverage (item 3).
## Commit plan
One PR, one feature per commit. Order picks low-risk mechanical changes first so failures in later commits don't block merging of earlier ones.
1. `feat(stats): add 365d range to trends dashboard` (item 3)
2. `fix(stats): tighten word/reading column in Top 50 table` (item 5)
3. `fix(stats): hide cards deleted from Anki in episode detail` (item 6)
4. `feat(stats): delete episode from library detail view` (item 4)
5. `feat(stats): collapsible series groups in library` (item 1)
6. `feat(stats): roll up same-episode sessions within a day` (item 2)
7. `feat(stats): gridlines and unified theme for trend charts` (item 7)
Each commit must pass `bun run typecheck`, `bun run test:fast`, and any change-specific checks listed below.
---
## Item 1 — Library collapsible series groups
### Current behavior
`LibraryTab.tsx` groups media via `groupMediaLibraryItems` and always renders the full grid of `MediaCard`s beneath each group header.
### Target behavior
Each group header becomes clickable. Groups with `items.length > 1` default to **collapsed**; single-video groups stay expanded (collapsing them would be visual noise).
### Implementation
- State: `const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(...)`. Initialize from `grouped` where `items.length > 1`.
- Toggle helper: `toggleGroup(key: string)` adds/removes from the set.
- Group header: wrap in a `<button>` with `aria-expanded` and a chevron icon (`▶`/`▼`). Keep the existing cover + title + subtitle layout inside the button.
- Children grid is conditionally rendered on `!collapsedGroups.has(group.key)`.
- Header summary (`N videos · duration · cards`) stays visible in both states so collapsed groups remain informative.
### Tests
- New `LibraryTab.test.tsx` (if not already present — check first) covering:
- Multi-video group renders collapsed on first mount.
- Single-video group renders expanded on first mount.
- Clicking the header toggles visibility.
- Header summary is visible in both states.
---
## Item 2 — Sessions episode rollup within a day
### Current behavior
`SessionsTab.tsx:10-24` groups sessions by day label only (`formatSessionDayLabel(startedAtMs)`). Multiple sessions of the same episode on the same day show as independent rows. `MediaSessionList.tsx` has the same problem inside the library detail view.
### Target behavior
Within each day, sessions with the same `videoId` collapse into one parent row showing combined totals. A chevron reveals the individual sessions. Single-session buckets render flat (no pointless nesting).
### Implementation
- New helper in `stats/src/lib/session-grouping.ts`:
```ts
export interface SessionBucket {
key: string; // videoId as string, or `s-${sessionId}` for singletons
videoId: number | null;
sessions: SessionSummary[];
totalActiveMs: number;
totalCardsMined: number;
representativeSession: SessionSummary; // most recent, for header display
}
export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[];
```
Sessions missing a `videoId` become singleton buckets.
- `SessionsTab.tsx`: after day grouping, pipe each `daySessions` through `groupSessionsByVideo`. Render each bucket:
- `sessions.length === 1`: existing `SessionRow` behavior, unchanged.
- `sessions.length >= 2`: render a **bucket row** that looks like `SessionRow` but shows combined totals and session count (e.g. `3 sessions · 1h 24m · 12 cards`). Chevron state stored in a second `Set<string>` on bucket key. Expanded buckets render the child `SessionRow`s indented (`pl-8`) beneath the header.
- `MediaSessionList.tsx`: within the media detail view, a single video's sessions are all the same `videoId` by definition — grouping here is by day only, and within a day multiple sessions render nested under a day header. Re-use the same visual pattern; factor the bucket row into a shared `SessionBucketRow` component.
### Delete semantics
- Deleting a bucket header offers "Delete all N sessions in this group" (reuse `confirmDayGroupDelete` pattern with a bucket-specific message, or add `confirmBucketDelete`).
- Deleting an individual session from inside an expanded bucket keeps the existing single-delete flow.
### Tests
- `session-grouping.test.ts`:
- Empty input → empty output.
- All unique videos → N singleton buckets.
- Two sessions same videoId → one bucket with correct totals and representative (most recent start time).
- Missing videoId → singleton bucket keyed by sessionId.
- `SessionsTab.test.tsx` (extend or add) verifying the rendered bucket rows expand/collapse and delete hooks fire with the right ID set.
---
## Item 3 — 365d trends range
### Backend
`src/core/services/immersion-tracker/query-trends.ts`:
- `type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';`
- Add `'365d': 365` to `TREND_DAY_LIMITS`.
- `getTrendDayLimit` picks up the new key automatically because of the `Exclude<TrendRange, 'all'>` generic.
`src/core/services/stats-server.ts`:
- Search for any hardcoded range validation (e.g. allow-list in the trends route handler) and extend it.
### Frontend
- `hooks/useTrends.ts`: widen the `TimeRange` union.
- `components/trends/DateRangeSelector.tsx`: add `'365d'` to the options list. Display label stays as `365d`.
- `lib/api-client.ts` / `api-client.test.ts`: if the client validates ranges, add `365d`.
### Tests
- `query.test.ts`: extend the existing range table to cover `365d` returning 365 days of data.
- `stats-server.test.ts`: ensure the route accepts `range=365d`.
- `api-client.test.ts`: ensure the client emits the new range.
### Change-specific checks
- `bun run test:config` is not required here (no schema/defaults change).
- Run `bun run typecheck` + `bun run test:fast`.
---
## Item 4 — Delete episode from library detail
### Current behavior
`MediaDetailView.tsx` provides session-level delete only. The backend `deleteVideo` exists (`query-maintenance.ts:509`), the API is exposed at `stats-server.ts:559`, and `api-client.deleteVideo` is already wired (`stats/src/lib/api-client.ts:146`). `EpisodeList.tsx:46` already uses it from the anime tab.
### Target behavior
A "Delete Episode" action in `MediaHeader` (top-right, small, `text-ctp-red`), gated by `confirmEpisodeDelete(title)`. On success, call `onBack()` and make sure the parent `LibraryTab` refetches.
### Implementation
- Add an `onDeleteEpisode?: () => void` prop to `MediaHeader` and render the button only if provided.
- In `MediaDetailView`:
- New handler `handleDeleteEpisode` that calls `apiClient.deleteVideo(videoId)`, then `onBack()`.
- Reuse `confirmEpisodeDelete` from `stats/src/lib/delete-confirm.ts`.
- In `LibraryTab`:
- `useMediaLibrary` returns fresh data on mount. The simplest fix: pass a `refresh` function from the hook (extend the hook if it doesn't already expose one) and call it when the detail view signals back.
- Alternative: force a remount by incrementing a `libraryVersion` key on the library list. Prefer `refresh` for clarity.
### Tests
- Extend the existing `MediaDetailView.test.tsx`: mock `apiClient.deleteVideo`, click the new button, confirm `onBack` fires after success.
- `useMediaLibrary.test.ts`: if we add a `refresh` method, cover it.
---
## Item 5 — Vocabulary word/reading column collapse
### Current behavior
`FrequencyRankTable.tsx:110-144` uses a 5-column table: `Rank | Word | Reading | POS | Seen`. Word and Reading are auto-sized, producing a large gap.
### Target behavior
Merge Word + Reading into a single column titled "Word". Reading sits immediately after the headword in a muted, smaller style.
### Implementation
- Drop the `<th>Reading</th>` header and cell.
- Word cell becomes:
```tsx
<td className="py-1.5 pr-3">
<span className="text-ctp-text font-medium">{w.headword}</span>
{reading && (
<span className="text-ctp-subtext0 text-xs ml-1.5">
【{reading}】
</span>
)}
</td>
```
where `reading = fullReading(w.headword, w.reading)` and differs from `headword`.
- Keep `fullReading` import from `reading-utils`.
### Tests
- Extend `FrequencyRankTable.test.tsx` (if present — otherwise add a focused test) to assert:
- Headword renders.
- Reading renders when different from headword.
- Reading does not render when equal to headword.
---
## Item 6 — Hide Anki-deleted cards in Cards Mined
### Current behavior
`EpisodeDetail.tsx:109-147` iterates `cardEvents`, fetches note info via `ankiNotesInfo(allNoteIds)`, and for each `noteId` renders a row even if no matching `info` came back — the user sees an empty word with an "Open in Anki" button that leads nowhere.
### Target behavior
After `ankiNotesInfo` resolves:
- Drop `noteId`s that are not in the resolved map.
- Drop `cardEvents` whose `noteIds` list was non-empty but is now empty after filtering.
- Card events with a positive `cardsDelta` but no `noteIds` (legacy rollup path) still render as `+N cards` — we have no way to cross-reference them, so leave them alone.
### Implementation
- Compute `filteredCardEvents` as a `useMemo` depending on `data.cardEvents` and `noteInfos`.
- Iterate `filteredCardEvents` instead of `cardEvents` in the render.
- Surface a subtle note (optional, muted) "N cards hidden (deleted from Anki)" at the end of the list if any were filtered — helps the user understand why counts here diverge from session totals. Final decision on the note can be made at PR review; default: **show it**.
### Tests
- Add a test in `EpisodeDetail.test.tsx` (add the file if not present) that stubs `ankiNotesInfo` to return only a subset of notes and verifies the missing ones are not rendered.
### Other call sites
- Grep so far shows `ankiNotesInfo` is only used in `EpisodeDetail.tsx`. Re-verify before landing the commit; if another call site appears, apply the same filter.
---
## Item 7 — Trend/watch chart clarity pass
### Current behavior
`TrendChart.tsx`, `StackedTrendChart.tsx`, and `WatchTimeChart.tsx` render Recharts components with:
- No `CartesianGrid` → no horizontal reference lines.
- 9px axis ticks → borderline unreadable.
- Height 120 → cramped.
- Tooltip uses raw labels (`04/04` etc.).
- No shared theme object; each chart redefines colors and tooltip styles inline.
`stats/src/lib/chart-theme.ts` already exists and currently exports a single `CHART_THEME` constant with tick/tooltip colors and `barFill`. It will be extended, not replaced, to preserve existing consumers.
### Target behavior
All three charts share a theme, have horizontal gridlines, readable ticks, and sensible tooltips.
### Implementation
Extend `stats/src/lib/chart-theme.ts` with the additional shared defaults (keeping the existing `CHART_THEME` export intact so current consumers don't break):
```ts
export const CHART_THEME = {
tick: '#a5adcb',
tooltipBg: '#363a4f',
tooltipBorder: '#494d64',
tooltipText: '#cad3f5',
tooltipLabel: '#b8c0e0',
barFill: '#8aadf4',
grid: '#494d64',
axisLine: '#494d64',
} as const;
export const CHART_DEFAULTS = {
height: 160,
tickFontSize: 11,
margin: { top: 8, right: 8, bottom: 0, left: 0 },
grid: { strokeDasharray: '3 3', vertical: false },
} as const;
export const TOOLTIP_CONTENT_STYLE = {
background: CHART_THEME.tooltipBg,
border: `1px solid ${CHART_THEME.tooltipBorder}`,
borderRadius: 6,
color: CHART_THEME.tooltipText,
fontSize: 12,
};
```
Apply to each chart:
- Import `CartesianGrid` from recharts.
- Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` inside each chart container.
- `<XAxis tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} />` and equivalent `YAxis`.
- `YAxis` gains `axisLine={{ stroke: CHART_THEME.axisLine }}`.
- `ResponsiveContainer` height changes from 120 → `CHART_DEFAULTS.height`.
- `Tooltip` `contentStyle` uses `TOOLTIP_CONTENT_STYLE`, and charts pass a `labelFormatter` when the label is a date key (e.g. show `Fri Apr 4`).
### Unit formatters
- `TrendChart` already accepts a `formatter` prop — extend usage sites to pass unit-aware formatters where they aren't already (`formatDuration`, `formatNumber`, etc.).
### Tests
- `chart-theme.test.ts` (if present — otherwise add a trivial snapshot to keep the shape stable).
- `TrendChart` snapshot/render tests: no regression, gridline element present.
---
## Verification gate
Before requesting code review, run:
```
bun run typecheck
bun run test:fast
bun run test:env
bun run test:runtime:compat # dist-sensitive check for the charts
bun run build
bun run test:smoke:dist
```
No docs-site changes are planned in this spec; if `docs-site/` ends up touched (e.g. screenshots), also run `bun run docs:test` and `bun run docs:build`.
No config schema changes → `bun run test:config` and `bun run generate:config-example` are not required.
## Risks and open questions
- **MediaDetailView refresh**: `useMediaLibrary` may not expose a `refresh` function. If it doesn't, the simplest path is adding one; the alternative (keying a remount) works but is harder to test. Decide during implementation.
- **Session bucket delete UX**: "Delete all N sessions in this group" is powerful. The copy must make it clear the underlying sessions are being removed, not just the grouping. Reuse `confirmBucketDelete` wording from existing confirm helpers if possible.
- **Anki-deleted-cards hidden notice**: Showing a subtle "N cards hidden" footer is a call that can be made at PR review.
- **Bucket delete helper**: `confirmBucketDelete` does not currently exist in `delete-confirm.ts`. Implementation either adds it or reuses `confirmDayGroupDelete` with bucket-specific wording — decide during the session-rollup commit.
## Changelog entry
User-visible PR → needs a fragment under `changes/*.md`. Suggested title:
`Stats dashboard: collapsible series, session rollups, 365d trends, chart polish, episode delete.`
+3 -5
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.12.0-beta.2",
"version": "0.11.2",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -26,7 +26,6 @@
"changelog:lint": "bun run scripts/build-changelog.ts lint",
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",
"changelog:prerelease-notes": "bun run scripts/build-changelog.ts prerelease-notes",
"format": "prettier --write .",
"format:check": "prettier --check .",
"format:src": "bash scripts/prettier-scope.sh --write",
@@ -45,7 +44,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-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-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/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",
@@ -70,7 +69,7 @@
"test:launcher": "bun run test:launcher:src",
"test:core": "bun run test:core:src",
"test:subtitle": "bun run test:subtitle:src",
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"generate:config-example": "bun run src/generate-config-example.ts",
"verify:config-example": "bun run src/verify-config-example.ts",
"start": "bun run build && electron . --start",
@@ -113,7 +112,6 @@
"commander": "^14.0.3",
"hono": "^4.12.7",
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"ws": "^8.19.0"
},
+4 -11
View File
@@ -55,23 +55,17 @@ function M.create(ctx)
if not image then
image = line:match('^"([^"]+)"')
end
if not image then
goto continue
end
if image then
if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then
return true
end
if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then
return true
end
end
else
local argv0 = line:match('^"([^"]+)"') or line:match("^%s*([^%s]+)")
if not argv0 then
goto continue
end
if argv0:find("subminer.lua", 1, true) or argv0:find("subminer.conf", 1, true) then
goto continue
end
if argv0 and not argv0:find("subminer.lua", 1, true) and not argv0:find("subminer.conf", 1, true) then
local exe = argv0:match("([^/\\]+)$") or argv0
if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then
return true
@@ -80,8 +74,7 @@ function M.create(ctx)
return true
end
end
::continue::
end
end
return false
end
+2 -6
View File
@@ -189,10 +189,7 @@ function M.create(ctx)
local source_len = #plain
local cursor = 1
for _, token in ipairs(payload.tokens or {}) do
if type(token) ~= "table" or type(token.text) ~= "string" or token.text == "" then
goto continue
end
if type(token) == "table" and type(token.text) == "string" and token.text ~= "" then
local token_text = token.text
local start_pos = nil
local end_pos = nil
@@ -222,8 +219,7 @@ function M.create(ctx)
end
cursor = end_pos + 1
end
::continue::
end
end
return nil
-183
View File
@@ -310,186 +310,3 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
}),
);
});
test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-beta-notes');
const projectRoot = path.join(workspace, 'SubMiner');
const changelogPath = path.join(projectRoot, 'CHANGELOG.md');
const docsChangelogPath = path.join(projectRoot, 'docs-site', 'changelog.md');
const existingChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable release.\n';
const existingDocsChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable docs release.\n';
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'docs-site'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
'utf8',
);
fs.writeFileSync(changelogPath, existingChangelog, 'utf8');
fs.writeFileSync(docsChangelogPath, existingDocsChangelog, 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Added prerelease coverage.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '002.md'),
['type: fixed', 'area: launcher', '', '- Fixed prerelease packaging checks.'].join('\n'),
'utf8',
);
try {
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.1',
});
assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md'));
assert.equal(
fs.readFileSync(changelogPath, 'utf8'),
existingChangelog,
'stable CHANGELOG.md should remain unchanged',
);
assert.equal(
fs.readFileSync(docsChangelogPath, 'utf8'),
existingDocsChangelog,
'docs-site changelog should remain unchanged',
);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), true);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), true);
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Overlay: Added prerelease coverage\./);
assert.match(
prereleaseNotes,
/### Fixed\n- Launcher: Fixed prerelease packaging checks\./,
);
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-rc-notes');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-rc.1' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: changed', 'area: release', '', '- Prepared release candidate notes.'].join('\n'),
'utf8',
);
try {
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-rc.1',
});
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(
prereleaseNotes,
/## Highlights\n### Changed\n- Release: Prepared release candidate notes\./,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion rejects unsupported prerelease identifiers', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-alpha-reject');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-alpha.1' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Unsupported alpha prerelease.'].join('\n'),
'utf8',
);
try {
assert.throws(
() =>
writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-alpha.1',
}),
/Unsupported prerelease version \(0\.11\.3-alpha\.1\)/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion rejects mismatched package versions', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-version-mismatch');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Mismatched prerelease.'].join('\n'),
'utf8',
);
try {
assert.throws(
() =>
writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.2',
}),
/package\.json version \(0\.11\.3-beta\.1\) does not match requested release version \(0\.11\.3-beta\.2\)/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion rejects empty prerelease note generation when no fragments exist', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-no-fragments');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
'utf8',
);
try {
assert.throws(
() =>
writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.1',
}),
/No changelog fragments found in changes\//,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
+4 -53
View File
@@ -38,7 +38,6 @@ type PullRequestChangelogOptions = {
};
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md');
const CHANGELOG_HEADER = '# Changelog';
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
@@ -76,10 +75,6 @@ function resolveVersion(options: Pick<ChangelogOptions, 'cwd' | 'version' | 'dep
return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync));
}
function isSupportedPrereleaseVersion(version: string): boolean {
return /^\d+\.\d+\.\d+-(beta|rc)\.\d+$/u.test(normalizeVersion(version));
}
function verifyRequestedVersionMatchesPackageVersion(
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
): void {
@@ -319,15 +314,8 @@ export function resolveChangelogOutputPaths(options?: { cwd?: string }): string[
return [path.join(cwd, 'CHANGELOG.md')];
}
function renderReleaseNotes(
changes: string,
options?: {
disclaimer?: string;
},
): string {
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
function renderReleaseNotes(changes: string): string {
return [
...prefix,
'## Highlights',
changes,
'',
@@ -346,21 +334,13 @@ function renderReleaseNotes(
].join('\n');
}
function writeReleaseNotesFile(
cwd: string,
changes: string,
deps?: ChangelogFsDeps,
options?: {
disclaimer?: string;
outputPath?: string;
},
): string {
function writeReleaseNotesFile(cwd: string, changes: string, deps?: ChangelogFsDeps): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
const releaseNotesPath = path.join(cwd, options?.outputPath ?? RELEASE_NOTES_PATH);
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
mkdirSync(path.dirname(releaseNotesPath), { recursive: true });
writeFileSync(releaseNotesPath, renderReleaseNotes(changes, options), 'utf8');
writeFileSync(releaseNotesPath, renderReleaseNotes(changes), 'utf8');
return releaseNotesPath;
}
@@ -633,30 +613,6 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
return writeReleaseNotesFile(cwd, changes, options?.deps);
}
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
verifyRequestedVersionMatchesPackageVersion(options ?? {});
const cwd = options?.cwd ?? process.cwd();
const version = resolveVersion(options ?? {});
if (!isSupportedPrereleaseVersion(version)) {
throw new Error(
`Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`,
);
}
const fragments = readChangeFragments(cwd, options?.deps);
if (fragments.length === 0) {
throw new Error('No changelog fragments found in changes/.');
}
const changes = renderGroupedChanges(fragments);
return writeReleaseNotesFile(cwd, changes, options?.deps, {
disclaimer:
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
outputPath: PRERELEASE_NOTES_PATH,
});
}
function parseCliArgs(argv: string[]): {
baseRef?: string;
cwd?: string;
@@ -754,11 +710,6 @@ function main(): void {
return;
}
if (command === 'prerelease-notes') {
writePrereleaseNotesForVersion(options);
return;
}
if (command === 'docs') {
generateDocsChangelog(options);
return;
+6 -180
View File
@@ -1,8 +1,7 @@
param(
[ValidateSet('geometry', 'foreground-process', 'bind-overlay', 'lower-overlay', 'set-owner', 'clear-owner')]
[ValidateSet('geometry')]
[string]$Mode = 'geometry',
[string]$SocketPath,
[string]$OverlayWindowHandle
[string]$SocketPath
)
$ErrorActionPreference = 'Stop'
@@ -36,89 +35,19 @@ public static class SubMinerWindowsHelper {
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool SetWindowPos(
IntPtr hWnd,
IntPtr hWndInsertAfter,
int X,
int Y,
int cx,
int cy,
uint uFlags
);
[DllImport("user32.dll", SetLastError = true)]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
[DllImport("user32.dll", SetLastError = true)]
public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
[DllImport("dwmapi.dll")]
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
}
"@
$DWMWA_EXTENDED_FRAME_BOUNDS = 9
$SWP_NOSIZE = 0x0001
$SWP_NOMOVE = 0x0002
$SWP_NOACTIVATE = 0x0010
$SWP_NOOWNERZORDER = 0x0200
$SWP_FLAGS = $SWP_NOSIZE -bor $SWP_NOMOVE -bor $SWP_NOACTIVATE -bor $SWP_NOOWNERZORDER
$GWL_EXSTYLE = -20
$WS_EX_TOPMOST = 0x00000008
$GWLP_HWNDPARENT = -8
$HWND_TOP = [IntPtr]::Zero
$HWND_BOTTOM = [IntPtr]::One
$HWND_TOPMOST = [IntPtr](-1)
$HWND_NOTOPMOST = [IntPtr](-2)
if ($Mode -eq 'foreground-process') {
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
if ($foregroundWindow -eq [IntPtr]::Zero) {
Write-Output 'not-found'
exit 0
}
[uint32]$foregroundProcessId = 0
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($foregroundWindow, [ref]$foregroundProcessId)
if ($foregroundProcessId -eq 0) {
Write-Output 'not-found'
exit 0
}
try {
$foregroundProcess = Get-Process -Id $foregroundProcessId -ErrorAction Stop
} catch {
Write-Output 'not-found'
exit 0
}
Write-Output "process=$($foregroundProcess.ProcessName)"
exit 0
}
if ($Mode -eq 'clear-owner') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required')
exit 1
}
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
[void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, [IntPtr]::Zero)
Write-Output 'ok'
exit 0
}
function Get-WindowBounds {
param([IntPtr]$hWnd)
@@ -161,7 +90,6 @@ public static class SubMinerWindowsHelper {
}
$mpvMatches = New-Object System.Collections.Generic.List[object]
$targetWindowState = 'not-found'
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
$callback = [SubMinerWindowsHelper+EnumWindowsProc]{
param([IntPtr]$hWnd, [IntPtr]$lParam)
@@ -170,6 +98,10 @@ public static class SubMinerWindowsHelper {
return $true
}
if ([SubMinerWindowsHelper]::IsIconic($hWnd)) {
return $true
}
[uint32]$windowProcessId = 0
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($hWnd, [ref]$windowProcessId)
if ($windowProcessId -eq 0) {
@@ -199,22 +131,11 @@ public static class SubMinerWindowsHelper {
}
}
if ([SubMinerWindowsHelper]::IsIconic($hWnd)) {
if (-not [string]::IsNullOrWhiteSpace($SocketPath) -and $targetWindowState -ne 'visible') {
$targetWindowState = 'minimized'
}
return $true
}
$bounds = Get-WindowBounds -hWnd $hWnd
if ($null -eq $bounds) {
return $true
}
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
$targetWindowState = 'visible'
}
$mpvMatches.Add([PSCustomObject]@{
HWnd = $hWnd
X = $bounds.X
@@ -230,45 +151,12 @@ public static class SubMinerWindowsHelper {
[void][SubMinerWindowsHelper]::EnumWindows($callback, [IntPtr]::Zero)
if ($Mode -eq 'lower-overlay') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required')
exit 1
}
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
[void][SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow,
$HWND_NOTOPMOST,
0,
0,
0,
0,
$SWP_FLAGS
)
[void][SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow,
$HWND_BOTTOM,
0,
0,
0,
0,
$SWP_FLAGS
)
Write-Output 'ok'
exit 0
}
$focusedMatch = $mpvMatches | Where-Object { $_.IsForeground } | Select-Object -First 1
if ($null -ne $focusedMatch) {
[Console]::Error.WriteLine('focus=focused')
} else {
[Console]::Error.WriteLine('focus=not-focused')
}
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
[Console]::Error.WriteLine("state=$targetWindowState")
}
if ($mpvMatches.Count -eq 0) {
Write-Output 'not-found'
@@ -280,68 +168,6 @@ public static class SubMinerWindowsHelper {
} else {
$mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1
}
if ($Mode -eq 'set-owner') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required')
exit 1
}
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
$targetWindow = [IntPtr]$bestMatch.HWnd
[void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
Write-Output 'ok'
exit 0
}
if ($Mode -eq 'bind-overlay') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required')
exit 1
}
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
$targetWindow = [IntPtr]$bestMatch.HWnd
[void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
$targetWindowExStyle = [SubMinerWindowsHelper]::GetWindowLong($targetWindow, $GWL_EXSTYLE)
$targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0
$overlayExStyle = [SubMinerWindowsHelper]::GetWindowLong($overlayWindow, $GWL_EXSTYLE)
$overlayIsTopmost = ($overlayExStyle -band $WS_EX_TOPMOST) -ne 0
if ($targetWindowIsTopmost -and -not $overlayIsTopmost) {
[void][SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_FLAGS
)
} elseif (-not $targetWindowIsTopmost -and $overlayIsTopmost) {
[void][SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_FLAGS
)
}
$GW_HWNDPREV = 3
$windowAboveMpv = [SubMinerWindowsHelper]::GetWindow($targetWindow, $GW_HWNDPREV)
if ($windowAboveMpv -ne [IntPtr]::Zero -and $windowAboveMpv -eq $overlayWindow) {
Write-Output 'ok'
exit 0
}
$insertAfter = $HWND_TOP
if ($windowAboveMpv -ne [IntPtr]::Zero) {
$aboveExStyle = [SubMinerWindowsHelper]::GetWindowLong($windowAboveMpv, $GWL_EXSTYLE)
$aboveIsTopmost = ($aboveExStyle -band $WS_EX_TOPMOST) -ne 0
if ($aboveIsTopmost -eq $targetWindowIsTopmost) {
$insertAfter = $windowAboveMpv
}
}
[void][SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $insertAfter, 0, 0, 0, 0, $SWP_FLAGS
)
Write-Output 'ok'
exit 0
}
Write-Output "$($bestMatch.X),$($bestMatch.Y),$($bestMatch.Width),$($bestMatch.Height)"
} catch {
[Console]::Error.WriteLine($_.Exception.Message)
+141
View File
@@ -0,0 +1,141 @@
local MODULE_PATHS = {
"plugin/subminer/hover.lua",
"plugin/subminer/environment.lua",
}
local LEGACY_PARSER_CANDIDATES = {
"luajit",
"lua5.1",
"lua51",
}
local function assert_true(condition, message)
if condition then
return
end
error(message or "assert_true failed")
end
local function read_file(path)
local file = assert(io.open(path, "r"), "failed to open " .. path)
local content = file:read("*a")
file:close()
return content
end
local function find_legacy_incompatible_continue(source)
local goto_start, goto_end = source:find("%f[%a]goto%s+continue%f[%A]")
if goto_start then
return "goto continue", goto_start, goto_end
end
local label_start, label_end = source:find("::continue::", 1, true)
if label_start then
return "::continue::", label_start, label_end
end
return nil
end
local function assert_no_legacy_incompatible_continue(path)
local source = read_file(path)
local match = find_legacy_incompatible_continue(source)
assert_true(match == nil, path .. " still contains legacy-incompatible continue control flow: " .. tostring(match))
end
local function assert_loadfile_ok(path)
local chunk, err = loadfile(path)
assert_true(chunk ~= nil, "loadfile failed for " .. path .. ": " .. tostring(err))
end
local function normalize_execute_result(ok, why, code)
if type(ok) == "number" then
return ok == 0, ok
end
if type(ok) == "boolean" then
if ok then
return true, code or 0
end
return false, code or 1
end
return false, code or 1
end
local function command_succeeds(command)
local ok, why, code = os.execute(command)
return normalize_execute_result(ok, why, code)
end
local function command_exists(command)
local shell = package.config:sub(1, 1) == "\\" and "where " or "command -v "
local redirect = package.config:sub(1, 1) == "\\" and " >NUL 2>NUL" or " >/dev/null 2>&1"
local escaped = command
local success = command_succeeds(shell .. escaped .. redirect)
return success
end
local function find_legacy_parser()
for _, command in ipairs(LEGACY_PARSER_CANDIDATES) do
if command_exists(command) then
return command
end
end
return nil
end
local function shell_redirect()
if package.config:sub(1, 1) == "\\" then
return " >NUL 2>NUL"
end
return " >/dev/null 2>&1"
end
local function assert_parser_accepts_file(parser, path)
local command = string.format("%s -e %q%s", parser, "assert(loadfile(" .. string.format("%q", path) .. "))", shell_redirect())
local success = command_succeeds(command)
assert_true(success, parser .. " failed to parse " .. path)
end
local function assert_parser_rejects_legacy_fixture(parser)
local legacy_fixture = [[
local tokens = {}
for _, token in ipairs(tokens or {}) do
if type(token) ~= "table" then
goto continue
end
::continue::
end
]]
local command = string.format("%s -e %q%s", parser, legacy_fixture, shell_redirect())
local success = command_succeeds(command)
assert_true(not success, parser .. " unexpectedly accepted legacy goto/label continue fixture")
end
do
local legacy_fixture = [[
for _, token in ipairs(tokens or {}) do
if type(token) ~= "table" then
goto continue
end
::continue::
end
]]
local match = find_legacy_incompatible_continue(legacy_fixture)
assert_true(match ~= nil, "legacy fixture should trigger incompatible continue detector")
end
for _, path in ipairs(MODULE_PATHS) do
assert_no_legacy_incompatible_continue(path)
assert_loadfile_ok(path)
end
local parser = find_legacy_parser()
if parser then
assert_parser_rejects_legacy_fixture(parser)
for _, path in ipairs(MODULE_PATHS) do
assert_parser_accepts_file(parser, path)
end
print("plugin lua compatibility regression tests: OK (" .. parser .. ")")
else
print("plugin lua compatibility regression tests: OK (legacy parser unavailable; structural checks only)")
end
@@ -166,14 +166,20 @@ const TRENDS_DASHBOARD = {
ratios: {
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
},
animePerDay: {
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }],
lookups: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 15 }],
lookupsPerHundred: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
librarySummary: [
{
title: 'Little Witch Academia',
watchTimeMin: 25,
videos: 1,
sessions: 1,
cards: 5,
words: 300,
lookups: 15,
lookupsPerHundred: 5,
firstWatched: 20_000,
lastWatched: 20_000,
},
],
animeCumulative: {
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
@@ -598,7 +604,23 @@ describe('stats server API routes', () => {
const body = await res.json();
assert.deepEqual(seenArgs, ['90d', 'month']);
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime);
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
});
it('GET /api/stats/trends/dashboard accepts 365d range', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
createMockTracker({
getTrendsDashboard: async (...args: unknown[]) => {
seenArgs = args;
return TRENDS_DASHBOARD;
},
}),
);
const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['365d', 'month']);
});
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
@@ -488,7 +488,7 @@ export class ImmersionTrackerService {
}
async getTrendsDashboard(
range: '7d' | '30d' | '90d' | 'all' = '30d',
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
groupBy: 'day' | 'month' = 'day',
): Promise<unknown> {
return getTrendsDashboard(this.db, range, groupBy);
@@ -687,7 +687,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
assert.equal(dashboard.progress.watchTime[1]?.value, 75);
assert.equal(dashboard.progress.lookups[1]?.value, 18);
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1));
assert.equal(dashboard.animePerDay.watchTime[0]?.animeTitle, 'Trend Dashboard Anime');
assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime');
assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75);
assert.equal(
dashboard.patterns.watchTimeByDayOfWeek.reduce((sum, point) => sum + point.value, 0),
@@ -835,6 +835,65 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
}
});
test('getTrendsDashboard supports 365d range and caps day buckets at 365', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/365d-trends.mkv', {
canonicalTitle: '365d Trends',
sourcePath: '/tmp/365d-trends.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: '365d Trends',
canonicalTitle: '365d Trends',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: '365d-trends.mkv',
parsedTitle: '365d Trends',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
const insertDailyRollup = db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
// Seed 400 distinct rollup days so we can prove the 365d range caps at 365.
const latestRollupDay = 20513;
const createdAtMs = '1772395200000';
for (let offset = 0; offset < 400; offset += 1) {
const rollupDay = latestRollupDay - offset;
insertDailyRollup.run(rollupDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
}
const dashboard = getTrendsDashboard(db, '365d', 'day');
assert.equal(dashboard.activity.watchTime.length, 365);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
});
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
@@ -1879,6 +1938,50 @@ test('getSessionEvents returns events ordered by ts_ms ascending', () => {
}
});
test('getSessionEvents round-trips wall-clock timestamps written through event inserts', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/events-wall-clock.mkv', {
canonicalTitle: 'Events Wall Clock',
sourcePath: '/tmp/events-wall-clock.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const startedAtMs = Date.now() - 10_000;
const eventTsMs = startedAtMs + 5_000;
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
stmts.eventInsertStmt.run(
sessionId,
toDbTimestamp(eventTsMs),
EVENT_SUBTITLE_LINE,
0,
0,
500,
1,
0,
'{"line":"wall-clock"}',
toDbTimestamp(eventTsMs),
toDbTimestamp(eventTsMs),
);
const events = getSessionEvents(db, sessionId, 10);
assert.equal(events.length, 1);
assert.equal(events[0]?.tsMs, eventTsMs);
assert.equal(events[0]?.payload, '{"line":"wall-clock"}');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getSessionEvents returns empty array for session with no events', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
@@ -3666,3 +3769,224 @@ test('deleteSession removes zero-session media from library and trends', () => {
cleanupDbPath(dbPath);
}
});
test('getTrendsDashboard builds librarySummary with per-title aggregates', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/library-summary-test.mkv', {
canonicalTitle: 'Library Summary Test',
sourcePath: '/tmp/library-summary-test.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Summary Anime',
canonicalTitle: 'Summary Anime',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: 'library-summary-test.mkv',
parsedTitle: 'Summary Anime',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
const dayOneStart = 1_700_000_000_000;
const dayTwoStart = dayOneStart + 86_400_000;
const sessionOne = startSessionRecord(db, videoId, dayOneStart);
const sessionTwo = startSessionRecord(db, videoId, dayTwoStart);
for (const [sessionId, startedAtMs, activeMs, cards, tokens, lookups] of [
[sessionOne.sessionId, dayOneStart, 30 * 60_000, 2, 120, 8],
[sessionTwo.sessionId, dayTwoStart, 45 * 60_000, 3, 140, 10],
] as const) {
stmts.telemetryInsertStmt.run(
sessionId,
`${startedAtMs + 60_000}`,
activeMs,
activeMs,
10,
tokens,
cards,
0,
0,
lookups,
0,
0,
0,
0,
`${startedAtMs + 60_000}`,
`${startedAtMs + 60_000}`,
);
db.prepare(
`
UPDATE imm_sessions
SET ended_at_ms = ?, total_watched_ms = ?, active_watched_ms = ?,
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
).run(
`${startedAtMs + activeMs}`,
activeMs,
activeMs,
10,
tokens,
cards,
lookups,
sessionId,
);
}
for (const [day, active, tokens, cards] of [
[Math.floor(dayOneStart / 86_400_000), 30, 120, 2],
[Math.floor(dayTwoStart / 86_400_000), 45, 140, 3],
] as const) {
db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards
) VALUES (?, ?, ?, ?, ?, ?, ?)
`,
).run(day, videoId, 1, active, 10, tokens, cards);
}
const dashboard = getTrendsDashboard(db, 'all', 'day');
assert.equal(dashboard.librarySummary.length, 1);
const row = dashboard.librarySummary[0]!;
assert.equal(row.title, 'Summary Anime');
assert.equal(row.watchTimeMin, 75);
assert.equal(row.videos, 1);
assert.equal(row.sessions, 2);
assert.equal(row.cards, 5);
assert.equal(row.words, 260);
assert.equal(row.lookups, 18);
assert.equal(row.lookupsPerHundred, +((18 / 260) * 100).toFixed(1));
assert.equal(row.firstWatched, Math.floor(dayOneStart / 86_400_000));
assert.equal(row.lastWatched, Math.floor(dayTwoStart / 86_400_000));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getTrendsDashboard librarySummary returns null lookupsPerHundred when words is zero', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/lib-summary-null.mkv', {
canonicalTitle: 'Null Lookups Title',
sourcePath: '/tmp/lib-summary-null.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Null Lookups Anime',
canonicalTitle: 'Null Lookups Anime',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: 'lib-summary-null.mkv',
parsedTitle: 'Null Lookups Anime',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
const startMs = 1_700_000_000_000;
const session = startSessionRecord(db, videoId, startMs);
stmts.telemetryInsertStmt.run(
session.sessionId,
`${startMs + 60_000}`,
20 * 60_000,
20 * 60_000,
5,
0,
0,
0,
0,
0,
0,
0,
0,
0,
`${startMs + 60_000}`,
`${startMs + 60_000}`,
);
db.prepare(
`
UPDATE imm_sessions
SET ended_at_ms = ?, total_watched_ms = ?, active_watched_ms = ?,
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
).run(
`${startMs + 20 * 60_000}`,
20 * 60_000,
20 * 60_000,
5,
0,
0,
0,
session.sessionId,
);
db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards
) VALUES (?, ?, ?, ?, ?, ?, ?)
`,
).run(Math.floor(startMs / 86_400_000), videoId, 1, 20, 5, 0, 0);
const dashboard = getTrendsDashboard(db, 'all', 'day');
assert.equal(dashboard.librarySummary.length, 1);
assert.equal(dashboard.librarySummary[0]!.lookupsPerHundred, null);
assert.equal(dashboard.librarySummary[0]!.words, 0);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const dashboard = getTrendsDashboard(db, 'all', 'day');
assert.deepEqual(dashboard.librarySummary, []);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -66,7 +66,7 @@ export function pruneRawRetention(
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
? (
db
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
.prepare(`DELETE FROM imm_session_events WHERE CAST(ts_ms AS REAL) < CAST(? AS REAL)`)
.run(resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays)) as {
changes: number;
}
@@ -133,7 +133,7 @@ export function getSessionEvents(
if (!eventTypes || eventTypes.length === 0) {
const stmt = db.prepare(`
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload
FROM imm_session_events WHERE session_id = ? ORDER BY ts_ms ASC LIMIT ?
FROM imm_session_events WHERE session_id = ? ORDER BY CAST(ts_ms AS REAL) ASC LIMIT ?
`);
const rows = stmt.all(sessionId, limit) as Array<SessionEventRow & { tsMs: number | string }>;
return rows.map((row) => ({
@@ -147,7 +147,7 @@ export function getSessionEvents(
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload
FROM imm_session_events
WHERE session_id = ? AND event_type IN (${placeholders})
ORDER BY ts_ms ASC
ORDER BY CAST(ts_ms AS REAL) ASC
LIMIT ?
`);
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<
@@ -602,7 +602,7 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
FROM imm_session_events e
JOIN imm_sessions s ON s.session_id = e.session_id
WHERE s.video_id = ? AND e.event_type = 4
ORDER BY e.ts_ms DESC
ORDER BY CAST(e.ts_ms AS REAL) DESC
`,
)
.all(videoId) as Array<{
@@ -345,7 +345,11 @@ export function fromDbTimestamp(ms: number | bigint | string | null | undefined)
if (typeof ms === 'bigint') {
return Number(ms);
}
return Number(ms);
const normalized = normalizeTimestampString(ms);
if (/^-?\d+$/.test(normalized)) {
return Number(BigInt(normalized));
}
return Math.trunc(Number.parseFloat(normalized));
}
function getNumericCalendarValue(
@@ -13,7 +13,7 @@ import {
} from './query-shared';
import { getDailyRollups, getMonthlyRollups } from './query-sessions';
type TrendRange = '7d' | '30d' | '90d' | 'all';
type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
type TrendGroupBy = 'day' | 'month';
interface TrendChartPoint {
@@ -27,6 +27,19 @@ interface TrendPerAnimePoint {
value: number;
}
export interface LibrarySummaryRow {
title: string;
watchTimeMin: number;
videos: number;
sessions: number;
cards: number;
words: number;
lookups: number;
lookupsPerHundred: number | null;
firstWatched: number;
lastWatched: number;
}
interface TrendSessionMetricRow {
startedAtMs: number;
epochDay: number;
@@ -61,14 +74,6 @@ export interface TrendsDashboardQueryResult {
ratios: {
lookupsPerHundred: TrendChartPoint[];
};
animePerDay: {
episodes: TrendPerAnimePoint[];
watchTime: TrendPerAnimePoint[];
cards: TrendPerAnimePoint[];
words: TrendPerAnimePoint[];
lookups: TrendPerAnimePoint[];
lookupsPerHundred: TrendPerAnimePoint[];
};
animeCumulative: {
watchTime: TrendPerAnimePoint[];
episodes: TrendPerAnimePoint[];
@@ -79,12 +84,14 @@ export interface TrendsDashboardQueryResult {
watchTimeByDayOfWeek: TrendChartPoint[];
watchTimeByHour: TrendChartPoint[];
};
librarySummary: LibrarySummaryRow[];
}
const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'365d': 365,
};
const MONTH_NAMES = [
@@ -300,61 +307,6 @@ function buildLookupsPerHundredWords(
});
}
function buildPerAnimeFromSessions(
sessions: TrendSessionMetricRow[],
getValue: (session: TrendSessionMetricRow) => number,
): TrendPerAnimePoint[] {
const byAnime = new Map<string, Map<number, number>>();
for (const session of sessions) {
const animeTitle = resolveTrendAnimeTitle(session);
const epochDay = session.epochDay;
const dayMap = byAnime.get(animeTitle) ?? new Map();
dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session));
byAnime.set(animeTitle, dayMap);
}
const result: TrendPerAnimePoint[] = [];
for (const [animeTitle, dayMap] of byAnime) {
for (const [epochDay, value] of dayMap) {
result.push({ epochDay, animeTitle, value });
}
}
return result;
}
function buildLookupsPerHundredPerAnime(sessions: TrendSessionMetricRow[]): TrendPerAnimePoint[] {
const lookups = new Map<string, Map<number, number>>();
const words = new Map<string, Map<number, number>>();
for (const session of sessions) {
const animeTitle = resolveTrendAnimeTitle(session);
const epochDay = session.epochDay;
const lookupMap = lookups.get(animeTitle) ?? new Map();
lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount);
lookups.set(animeTitle, lookupMap);
const wordMap = words.get(animeTitle) ?? new Map();
wordMap.set(epochDay, (wordMap.get(epochDay) ?? 0) + getTrendSessionWordCount(session));
words.set(animeTitle, wordMap);
}
const result: TrendPerAnimePoint[] = [];
for (const [animeTitle, dayMap] of lookups) {
const wordMap = words.get(animeTitle) ?? new Map();
for (const [epochDay, lookupCount] of dayMap) {
const wordCount = wordMap.get(epochDay) ?? 0;
result.push({
epochDay,
animeTitle,
value: wordCount > 0 ? +((lookupCount / wordCount) * 100).toFixed(1) : 0,
});
}
}
return result;
}
function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
const byAnime = new Map<string, Map<number, number>>();
const allDays = new Set<number>();
@@ -390,6 +342,89 @@ function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoi
return result;
}
function buildLibrarySummary(
rollups: ImmersionSessionRollupRow[],
sessions: TrendSessionMetricRow[],
titlesByVideoId: Map<number, string>,
): LibrarySummaryRow[] {
type Accum = {
watchTimeMin: number;
videos: Set<number>;
cards: number;
words: number;
firstWatched: number;
lastWatched: number;
sessions: number;
lookups: number;
};
const byTitle = new Map<string, Accum>();
const ensure = (title: string): Accum => {
const existing = byTitle.get(title);
if (existing) return existing;
const created: Accum = {
watchTimeMin: 0,
videos: new Set<number>(),
cards: 0,
words: 0,
firstWatched: Number.POSITIVE_INFINITY,
lastWatched: Number.NEGATIVE_INFINITY,
sessions: 0,
lookups: 0,
};
byTitle.set(title, created);
return created;
};
for (const rollup of rollups) {
if (rollup.videoId === null) continue;
const title = resolveVideoAnimeTitle(rollup.videoId, titlesByVideoId);
const acc = ensure(title);
acc.watchTimeMin += rollup.totalActiveMin;
acc.cards += rollup.totalCards;
acc.words += rollup.totalTokensSeen;
acc.videos.add(rollup.videoId);
if (rollup.rollupDayOrMonth < acc.firstWatched) {
acc.firstWatched = rollup.rollupDayOrMonth;
}
if (rollup.rollupDayOrMonth > acc.lastWatched) {
acc.lastWatched = rollup.rollupDayOrMonth;
}
}
for (const session of sessions) {
const title = resolveTrendAnimeTitle(session);
if (!byTitle.has(title)) continue;
const acc = byTitle.get(title)!;
acc.sessions += 1;
acc.lookups += session.yomitanLookupCount;
}
const rows: LibrarySummaryRow[] = [];
for (const [title, acc] of byTitle) {
if (!Number.isFinite(acc.firstWatched) || !Number.isFinite(acc.lastWatched)) {
continue;
}
rows.push({
title,
watchTimeMin: Math.round(acc.watchTimeMin),
videos: acc.videos.size,
sessions: acc.sessions,
cards: acc.cards,
words: acc.words,
lookups: acc.lookups,
lookupsPerHundred:
acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
firstWatched: acc.firstWatched,
lastWatched: acc.lastWatched,
});
}
rows.sort((a, b) => b.watchTimeMin - a.watchTimeMin || a.title.localeCompare(b.title));
return rows;
}
function getVideoAnimeTitleMap(
db: DatabaseSync,
videoIds: Array<number | null>,
@@ -662,8 +697,6 @@ export function getTrendsDashboard(
titlesByVideoId,
(rollup) => rollup.totalTokensSeen,
),
lookups: buildPerAnimeFromSessions(sessions, (session) => session.yomitanLookupCount),
lookupsPerHundred: buildLookupsPerHundredPerAnime(sessions),
};
return {
@@ -690,7 +723,6 @@ export function getTrendsDashboard(
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
},
animePerDay,
animeCumulative: {
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
episodes: buildCumulativePerAnime(animePerDay.episodes),
@@ -701,5 +733,6 @@ export function getTrendsDashboard(
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
watchTimeByHour: buildWatchTimeByHour(sessions),
},
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
};
}
@@ -263,6 +263,370 @@ test('ensureSchema adds youtube metadata table to existing schema version 15 dat
}
});
test('ensureSchema migrates session event timestamps to text and repairs libsql-truncated wall-clock values', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
db.exec(`
CREATE TABLE imm_schema_version (
schema_version INTEGER PRIMARY KEY,
applied_at_ms INTEGER NOT NULL
);
INSERT INTO imm_schema_version(schema_version, applied_at_ms) VALUES (16, 1000);
CREATE TABLE imm_rollup_state(
state_key TEXT PRIMARY KEY,
state_value INTEGER NOT NULL
);
INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('last_rollup_sample_ms', 0);
CREATE TABLE imm_anime(
anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
normalized_title_key TEXT NOT NULL UNIQUE,
canonical_title TEXT NOT NULL,
anilist_id INTEGER UNIQUE,
title_romaji TEXT,
title_english TEXT,
title_native TEXT,
episodes_total INTEGER,
description TEXT,
metadata_json TEXT,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT
);
CREATE TABLE imm_videos(
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
video_key TEXT NOT NULL UNIQUE,
anime_id INTEGER,
canonical_title TEXT NOT NULL,
source_type INTEGER NOT NULL,
source_path TEXT,
source_url TEXT,
parsed_basename TEXT,
parsed_title TEXT,
parsed_season INTEGER,
parsed_episode INTEGER,
parser_source TEXT,
parser_confidence REAL,
parse_metadata_json TEXT,
watched INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
codec_id INTEGER, container_id INTEGER,
width_px INTEGER, height_px INTEGER, fps_x100 INTEGER,
bitrate_kbps INTEGER, audio_codec_id INTEGER,
hash_sha256 TEXT, screenshot_path TEXT,
metadata_json TEXT,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
);
CREATE TABLE imm_sessions(
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_uuid TEXT NOT NULL UNIQUE,
video_id INTEGER NOT NULL,
started_at_ms TEXT NOT NULL,
ended_at_ms TEXT,
status INTEGER NOT NULL,
locale_id INTEGER,
target_lang_id INTEGER,
difficulty_tier INTEGER,
subtitle_mode INTEGER,
ended_media_ms INTEGER,
total_watched_ms INTEGER NOT NULL DEFAULT 0,
active_watched_ms INTEGER NOT NULL DEFAULT 0,
lines_seen INTEGER NOT NULL DEFAULT 0,
tokens_seen INTEGER NOT NULL DEFAULT 0,
cards_mined INTEGER NOT NULL DEFAULT 0,
lookup_count INTEGER NOT NULL DEFAULT 0,
lookup_hits INTEGER NOT NULL DEFAULT 0,
yomitan_lookup_count INTEGER NOT NULL DEFAULT 0,
pause_count INTEGER NOT NULL DEFAULT 0,
pause_ms INTEGER NOT NULL DEFAULT 0,
seek_forward_count INTEGER NOT NULL DEFAULT 0,
seek_backward_count INTEGER NOT NULL DEFAULT 0,
media_buffer_events INTEGER NOT NULL DEFAULT 0,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id)
);
CREATE TABLE imm_session_telemetry(
telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
sample_ms TEXT NOT NULL,
total_watched_ms INTEGER NOT NULL DEFAULT 0,
active_watched_ms INTEGER NOT NULL DEFAULT 0,
lines_seen INTEGER NOT NULL DEFAULT 0,
tokens_seen INTEGER NOT NULL DEFAULT 0,
cards_mined INTEGER NOT NULL DEFAULT 0,
lookup_count INTEGER NOT NULL DEFAULT 0,
lookup_hits INTEGER NOT NULL DEFAULT 0,
yomitan_lookup_count INTEGER NOT NULL DEFAULT 0,
pause_count INTEGER NOT NULL DEFAULT 0,
pause_ms INTEGER NOT NULL DEFAULT 0,
seek_forward_count INTEGER NOT NULL DEFAULT 0,
seek_backward_count INTEGER NOT NULL DEFAULT 0,
media_buffer_events INTEGER NOT NULL DEFAULT 0,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
);
CREATE TABLE imm_session_events(
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
ts_ms INTEGER NOT NULL,
event_type INTEGER NOT NULL,
line_index INTEGER,
segment_start_ms INTEGER,
segment_end_ms INTEGER,
tokens_delta INTEGER NOT NULL DEFAULT 0,
cards_delta INTEGER NOT NULL DEFAULT 0,
payload_json TEXT,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
);
CREATE TABLE imm_daily_rollups(
rollup_day INTEGER NOT NULL,
video_id INTEGER,
total_sessions INTEGER NOT NULL DEFAULT 0,
total_active_min REAL NOT NULL DEFAULT 0,
total_lines_seen INTEGER NOT NULL DEFAULT 0,
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
total_cards INTEGER NOT NULL DEFAULT 0,
cards_per_hour REAL,
tokens_per_min REAL,
lookup_hit_rate REAL,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
PRIMARY KEY (rollup_day, video_id)
);
CREATE TABLE imm_monthly_rollups(
rollup_month INTEGER NOT NULL,
video_id INTEGER,
total_sessions INTEGER NOT NULL DEFAULT 0,
total_active_min REAL NOT NULL DEFAULT 0,
total_lines_seen INTEGER NOT NULL DEFAULT 0,
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
total_cards INTEGER NOT NULL DEFAULT 0,
cards_per_hour REAL,
tokens_per_min REAL,
lookup_hit_rate REAL,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
PRIMARY KEY (rollup_month, video_id)
);
CREATE TABLE imm_words(
id INTEGER PRIMARY KEY AUTOINCREMENT,
headword TEXT NOT NULL,
word TEXT NOT NULL,
reading TEXT NOT NULL,
part_of_speech TEXT,
pos1 TEXT,
pos2 TEXT,
pos3 TEXT,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
frequency INTEGER NOT NULL DEFAULT 0,
frequency_rank INTEGER,
UNIQUE(headword, word, reading)
);
CREATE TABLE imm_kanji(
id INTEGER PRIMARY KEY AUTOINCREMENT,
kanji TEXT NOT NULL UNIQUE,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
frequency INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE imm_subtitle_lines(
line_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
event_id INTEGER,
video_id INTEGER NOT NULL,
anime_id INTEGER,
line_index INTEGER NOT NULL,
segment_start_ms INTEGER,
segment_end_ms INTEGER,
text TEXT NOT NULL,
secondary_text TEXT,
CREATED_DATE INTEGER,
LAST_UPDATE_DATE INTEGER,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE,
FOREIGN KEY(event_id) REFERENCES imm_session_events(event_id) ON DELETE SET NULL,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE,
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
);
CREATE TABLE imm_word_line_occurrences(
line_id INTEGER NOT NULL,
word_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
PRIMARY KEY(line_id, word_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
);
CREATE TABLE imm_kanji_line_occurrences(
line_id INTEGER NOT NULL,
kanji_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
PRIMARY KEY(line_id, kanji_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
);
CREATE TABLE imm_lifetime_global(
global_id INTEGER PRIMARY KEY CHECK(global_id = 1),
total_sessions INTEGER NOT NULL DEFAULT 0,
total_active_ms INTEGER NOT NULL DEFAULT 0,
total_cards INTEGER NOT NULL DEFAULT 0,
active_days INTEGER NOT NULL DEFAULT 0,
episodes_started INTEGER NOT NULL DEFAULT 0,
episodes_completed INTEGER NOT NULL DEFAULT 0,
anime_completed INTEGER NOT NULL DEFAULT 0,
last_rebuilt_ms TEXT,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT
);
CREATE TABLE imm_lifetime_anime(
anime_id INTEGER PRIMARY KEY,
total_sessions INTEGER NOT NULL DEFAULT 0,
total_active_ms INTEGER NOT NULL DEFAULT 0,
total_cards INTEGER NOT NULL DEFAULT 0,
total_lines_seen INTEGER NOT NULL DEFAULT 0,
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
episodes_started INTEGER NOT NULL DEFAULT 0,
episodes_completed INTEGER NOT NULL DEFAULT 0,
first_watched_ms TEXT,
last_watched_ms TEXT,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
);
CREATE TABLE imm_lifetime_media(
video_id INTEGER PRIMARY KEY,
total_sessions INTEGER NOT NULL DEFAULT 0,
total_active_ms INTEGER NOT NULL DEFAULT 0,
total_cards INTEGER NOT NULL DEFAULT 0,
total_lines_seen INTEGER NOT NULL DEFAULT 0,
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
completed INTEGER NOT NULL DEFAULT 0,
first_watched_ms TEXT,
last_watched_ms TEXT,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
);
CREATE TABLE imm_lifetime_applied_sessions(
session_id INTEGER PRIMARY KEY,
applied_at_ms TEXT NOT NULL,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
);
CREATE TABLE imm_media_art(
video_id INTEGER PRIMARY KEY,
anilist_id INTEGER,
cover_url TEXT,
cover_blob BLOB,
cover_blob_hash TEXT,
fetched_at_ms TEXT,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
);
CREATE TABLE imm_cover_art_blobs(
blob_hash TEXT PRIMARY KEY,
cover_blob BLOB NOT NULL,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT
);
CREATE TABLE imm_youtube_videos(
video_id INTEGER PRIMARY KEY,
youtube_video_id TEXT,
video_url TEXT,
video_title TEXT,
video_thumbnail_url TEXT,
channel_id TEXT,
channel_name TEXT,
channel_url TEXT,
channel_thumbnail_url TEXT,
uploader_id TEXT,
uploader_url TEXT,
description TEXT,
metadata_json TEXT,
fetched_at_ms TEXT NOT NULL,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
);
INSERT INTO imm_videos (
video_id, video_key, canonical_title, source_type, source_path, source_url, watched, duration_ms,
CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
1, 'local:/tmp/repaired-event.mkv', 'Repaired Event', 1, '/tmp/repaired-event.mkv', NULL, 0, 0, '1000', '1000'
);
INSERT INTO imm_sessions (
session_id, session_uuid, video_id, started_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
1, 'session-1', 1, '1775940000000', 1, '1775940000000', '1775940000000'
);
INSERT INTO imm_session_events (
event_id, session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms,
tokens_delta, cards_delta, payload_json, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
1, 1, -2147483648, 4, NULL, NULL, NULL, 0, 1, '{\"noteIds\":[1]}', '1775943304128', '1775943304128'
);
`);
ensureSchema(db);
const column = db.prepare(`PRAGMA table_info(imm_session_events)`).all() as Array<{
name: string;
type: string;
}>;
assert.equal(column.find((entry) => entry.name === 'ts_ms')?.type, 'TEXT');
const row = db.prepare(
`
SELECT ts_ms AS tsMs, typeof(ts_ms) AS tsType, CREATED_DATE AS createdDate
FROM imm_session_events
WHERE event_id = 1
`,
).get() as {
tsMs: string;
tsType: string;
createdDate: string;
};
assert.equal(row.tsType, 'text');
assert.equal(row.tsMs, '1775943304128');
assert.equal(row.createdDate, '1775943304128');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('ensureSchema creates large-history performance indexes', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
+100 -3
View File
@@ -170,6 +170,14 @@ function hasColumn(db: DatabaseSync, tableName: string, columnName: string): boo
.some((row: unknown) => (row as { name: string }).name === columnName);
}
function getColumnType(db: DatabaseSync, tableName: string, columnName: string): string | null {
const row = (db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
name: string;
type: string;
}>).find((entry) => entry.name === columnName);
return row?.type ?? null;
}
function addColumnIfMissing(
db: DatabaseSync,
tableName: string,
@@ -187,6 +195,92 @@ function dropColumnIfExists(db: DatabaseSync, tableName: string, columnName: str
}
}
function migrateSessionEventTimestampsToText(db: DatabaseSync): void {
if (getColumnType(db, 'imm_session_events', 'ts_ms') === 'TEXT') {
return;
}
const lineIndexExpr = hasColumn(db, 'imm_session_events', 'line_index') ? 'line_index' : 'NULL';
const segmentStartExpr = hasColumn(db, 'imm_session_events', 'segment_start_ms')
? 'segment_start_ms'
: 'NULL';
const segmentEndExpr = hasColumn(db, 'imm_session_events', 'segment_end_ms')
? 'segment_end_ms'
: 'NULL';
const tokensDeltaExpr = hasColumn(db, 'imm_session_events', 'tokens_delta')
? 'tokens_delta'
: '0';
const cardsDeltaExpr = hasColumn(db, 'imm_session_events', 'cards_delta') ? 'cards_delta' : '0';
const payloadExpr = hasColumn(db, 'imm_session_events', 'payload_json') ? 'payload_json' : 'NULL';
const createdDateExpr = hasColumn(db, 'imm_session_events', 'CREATED_DATE')
? 'CAST(CREATED_DATE AS TEXT)'
: 'NULL';
const lastUpdateExpr = hasColumn(db, 'imm_session_events', 'LAST_UPDATE_DATE')
? 'CAST(LAST_UPDATE_DATE AS TEXT)'
: 'NULL';
const repairedTimestampExpr =
hasColumn(db, 'imm_session_events', 'CREATED_DATE') ||
hasColumn(db, 'imm_session_events', 'LAST_UPDATE_DATE')
? `CASE
WHEN ts_ms < 0 AND COALESCE(CREATED_DATE, LAST_UPDATE_DATE) IS NOT NULL
THEN CAST(COALESCE(CREATED_DATE, LAST_UPDATE_DATE) AS TEXT)
ELSE CAST(ts_ms AS TEXT)
END`
: 'CAST(ts_ms AS TEXT)';
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`
CREATE TABLE imm_session_events_new(
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
ts_ms TEXT NOT NULL,
event_type INTEGER NOT NULL,
line_index INTEGER,
segment_start_ms INTEGER,
segment_end_ms INTEGER,
tokens_delta INTEGER NOT NULL DEFAULT 0,
cards_delta INTEGER NOT NULL DEFAULT 0,
payload_json TEXT,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
);
`);
db.exec(`
INSERT INTO imm_session_events_new(
event_id,
session_id,
ts_ms,
event_type,
line_index,
segment_start_ms,
segment_end_ms,
tokens_delta,
cards_delta,
payload_json,
CREATED_DATE,
LAST_UPDATE_DATE
)
SELECT
event_id,
session_id,
${repairedTimestampExpr},
event_type,
${lineIndexExpr},
${segmentStartExpr},
${segmentEndExpr},
${tokensDeltaExpr},
${cardsDeltaExpr},
${payloadExpr},
${createdDateExpr},
${lastUpdateExpr}
FROM imm_session_events
`);
db.exec('DROP TABLE imm_session_events');
db.exec('ALTER TABLE imm_session_events_new RENAME TO imm_session_events');
db.exec('PRAGMA foreign_keys = ON');
}
export function applyPragmas(db: DatabaseSync): void {
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA synchronous = NORMAL');
@@ -685,7 +779,7 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE TABLE IF NOT EXISTS imm_session_events(
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
ts_ms INTEGER NOT NULL,
ts_ms TEXT NOT NULL,
event_type INTEGER NOT NULL,
line_index INTEGER,
segment_start_ms INTEGER,
@@ -1122,6 +1216,8 @@ export function ensureSchema(db: DatabaseSync): void {
addColumnIfMissing(db, 'imm_sessions', 'ended_media_ms', 'INTEGER');
}
migrateSessionEventTimestampsToText(db);
ensureLifetimeSummaryTables(db);
db.exec(`
@@ -1420,7 +1516,8 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
) {
throw new Error('Incomplete telemetry write');
}
const telemetrySampleMs = toDbTimestamp(write.sampleMs ?? Number(currentMs));
const telemetrySampleMs =
write.sampleMs === undefined ? currentMs : toDbTimestamp(write.sampleMs);
stmts.telemetryInsertStmt.run(
write.sessionId,
telemetrySampleMs,
@@ -1495,7 +1592,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
stmts.eventInsertStmt.run(
write.sessionId,
toDbTimestamp(write.sampleMs ?? Number(currentMs)),
write.sampleMs === undefined ? currentMs : toDbTimestamp(write.sampleMs),
write.eventType ?? 0,
write.lineIndex ?? null,
write.segmentStartMs ?? null,
+1 -1
View File
@@ -1,4 +1,4 @@
export const SCHEMA_VERSION = 16;
export const SCHEMA_VERSION = 17;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
-1
View File
@@ -72,7 +72,6 @@ export {
createOverlayWindow,
enforceOverlayLayerOrder,
ensureOverlayWindowLevel,
isOverlayWindowContentReady,
syncOverlayWindowLayer,
updateOverlayWindowBounds,
} from './overlay-window';
-38
View File
@@ -2,8 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMpvLoadfileCommands,
buildMpvSubtitleAddCommands,
collectDroppedSubtitlePaths,
collectDroppedVideoPaths,
parseClipboardVideoPath,
type DropDataTransferLike,
@@ -43,33 +41,6 @@ test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates',
assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']);
});
test('collectDroppedSubtitlePaths keeps supported dropped subtitle paths in order', () => {
const transfer = makeTransfer({
files: [
{ path: '/subs/ep02.ass' },
{ path: '/subs/readme.txt' },
{ path: '/subs/ep03.SRT' },
],
});
const result = collectDroppedSubtitlePaths(transfer);
assert.deepEqual(result, ['/subs/ep02.ass', '/subs/ep03.SRT']);
});
test('collectDroppedSubtitlePaths parses text/uri-list entries and de-duplicates', () => {
const transfer = makeTransfer({
getData: (format: string) =>
format === 'text/uri-list'
? '#comment\nfile:///tmp/ep01.ass\nfile:///tmp/ep01.ass\nfile:///tmp/ep02.vtt\nfile:///tmp/readme.md\n'
: '',
});
const result = collectDroppedSubtitlePaths(transfer);
assert.deepEqual(result, ['/tmp/ep01.ass', '/tmp/ep02.vtt']);
});
test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => {
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false);
@@ -88,15 +59,6 @@ test('buildMpvLoadfileCommands uses append mode when shift-drop is used', () =>
]);
});
test('buildMpvSubtitleAddCommands selects first subtitle and adds remainder', () => {
const commands = buildMpvSubtitleAddCommands(['/tmp/ep01.ass', '/tmp/ep02.srt']);
assert.deepEqual(commands, [
['sub-add', '/tmp/ep01.ass', 'select'],
['sub-add', '/tmp/ep02.srt'],
]);
});
test('parseClipboardVideoPath accepts quoted local paths', () => {
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
});
+4 -29
View File
@@ -22,8 +22,6 @@ const VIDEO_EXTENSIONS = new Set([
'.wmv',
]);
const SUBTITLE_EXTENSIONS = new Set(['.ass', '.srt', '.ssa', '.sub', '.vtt']);
function getPathExtension(pathValue: string): string {
const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
const dot = normalized.lastIndexOf('.');
@@ -34,11 +32,7 @@ function isSupportedVideoPath(pathValue: string): boolean {
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
}
function isSupportedSubtitlePath(pathValue: string): boolean {
return SUBTITLE_EXTENSIONS.has(getPathExtension(pathValue));
}
function parseUriList(data: string, isSupportedPath: (pathValue: string) => boolean): string[] {
function parseUriList(data: string): string[] {
if (!data.trim()) return [];
const out: string[] = [];
@@ -53,7 +47,7 @@ function parseUriList(data: string, isSupportedPath: (pathValue: string) => bool
if (/^\/[A-Za-z]:\//.test(filePath)) {
filePath = filePath.slice(1);
}
if (filePath && isSupportedPath(filePath)) {
if (filePath && isSupportedVideoPath(filePath)) {
out.push(filePath);
}
} catch {
@@ -93,19 +87,6 @@ export function parseClipboardVideoPath(text: string): string | null {
export function collectDroppedVideoPaths(
dataTransfer: DropDataTransferLike | null | undefined,
): string[] {
return collectDroppedPaths(dataTransfer, isSupportedVideoPath);
}
export function collectDroppedSubtitlePaths(
dataTransfer: DropDataTransferLike | null | undefined,
): string[] {
return collectDroppedPaths(dataTransfer, isSupportedSubtitlePath);
}
function collectDroppedPaths(
dataTransfer: DropDataTransferLike | null | undefined,
isSupportedPath: (pathValue: string) => boolean,
): string[] {
if (!dataTransfer) return [];
@@ -115,7 +96,7 @@ function collectDroppedPaths(
const addPath = (candidate: string | null | undefined): void => {
if (!candidate) return;
const trimmed = candidate.trim();
if (!trimmed || !isSupportedPath(trimmed) || seen.has(trimmed)) return;
if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return;
seen.add(trimmed);
out.push(trimmed);
};
@@ -128,7 +109,7 @@ function collectDroppedPaths(
}
if (typeof dataTransfer.getData === 'function') {
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'), isSupportedPath)) {
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'))) {
addPath(pathValue);
}
}
@@ -149,9 +130,3 @@ export function buildMpvLoadfileCommands(
index === 0 ? 'replace' : 'append',
]);
}
export function buildMpvSubtitleAddCommands(paths: string[]): Array<(string | number)[]> {
return paths.map((pathValue, index) =>
index === 0 ? ['sub-add', pathValue, 'select'] : ['sub-add', pathValue],
);
}
@@ -2,23 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init';
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true,
});
try {
run();
} finally {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
});
}
}
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
let createdIntegrations = 0;
let startedIntegrations = 0;
@@ -460,216 +443,3 @@ test('initializeOverlayRuntime refreshes visible overlay when tracker focus chan
assert.equal(visibilityRefreshCalls, 2);
});
test('initializeOverlayRuntime refreshes the current subtitle when tracker finds the target window again', () => {
let subtitleRefreshCalls = 0;
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
start: () => {},
};
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => {},
refreshCurrentSubtitle: () => {
subtitleRefreshCalls += 1;
},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
tracker.onWindowFound?.({ x: 100, y: 200, width: 1280, height: 720 });
assert.equal(subtitleRefreshCalls, 1);
});
test('initializeOverlayRuntime hides overlay windows when tracker loses the target window', () => {
const calls: string[] = [];
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
isTargetWindowMinimized: () => true,
start: () => {},
};
const overlayWindows = [
{
hide: () => calls.push('hide-visible'),
},
{
hide: () => calls.push('hide-modal'),
},
];
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => {},
refreshCurrentSubtitle: () => {},
getOverlayWindows: () => overlayWindows as never,
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
tracker.onWindowLost?.();
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
});
test('initializeOverlayRuntime preserves visible overlay on Windows tracker loss when target is not minimized', () => {
withPlatform('win32', () => {
const calls: string[] = [];
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
isTargetWindowMinimized: () => false,
start: () => {},
};
const overlayWindows = [
{
hide: () => calls.push('hide-visible'),
},
];
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => {
calls.push('update-visible');
},
refreshCurrentSubtitle: () => {},
getOverlayWindows: () => overlayWindows as never,
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
calls.length = 0;
tracker.onWindowLost?.();
assert.deepEqual(calls, ['sync-shortcuts']);
});
});
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {
const bounds: Array<{ x: number; y: number; width: number; height: number }> = [];
let visibilityRefreshCalls = 0;
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
start: () => {},
};
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: (geometry) => {
bounds.push(geometry);
},
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => {
visibilityRefreshCalls += 1;
},
refreshCurrentSubtitle: () => {},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
const restoredGeometry = { x: 100, y: 200, width: 1280, height: 720 };
tracker.onWindowFound?.(restoredGeometry);
assert.deepEqual(bounds, [restoredGeometry]);
assert.equal(visibilityRefreshCalls, 2);
});
-15
View File
@@ -71,7 +71,6 @@ export function initializeOverlayRuntime(options: {
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
refreshCurrentSubtitle?: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
@@ -79,8 +78,6 @@ export function initializeOverlayRuntime(options: {
override?: string | null,
targetMpvSocketPath?: string | null,
) => BaseWindowTracker | null;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
}): void {
options.createMainWindow();
options.registerGlobalShortcuts();
@@ -97,23 +94,11 @@ export function initializeOverlayRuntime(options: {
};
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry);
options.bindOverlayOwner?.();
if (options.isVisibleOverlayVisible()) {
options.updateVisibleOverlayVisibility();
options.refreshCurrentSubtitle?.();
}
};
windowTracker.onWindowLost = () => {
options.releaseOverlayOwner?.();
if (
process.platform === 'win32' &&
typeof windowTracker.isTargetWindowMinimized === 'function' &&
!windowTracker.isTargetWindowMinimized()
) {
options.syncOverlayShortcuts();
return;
}
for (const window of options.getOverlayWindows()) {
window.hide();
}
+3 -661
View File
@@ -6,59 +6,27 @@ import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './over
type WindowTrackerStub = {
isTracking: () => boolean;
getGeometry: () => { x: number; y: number; width: number; height: number } | null;
isTargetWindowFocused?: () => boolean;
isTargetWindowMinimized?: () => boolean;
};
function createMainWindowRecorder() {
const calls: string[] = [];
let visible = false;
let focused = false;
let opacity = 1;
const window = {
isDestroyed: () => false,
isVisible: () => visible,
isFocused: () => focused,
hide: () => {
visible = false;
focused = false;
calls.push('hide');
},
show: () => {
visible = true;
calls.push('show');
},
showInactive: () => {
visible = true;
calls.push('show-inactive');
},
focus: () => {
focused = true;
calls.push('focus');
},
setAlwaysOnTop: (flag: boolean) => {
calls.push(`always-on-top:${flag}`);
},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
},
setOpacity: (nextOpacity: number) => {
opacity = nextOpacity;
calls.push(`opacity:${nextOpacity}`);
},
moveTop: () => {
calls.push('move-top');
},
};
return {
window,
calls,
getOpacity: () => opacity,
setFocused: (nextFocused: boolean) => {
focused = nextFocused;
},
};
return { window, calls };
}
test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => {
@@ -195,7 +163,7 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
assert.ok(!calls.includes('osd'));
});
test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
test('Windows visible overlay stays click-through and does not steal focus while tracked', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
@@ -214,9 +182,6 @@ test('Windows visible overlay stays click-through and binds to mpv while tracked
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
@@ -230,483 +195,11 @@ test('Windows visible overlay stays click-through and binds to mpv while tracked
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('opacity:0'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('move-top'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('Windows visible overlay restores opacity after the deferred reveal delay', async () => {
const { window, calls, getOpacity } = createMainWindowRecorder();
let syncWindowsZOrderCalls = 0;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
syncWindowsZOrderCalls += 1;
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.equal(getOpacity(), 0);
assert.equal(syncWindowsZOrderCalls, 1);
await new Promise<void>((resolve) => setTimeout(resolve, 60));
assert.equal(getOpacity(), 1);
assert.equal(syncWindowsZOrderCalls, 2);
assert.ok(calls.includes('opacity:1'));
});
test('tracked Windows overlay refresh rebinds while already visible', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('move-top'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(calls.includes('sync-shortcuts'));
});
test('forced passthrough still reapplies while visible on Windows', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('forced passthrough still shows tracked overlay while bound to mpv on Windows', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('sync-windows-z-order'));
});
test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('tracked Windows overlay rebinds without hiding when tracker focus changes', () => {
const { window, calls } = createMainWindowRecorder();
let focused = true;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => focused,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
focused = 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');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('show'));
});
test('tracked Windows overlay stays interactive while the overlay window itself is focused', () => {
const { window, calls, setFocused } = 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');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
setFocused(true);
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('move-top'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('tracked Windows overlay binds above mpv even when tracker focus lags', () => {
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');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level'));
});
test('visible overlay stays hidden while a modal window is active', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
@@ -862,157 +355,6 @@ test('Windows keeps visible overlay hidden while tracker is not ready', () => {
assert.ok(!calls.includes('update-bounds'));
});
test('Windows preserves visible overlay and rebinds to mpv while tracker transiently loses a non-minimized window', () => {
const { window, calls } = createMainWindowRecorder();
let tracking = true;
const tracker: WindowTrackerStub = {
isTracking: () => tracking,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => 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');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
tracking = 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');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(calls.includes('sync-shortcuts'));
});
test('Windows hides the visible overlay when the tracked window is minimized', () => {
const { window, calls } = createMainWindowRecorder();
let tracking = true;
const tracker: WindowTrackerStub = {
isTracking: () => tracking,
getGeometry: () => (tracking ? { x: 0, y: 0, width: 1280, height: 720 } : null),
isTargetWindowMinimized: () => !tracking,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
tracking = 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');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('sync-windows-z-order'));
});
test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
+1 -177
View File
@@ -2,67 +2,16 @@ import type { BrowserWindow } from 'electron';
import { BaseWindowTracker } from '../../window-trackers';
import { WindowGeometry } from '../../types';
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
BrowserWindow,
ReturnType<typeof setTimeout>
>();
const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
const opacityCapableWindow = window as BrowserWindow & {
setOpacity?: (opacity: number) => void;
};
opacityCapableWindow.setOpacity?.(opacity);
}
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
if (!pendingTimeout) {
return;
}
clearTimeout(pendingTimeout);
pendingWindowsOverlayRevealTimeoutByWindow.delete(window);
}
function scheduleWindowsOverlayReveal(
window: BrowserWindow,
onReveal?: (window: BrowserWindow) => void,
): void {
clearPendingWindowsOverlayReveal(window);
const timeout = setTimeout(() => {
pendingWindowsOverlayRevealTimeoutByWindow.delete(window);
if (window.isDestroyed() || !window.isVisible()) {
return;
}
setOverlayWindowOpacity(window, 1);
onReveal?.(window);
}, WINDOWS_OVERLAY_REVEAL_DELAY_MS);
pendingWindowsOverlayRevealTimeoutByWindow.set(window, timeout);
}
function isOverlayWindowContentReady(window: BrowserWindow): boolean {
return (
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] === true
);
}
export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
modalActive?: boolean;
forceMousePassthrough?: boolean;
mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null;
lastKnownWindowsForegroundProcessName?: string | null;
windowsOverlayProcessName?: string | null;
windowsFocusHandoffGraceActive?: boolean;
trackerNotReadyWarningShown: boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void;
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
@@ -81,10 +30,6 @@ export function updateVisibleOverlayVisibility(args: {
const mainWindow = args.mainWindow;
if (args.modalActive) {
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.hide();
args.syncOverlayShortcuts();
return;
@@ -92,92 +37,13 @@ export function updateVisibleOverlayVisibility(args: {
const showPassiveVisibleOverlay = (): void => {
const forceMousePassthrough = args.forceMousePassthrough === true;
const shouldDefaultToPassthrough =
args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough;
const isVisibleOverlayFocused =
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
const windowsForegroundProcessName =
args.lastKnownWindowsForegroundProcessName?.trim().toLowerCase() ?? null;
const windowsOverlayProcessName = args.windowsOverlayProcessName?.trim().toLowerCase() ?? null;
const hasWindowsForegroundProcessSignal =
args.isWindowsPlatform && windowsForegroundProcessName !== null;
const isTrackedWindowsTargetFocused = args.windowTracker?.isTargetWindowFocused?.() ?? true;
const isTrackedWindowsTargetMinimized =
args.isWindowsPlatform &&
typeof args.windowTracker?.isTargetWindowMinimized === 'function' &&
args.windowTracker.isTargetWindowMinimized();
const shouldPreserveWindowsOverlayDuringFocusHandoff =
args.isWindowsPlatform &&
args.windowsFocusHandoffGraceActive === true &&
!!args.windowTracker &&
(!hasWindowsForegroundProcessSignal ||
windowsForegroundProcessName === 'mpv' ||
(windowsOverlayProcessName !== null &&
windowsForegroundProcessName === windowsOverlayProcessName)) &&
!isTrackedWindowsTargetMinimized &&
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
const shouldIgnoreMouseEvents =
forceMousePassthrough || (shouldDefaultToPassthrough && !isVisibleOverlayFocused);
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
const shouldKeepTrackedWindowsOverlayTopmost =
!args.isWindowsPlatform ||
!args.windowTracker ||
isVisibleOverlayFocused ||
isTrackedWindowsTargetFocused ||
shouldPreserveWindowsOverlayDuringFocusHandoff ||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
const wasVisible = mainWindow.isVisible();
if (shouldIgnoreMouseEvents) {
if (args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough) {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
} else {
mainWindow.setIgnoreMouseEvents(false);
}
if (shouldBindTrackedWindowsOverlay) {
// On Windows, z-order is enforced by the OS via the owner window mechanism
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
// without any manual z-order management.
} else if (!forceMousePassthrough) {
args.ensureOverlayWindowLevel(mainWindow);
} else {
mainWindow.setAlwaysOnTop(false);
}
if (!wasVisible) {
const hasWebContents =
typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object';
if (
args.isWindowsPlatform &&
hasWebContents &&
!isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow)
) {
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
// callback will trigger another visibility update when the renderer
// has painted its first frame.
} else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) {
setOverlayWindowOpacity(mainWindow, 0);
mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined);
} else {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.show();
if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined);
}
}
}
if (shouldBindTrackedWindowsOverlay) {
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
}
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
mainWindow.focus();
}
@@ -197,27 +63,12 @@ export function updateVisibleOverlayVisibility(args: {
if (!args.visibleOverlayVisible) {
args.setTrackerNotReadyWarningShown(false);
args.resetOverlayLoadingOsdSuppression?.();
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
if (args.windowTracker && args.windowTracker.isTracking()) {
if (
args.isWindowsPlatform &&
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
args.windowTracker.isTargetWindowMinimized()
) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
args.setTrackerNotReadyWarningShown(false);
const geometry = args.windowTracker.getGeometry();
if (geometry) {
@@ -225,9 +76,7 @@ export function updateVisibleOverlayVisibility(args: {
}
args.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay();
if (!args.forceMousePassthrough && !args.isWindowsPlatform) {
args.enforceOverlayLayerOrder();
}
args.syncOverlayShortcuts();
return;
}
@@ -238,10 +87,6 @@ export function updateVisibleOverlayVisibility(args: {
args.setTrackerNotReadyWarningShown(true);
maybeShowOverlayLoadingOsd();
}
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.hide();
args.syncOverlayShortcuts();
return;
@@ -254,32 +99,11 @@ export function updateVisibleOverlayVisibility(args: {
return;
}
if (
args.isWindowsPlatform &&
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
!args.windowTracker.isTargetWindowMinimized() &&
(mainWindow.isVisible() || args.windowTracker.getGeometry() !== null)
) {
args.setTrackerNotReadyWarningShown(false);
const geometry = args.windowTracker.getGeometry();
if (geometry) {
args.updateVisibleOverlayBounds(geometry);
}
args.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay();
args.syncOverlayShortcuts();
return;
}
if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true);
maybeShowOverlayLoadingOsd();
}
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.hide();
args.syncOverlayShortcuts();
}
@@ -8,32 +8,7 @@ test('overlay window config explicitly disables renderer sandbox for preload com
yomitanSession: null,
});
assert.equal(options.backgroundColor, '#00000000');
assert.equal(options.webPreferences?.sandbox, false);
assert.equal(options.webPreferences?.backgroundThrottling, false);
});
test('Windows visible overlay window config does not start as always-on-top', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32',
});
try {
const options = buildOverlayWindowOptions('visible', {
isDev: false,
yomitanSession: null,
});
assert.equal(options.alwaysOnTop, false);
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform,
});
}
});
test('overlay window config uses the provided Yomitan session when available', () => {
@@ -66,14 +66,7 @@ export function handleOverlayWindowBlurred(options: {
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
ensureOverlayWindowLevel: () => void;
moveWindowTop: () => void;
onWindowsVisibleOverlayBlur?: () => void;
platform?: NodeJS.Platform;
}): boolean {
if ((options.platform ?? process.platform) === 'win32' && options.kind === 'visible') {
options.onWindowsVisibleOverlayBlur?.();
return false;
}
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
return false;
}
+1 -4
View File
@@ -10,7 +10,6 @@ export function buildOverlayWindowOptions(
},
): BrowserWindowConstructorOptions {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
return {
show: false,
@@ -19,9 +18,8 @@ export function buildOverlayWindowOptions(
x: 0,
y: 0,
transparent: true,
backgroundColor: '#00000000',
frame: false,
alwaysOnTop: shouldStartAlwaysOnTop,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
hasShadow: false,
@@ -33,7 +31,6 @@ export function buildOverlayWindowOptions(
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
backgroundThrottling: false,
webSecurity: true,
session: options.yomitanSession ?? undefined,
additionalArguments: [`--overlay-layer=${kind}`],
-44
View File
@@ -103,49 +103,6 @@ test('handleOverlayWindowBlurred skips visible overlay restacking after manual h
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred skips Windows 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: 'win32',
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback without restacking', () => {
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: 'win32',
});
assert.equal(handled, false);
assert.deepEqual(calls, ['windows-visible-blur']);
});
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
const calls: string[] = [];
@@ -160,7 +117,6 @@ test('handleOverlayWindowBlurred preserves active visible/modal window stacking'
moveWindowTop: () => {
calls.push('move-visible');
},
platform: 'linux',
}),
true,
);
-25
View File
@@ -13,17 +13,6 @@ import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds
const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
const overlayWindowContentReady = new WeakSet<BrowserWindow>();
const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
export function isOverlayWindowContentReady(window: BrowserWindow): boolean {
return (
overlayWindowContentReady.has(window) ||
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] === true
);
}
function getOverlayWindowHtmlPath(): string {
return path.join(__dirname, '..', '..', 'renderer', 'index.html');
@@ -87,17 +76,13 @@ export function createOverlayWindow(
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onVisibleWindowBlurred?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (kind: OverlayWindowKind) => void;
yomitanSession?: Session | null;
},
): BrowserWindow {
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
if (!(process.platform === 'win32' && kind === 'visible')) {
options.ensureOverlayWindowLevel(window);
}
loadOverlayWindowLayer(window, kind);
window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
@@ -108,14 +93,6 @@ export function createOverlayWindow(
options.onRuntimeOptionsChanged();
});
window.once('ready-to-show', () => {
overlayWindowContentReady.add(window);
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = true;
options.onWindowContentReady?.();
});
if (kind === 'visible') {
window.webContents.on('devtools-opened', () => {
options.setOverlayDebugVisualizationEnabled(true);
@@ -159,8 +136,6 @@ export function createOverlayWindow(
moveWindowTop: () => {
window.moveTop();
},
onWindowsVisibleOverlayBlur:
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
});
});
+4 -2
View File
@@ -30,8 +30,10 @@ function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: num
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
}
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | 'all' {
return raw === '7d' || raw === '30d' || raw === '90d' || raw === 'all' ? raw : '30d';
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
? raw
: '30d';
}
function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
+1 -284
View File
@@ -130,15 +130,6 @@ import {
type LogLevelSource,
} from './logger';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
import {
bindWindowsOverlayAboveMpvNative,
clearWindowsOverlayOwnerNative,
ensureWindowsOverlayTransparencyNative,
getWindowsForegroundProcessNameNative,
queryWindowsForegroundProcessName,
setWindowsOverlayOwnerNative,
syncWindowsOverlayToMpvZOrder,
} from './window-trackers/windows-helper';
import {
commandNeedsOverlayStartupPrereqs,
commandNeedsOverlayRuntime,
@@ -1844,9 +1835,6 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible,
getWindowTracker: () => appState.windowTracker,
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(),
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown: boolean) => {
appState.trackerNotReadyWarningShown = shown;
@@ -1855,9 +1843,6 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
ensureOverlayWindowLevel: (window) => {
ensureOverlayWindowLevel(window);
},
syncWindowsOverlayToMpvZOrder: (_window) => {
requestWindowsVisibleOverlayZOrderSync();
},
syncPrimaryOverlayWindowLayer: (layer) => {
syncPrimaryOverlayWindowLayer(layer);
},
@@ -1885,223 +1870,6 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
},
})(),
);
const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderSyncInFlight = false;
let windowsVisibleOverlayZOrderSyncQueued = false;
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
let windowsVisibleOverlayForegroundPollInFlight = false;
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0;
function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) {
clearTimeout(timeout);
}
windowsVisibleOverlayBlurRefreshTimeouts = [];
}
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) {
clearTimeout(timeout);
}
windowsVisibleOverlayZOrderRetryTimeouts = [];
}
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
const handle = window.getNativeWindowHandle();
return handle.length >= 8
? handle.readBigUInt64LE(0).toString()
: BigInt(handle.readUInt32LE(0)).toString();
}
function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
const handle = window.getNativeWindowHandle();
return handle.length >= 8
? Number(handle.readBigUInt64LE(0))
: handle.readUInt32LE(0);
}
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
if (process.platform !== 'win32') {
return false;
}
const mainWindow = overlayManager.getMainWindow();
if (
!mainWindow ||
mainWindow.isDestroyed() ||
!mainWindow.isVisible() ||
!overlayManager.getVisibleOverlayVisible()
) {
return false;
}
const windowTracker = appState.windowTracker;
if (!windowTracker) {
return false;
}
if (
typeof windowTracker.isTargetWindowMinimized === 'function' &&
windowTracker.isTargetWindowMinimized()
) {
return false;
}
if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) {
return false;
}
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (bindWindowsOverlayAboveMpvNative(overlayHwnd)) {
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
return true;
}
const synced = await syncWindowsOverlayToMpvZOrder({
overlayWindowHandle: getWindowsNativeWindowHandle(mainWindow),
targetMpvSocketPath: appState.mpvSocketPath,
});
if (synced) {
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
}
return synced;
}
function requestWindowsVisibleOverlayZOrderSync(): void {
if (process.platform !== 'win32') {
return;
}
if (windowsVisibleOverlayZOrderSyncInFlight) {
windowsVisibleOverlayZOrderSyncQueued = true;
return;
}
windowsVisibleOverlayZOrderSyncInFlight = true;
void syncWindowsVisibleOverlayToMpvZOrder()
.catch((error) => {
logger.warn('Failed to bind Windows overlay z-order to mpv', error);
})
.finally(() => {
windowsVisibleOverlayZOrderSyncInFlight = false;
if (!windowsVisibleOverlayZOrderSyncQueued) {
return;
}
windowsVisibleOverlayZOrderSyncQueued = false;
requestWindowsVisibleOverlayZOrderSync();
});
}
function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void {
if (process.platform !== 'win32') {
return;
}
clearWindowsVisibleOverlayZOrderRetryTimeouts();
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS) {
const retryTimeout = setTimeout(() => {
windowsVisibleOverlayZOrderRetryTimeouts = windowsVisibleOverlayZOrderRetryTimeouts.filter(
(timeout) => timeout !== retryTimeout,
);
requestWindowsVisibleOverlayZOrderSync();
}, delayMs);
windowsVisibleOverlayZOrderRetryTimeouts.push(retryTimeout);
}
}
function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean {
return (
process.platform === 'win32' &&
lastWindowsVisibleOverlayBlurredAtMs > 0 &&
Date.now() - lastWindowsVisibleOverlayBlurredAtMs <=
WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
);
}
function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean {
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
return false;
}
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return false;
}
const windowTracker = appState.windowTracker;
if (!windowTracker) {
return false;
}
if (
typeof windowTracker.isTargetWindowMinimized === 'function' &&
windowTracker.isTargetWindowMinimized()
) {
return false;
}
const overlayFocused = mainWindow.isFocused();
const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false;
return !overlayFocused && !trackerFocused;
}
function maybePollWindowsVisibleOverlayForegroundProcess(): void {
if (!shouldPollWindowsVisibleOverlayForegroundProcess()) {
lastWindowsVisibleOverlayForegroundProcessName = null;
return;
}
const processName = getWindowsForegroundProcessNameNative();
const normalizedProcessName = processName?.trim().toLowerCase() ?? null;
const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName;
lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName;
if (normalizedProcessName !== previousProcessName) {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}
if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') {
requestWindowsVisibleOverlayZOrderSync();
}
}
function ensureWindowsVisibleOverlayForegroundPollLoop(): void {
if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) {
return;
}
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
maybePollWindowsVisibleOverlayForegroundProcess();
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
}
function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform !== 'win32') {
return;
}
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
clearWindowsVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => {
windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter(
(timeout) => timeout !== refreshTimeout,
);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, delayMs);
windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
}
}
ensureWindowsVisibleOverlayForegroundPollLoop();
const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler(
{
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
@@ -3906,12 +3674,6 @@ function applyOverlayRegions(geometry: WindowGeometry): void {
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
afterSetOverlayWindowBounds: () => {
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
return;
}
scheduleWindowsVisibleOverlayZOrderSyncBurst();
},
});
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
@@ -4034,14 +3796,7 @@ function createModalWindow(): BrowserWindow {
}
function createMainWindow(): BrowserWindow {
const window = createMainWindowHandler();
if (process.platform === 'win32') {
const overlayHwnd = getWindowsNativeWindowHandleNumber(window);
if (!ensureWindowsOverlayTransparencyNative(overlayHwnd)) {
logger.warn('Failed to eagerly extend Windows overlay transparency via koffi');
}
}
return window;
return createMainWindowHandler();
}
function ensureTray(): void {
@@ -4840,8 +4595,6 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
tryHandleOverlayShortcutLocalFallback: (input) =>
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
onWindowClosed: (windowKind) => {
if (windowKind === 'visible') {
overlayManager.setMainWindow(null);
@@ -4943,9 +4696,6 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
updateVisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
},
refreshCurrentSubtitle: () => {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
},
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
},
@@ -4969,39 +4719,6 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
},
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
updateVisibleOverlayBounds(geometry),
bindOverlayOwner: () => {
const mainWindow = overlayManager.getMainWindow();
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (bindWindowsOverlayAboveMpvNative(overlayHwnd)) {
return;
}
const tracker = appState.windowTracker;
const mpvResult = tracker
? (() => {
try {
const win32 = require('./window-trackers/win32') as typeof import('./window-trackers/win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
return focused ?? poll.matches.sort((a, b) => b.area - a.area)[0] ?? null;
} catch {
return null;
}
})()
: null;
if (!mpvResult) return;
if (!setWindowsOverlayOwnerNative(overlayHwnd, mpvResult.hwnd)) {
logger.warn('Failed to set overlay owner via koffi');
}
},
releaseOverlayOwner: () => {
const mainWindow = overlayManager.getMainWindow();
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (!clearWindowsOverlayOwnerNative(overlayHwnd)) {
logger.warn('Failed to clear overlay owner via koffi');
}
},
getOverlayWindows: () => getOverlayWindows(),
getResolvedConfig: () => getResolvedConfig(),
showDesktopNotification,
+4 -18
View File
@@ -12,14 +12,10 @@ export interface OverlayVisibilityRuntimeDeps {
getVisibleOverlayVisible: () => boolean;
getForceMousePassthrough: () => boolean;
getWindowTracker: () => BaseWindowTracker | null;
getLastKnownWindowsForegroundProcessName?: () => string | null;
getWindowsOverlayProcessName?: () => string | null;
getWindowsFocusHandoffGraceActive?: () => boolean;
getTrackerNotReadyWarningShown: () => boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void;
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
@@ -40,20 +36,12 @@ export function createOverlayVisibilityRuntimeService(
return {
updateVisibleOverlayVisibility(): void {
const visibleOverlayVisible = deps.getVisibleOverlayVisible();
const forceMousePassthrough = deps.getForceMousePassthrough();
const windowTracker = deps.getWindowTracker();
const mainWindow = deps.getMainWindow();
updateVisibleOverlayVisibility({
visibleOverlayVisible,
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
modalActive: deps.getModalActive(),
forceMousePassthrough,
mainWindow,
windowTracker,
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null,
windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false,
forceMousePassthrough: deps.getForceMousePassthrough(),
mainWindow: deps.getMainWindow(),
windowTracker: deps.getWindowTracker(),
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => {
deps.setTrackerNotReadyWarningShown(shown);
@@ -61,8 +49,6 @@ export function createOverlayVisibilityRuntimeService(
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) =>
deps.syncWindowsOverlayToMpvZOrder?.(window),
syncPrimaryOverlayWindowLayer: (layer: 'visible') =>
deps.syncPrimaryOverlayWindowLayer(layer),
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
@@ -31,8 +31,6 @@ type InitializeOverlayRuntimeCore = (options: {
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
}) => void;
export function createInitializeOverlayRuntimeHandler(deps: {
@@ -23,7 +23,6 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
},
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
},
@@ -54,7 +53,6 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
deps.registerGlobalShortcuts();
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
deps.updateVisibleOverlayVisibility();
deps.refreshCurrentSubtitle?.();
deps.syncOverlayShortcuts();
deps.showDesktopNotification('title', {});
@@ -70,7 +68,6 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
'register-shortcuts',
'visible-bounds',
'update-visible',
'refresh-subtitle',
'sync-shortcuts',
'notify',
]);
@@ -21,7 +21,6 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => void;
};
refreshCurrentSubtitle?: () => void;
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => void;
};
@@ -40,8 +39,6 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
}) {
return (): OverlayRuntimeOptionsMainDeps => ({
getBackendOverride: () => deps.appState.backendOverride,
@@ -56,7 +53,6 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
updateVisibleOverlayVisibility: () =>
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
getOverlayWindows: () => deps.getOverlayWindows(),
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
setWindowTracker: (tracker) => {
@@ -75,7 +71,5 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
bindOverlayOwner: deps.bindOverlayOwner,
releaseOverlayOwner: deps.releaseOverlayOwner,
});
}
@@ -11,7 +11,6 @@ test('build initialize overlay runtime options maps dependencies', () => {
updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'),
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
getOverlayWindows: () => [],
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
setWindowTracker: () => calls.push('set-tracker'),
@@ -42,7 +41,6 @@ test('build initialize overlay runtime options maps dependencies', () => {
options.registerGlobalShortcuts();
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
options.updateVisibleOverlayVisibility();
options.refreshCurrentSubtitle?.();
options.syncOverlayShortcuts();
options.setWindowTracker(null);
options.setAnkiIntegration(null);
@@ -53,7 +51,6 @@ test('build initialize overlay runtime options maps dependencies', () => {
'register-shortcuts',
'update-visible-bounds',
'update-visible',
'refresh-subtitle',
'sync-shortcuts',
'set-tracker',
'set-anki',
@@ -14,7 +14,6 @@ type OverlayRuntimeOptions = {
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
refreshCurrentSubtitle?: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
@@ -36,8 +35,6 @@ type OverlayRuntimeOptions = {
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
};
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
@@ -47,7 +44,6 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
refreshCurrentSubtitle?: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
@@ -69,8 +65,6 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
}) {
return (): OverlayRuntimeOptions => ({
backendOverride: deps.getBackendOverride(),
@@ -79,7 +73,6 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds,
isVisibleOverlayVisible: deps.isVisibleOverlayVisible,
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
refreshCurrentSubtitle: deps.refreshCurrentSubtitle,
getOverlayWindows: deps.getOverlayWindows,
syncOverlayShortcuts: deps.syncOverlayShortcuts,
setWindowTracker: deps.setWindowTracker,
@@ -94,7 +87,5 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
createFieldGroupingCallback: deps.createFieldGroupingCallback,
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
bindOverlayOwner: deps.bindOverlayOwner,
releaseOverlayOwner: deps.releaseOverlayOwner,
});
}
@@ -16,9 +16,6 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
getVisibleOverlayVisible: () => true,
getForceMousePassthrough: () => true,
getWindowTracker: () => tracker,
getLastKnownWindowsForegroundProcessName: () => 'mpv',
getWindowsOverlayProcessName: () => 'subminer',
getWindowsFocusHandoffGraceActive: () => true,
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown) => {
trackerNotReadyWarningShown = shown;
@@ -26,7 +23,6 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
},
updateVisibleOverlayBounds: () => calls.push('visible-bounds'),
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
syncWindowsOverlayToMpvZOrder: () => calls.push('sync-windows-z-order'),
syncPrimaryOverlayWindowLayer: (layer) => calls.push(`primary-layer:${layer}`),
enforceOverlayLayerOrder: () => calls.push('enforce-order'),
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
@@ -40,14 +36,10 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
assert.equal(deps.getModalActive(), true);
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getForceMousePassthrough(), true);
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
deps.setTrackerNotReadyWarningShown(true);
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
deps.ensureOverlayWindowLevel(mainWindow);
deps.syncWindowsOverlayToMpvZOrder?.(mainWindow);
deps.syncPrimaryOverlayWindowLayer('visible');
deps.enforceOverlayLayerOrder();
deps.syncOverlayShortcuts();
@@ -60,7 +52,6 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
'tracker-warning:true',
'visible-bounds',
'ensure-level',
'sync-windows-z-order',
'primary-layer:visible',
'enforce-order',
'sync-shortcuts',
@@ -11,17 +11,11 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
getWindowTracker: () => deps.getWindowTracker(),
getLastKnownWindowsForegroundProcessName: () =>
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null,
getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false,
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) =>
deps.syncWindowsOverlayToMpvZOrder?.(window),
syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer),
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
@@ -11,8 +11,6 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onVisibleWindowBlurred?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
yomitanSession?: Session | null;
},
@@ -24,8 +22,6 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onVisibleWindowBlurred?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
getYomitanSession?: () => Session | null;
}) {
@@ -38,8 +34,6 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
isOverlayVisible: deps.isOverlayVisible,
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv,
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
onWindowContentReady: deps.onWindowContentReady,
onWindowClosed: deps.onWindowClosed,
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
});
@@ -13,8 +13,6 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onVisibleWindowBlurred?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
yomitanSession?: Session | null;
},
@@ -26,8 +24,6 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onVisibleWindowBlurred?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
getYomitanSession?: () => Session | null;
}) {
@@ -40,8 +36,6 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
isOverlayVisible: deps.isOverlayVisible,
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv,
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
onWindowContentReady: deps.onWindowContentReady,
onWindowClosed: deps.onWindowClosed,
yomitanSession: deps.getYomitanSession?.() ?? null,
});
@@ -15,7 +15,6 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
) {
return (): UpdateVisibleOverlayBoundsMainDeps => ({
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry),
});
}
@@ -16,22 +16,6 @@ test('visible bounds handler writes visible layer geometry', () => {
assert.deepEqual(calls, [geometry]);
});
test('visible bounds handler runs follow-up callback after applying geometry', () => {
const calls: string[] = [];
const geometry = { x: 0, y: 0, width: 100, height: 50 };
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
setOverlayWindowBounds: () => calls.push('set-bounds'),
afterSetOverlayWindowBounds: (nextGeometry) => {
assert.deepEqual(nextGeometry, geometry);
calls.push('after-bounds');
},
});
handleVisible(geometry);
assert.deepEqual(calls, ['set-bounds', 'after-bounds']);
});
test('ensure overlay window level handler delegates to core', () => {
const calls: string[] = [];
const ensureLevel = createEnsureOverlayWindowLevelHandler({
@@ -2,11 +2,9 @@ import type { WindowGeometry } from '../../types';
export function createUpdateVisibleOverlayBoundsHandler(deps: {
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
afterSetOverlayWindowBounds?: (geometry: WindowGeometry) => void;
}) {
return (geometry: WindowGeometry): void => {
deps.setOverlayWindowBounds(geometry);
deps.afterSetOverlayWindowBounds?.(geometry);
};
}
-61
View File
@@ -1,61 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml');
const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8');
const packageJsonPath = resolve(__dirname, '../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
scripts: Record<string, string>;
};
test('prerelease workflow triggers on beta and rc tags only', () => {
assert.match(prereleaseWorkflow, /name: Prerelease/);
assert.match(prereleaseWorkflow, /tags:\s*\n\s*-\s*'v\*-beta\.\*'/);
assert.match(prereleaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'v\*-rc\.\*'/);
});
test('package scripts expose prerelease notes generation separately from stable changelog build', () => {
assert.equal(
packageJson.scripts['changelog:prerelease-notes'],
'bun run scripts/build-changelog.ts prerelease-notes',
);
});
test('prerelease workflow generates prerelease notes from pending fragments', () => {
assert.match(prereleaseWorkflow, /bun run changelog:prerelease-notes --version/);
assert.doesNotMatch(prereleaseWorkflow, /bun run changelog:build --version/);
});
test('prerelease workflow publishes GitHub prereleases and keeps them off latest', () => {
assert.match(prereleaseWorkflow, /gh release edit[\s\S]*--prerelease/);
assert.match(prereleaseWorkflow, /gh release create[\s\S]*--prerelease/);
assert.match(prereleaseWorkflow, /gh release create[\s\S]*--latest=false/);
});
test('prerelease workflow builds and uploads all release platforms', () => {
assert.match(prereleaseWorkflow, /build-linux:/);
assert.match(prereleaseWorkflow, /build-macos:/);
assert.match(prereleaseWorkflow, /build-windows:/);
assert.match(prereleaseWorkflow, /name: appimage/);
assert.match(prereleaseWorkflow, /name: macos/);
assert.match(prereleaseWorkflow, /name: windows/);
});
test('prerelease workflow publishes the same release assets as the stable workflow', () => {
assert.match(
prereleaseWorkflow,
/files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz dist\/launcher\/subminer\)/,
);
assert.match(
prereleaseWorkflow,
/artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/,
);
});
test('prerelease workflow does not publish to AUR', () => {
assert.doesNotMatch(prereleaseWorkflow, /aur-publish:/);
assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/);
assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/);
});
-6
View File
@@ -22,12 +22,6 @@ test('publish release leaves prerelease unset so gh creates a normal release', (
assert.ok(!releaseWorkflow.includes('--prerelease'));
});
test('stable release workflow excludes prerelease beta and rc tags', () => {
assert.match(releaseWorkflow, /tags:\s*\n\s*-\s*'v\*'/);
assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-beta\.\*'/);
assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-rc\.\*'/);
});
test('publish release forces an existing draft tag release to become public', () => {
assert.ok(releaseWorkflow.includes('--draft=false'));
});
+3 -79
View File
@@ -3,9 +3,7 @@ import assert from 'node:assert/strict';
import { createRendererRecoveryController } from './error-recovery.js';
import {
YOMITAN_POPUP_HOST_SELECTOR,
YOMITAN_POPUP_IFRAME_SELECTOR,
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
hasYomitanPopupIframe,
isYomitanPopupIframe,
isYomitanPopupVisible,
@@ -230,42 +228,6 @@ test('resolvePlatformInfo supports modal layer and disables mouse-ignore toggles
}
});
test('resolvePlatformInfo flags Windows platforms', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getOverlayLayer: () => 'visible',
},
location: { search: '' },
},
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
platform: 'Win32',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
},
});
try {
const info = resolvePlatformInfo();
assert.equal(info.isWindowsPlatform, true);
assert.equal(info.isMacOSPlatform, false);
assert.equal(info.isLinuxPlatform, false);
assert.equal(info.shouldToggleMouseIgnore, true);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: previousNavigator,
});
}
});
test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => {
const createElement = (options: {
tagName: string;
@@ -322,25 +284,9 @@ test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
});
test('hasYomitanPopupIframe falls back to popup host selector for shadow-hosted popups', () => {
const selectors: string[] = [];
const root = {
querySelector: (value: string) => {
selectors.push(value);
if (value === YOMITAN_POPUP_HOST_SELECTOR) {
return {};
}
return null;
},
} as unknown as ParentNode;
assert.equal(hasYomitanPopupIframe(root), true);
assert.deepEqual(selectors, [YOMITAN_POPUP_IFRAME_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR]);
});
test('isYomitanPopupVisible requires visible iframe geometry', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const selectors: string[] = [];
let selector = '';
const visibleFrame = {
getBoundingClientRect: () => ({ width: 320, height: 180 }),
} as unknown as HTMLIFrameElement;
@@ -363,40 +309,18 @@ test('isYomitanPopupVisible requires visible iframe geometry', () => {
try {
const root = {
querySelectorAll: (value: string) => {
selectors.push(value);
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || value === YOMITAN_POPUP_HOST_SELECTOR) {
return [];
}
selector = value;
return [hiddenFrame, visibleFrame];
},
} as unknown as ParentNode;
assert.equal(isYomitanPopupVisible(root), true);
assert.deepEqual(selectors, [
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
YOMITAN_POPUP_IFRAME_SELECTOR,
]);
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
test('isYomitanPopupVisible detects visible shadow-hosted popup marker without iframe access', () => {
let selector = '';
const root = {
querySelectorAll: (value: string) => {
selector = value;
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) {
return [{ getAttribute: () => 'true' }];
}
return [];
},
} as unknown as ParentNode;
assert.equal(isYomitanPopupVisible(root), true);
assert.equal(selector, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
});
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
const activeItem = {
-4
View File
@@ -2,7 +2,6 @@ import { SPECIAL_COMMANDS } from '../../config/definitions';
import type { Keybinding, ShortcutsConfig } from '../../types';
import type { RendererContext } from '../context';
import {
YOMITAN_POPUP_HOST_SELECTOR,
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
YOMITAN_POPUP_COMMAND_EVENT,
@@ -62,9 +61,6 @@ export function createKeyboardHandlers(
if (target.closest('.modal')) return true;
if (ctx.dom.subtitleContainer.contains(target)) return true;
if (isYomitanPopupIframe(target)) return true;
if (target.closest && target.closest(YOMITAN_POPUP_HOST_SELECTOR)) {
return true;
}
if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]'))
return true;
return false;
+5 -616
View File
@@ -3,12 +3,7 @@ import test from 'node:test';
import type { SubtitleSidebarConfig } from '../../types';
import { createMouseHandlers } from './mouse.js';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_HOST_SELECTOR,
YOMITAN_POPUP_SHOWN_EVENT,
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
} from '../yomitan-popup.js';
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
function createClassList() {
const classes = new Set<string>();
@@ -83,13 +78,11 @@ function createMouseTestContext() {
},
platform: {
shouldToggleMouseIgnore: false,
isLinuxPlatform: false,
isMacOSPlatform: false,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
isDragging: false,
@@ -719,257 +712,6 @@ test('popup open pauses and popup close resumes when yomitan popup auto-pause is
}
});
test('nested popup close reasserts interactive state and focus when another popup remains visible on Windows', async () => {
const ctx = createMouseTestContext();
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document;
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
const previousNode = (globalThis as { Node?: unknown }).Node;
const windowListeners = new Map<string, Array<() => void>>();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
let focusMainWindowCalls = 0;
let windowFocusCalls = 0;
let overlayFocusCalls = 0;
ctx.platform.shouldToggleMouseIgnore = true;
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
overlayFocusCalls += 1;
};
const visiblePopupHost = {
tagName: 'DIV',
getAttribute: (name: string) =>
name === 'data-subminer-yomitan-popup-visible' ? 'true' : null,
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener: (type: string, listener: () => void) => {
const bucket = windowListeners.get(type) ?? [];
bucket.push(listener);
windowListeners.set(type, bucket);
},
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
focusMainWindow: () => {
focusMainWindowCalls += 1;
},
},
focus: () => {
windowFocusCalls += 1;
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
innerHeight: 1000,
getSelection: () => null,
setTimeout,
clearTimeout,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
querySelector: () => null,
querySelectorAll: (selector: string) => {
if (
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
selector === YOMITAN_POPUP_HOST_SELECTOR
) {
return [visiblePopupHost];
}
return [];
},
body: {},
elementFromPoint: () => null,
addEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
},
});
Object.defineProperty(globalThis, 'Node', {
configurable: true,
value: {
ELEMENT_NODE: 1,
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupYomitanObserver();
ignoreCalls.length = 0;
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
listener();
}
assert.equal(ctx.state.yomitanPopupVisible, true);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
assert.equal(focusMainWindowCalls, 1);
assert.equal(windowFocusCalls, 1);
assert.equal(overlayFocusCalls, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: previousMutationObserver,
});
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
}
});
test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => {
const ctx = createMouseTestContext();
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document;
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
const previousNode = (globalThis as { Node?: unknown }).Node;
const windowListeners = new Map<string, Array<() => void>>();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
let focusMainWindowCalls = 0;
let windowFocusCalls = 0;
let overlayFocusCalls = 0;
ctx.platform.shouldToggleMouseIgnore = true;
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
overlayFocusCalls += 1;
};
const visiblePopupHost = {
tagName: 'DIV',
getAttribute: (name: string) =>
name === 'data-subminer-yomitan-popup-visible' ? 'true' : null,
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener: (type: string, listener: () => void) => {
const bucket = windowListeners.get(type) ?? [];
bucket.push(listener);
windowListeners.set(type, bucket);
},
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
focusMainWindow: () => {
focusMainWindowCalls += 1;
},
},
focus: () => {
windowFocusCalls += 1;
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
innerHeight: 1000,
getSelection: () => null,
setTimeout,
clearTimeout,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
visibilityState: 'visible',
querySelector: () => null,
querySelectorAll: (selector: string) => {
if (
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
selector === YOMITAN_POPUP_HOST_SELECTOR
) {
return [visiblePopupHost];
}
return [];
},
body: {},
elementFromPoint: () => null,
addEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
},
});
Object.defineProperty(globalThis, 'Node', {
configurable: true,
value: {
ELEMENT_NODE: 1,
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupYomitanObserver();
assert.equal(ctx.state.yomitanPopupVisible, true);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
ignoreCalls.length = 0;
for (const listener of windowListeners.get('blur') ?? []) {
listener();
}
await Promise.resolve();
assert.equal(ctx.state.yomitanPopupVisible, true);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
assert.equal(focusMainWindowCalls, 1);
assert.equal(windowFocusCalls, 1);
assert.equal(overlayFocusCalls, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: previousMutationObserver,
});
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
}
});
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
@@ -1041,361 +783,6 @@ test('restorePointerInteractionState re-enables subtitle hover when pointer is a
}
});
test('visibility recovery re-enables subtitle hover without needing a fresh pointer move', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
let visibilityState: 'hidden' | 'visible' = 'visible';
ctx.platform.shouldToggleMouseIgnore = true;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
getComputedStyle: () => ({
visibility: 'hidden',
display: 'none',
opacity: '0',
}),
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
addEventListener: (type: string, listener: (event: unknown) => void) => {
const bucket = documentListeners.get(type) ?? [];
bucket.push(listener);
documentListeners.set(type, bucket);
},
get visibilityState() {
return visibilityState;
},
elementFromPoint: () => ctx.dom.subtitleContainer,
querySelectorAll: () => [],
body: {},
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupPointerTracking();
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 120, clientY: 240 });
}
ctx.state.isOverSubtitle = false;
ctx.dom.overlay.classList.remove('interactive');
ignoreCalls.length = 0;
visibilityState = 'hidden';
visibilityState = 'visible';
for (const listener of documentListeners.get('visibilitychange') ?? []) {
listener({});
}
assert.equal(ctx.state.isOverSubtitle, true);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});
test('visibility recovery ignores synthetic subtitle enter until the pointer moves again', async () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const mpvCommands: Array<(string | number)[]> = [];
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
let hoveredElement: unknown = ctx.dom.subtitleContainer;
let visibilityState: 'hidden' | 'visible' = 'visible';
let subtitleHoverAutoPauseEnabled = false;
ctx.platform.shouldToggleMouseIgnore = true;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
getComputedStyle: () => ({
visibility: 'hidden',
display: 'none',
opacity: '0',
}),
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
addEventListener: (type: string, listener: (event: unknown) => void) => {
const bucket = documentListeners.get(type) ?? [];
bucket.push(listener);
documentListeners.set(type, bucket);
},
get visibilityState() {
return visibilityState;
},
elementFromPoint: () => hoveredElement,
querySelectorAll: () => [],
body: {},
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => subtitleHoverAutoPauseEnabled,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: (command) => {
mpvCommands.push(command);
},
});
handlers.setupPointerTracking();
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 120, clientY: 240 });
}
await waitForNextTick();
ignoreCalls.length = 0;
visibilityState = 'hidden';
visibilityState = 'visible';
subtitleHoverAutoPauseEnabled = true;
for (const listener of documentListeners.get('visibilitychange') ?? []) {
listener({});
}
await handlers.handlePrimaryMouseEnter();
assert.deepEqual(mpvCommands, []);
hoveredElement = null;
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 32, clientY: 48 });
}
hoveredElement = ctx.dom.subtitleContainer;
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 120, clientY: 240 });
}
await waitForNextTick();
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('window resize ignores synthetic subtitle enter until the pointer moves again', async () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const mpvCommands: Array<(string | number)[]> = [];
const windowListeners = new Map<string, Array<() => void>>();
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
let hoveredElement: unknown = ctx.dom.subtitleContainer;
let subtitleHoverAutoPauseEnabled = false;
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: (type: string, listener: (event: unknown) => void) => {
const bucket = documentListeners.get(type) ?? [];
bucket.push(listener);
documentListeners.set(type, bucket);
},
elementFromPoint: () => hoveredElement,
querySelectorAll: () => [],
body: {},
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => subtitleHoverAutoPauseEnabled,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: (command) => {
mpvCommands.push(command);
},
});
handlers.setupPointerTracking();
handlers.setupResizeHandler();
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 120, clientY: 240 });
}
await waitForNextTick();
subtitleHoverAutoPauseEnabled = true;
for (const listener of windowListeners.get('resize') ?? []) {
listener();
}
await handlers.handlePrimaryMouseEnter();
assert.deepEqual(mpvCommands, []);
hoveredElement = null;
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 32, clientY: 48 });
}
hoveredElement = ctx.dom.subtitleContainer;
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 120, clientY: 240 });
}
await waitForNextTick();
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;
const originalDocument = globalThis.document;
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
let hoveredElement: unknown = null;
let visibilityState: 'hidden' | 'visible' = 'visible';
ctx.platform.shouldToggleMouseIgnore = true;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
getComputedStyle: () => ({
visibility: 'hidden',
display: 'none',
opacity: '0',
}),
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
addEventListener: (type: string, listener: (event: unknown) => void) => {
const bucket = documentListeners.get(type) ?? [];
bucket.push(listener);
documentListeners.set(type, bucket);
},
get visibilityState() {
return visibilityState;
},
elementFromPoint: () => hoveredElement,
querySelectorAll: () => [],
body: {},
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupPointerTracking();
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 320, clientY: 180 });
}
ctx.dom.overlay.classList.add('interactive');
ignoreCalls.length = 0;
visibilityState = 'hidden';
visibilityState = 'visible';
for (const listener of documentListeners.get('visibilitychange') ?? []) {
listener({});
}
assert.equal(ctx.state.isOverSubtitle, false);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});
test('pointer tracking enables overlay interaction as soon as the cursor reaches subtitles', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
@@ -1529,8 +916,10 @@ test('pointer tracking restores click-through after the cursor leaves subtitles'
assert.equal(ctx.state.isOverSubtitle, false);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
assert.equal(ignoreCalls[0]?.ignore, false);
assert.deepEqual(ignoreCalls.at(-1), { ignore: true, forward: true });
assert.deepEqual(ignoreCalls, [
{ ignore: false, forward: undefined },
{ ignore: true, forward: true },
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
+15 -123
View File
@@ -2,16 +2,11 @@ import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
isYomitanPopupVisible,
isYomitanPopupIframe,
} from '../yomitan-popup.js';
const VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE = 'visibility-recovery';
const WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE = 'window-resize';
export function createMouseHandlers(
ctx: RendererContext,
options: {
@@ -38,61 +33,6 @@ export function createMouseHandlers(
let pausedByYomitanPopup = false;
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
let pendingPointerResync = false;
let suppressDirectHoverEnterSource: string | null = null;
function getPopupVisibilityFromDom(): boolean {
return typeof document !== 'undefined' && isYomitanPopupVisible(document);
}
function syncPopupVisibilityState(assumeVisible = false): boolean {
const popupVisible = assumeVisible || getPopupVisibilityFromDom();
yomitanPopupVisible = popupVisible;
ctx.state.yomitanPopupVisible = popupVisible;
return popupVisible;
}
function reclaimOverlayWindowFocusForPopup(): void {
if (!ctx.platform.shouldToggleMouseIgnore) {
return;
}
if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) {
return;
}
if (typeof window.electronAPI.focusMainWindow === 'function') {
void window.electronAPI.focusMainWindow();
}
window.focus();
if (typeof ctx.dom.overlay.focus === 'function') {
ctx.dom.overlay.focus({ preventScroll: true });
}
}
function sustainPopupInteraction(): void {
syncPopupVisibilityState(true);
syncOverlayMouseIgnoreState(ctx);
}
function reconcilePopupInteraction(args: {
assumeVisible?: boolean;
reclaimFocus?: boolean;
allowPause?: boolean;
} = {}): boolean {
const popupVisible = syncPopupVisibilityState(args.assumeVisible === true);
if (!popupVisible) {
syncOverlayMouseIgnoreState(ctx);
return false;
}
syncOverlayMouseIgnoreState(ctx);
if (args.reclaimFocus === true) {
reclaimOverlayWindowFocusForPopup();
}
if (args.allowPause === true) {
void maybePauseForYomitanPopup();
}
return true;
}
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
if (!element) {
@@ -146,7 +86,6 @@ export function createMouseHandlers(
return;
}
suppressDirectHoverEnterSource = null;
const wasOverSubtitle = ctx.state.isOverSubtitle;
const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains(
'secondary-sub-hover-active',
@@ -154,7 +93,7 @@ export function createMouseHandlers(
const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY);
if (!wasOverSubtitle && hoverState.isOverSubtitle) {
void handleMouseEnter(undefined, hoverState.overSecondarySubtitle, 'tracked-pointer');
void handleMouseEnter(undefined, hoverState.overSecondarySubtitle);
return;
}
@@ -171,13 +110,9 @@ export function createMouseHandlers(
}
}
function resyncPointerInteractionState(options: {
allowInteractiveFallback: boolean;
suppressDirectHoverEnterSource?: string | null;
}): void {
function restorePointerInteractionState(): void {
const pointerPosition = lastPointerPosition;
pendingPointerResync = false;
suppressDirectHoverEnterSource = options.suppressDirectHoverEnterSource ?? null;
if (pointerPosition) {
syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY);
} else {
@@ -186,11 +121,7 @@ export function createMouseHandlers(
}
syncOverlayMouseIgnoreState(ctx);
if (
!options.allowInteractiveFallback ||
!ctx.platform.shouldToggleMouseIgnore ||
ctx.state.isOverSubtitle
) {
if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isOverSubtitle) {
return;
}
@@ -199,10 +130,6 @@ export function createMouseHandlers(
window.electronAPI.setIgnoreMouseEvents(false);
}
function restorePointerInteractionState(): void {
resyncPointerInteractionState({ allowInteractiveFallback: true });
}
function maybeResyncPointerHoverState(event: MouseEvent | PointerEvent): void {
if (!pendingPointerResync) {
return;
@@ -278,14 +205,18 @@ export function createMouseHandlers(
}
function enablePopupInteraction(): void {
sustainPopupInteraction();
yomitanPopupVisible = true;
ctx.state.yomitanPopupVisible = true;
syncOverlayMouseIgnoreState(ctx);
if (ctx.platform.isMacOSPlatform) {
window.focus();
}
}
function disablePopupInteractionIfIdle(): void {
if (reconcilePopupInteraction({ reclaimFocus: true })) {
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
yomitanPopupVisible = true;
ctx.state.yomitanPopupVisible = true;
return;
}
@@ -297,15 +228,7 @@ export function createMouseHandlers(
syncOverlayMouseIgnoreState(ctx);
}
async function handleMouseEnter(
_event?: MouseEvent,
showSecondaryHover = false,
source: 'direct' | 'tracked-pointer' = 'direct',
): Promise<void> {
if (source === 'direct' && suppressDirectHoverEnterSource !== null) {
return;
}
async function handleMouseEnter(_event?: MouseEvent, showSecondaryHover = false): Promise<void> {
ctx.state.isOverSubtitle = true;
if (showSecondaryHover) {
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active');
@@ -403,10 +326,6 @@ export function createMouseHandlers(
function setupResizeHandler(): void {
window.addEventListener('resize', () => {
options.applyYPercent(options.getCurrentYPercent());
resyncPointerInteractionState({
allowInteractiveFallback: false,
suppressDirectHoverEnterSource: WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE,
});
});
}
@@ -421,15 +340,6 @@ export function createMouseHandlers(
syncHoverStateFromTrackedPointer(event);
maybeResyncPointerHoverState(event);
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') {
return;
}
resyncPointerInteractionState({
allowInteractiveFallback: false,
suppressDirectHoverEnterSource: VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE,
});
});
}
function setupSelectionObserver(): void {
@@ -446,37 +356,19 @@ export function createMouseHandlers(
}
function setupYomitanObserver(): void {
reconcilePopupInteraction({ allowPause: true });
yomitanPopupVisible = isYomitanPopupVisible(document);
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
void maybePauseForYomitanPopup();
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
reconcilePopupInteraction({ assumeVisible: true, allowPause: true });
enablePopupInteraction();
void maybePauseForYomitanPopup();
});
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
disablePopupInteractionIfIdle();
});
window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => {
reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true });
});
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
reconcilePopupInteraction({ assumeVisible: true });
});
window.addEventListener('focus', () => {
reconcilePopupInteraction();
});
window.addEventListener('blur', () => {
queueMicrotask(() => {
if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
return;
}
reconcilePopupInteraction({ reclaimFocus: true });
});
});
const observer = new MutationObserver((mutations: MutationRecord[]) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
-106
View File
@@ -15,53 +15,6 @@ function createClassList() {
};
}
test('idle visible overlay starts click-through on platforms that toggle mouse ignore', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const originalWindow = globalThis.window;
Object.assign(globalThis, {
window: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
shouldToggleMouseIgnore: true,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), false);
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
} finally {
Object.assign(globalThis, { window: originalWindow });
}
});
test('youtube picker keeps overlay interactive even when subtitle hover is inactive', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
@@ -108,62 +61,3 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact
Object.assign(globalThis, { window: originalWindow });
}
});
test('visible yomitan popup host keeps overlay interactive even when cached popup state is false', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
Object.assign(globalThis, {
window: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
},
document: {
querySelectorAll: (selector: string) =>
selector === '[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
? [{ getAttribute: () => 'true' }]
: [],
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
shouldToggleMouseIgnore: true,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
Object.assign(globalThis, { window: originalWindow, document: originalDocument });
}
});
+1 -12
View File
@@ -1,6 +1,5 @@
import type { RendererContext } from './context';
import type { RendererState } from './state';
import { isYomitanPopupVisible } from './yomitan-popup.js';
function isBlockingOverlayModalOpen(state: RendererState): boolean {
return Boolean(
@@ -15,21 +14,11 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
);
}
function isYomitanPopupInteractionActive(state: RendererState): boolean {
if (state.yomitanPopupVisible) {
return true;
}
if (typeof document === 'undefined') {
return false;
}
return isYomitanPopupVisible(document);
}
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
const shouldStayInteractive =
ctx.state.isOverSubtitle ||
ctx.state.isOverSubtitleSidebar ||
isYomitanPopupInteractionActive(ctx.state) ||
ctx.state.yomitanPopupVisible ||
isBlockingOverlayModalOpen(ctx.state);
if (shouldStayInteractive) {
+11 -25
View File
@@ -55,8 +55,6 @@ import { resolveRendererDom } from './utils/dom.js';
import { resolvePlatformInfo } from './utils/platform.js';
import {
buildMpvLoadfileCommands,
buildMpvSubtitleAddCommands,
collectDroppedSubtitlePaths,
collectDroppedVideoPaths,
} from '../core/services/overlay-drop.js';
@@ -529,12 +527,6 @@ async function init(): Promise<void> {
if (ctx.platform.isMacOSPlatform) {
document.body.classList.add('platform-macos');
}
if (ctx.platform.isWindowsPlatform) {
document.body.classList.add('platform-windows');
}
if (ctx.platform.shouldToggleMouseIgnore) {
syncOverlayMouseIgnoreState(ctx);
}
window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => {
@@ -662,6 +654,10 @@ async function init(): Promise<void> {
);
measurementReporter.schedule();
if (ctx.platform.shouldToggleMouseIgnore) {
syncOverlayMouseIgnoreState(ctx);
}
measurementReporter.emitNow();
}
@@ -710,28 +706,18 @@ function setupDragDropToMpvQueue(): void {
if (!event.dataTransfer) return;
event.preventDefault();
const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer);
const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer);
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, event.shiftKey);
const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths);
const droppedPaths = collectDroppedVideoPaths(event.dataTransfer);
const loadCommands = buildMpvLoadfileCommands(droppedPaths, event.shiftKey);
for (const command of loadCommands) {
window.electronAPI.sendMpvCommand(command);
}
for (const command of subtitleCommands) {
window.electronAPI.sendMpvCommand(command);
}
const osdParts: string[] = [];
if (loadCommands.length > 0) {
const action = event.shiftKey ? 'Queued' : 'Loaded';
osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`);
}
if (subtitleCommands.length > 0) {
osdParts.push(
`Loaded ${subtitleCommands.length} subtitle file${subtitleCommands.length === 1 ? '' : 's'}`,
);
}
if (osdParts.length > 0) {
window.electronAPI.sendMpvCommand(['show-text', osdParts.join(' | '), '1500']);
window.electronAPI.sendMpvCommand([
'show-text',
`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`,
'1500',
]);
}
clearDropInteractive();
+2 -9
View File
@@ -684,8 +684,7 @@ body.settings-modal-open #subtitleContainer {
}
body.settings-modal-open iframe.yomitan-popup,
body.settings-modal-open iframe[id^='yomitan-popup'],
body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
body.settings-modal-open iframe[id^='yomitan-popup'] {
display: none !important;
pointer-events: none !important;
}
@@ -1131,11 +1130,6 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
justify-content: center;
}
body.platform-windows #secondarySubContainer.secondary-sub-hover {
top: 40px;
padding-top: 0;
}
#secondarySubContainer.secondary-sub-hover #secondarySubRoot {
background: transparent;
backdrop-filter: none;
@@ -1157,8 +1151,7 @@ body.platform-windows #secondarySubContainer.secondary-sub-hover {
}
iframe.yomitan-popup,
iframe[id^='yomitan-popup'],
[data-subminer-yomitan-popup-host='true'] {
iframe[id^='yomitan-popup'] {
pointer-events: auto !important;
z-index: 2147483647 !important;
}
-7
View File
@@ -989,13 +989,6 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
/transform:\s*translateX\(calc\(var\(--subtitle-sidebar-reserved-width\)\s*\*\s*-0\.5\)\);/,
);
const secondaryHoverWindowsBlock = extractClassBlock(
cssText,
'body.platform-windows #secondarySubContainer.secondary-sub-hover',
);
assert.match(secondaryHoverWindowsBlock, /top:\s*40px;/);
assert.match(secondaryHoverWindowsBlock, /padding-top:\s*0;/);
const subtitleSidebarListBlock = extractClassBlock(cssText, '.subtitle-sidebar-list');
assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/);
-4
View File
@@ -5,7 +5,6 @@ export type PlatformInfo = {
isModalLayer: boolean;
isLinuxPlatform: boolean;
isMacOSPlatform: boolean;
isWindowsPlatform: boolean;
shouldToggleMouseIgnore: boolean;
};
@@ -25,15 +24,12 @@ export function resolvePlatformInfo(): PlatformInfo {
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
const isMacOSPlatform =
navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent);
const isWindowsPlatform =
navigator.platform.toLowerCase().includes('win') || /windows/i.test(navigator.userAgent);
return {
overlayLayer,
isModalLayer,
isLinuxPlatform,
isMacOSPlatform,
isWindowsPlatform,
shouldToggleMouseIgnore: !isLinuxPlatform && !isModalLayer,
};
}
+10 -49
View File
@@ -1,8 +1,4 @@
export const YOMITAN_POPUP_IFRAME_SELECTOR = 'iframe.yomitan-popup, iframe[id^="yomitan-popup"]';
export const YOMITAN_POPUP_HOST_SELECTOR = '[data-subminer-yomitan-popup-host="true"]';
export const YOMITAN_POPUP_VISIBLE_HOST_SELECTOR =
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]';
const YOMITAN_POPUP_VISIBLE_ATTRIBUTE = 'data-subminer-yomitan-popup-visible';
export const YOMITAN_POPUP_SHOWN_EVENT = 'yomitan-popup-shown';
export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
@@ -33,56 +29,21 @@ export function isYomitanPopupIframe(element: Element | null): boolean {
}
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
return (
typeof root.querySelector === 'function' &&
(root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null ||
root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null)
);
}
function isVisiblePopupElement(element: Element): boolean {
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
return false;
}
const styles = window.getComputedStyle(element);
if (styles.visibility === 'hidden' || styles.display === 'none' || styles.opacity === '0') {
return false;
}
return true;
}
function isMarkedVisiblePopupHost(element: Element): boolean {
return element.getAttribute(YOMITAN_POPUP_VISIBLE_ATTRIBUTE) === 'true';
}
function queryPopupElements<T extends Element>(root: ParentNode, selector: string): T[] {
if (typeof root.querySelectorAll !== 'function') {
return [];
}
return Array.from(root.querySelectorAll<T>(selector));
return root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null;
}
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
const visiblePopupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
if (visiblePopupHosts.length > 0) {
return true;
}
const popupIframes = queryPopupElements<HTMLIFrameElement>(root, YOMITAN_POPUP_IFRAME_SELECTOR);
const popupIframes = root.querySelectorAll<HTMLIFrameElement>(YOMITAN_POPUP_IFRAME_SELECTOR);
for (const iframe of popupIframes) {
if (isVisiblePopupElement(iframe)) {
const rect = iframe.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const styles = window.getComputedStyle(iframe);
if (styles.visibility === 'hidden' || styles.display === 'none' || styles.opacity === '0') {
continue;
}
return true;
}
}
const popupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_HOST_SELECTOR);
for (const host of popupHosts) {
if (isMarkedVisiblePopupHost(host)) {
return true;
}
}
return false;
}
-4
View File
@@ -62,10 +62,6 @@ export abstract class BaseWindowTracker {
return this.targetWindowFocused;
}
isTargetWindowMinimized(): boolean {
return false;
}
protected updateTargetWindowFocused(focused: boolean): void {
if (this.targetWindowFocused === focused) {
return;
-250
View File
@@ -1,250 +0,0 @@
import koffi from 'koffi';
const user32 = koffi.load('user32.dll');
const dwmapi = koffi.load('dwmapi.dll');
const kernel32 = koffi.load('kernel32.dll');
const RECT = koffi.struct('RECT', {
Left: 'int',
Top: 'int',
Right: 'int',
Bottom: 'int',
});
const MARGINS = koffi.struct('MARGINS', {
cxLeftWidth: 'int',
cxRightWidth: 'int',
cyTopHeight: 'int',
cyBottomHeight: 'int',
});
const WNDENUMPROC = koffi.proto('bool __stdcall WNDENUMPROC(intptr hwnd, intptr lParam)');
const EnumWindows = user32.func('bool __stdcall EnumWindows(WNDENUMPROC *cb, intptr lParam)');
const IsWindowVisible = user32.func('bool __stdcall IsWindowVisible(intptr hwnd)');
const IsIconic = user32.func('bool __stdcall IsIconic(intptr hwnd)');
const GetForegroundWindow = user32.func('intptr __stdcall GetForegroundWindow()');
const SetWindowPos = user32.func(
'bool __stdcall SetWindowPos(intptr hwnd, intptr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags)',
);
const GetWindowThreadProcessId = user32.func(
'uint __stdcall GetWindowThreadProcessId(intptr hwnd, _Out_ uint *lpdwProcessId)',
);
const GetWindowLongW = user32.func('int __stdcall GetWindowLongW(intptr hwnd, int nIndex)');
const SetWindowLongPtrW = user32.func(
'intptr __stdcall SetWindowLongPtrW(intptr hwnd, int nIndex, intptr dwNewLong)',
);
const GetWindowFn = user32.func('intptr __stdcall GetWindow(intptr hwnd, uint uCmd)');
const GetWindowRect = user32.func('bool __stdcall GetWindowRect(intptr hwnd, _Out_ RECT *lpRect)');
const DwmGetWindowAttribute = dwmapi.func(
'int __stdcall DwmGetWindowAttribute(intptr hwnd, uint dwAttribute, _Out_ RECT *pvAttribute, uint cbAttribute)',
);
const DwmExtendFrameIntoClientArea = dwmapi.func(
'int __stdcall DwmExtendFrameIntoClientArea(intptr hwnd, MARGINS *pMarInset)',
);
const OpenProcess = kernel32.func(
'intptr __stdcall OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId)',
);
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(intptr hObject)');
const QueryFullProcessImageNameW = kernel32.func(
'bool __stdcall QueryFullProcessImageNameW(intptr hProcess, uint dwFlags, _Out_ uint16 *lpExeName, _Inout_ uint *lpdwSize)',
);
const GWL_EXSTYLE = -20;
const WS_EX_TOPMOST = 0x00000008;
const GWLP_HWNDPARENT = -8;
const GW_HWNDPREV = 3;
const DWMWA_EXTENDED_FRAME_BOUNDS = 9;
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
const SWP_NOSIZE = 0x0001;
const SWP_NOMOVE = 0x0002;
const SWP_NOACTIVATE = 0x0010;
const SWP_NOOWNERZORDER = 0x0200;
const SWP_FLAGS = SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_NOOWNERZORDER;
const HWND_TOP = 0;
const HWND_BOTTOM = 1;
const HWND_TOPMOST = -1;
const HWND_NOTOPMOST = -2;
function extendOverlayFrameIntoClientArea(overlayHwnd: number): void {
DwmExtendFrameIntoClientArea(overlayHwnd, {
cxLeftWidth: -1,
cxRightWidth: -1,
cyTopHeight: -1,
cyBottomHeight: -1,
});
}
export interface WindowBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface MpvWindowMatch {
hwnd: number;
bounds: WindowBounds;
area: number;
isForeground: boolean;
}
export interface MpvPollResult {
matches: MpvWindowMatch[];
focusState: boolean;
windowState: 'visible' | 'minimized' | 'not-found';
}
function getWindowBounds(hwnd: number): WindowBounds | null {
const rect = { Left: 0, Top: 0, Right: 0, Bottom: 0 };
const hr = DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, rect, koffi.sizeof(RECT));
if (hr !== 0) {
if (!GetWindowRect(hwnd, rect)) {
return null;
}
}
const width = rect.Right - rect.Left;
const height = rect.Bottom - rect.Top;
if (width <= 0 || height <= 0) return null;
return { x: rect.Left, y: rect.Top, width, height };
}
function getProcessNameByPid(pid: number): string | null {
const hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
if (!hProcess) return null;
try {
const buffer = new Uint16Array(260);
const size = new Uint32Array([260]);
if (!QueryFullProcessImageNameW(hProcess, 0, buffer, size)) {
return null;
}
const fullPath = String.fromCharCode(...buffer.slice(0, size[0]));
const fileName = fullPath.split('\\').pop() || '';
return fileName.replace(/\.exe$/i, '');
} finally {
CloseHandle(hProcess);
}
}
export function findMpvWindows(): MpvPollResult {
const foregroundHwnd = GetForegroundWindow();
const matches: MpvWindowMatch[] = [];
let hasMinimized = false;
let hasFocused = false;
const processNameCache = new Map<number, string | null>();
const cb = koffi.register((hwnd: number, _lParam: number) => {
if (!IsWindowVisible(hwnd)) return true;
const pid = new Uint32Array(1);
GetWindowThreadProcessId(hwnd, pid);
const pidValue = pid[0]!;
if (pidValue === 0) return true;
let processName = processNameCache.get(pidValue);
if (processName === undefined) {
processName = getProcessNameByPid(pidValue);
processNameCache.set(pidValue, processName);
}
if (!processName || processName.toLowerCase() !== 'mpv') return true;
if (IsIconic(hwnd)) {
hasMinimized = true;
return true;
}
const bounds = getWindowBounds(hwnd);
if (!bounds) return true;
const isForeground = foregroundHwnd !== 0 && hwnd === foregroundHwnd;
if (isForeground) hasFocused = true;
matches.push({
hwnd,
bounds,
area: bounds.width * bounds.height,
isForeground,
});
return true;
}, koffi.pointer(WNDENUMPROC));
try {
EnumWindows(cb, 0);
} finally {
koffi.unregister(cb);
}
return {
matches,
focusState: hasFocused,
windowState: matches.length > 0 ? 'visible' : hasMinimized ? 'minimized' : 'not-found',
};
}
export function getForegroundProcessName(): string | null {
const foregroundHwnd = GetForegroundWindow();
if (!foregroundHwnd) return null;
const pid = new Uint32Array(1);
GetWindowThreadProcessId(foregroundHwnd, pid);
const pidValue = pid[0]!;
if (pidValue === 0) return null;
return getProcessNameByPid(pidValue);
}
export function setOverlayOwner(overlayHwnd: number, mpvHwnd: number): void {
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd);
extendOverlayFrameIntoClientArea(overlayHwnd);
}
export function ensureOverlayTransparency(overlayHwnd: number): void {
extendOverlayFrameIntoClientArea(overlayHwnd);
}
export function clearOverlayOwner(overlayHwnd: number): void {
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, 0);
}
export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void {
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd);
const mpvExStyle = GetWindowLongW(mpvHwnd, GWL_EXSTYLE);
const mpvIsTopmost = (mpvExStyle & WS_EX_TOPMOST) !== 0;
const overlayExStyle = GetWindowLongW(overlayHwnd, GWL_EXSTYLE);
const overlayIsTopmost = (overlayExStyle & WS_EX_TOPMOST) !== 0;
if (mpvIsTopmost && !overlayIsTopmost) {
SetWindowPos(overlayHwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_FLAGS);
} else if (!mpvIsTopmost && overlayIsTopmost) {
SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS);
}
const windowAboveMpv = GetWindowFn(mpvHwnd, GW_HWNDPREV);
if (windowAboveMpv !== 0 && windowAboveMpv === overlayHwnd) return;
let insertAfter = HWND_TOP;
if (windowAboveMpv !== 0) {
const aboveExStyle = GetWindowLongW(windowAboveMpv, GWL_EXSTYLE);
const aboveIsTopmost = (aboveExStyle & WS_EX_TOPMOST) !== 0;
if (aboveIsTopmost === mpvIsTopmost) {
insertAfter = windowAboveMpv;
}
}
SetWindowPos(overlayHwnd, insertAfter, 0, 0, 0, 0, SWP_FLAGS);
}
export function lowerOverlay(overlayHwnd: number): void {
SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS);
SetWindowPos(overlayHwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_FLAGS);
}
-104
View File
@@ -1,14 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
lowerWindowsOverlayInZOrder,
parseWindowTrackerHelperForegroundProcess,
parseWindowTrackerHelperFocusState,
parseWindowTrackerHelperOutput,
parseWindowTrackerHelperState,
queryWindowsForegroundProcessName,
resolveWindowsTrackerHelper,
syncWindowsOverlayToMpvZOrder,
} from './windows-helper';
test('parseWindowTrackerHelperOutput parses helper geometry output', () => {
@@ -33,105 +28,6 @@ test('parseWindowTrackerHelperFocusState parses helper stderr metadata', () => {
assert.equal(parseWindowTrackerHelperFocusState(''), null);
});
test('parseWindowTrackerHelperState parses helper stderr metadata', () => {
assert.equal(parseWindowTrackerHelperState('state=visible'), 'visible');
assert.equal(parseWindowTrackerHelperState('focus=not-focused\nstate=minimized'), 'minimized');
assert.equal(parseWindowTrackerHelperState('state=unknown'), null);
assert.equal(parseWindowTrackerHelperState(''), null);
});
test('parseWindowTrackerHelperForegroundProcess parses helper stdout metadata', () => {
assert.equal(parseWindowTrackerHelperForegroundProcess('process=mpv'), 'mpv');
assert.equal(parseWindowTrackerHelperForegroundProcess('process=chrome'), 'chrome');
assert.equal(parseWindowTrackerHelperForegroundProcess('not-found'), null);
assert.equal(parseWindowTrackerHelperForegroundProcess(''), null);
});
test('queryWindowsForegroundProcessName reads foreground process from powershell helper', async () => {
const processName = await queryWindowsForegroundProcessName({
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async () => ({
stdout: 'process=mpv',
stderr: '',
}),
});
assert.equal(processName, 'mpv');
});
test('queryWindowsForegroundProcessName returns null when no powershell helper is available', async () => {
const processName = await queryWindowsForegroundProcessName({
resolveHelper: () => ({
kind: 'native',
command: 'helper.exe',
args: [],
helperPath: 'helper.exe',
}),
});
assert.equal(processName, null);
});
test('syncWindowsOverlayToMpvZOrder forwards socket path and overlay handle to powershell helper', async () => {
let capturedMode: string | null = null;
let capturedArgs: string[] | null = null;
const synced = await syncWindowsOverlayToMpvZOrder({
overlayWindowHandle: '12345',
targetMpvSocketPath: '\\\\.\\pipe\\subminer-socket',
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async (_spec, mode, extraArgs = []) => {
capturedMode = mode;
capturedArgs = extraArgs;
return {
stdout: 'ok',
stderr: '',
};
},
});
assert.equal(synced, true);
assert.equal(capturedMode, 'bind-overlay');
assert.deepEqual(capturedArgs, ['\\\\.\\pipe\\subminer-socket', '12345']);
});
test('lowerWindowsOverlayInZOrder forwards overlay handle to powershell helper', async () => {
let capturedMode: string | null = null;
let capturedArgs: string[] | null = null;
const lowered = await lowerWindowsOverlayInZOrder({
overlayWindowHandle: '67890',
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async (_spec, mode, extraArgs = []) => {
capturedMode = mode;
capturedArgs = extraArgs;
return {
stdout: 'ok',
stderr: '',
};
},
});
assert.equal(lowered, true);
assert.equal(capturedMode, 'lower-overlay');
assert.deepEqual(capturedArgs, ['67890']);
});
test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => {
const helper = resolveWindowsTrackerHelper({
dirname: 'C:\\repo\\dist\\window-trackers',
-199
View File
@@ -19,7 +19,6 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { execFile, type ExecFileException } from 'child_process';
import type { WindowGeometry } from '../types';
import { createLogger } from '../logger';
@@ -27,13 +26,6 @@ const log = createLogger('tracker').child('windows-helper');
export type WindowsTrackerHelperKind = 'powershell' | 'native';
export type WindowsTrackerHelperMode = 'auto' | 'powershell' | 'native';
export type WindowsTrackerHelperRunMode =
| 'geometry'
| 'foreground-process'
| 'bind-overlay'
| 'lower-overlay'
| 'set-owner'
| 'clear-owner';
export type WindowsTrackerHelperLaunchSpec = {
kind: WindowsTrackerHelperKind;
@@ -227,197 +219,6 @@ export function parseWindowTrackerHelperFocusState(output: string): boolean | nu
return null;
}
export function parseWindowTrackerHelperState(output: string): 'visible' | 'minimized' | null {
const stateLine = output
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith('state='));
if (!stateLine) {
return null;
}
const value = stateLine.slice('state='.length).trim().toLowerCase();
if (value === 'visible') {
return 'visible';
}
if (value === 'minimized') {
return 'minimized';
}
return null;
}
export function parseWindowTrackerHelperForegroundProcess(output: string): string | null {
const processLine = output
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith('process='));
if (!processLine) {
return null;
}
const value = processLine.slice('process='.length).trim();
return value.length > 0 ? value : null;
}
type WindowsTrackerHelperRunnerResult = {
stdout: string;
stderr: string;
};
function runWindowsTrackerHelperWithExecFile(
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs: string[] = [],
): Promise<WindowsTrackerHelperRunnerResult> {
return new Promise((resolve, reject) => {
const modeArgs = spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode];
execFile(
spec.command,
[...spec.args, ...modeArgs, ...extraArgs],
{
encoding: 'utf-8',
timeout: 1000,
maxBuffer: 1024 * 1024,
windowsHide: true,
},
(error: ExecFileException | null, stdout: string, stderr: string) => {
if (error) {
reject(Object.assign(error, { stderr }));
return;
}
resolve({ stdout, stderr });
},
);
});
}
export async function queryWindowsForegroundProcessName(deps: {
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelper?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs?: string[],
) => Promise<WindowsTrackerHelperRunnerResult>;
} = {}): Promise<string | null> {
const spec =
deps.resolveHelper?.() ??
resolveWindowsTrackerHelper({
helperModeEnv: 'powershell',
});
if (!spec || spec.kind !== 'powershell') {
return null;
}
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
const { stdout } = await runHelper(spec, 'foreground-process');
return parseWindowTrackerHelperForegroundProcess(stdout);
}
export async function syncWindowsOverlayToMpvZOrder(deps: {
overlayWindowHandle: string;
targetMpvSocketPath?: string | null;
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelper?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs?: string[],
) => Promise<WindowsTrackerHelperRunnerResult>;
}): Promise<boolean> {
const spec =
deps.resolveHelper?.() ??
resolveWindowsTrackerHelper({
helperModeEnv: 'powershell',
});
if (!spec || spec.kind !== 'powershell') {
return false;
}
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
const extraArgs = [deps.targetMpvSocketPath ?? '', deps.overlayWindowHandle];
const { stdout } = await runHelper(spec, 'bind-overlay', extraArgs);
return stdout.trim() === 'ok';
}
export async function lowerWindowsOverlayInZOrder(deps: {
overlayWindowHandle: string;
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelper?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs?: string[],
) => Promise<WindowsTrackerHelperRunnerResult>;
}): Promise<boolean> {
const spec =
deps.resolveHelper?.() ??
resolveWindowsTrackerHelper({
helperModeEnv: 'powershell',
});
if (!spec || spec.kind !== 'powershell') {
return false;
}
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
const { stdout } = await runHelper(spec, 'lower-overlay', [deps.overlayWindowHandle]);
return stdout.trim() === 'ok';
}
export function setWindowsOverlayOwnerNative(overlayHwnd: number, mpvHwnd: number): boolean {
try {
const win32 = require('./win32') as typeof import('./win32');
win32.setOverlayOwner(overlayHwnd, mpvHwnd);
return true;
} catch {
return false;
}
}
export function ensureWindowsOverlayTransparencyNative(overlayHwnd: number): boolean {
try {
const win32 = require('./win32') as typeof import('./win32');
win32.ensureOverlayTransparency(overlayHwnd);
return true;
} catch {
return false;
}
}
export function bindWindowsOverlayAboveMpvNative(overlayHwnd: number): boolean {
try {
const win32 = require('./win32') as typeof import('./win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
const best = focused ?? poll.matches.sort((a, b) => b.area - a.area)[0];
if (!best) return false;
win32.bindOverlayAboveMpv(overlayHwnd, best.hwnd);
win32.ensureOverlayTransparency(overlayHwnd);
return true;
} catch {
return false;
}
}
export function clearWindowsOverlayOwnerNative(overlayHwnd: number): boolean {
try {
const win32 = require('./win32') as typeof import('./win32');
win32.clearOverlayOwner(overlayHwnd);
return true;
} catch {
return false;
}
}
export function getWindowsForegroundProcessNameNative(): string | null {
try {
const win32 = require('./win32') as typeof import('./win32');
return win32.getForegroundProcessName();
} catch {
return null;
}
}
export function resolveWindowsTrackerHelper(
options: ResolveWindowsTrackerHelperOptions = {},
): WindowsTrackerHelperLaunchSpec | null {
+82 -209
View File
@@ -1,62 +1,56 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { WindowsWindowTracker } from './windows-tracker';
import type { MpvPollResult } from './win32';
function mpvVisible(
overrides: Partial<MpvPollResult & { x?: number; y?: number; width?: number; height?: number; focused?: boolean }> = {},
): MpvPollResult {
test('WindowsWindowTracker skips overlapping polls while helper is in flight', async () => {
let helperCalls = 0;
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const tracker = new WindowsWindowTracker(undefined, {
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async () => {
helperCalls += 1;
await gate;
return {
matches: [
{
hwnd: 12345,
bounds: {
x: overrides.x ?? 0,
y: overrides.y ?? 0,
width: overrides.width ?? 1280,
height: overrides.height ?? 720,
stdout: '0,0,640,360',
stderr: 'focus=focused',
};
},
area: (overrides.width ?? 1280) * (overrides.height ?? 720),
isForeground: overrides.focused ?? true,
},
],
focusState: overrides.focused ?? true,
windowState: 'visible',
};
}
});
const mpvNotFound: MpvPollResult = {
matches: [],
focusState: false,
windowState: 'not-found',
};
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(helperCalls, 1);
const mpvMinimized: MpvPollResult = {
matches: [],
focusState: false,
windowState: 'minimized',
};
assert.ok(release);
release();
await new Promise((resolve) => setTimeout(resolve, 0));
});
test('WindowsWindowTracker skips overlapping polls while poll is in flight', () => {
let pollCalls = 0;
test('WindowsWindowTracker updates geometry from helper output', async () => {
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => {
pollCalls += 1;
return mpvVisible();
},
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(pollCalls, 2);
});
test('WindowsWindowTracker updates geometry from poll output', () => {
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async () => ({
stdout: '10,20,1280,720',
stderr: 'focus=focused',
}),
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(tracker.getGeometry(), {
x: 10,
@@ -67,180 +61,59 @@ test('WindowsWindowTracker updates geometry from poll output', () => {
assert.equal(tracker.isTargetWindowFocused(), true);
});
test('WindowsWindowTracker clears geometry for poll misses', () => {
test('WindowsWindowTracker clears geometry for helper misses', async () => {
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => mpvNotFound,
trackingLossGraceMs: 0,
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async () => ({
stdout: 'not-found',
stderr: 'focus=not-focused',
}),
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.getGeometry(), null);
assert.equal(tracker.isTargetWindowFocused(), false);
});
test('WindowsWindowTracker keeps the last geometry through a single poll miss', () => {
let callIndex = 0;
const outputs = [
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
mpvNotFound,
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
];
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!,
trackingLossGraceMs: 0,
test('WindowsWindowTracker retries without socket filter when filtered helper lookup misses', async () => {
const helperCalls: Array<string | null> = [];
const tracker = new WindowsWindowTracker('\\\\.\\pipe\\subminer-socket', {
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async (_spec, _mode, targetMpvSocketPath) => {
helperCalls.push(targetMpvSocketPath);
if (targetMpvSocketPath) {
return {
stdout: 'not-found',
stderr: 'focus=not-focused',
};
}
return {
stdout: '25,30,1440,810',
stderr: 'focus=focused',
};
},
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
await new Promise((resolve) => setTimeout(resolve, 0));
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
assert.deepEqual(helperCalls, ['\\\\.\\pipe\\subminer-socket', null]);
assert.deepEqual(tracker.getGeometry(), {
x: 25,
y: 30,
width: 1440,
height: 810,
});
test('WindowsWindowTracker drops tracking after grace window expires', () => {
let callIndex = 0;
let now = 1_000;
const outputs = [
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
mpvNotFound,
mpvNotFound,
mpvNotFound,
mpvNotFound,
];
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!,
now: () => now,
trackingLossGraceMs: 500,
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), false);
assert.equal(tracker.getGeometry(), null);
});
test('WindowsWindowTracker keeps tracking through repeated poll misses inside grace window', () => {
let callIndex = 0;
let now = 1_000;
const outputs = [
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
mpvNotFound,
mpvNotFound,
mpvNotFound,
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
];
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!,
now: () => now,
trackingLossGraceMs: 1_500,
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
});
test('WindowsWindowTracker keeps tracking through a transient minimized report inside minimized grace window', () => {
let callIndex = 0;
let now = 1_000;
const outputs: MpvPollResult[] = [
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
mpvMinimized,
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
];
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!,
now: () => now,
minimizedTrackingLossGraceMs: 200,
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 100;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
now += 100;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
});
test('WindowsWindowTracker keeps tracking through repeated transient minimized reports inside minimized grace window', () => {
let callIndex = 0;
let now = 1_000;
const outputs: MpvPollResult[] = [
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
mpvMinimized,
mpvMinimized,
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
];
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!,
now: () => now,
minimizedTrackingLossGraceMs: 500,
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowMinimized(), true);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowMinimized(), true);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowMinimized(), false);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
assert.equal(tracker.isTargetWindowFocused(), true);
});
+110 -107
View File
@@ -16,50 +16,80 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { execFile, type ExecFileException } from 'child_process';
import { BaseWindowTracker } from './base-tracker';
import type { WindowGeometry } from '../types';
import type { MpvPollResult } from './win32';
import {
parseWindowTrackerHelperFocusState,
parseWindowTrackerHelperOutput,
resolveWindowsTrackerHelper,
type WindowsTrackerHelperLaunchSpec,
} from './windows-helper';
import { createLogger } from '../logger';
const log = createLogger('tracker').child('windows');
type WindowsTrackerDeps = {
pollMpvWindows?: () => MpvPollResult;
maxConsecutiveMisses?: number;
trackingLossGraceMs?: number;
minimizedTrackingLossGraceMs?: number;
now?: () => number;
type WindowsTrackerRunnerResult = {
stdout: string;
stderr: string;
};
function defaultPollMpvWindows(): MpvPollResult {
const win32 = require('./win32') as typeof import('./win32');
return win32.findMpvWindows();
type WindowsTrackerDeps = {
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelper?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: 'geometry',
targetMpvSocketPath: string | null,
) => Promise<WindowsTrackerRunnerResult>;
};
function runHelperWithExecFile(
spec: WindowsTrackerHelperLaunchSpec,
mode: 'geometry',
targetMpvSocketPath: string | null,
): Promise<WindowsTrackerRunnerResult> {
return new Promise((resolve, reject) => {
const modeArgs = spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode];
const args = targetMpvSocketPath
? [...spec.args, ...modeArgs, targetMpvSocketPath]
: [...spec.args, ...modeArgs];
execFile(
spec.command,
args,
{
encoding: 'utf-8',
timeout: 1000,
maxBuffer: 1024 * 1024,
windowsHide: true,
},
(error: ExecFileException | null, stdout: string, stderr: string) => {
if (error) {
reject(Object.assign(error, { stderr }));
return;
}
resolve({ stdout, stderr });
},
);
});
}
export class WindowsWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private pollInFlight = false;
private readonly pollMpvWindows: () => MpvPollResult;
private readonly maxConsecutiveMisses: number;
private readonly trackingLossGraceMs: number;
private readonly minimizedTrackingLossGraceMs: number;
private readonly now: () => number;
private lastPollErrorFingerprint: string | null = null;
private lastPollErrorLoggedAtMs = 0;
private consecutiveMisses = 0;
private trackingLossStartedAtMs: number | null = null;
private targetWindowMinimized = false;
private helperSpec: WindowsTrackerHelperLaunchSpec | null;
private readonly targetMpvSocketPath: string | null;
private readonly runHelper: (
spec: WindowsTrackerHelperLaunchSpec,
mode: 'geometry',
targetMpvSocketPath: string | null,
) => Promise<WindowsTrackerRunnerResult>;
private lastExecErrorFingerprint: string | null = null;
private lastExecErrorLoggedAtMs = 0;
constructor(_targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
constructor(targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
super();
this.pollMpvWindows = deps.pollMpvWindows ?? defaultPollMpvWindows;
this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2));
this.trackingLossGraceMs = Math.max(0, Math.floor(deps.trackingLossGraceMs ?? 1_500));
this.minimizedTrackingLossGraceMs = Math.max(
0,
Math.floor(deps.minimizedTrackingLossGraceMs ?? 500),
);
this.now = deps.now ?? (() => Date.now());
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.helperSpec = deps.resolveHelper ? deps.resolveHelper() : resolveWindowsTrackerHelper();
this.runHelper = deps.runHelper ?? runHelperWithExecFile;
}
start(): void {
@@ -74,99 +104,72 @@ export class WindowsWindowTracker extends BaseWindowTracker {
}
}
override isTargetWindowMinimized(): boolean {
return this.targetWindowMinimized;
}
private maybeLogPollError(error: Error): void {
private maybeLogExecError(error: Error, stderr: string): void {
const now = Date.now();
const fingerprint = error.message;
const fingerprint = `${error.message}|${stderr.trim()}`;
const shouldLog =
this.lastPollErrorFingerprint !== fingerprint || now - this.lastPollErrorLoggedAtMs >= 5000;
if (!shouldLog) return;
this.lastPollErrorFingerprint = fingerprint;
this.lastPollErrorLoggedAtMs = now;
log.warn('Windows native poll failed', { error: error.message });
this.lastExecErrorFingerprint !== fingerprint || now - this.lastExecErrorLoggedAtMs >= 5000;
if (!shouldLog) {
return;
}
private resetTrackingLossState(): void {
this.consecutiveMisses = 0;
this.trackingLossStartedAtMs = null;
this.lastExecErrorFingerprint = fingerprint;
this.lastExecErrorLoggedAtMs = now;
log.warn('Windows helper execution failed', {
helperPath: this.helperSpec?.helperPath ?? null,
helperKind: this.helperSpec?.kind ?? null,
error: error.message,
stderr: stderr.trim(),
});
}
private shouldDropTracking(graceMs = this.trackingLossGraceMs): boolean {
if (!this.isTracking()) {
return true;
}
if (graceMs === 0) {
return this.consecutiveMisses >= this.maxConsecutiveMisses;
}
if (this.trackingLossStartedAtMs === null) {
this.trackingLossStartedAtMs = this.now();
return false;
}
return this.now() - this.trackingLossStartedAtMs > graceMs;
private async runHelperWithSocketFallback(): Promise<WindowsTrackerRunnerResult> {
if (!this.helperSpec) {
return { stdout: 'not-found', stderr: '' };
}
private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void {
this.consecutiveMisses += 1;
if (this.shouldDropTracking(graceMs)) {
this.updateGeometry(null);
this.resetTrackingLossState();
try {
const primary = await this.runHelper(this.helperSpec, 'geometry', this.targetMpvSocketPath);
const primaryGeometry = parseWindowTrackerHelperOutput(primary.stdout);
if (primaryGeometry || !this.targetMpvSocketPath) {
return primary;
}
} catch (error) {
if (!this.targetMpvSocketPath) {
throw error;
}
}
private selectBestMatch(
result: MpvPollResult,
): { geometry: WindowGeometry; focused: boolean } | null {
if (result.matches.length === 0) return null;
const focusedMatch = result.matches.find((m) => m.isForeground);
const best =
focusedMatch ??
result.matches.sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0]!;
return {
geometry: best.bounds,
focused: best.isForeground,
};
return await this.runHelper(this.helperSpec, 'geometry', null);
}
private pollGeometry(): void {
if (this.pollInFlight) return;
if (this.pollInFlight || !this.helperSpec) {
return;
}
this.pollInFlight = true;
try {
const result = this.pollMpvWindows();
const best = this.selectBestMatch(result);
if (best) {
this.resetTrackingLossState();
this.targetWindowMinimized = false;
this.updateTargetWindowFocused(best.focused);
this.updateGeometry(best.geometry);
return;
}
if (result.windowState === 'minimized') {
this.targetWindowMinimized = true;
this.updateTargetWindowFocused(false);
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
return;
}
this.targetWindowMinimized = false;
this.updateTargetWindowFocused(false);
this.registerTrackingMiss();
} catch (error: unknown) {
void this.runHelperWithSocketFallback()
.then(({ stdout, stderr }) => {
const geometry = parseWindowTrackerHelperOutput(stdout);
const focusState = parseWindowTrackerHelperFocusState(stderr);
this.updateTargetWindowFocused(focusState ?? Boolean(geometry));
this.updateGeometry(geometry);
})
.catch((error: unknown) => {
const err = error instanceof Error ? error : new Error(String(error));
this.maybeLogPollError(err);
this.targetWindowMinimized = false;
this.updateTargetWindowFocused(false);
this.registerTrackingMiss();
} finally {
const stderr =
typeof error === 'object' &&
error !== null &&
'stderr' in error &&
typeof (error as { stderr?: unknown }).stderr === 'string'
? (error as { stderr: string }).stderr
: '';
this.maybeLogExecError(err, stderr);
this.updateGeometry(null);
})
.finally(() => {
this.pollInFlight = false;
}
});
}
}
+3 -3
View File
@@ -93,7 +93,7 @@ export function AnimeTab({
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Search anime..."
placeholder="Search library..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
@@ -125,12 +125,12 @@ export function AnimeTab({
))}
</div>
<div className="text-xs text-ctp-overlay2 shrink-0">
{filtered.length} anime · {formatDuration(totalMs)}
{filtered.length} titles · {formatDuration(totalMs)}
</div>
</div>
{filtered.length === 0 ? (
<div className="text-sm text-ctp-overlay2 p-4">No anime found</div>
<div className="text-sm text-ctp-overlay2 p-4">No titles found</div>
) : (
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
{filtered.map((item) => (
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { filterCardEvents } from './EpisodeDetail';
import type { EpisodeCardEvent } from '../../types/stats';
function makeEvent(over: Partial<EpisodeCardEvent> & { eventId: number }): EpisodeCardEvent {
return {
sessionId: 1,
tsMs: 0,
cardsDelta: 1,
noteIds: [],
...over,
};
}
test('filterCardEvents: before load, returns all events unchanged', () => {
const ev1 = makeEvent({ eventId: 1, noteIds: [101] });
const ev2 = makeEvent({ eventId: 2, noteIds: [102] });
const noteInfos = new Map(); // empty — simulates pre-load state
const result = filterCardEvents([ev1, ev2], noteInfos, /* noteInfosLoaded */ false);
assert.equal(result.length, 2, 'should return both events before load');
assert.deepEqual(result[0]?.noteIds, [101]);
assert.deepEqual(result[1]?.noteIds, [102]);
});
test('filterCardEvents: after load, drops noteIds not in noteInfos', () => {
const ev1 = makeEvent({ eventId: 1, noteIds: [101] }); // survives
const ev2 = makeEvent({ eventId: 2, noteIds: [102] }); // deleted from Anki
const noteInfos = new Map([[101, { noteId: 101, expression: '食べる' }]]);
const result = filterCardEvents([ev1, ev2], noteInfos, /* noteInfosLoaded */ true);
assert.equal(result.length, 1, 'should drop event whose noteId was deleted from Anki');
assert.equal(result[0]?.eventId, 1);
assert.deepEqual(result[0]?.noteIds, [101]);
});
test('filterCardEvents: after load, legacy rollup events (empty noteIds, positive cardsDelta) are kept', () => {
const rollup = makeEvent({ eventId: 3, noteIds: [], cardsDelta: 5 });
const noteInfos = new Map<number, { noteId: number; expression: string }>();
const result = filterCardEvents([rollup], noteInfos, true);
assert.equal(result.length, 1, 'legacy rollup event should survive filtering');
assert.equal(result[0]?.cardsDelta, 5);
});
test('filterCardEvents: after load, event with multiple noteIds keeps surviving ones', () => {
const ev = makeEvent({ eventId: 4, noteIds: [201, 202, 203] });
const noteInfos = new Map([
[201, { noteId: 201, expression: 'A' }],
[203, { noteId: 203, expression: 'C' }],
]);
const result = filterCardEvents([ev], noteInfos, true);
assert.equal(result.length, 1, 'event with surviving noteIds should be kept');
assert.deepEqual(result[0]?.noteIds, [201, 203], 'only surviving noteIds should remain');
});
test('filterCardEvents: after load, event where all noteIds deleted is dropped', () => {
const ev = makeEvent({ eventId: 5, noteIds: [301, 302] });
const noteInfos = new Map<number, { noteId: number; expression: string }>();
const result = filterCardEvents([ev], noteInfos, true);
assert.equal(result.length, 0, 'event with all noteIds deleted should be dropped');
});
+47 -3
View File
@@ -16,10 +16,32 @@ interface NoteInfo {
expression: string;
}
export function filterCardEvents(
cardEvents: EpisodeDetailData['cardEvents'],
noteInfos: Map<number, NoteInfo>,
noteInfosLoaded: boolean,
): EpisodeDetailData['cardEvents'] {
if (!noteInfosLoaded) return cardEvents;
return cardEvents
.map((ev) => {
// Legacy rollup events: no noteIds, just a cardsDelta count — keep as-is.
if (ev.noteIds.length === 0) return ev;
const survivingNoteIds = ev.noteIds.filter((id) => noteInfos.has(id));
return { ...ev, noteIds: survivingNoteIds };
})
.filter((ev, i) => {
// If the event originally had noteIds, only keep it if some survived.
if ((cardEvents[i]?.noteIds.length ?? 0) > 0) return ev.noteIds.length > 0;
// Legacy rollup event (originally no noteIds): keep if it has a positive delta.
return ev.cardsDelta > 0;
});
}
export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {
const [data, setData] = useState<EpisodeDetailData | null>(null);
const [loading, setLoading] = useState(true);
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -41,8 +63,14 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
map.set(note.noteId, { noteId: note.noteId, expression: expr });
}
setNoteInfos(map);
setNoteInfosLoaded(true);
})
.catch((err) => console.warn('Failed to fetch Anki note info:', err));
.catch((err) => {
console.warn('Failed to fetch Anki note info:', err);
if (!cancelled) setNoteInfosLoaded(true);
});
} else {
if (!cancelled) setNoteInfosLoaded(true);
}
})
.catch(() => {
@@ -72,6 +100,16 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
const { sessions, cardEvents } = data;
const filteredCardEvents = filterCardEvents(cardEvents, noteInfos, noteInfosLoaded);
const hiddenCardCount = noteInfosLoaded
? cardEvents.reduce((sum, ev) => {
if (ev.noteIds.length === 0) return sum;
const surviving = ev.noteIds.filter((id) => noteInfos.has(id));
return sum + (ev.noteIds.length - surviving.length);
}, 0)
: 0;
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
{sessions.length > 0 && (
@@ -106,11 +144,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
</div>
)}
{cardEvents.length > 0 && (
{filteredCardEvents.length > 0 && (
<div className="p-3 border-b border-ctp-surface1">
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
<div className="space-y-1.5">
{cardEvents.map((ev) => (
{filteredCardEvents.map((ev) => (
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
{ev.noteIds.length > 0 ? (
@@ -144,6 +182,12 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
</div>
))}
</div>
{hiddenCardCount > 0 && (
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
{hiddenCardCount} {hiddenCardCount === 1 ? 'card' : 'cards'} hidden (deleted from
Anki)
</div>
)}
</div>
)}
-120
View File
@@ -1,120 +0,0 @@
import { useState, useMemo } from 'react';
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
import { formatDuration, formatNumber } from '../../lib/formatters';
import {
groupMediaLibraryItems,
summarizeMediaLibraryGroups,
} from '../../lib/media-library-grouping';
import { CoverImage } from './CoverImage';
import { MediaCard } from './MediaCard';
import { MediaDetailView } from './MediaDetailView';
interface LibraryTabProps {
onNavigateToSession: (sessionId: number) => void;
}
export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
const { media, loading, error } = useMediaLibrary();
const [search, setSearch] = useState('');
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
const filtered = useMemo(() => {
if (!search.trim()) return media;
const q = search.toLowerCase();
return media.filter((m) => {
const haystacks = [
m.canonicalTitle,
m.videoTitle,
m.channelName,
m.uploaderId,
m.channelId,
].filter(Boolean);
return haystacks.some((value) => value!.toLowerCase().includes(q));
});
}, [media, search]);
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]);
if (selectedVideoId !== null) {
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />;
}
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Search titles..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
<div className="text-xs text-ctp-overlay2 shrink-0">
{grouped.length} group{grouped.length !== 1 ? 's' : ''} · {summary.totalVideos} video
{summary.totalVideos !== 1 ? 's' : ''} · {formatDuration(summary.totalMs)}
</div>
</div>
{filtered.length === 0 ? (
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
) : (
<div className="space-y-6">
{grouped.map((group) => (
<section
key={group.key}
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
>
<div className="flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40">
<CoverImage
videoId={group.items[0]!.videoId}
title={group.title}
src={group.imageUrl}
className="w-16 h-16 rounded-2xl shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{group.channelUrl ? (
<a
href={group.channelUrl}
target="_blank"
rel="noreferrer"
className="text-base font-semibold text-ctp-text truncate hover:text-ctp-blue transition-colors"
>
{group.title}
</a>
) : (
<h3 className="text-base font-semibold text-ctp-text truncate">
{group.title}
</h3>
)}
</div>
{group.subtitle ? (
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
) : null}
<div className="text-xs text-ctp-overlay2 mt-2">
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
</div>
</div>
</div>
<div className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{group.items.map((item) => (
<MediaCard
key={item.videoId}
item={item}
onClick={() => setSelectedVideoId(item.videoId)}
/>
))}
</div>
</div>
</section>
))}
</div>
)}
</div>
);
}
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getRelatedCollectionLabel } from './MediaDetailView';
import { renderToStaticMarkup } from 'react-dom/server';
import { createElement } from 'react';
import { getRelatedCollectionLabel, buildDeleteEpisodeHandler } from './MediaDetailView';
test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => {
assert.equal(
@@ -41,3 +43,85 @@ test('getRelatedCollectionLabel returns View Anime for non-youtube media', () =>
'View Anime',
);
});
test('buildDeleteEpisodeHandler calls deleteVideo then onBack when confirm returns true', async () => {
let deletedVideoId: number | null = null;
let onBackCalled = false;
const fakeApiClient = {
deleteVideo: async (id: number) => {
deletedVideoId = id;
},
};
const fakeConfirm = (_title: string) => true;
const handler = buildDeleteEpisodeHandler({
videoId: 42,
title: 'Test Episode',
apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise<void> },
confirmFn: fakeConfirm,
onBack: () => {
onBackCalled = true;
},
setDeleteError: () => {},
});
await handler();
assert.equal(deletedVideoId, 42);
assert.equal(onBackCalled, true);
});
test('buildDeleteEpisodeHandler does nothing when confirm returns false', async () => {
let deletedVideoId: number | null = null;
let onBackCalled = false;
const fakeApiClient = {
deleteVideo: async (id: number) => {
deletedVideoId = id;
},
};
const fakeConfirm = (_title: string) => false;
const handler = buildDeleteEpisodeHandler({
videoId: 42,
title: 'Test Episode',
apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise<void> },
confirmFn: fakeConfirm,
onBack: () => {
onBackCalled = true;
},
setDeleteError: () => {},
});
await handler();
assert.equal(deletedVideoId, null);
assert.equal(onBackCalled, false);
});
test('buildDeleteEpisodeHandler sets error when deleteVideo throws', async () => {
let capturedError: string | null = null;
const fakeApiClient = {
deleteVideo: async (_id: number) => {
throw new Error('Network failure');
},
};
const fakeConfirm = (_title: string) => true;
const handler = buildDeleteEpisodeHandler({
videoId: 42,
title: 'Test Episode',
apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise<void> },
confirmFn: fakeConfirm,
onBack: () => {},
setDeleteError: (msg) => {
capturedError = msg;
},
});
await handler();
assert.equal(capturedError, 'Network failure');
});
@@ -1,12 +1,48 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useMediaDetail } from '../../hooks/useMediaDetail';
import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete } from '../../lib/delete-confirm';
import { confirmSessionDelete, confirmEpisodeDelete } from '../../lib/delete-confirm';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { MediaHeader } from './MediaHeader';
import { MediaSessionList } from './MediaSessionList';
import type { MediaDetailData, SessionSummary } from '../../types/stats';
interface DeleteEpisodeHandlerOptions {
videoId: number;
title: string;
apiClient: { deleteVideo: (id: number) => Promise<void> };
confirmFn: (title: string) => boolean;
onBack: () => void;
setDeleteError: (msg: string | null) => void;
/**
* Ref used to guard against reentrant delete calls synchronously. When set,
* a subsequent invocation while the previous request is still pending is
* ignored so clicks during the await window can't trigger duplicate deletes.
*/
isDeletingRef?: { current: boolean };
/** Optional React state setter so the UI can reflect the pending state. */
setIsDeleting?: (value: boolean) => void;
}
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
return async () => {
if (opts.isDeletingRef?.current) return;
if (!opts.confirmFn(opts.title)) return;
if (opts.isDeletingRef) opts.isDeletingRef.current = true;
opts.setIsDeleting?.(true);
opts.setDeleteError(null);
try {
await opts.apiClient.deleteVideo(opts.videoId);
opts.onBack();
} catch (err) {
opts.setDeleteError(err instanceof Error ? err.message : 'Failed to delete episode.');
} finally {
if (opts.isDeletingRef) opts.isDeletingRef.current = false;
opts.setIsDeleting?.(false);
}
};
}
export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string {
if (detail?.channelName?.trim()) {
return 'View Channel';
@@ -35,6 +71,8 @@ export function MediaDetailView({
const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
const [isDeletingEpisode, setIsDeletingEpisode] = useState(false);
const isDeletingEpisodeRef = useRef(false);
useEffect(() => {
setLocalSessions(data?.sessions ?? null);
@@ -79,6 +117,17 @@ export function MediaDetailView({
}
};
const handleDeleteEpisode = buildDeleteEpisodeHandler({
videoId,
title: detail.canonicalTitle,
apiClient,
confirmFn: confirmEpisodeDelete,
onBack,
setDeleteError,
isDeletingRef: isDeletingEpisodeRef,
setIsDeleting: setIsDeletingEpisode,
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -99,7 +148,11 @@ export function MediaDetailView({
</button>
) : null}
</div>
<MediaHeader detail={detail} />
<MediaHeader
detail={detail}
onDeleteEpisode={handleDeleteEpisode}
isDeletingEpisode={isDeletingEpisode}
/>
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<MediaSessionList
sessions={sessions}
+23 -2
View File
@@ -12,9 +12,16 @@ interface MediaHeaderProps {
totalUniqueWords: number;
knownWordCount: number;
} | null;
onDeleteEpisode?: () => void;
isDeletingEpisode?: boolean;
}
export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) {
export function MediaHeader({
detail,
initialKnownWordsSummary = null,
onDeleteEpisode,
isDeletingEpisode = false,
}: MediaHeaderProps) {
const knownTokenRate =
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
const avgSessionMs =
@@ -50,7 +57,21 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
className="w-32 h-44 rounded-lg shrink-0"
/>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
<div className="flex items-start justify-between gap-2">
<h2 className="min-w-0 flex-1 text-lg font-bold text-ctp-text truncate">
{detail.canonicalTitle}
</h2>
{onDeleteEpisode != null ? (
<button
type="button"
onClick={onDeleteEpisode}
disabled={isDeletingEpisode}
className="shrink-0 text-xs text-ctp-red hover:opacity-75 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeletingEpisode ? 'Deleting...' : 'Delete Episode'}
</button>
) : null}
</div>
{detail.channelName ? (
<div className="mt-1 text-sm text-ctp-subtext1 truncate">
{detail.channelUrl ? (
+1 -1
View File
@@ -36,7 +36,7 @@ export function HeroStats({ summary, sessions }: HeroStatsProps) {
/>
<StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" />
<StatCard
label="Active Anime"
label="Active Titles"
value={formatNumber(summary.activeAnimeCount)}
color="text-ctp-mauve"
/>
@@ -71,7 +71,7 @@ export function TrackingSnapshot({
</div>
</div>
</Tooltip>
<Tooltip text="Total unique episodes (videos) watched across all anime">
<Tooltip text="Total unique videos watched across all titles in your library">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
@@ -79,9 +79,9 @@ export function TrackingSnapshot({
</div>
</div>
</Tooltip>
<Tooltip text="Number of anime series fully completed">
<Tooltip text="Number of titles fully completed">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime</div>
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Titles</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
{formatNumber(summary.totalAnimeCompleted)}
</div>
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { BarChart, Bar, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { epochDayToDate } from '../../lib/formatters';
import { CHART_THEME } from '../../lib/chart-theme';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
import type { DailyRollup } from '../../types/stats';
interface WatchTimeChartProps {
@@ -52,28 +52,23 @@ export function WatchTimeChart({ rollups }: WatchTimeChartProps) {
))}
</div>
</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={chartData}>
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
<BarChart data={chartData} margin={CHART_DEFAULTS.margin}>
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
width={30}
width={32}
/>
<Tooltip
contentStyle={{
background: CHART_THEME.tooltipBg,
border: `1px solid ${CHART_THEME.tooltipBorder}`,
borderRadius: 6,
color: CHART_THEME.tooltipText,
fontSize: 12,
}}
contentStyle={TOOLTIP_CONTENT_STYLE}
labelStyle={{ color: CHART_THEME.tooltipLabel }}
formatter={formatActiveMinutes}
/>
@@ -125,14 +125,13 @@ export function SessionDetail({ session }: SessionDetailProps) {
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
const hasKnownWords = knownWordsMap.size > 0;
const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } =
const { cardEvents, yomitanLookupEvents, pauseRegions, markers } =
buildSessionChartEvents(events);
const lookupRate = buildLookupRateDisplay(
session.yomitanLookupCount,
getSessionDisplayWordCount(session),
);
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
const seekCount = seekEvents.length;
const cardEventCount = cardEvents.length;
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
const activeMarker = useMemo<SessionChartMarker | null>(
@@ -230,7 +229,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
sorted={sorted}
knownWordsMap={knownWordsMap}
cardEvents={cardEvents}
seekEvents={seekEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
markers={markers}
@@ -242,7 +240,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
lookupRate={lookupRate}
session={session}
@@ -254,7 +251,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
<FallbackView
sorted={sorted}
cardEvents={cardEvents}
seekEvents={seekEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
markers={markers}
@@ -266,7 +262,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
lookupRate={lookupRate}
session={session}
@@ -280,7 +275,6 @@ function RatioView({
sorted,
knownWordsMap,
cardEvents,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
@@ -292,7 +286,6 @@ function RatioView({
loadingNoteIds,
onOpenNote,
pauseCount,
seekCount,
cardEventCount,
lookupRate,
session,
@@ -300,7 +293,6 @@ function RatioView({
sorted: TimelineEntry[];
knownWordsMap: Map<number, number>;
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[];
@@ -312,7 +304,6 @@ function RatioView({
loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void;
pauseCount: number;
seekCount: number;
cardEventCount: number;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
session: SessionSummary;
@@ -450,22 +441,6 @@ function RatioView({
/>
))}
{seekEvents.map((e, i) => {
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
return (
<ReferenceLine
key={`seek-${i}`}
yAxisId="pct"
x={e.tsMs}
stroke={stroke}
strokeWidth={1.5}
strokeOpacity={0.75}
strokeDasharray="4 3"
/>
);
})}
{/* Yomitan lookup markers */}
{yomitanLookupEvents.map((e, i) => (
<ReferenceLine
@@ -549,7 +524,6 @@ function RatioView({
<StatsBar
hasKnownWords
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
session={session}
lookupRate={lookupRate}
@@ -563,7 +537,6 @@ function RatioView({
function FallbackView({
sorted,
cardEvents,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
@@ -575,14 +548,12 @@ function FallbackView({
loadingNoteIds,
onOpenNote,
pauseCount,
seekCount,
cardEventCount,
lookupRate,
session,
}: {
sorted: TimelineEntry[];
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[];
@@ -594,7 +565,6 @@ function FallbackView({
loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void;
pauseCount: number;
seekCount: number;
cardEventCount: number;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
session: SessionSummary;
@@ -680,20 +650,6 @@ function FallbackView({
strokeOpacity={0.8}
/>
))}
{seekEvents.map((e, i) => {
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
return (
<ReferenceLine
key={`seek-${i}`}
x={e.tsMs}
stroke={stroke}
strokeWidth={1.5}
strokeOpacity={0.75}
strokeDasharray="4 3"
/>
);
})}
{yomitanLookupEvents.map((e, i) => (
<ReferenceLine
key={`yomitan-${i}`}
@@ -735,7 +691,6 @@ function FallbackView({
<StatsBar
hasKnownWords={false}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
session={session}
lookupRate={lookupRate}
@@ -749,14 +704,12 @@ function FallbackView({
function StatsBar({
hasKnownWords,
pauseCount,
seekCount,
cardEventCount,
session,
lookupRate,
}: {
hasKnownWords: boolean;
pauseCount: number;
seekCount: number;
cardEventCount: number;
session: SessionSummary;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
@@ -791,12 +744,7 @@ function StatsBar({
{pauseCount !== 1 ? 's' : ''}
</span>
)}
{seekCount > 0 && (
<span className="text-ctp-overlay2">
<span className="text-ctp-teal">{seekCount}</span> seek{seekCount !== 1 ? 's' : ''}
</span>
)}
{(pauseCount > 0 || seekCount > 0) && <span className="text-ctp-surface2">|</span>}
{pauseCount > 0 && <span className="text-ctp-surface2">|</span>}
{/* Group 3: Learning events */}
<span className="flex items-center gap-1.5">
@@ -33,8 +33,6 @@ function markerLabel(marker: SessionChartMarker): string {
switch (marker.kind) {
case 'pause':
return '||';
case 'seek':
return marker.direction === 'backward' ? '<<' : '>>';
case 'card':
return '\u26CF';
}
@@ -44,10 +42,6 @@ function markerColors(marker: SessionChartMarker): { border: string; bg: string;
switch (marker.kind) {
case 'pause':
return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' };
case 'seek':
return marker.direction === 'backward'
? { border: '#f5bde6', bg: 'rgba(245,189,230,0.16)', text: '#f5bde6' }
: { border: '#8bd5ca', bg: 'rgba(139,213,202,0.16)', text: '#8bd5ca' };
case 'card':
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
}
@@ -41,35 +41,6 @@ test('SessionEventPopover renders formatted card-mine details with fetched note
assert.match(markup, /Open in Anki/);
});
test('SessionEventPopover renders seek metadata compactly', () => {
const marker: SessionChartMarker = {
key: 'seek-3000',
kind: 'seek',
anchorTsMs: 3_000,
eventTsMs: 3_000,
direction: 'backward',
fromMs: 5_000,
toMs: 1_500,
};
const markup = renderToStaticMarkup(
<SessionEventPopover
marker={marker}
noteInfos={new Map()}
loading={false}
pinned={false}
onTogglePinned={() => {}}
onClose={() => {}}
onOpenNote={() => {}}
/>,
);
assert.match(markup, /Seek backward/);
assert.match(markup, /5\.0s/);
assert.match(markup, /1\.5s/);
assert.match(markup, /3\.5s/);
});
test('SessionEventPopover renders a cleaner fallback when AnkiConnect provides no preview fields', () => {
const marker: SessionChartMarker = {
key: 'card-9000',
@@ -31,18 +31,12 @@ export function SessionEventPopover({
onClose,
onOpenNote,
}: SessionEventPopoverProps) {
const seekDurationLabel =
marker.kind === 'seek' && marker.fromMs !== null && marker.toMs !== null
? formatEventSeconds(Math.abs(marker.toMs - marker.fromMs))?.replace(/\.0s$/, 's')
: null;
return (
<div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm">
<div className="mb-2 flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold text-ctp-text">
{marker.kind === 'pause' && 'Paused'}
{marker.kind === 'seek' && `Seek ${marker.direction}`}
{marker.kind === 'card' && 'Card mined'}
</div>
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
@@ -72,7 +66,6 @@ export function SessionEventPopover({
) : null}
<div className="text-sm">
{marker.kind === 'pause' && '||'}
{marker.kind === 'seek' && (marker.direction === 'backward' ? '<<' : '>>')}
{marker.kind === 'card' && '\u26CF'}
</div>
</div>
@@ -84,19 +77,6 @@ export function SessionEventPopover({
</div>
)}
{marker.kind === 'seek' && (
<div className="space-y-1 text-xs text-ctp-subtext0">
<div>
From{' '}
<span className="text-ctp-teal">{formatEventSeconds(marker.fromMs) ?? '\u2014'}</span>{' '}
to <span className="text-ctp-teal">{formatEventSeconds(marker.toMs) ?? '\u2014'}</span>
</div>
<div>
Length <span className="text-ctp-peach">{seekDurationLabel ?? '\u2014'}</span>
</div>
</div>
)}
{marker.kind === 'card' && (
<div className="space-y-2">
<div className="text-xs text-ctp-cards-mined">

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