mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-12 04:19:25 -07:00
Compare commits
14 Commits
stats-upda
...
v0.12.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
| f79e6bde3b | |||
| bd5275fbf8 | |||
| 3281a7b39e | |||
| 3e7573c9fc | |||
| 20a0efe572 | |||
| 7698258f61 | |||
| ac25213255 | |||
| a5dbe055fc | |||
| 04742b1806 | |||
| f0e15c5dc4 | |||
| 9145c730b5 | |||
| cf86817cd8 | |||
| 3f7de73734 | |||
| de9b887798 |
389
.github/workflows/prerelease.yml
vendored
Normal file
389
.github/workflows/prerelease.yml
vendored
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
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
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -4,6 +4,8 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
- '!v*-beta.*'
|
||||||
|
- '!v*-rc.*'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
---
|
|
||||||
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 -->
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
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 -->
|
||||||
3
bun.lock
3
bun.lock
@@ -12,6 +12,7 @@
|
|||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"hono": "^4.12.7",
|
"hono": "^4.12.7",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
|
"koffi": "^2.15.6",
|
||||||
"libsql": "^0.5.22",
|
"libsql": "^0.5.22",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
},
|
},
|
||||||
@@ -478,6 +479,8 @@
|
|||||||
|
|
||||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
5
changes/2026-04-09-prerelease-workflow.md
Normal file
5
changes/2026-04-09-prerelease-workflow.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
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.
|
||||||
@@ -30,3 +30,9 @@ Rules:
|
|||||||
- each non-empty body line becomes a bullet
|
- each non-empty body line becomes a bullet
|
||||||
- `README.md` is ignored by the generator
|
- `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
|
- 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
|
||||||
|
|||||||
4
changes/fix-overlay-subtitle-drop-routing.md
Normal file
4
changes/fix-overlay-subtitle-drop-routing.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
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
changes/fix-windows-overlay-z-order.md
Normal file
11
changes/fix-windows-overlay-z-order.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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.
|
||||||
4
changes/fix-windows-secondary-hover-titlebar.md
Normal file
4
changes/fix-windows-secondary-hover-titlebar.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
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.
|
||||||
4
changes/fix-yomitan-nested-popup-focus.md
Normal file
4
changes/fix-yomitan-nested-popup-focus.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
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.
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
# Releasing
|
# Releasing
|
||||||
|
|
||||||
|
## Stable Release
|
||||||
|
|
||||||
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
|
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.
|
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`.
|
3. Run `bun run changelog:lint`.
|
||||||
@@ -24,15 +26,37 @@
|
|||||||
10. Tag the commit: `git tag v<version>`.
|
10. Tag the commit: `git tag v<version>`.
|
||||||
11. Push commit + tag.
|
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:
|
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`.
|
- 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.
|
- 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: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.
|
- `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.
|
- 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.
|
- 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 version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
- If you need to repair a published release body (for example, a prior version’s 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.
|
- 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.
|
- 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.
|
- 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
@@ -1,184 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
# Stats Dashboard Feedback Pass — Design
|
|
||||||
|
|
||||||
Date: 2026-04-09
|
|
||||||
Scope: Stats dashboard UX follow-ups from user feedback (items 1–7).
|
|
||||||
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.`
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.11.2",
|
"version": "0.12.0-beta.2",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"changelog:lint": "bun run scripts/build-changelog.ts lint",
|
"changelog:lint": "bun run scripts/build-changelog.ts lint",
|
||||||
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
|
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
|
||||||
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",
|
"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": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"format:src": "bash scripts/prettier-scope.sh --write",
|
"format:src": "bash scripts/prettier-scope.sh --write",
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
"test:launcher": "bun run test:launcher:src",
|
"test:launcher": "bun run test:launcher:src",
|
||||||
"test:core": "bun run test:core:src",
|
"test:core": "bun run test:core:src",
|
||||||
"test:subtitle": "bun run test:subtitle: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/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/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",
|
||||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||||
"start": "bun run build && electron . --start",
|
"start": "bun run build && electron . --start",
|
||||||
@@ -112,6 +113,7 @@
|
|||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"hono": "^4.12.7",
|
"hono": "^4.12.7",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
|
"koffi": "^2.15.6",
|
||||||
"libsql": "^0.5.22",
|
"libsql": "^0.5.22",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -310,3 +310,186 @@ 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type PullRequestChangelogOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
|
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 CHANGELOG_HEADER = '# Changelog';
|
||||||
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
|
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
|
||||||
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
|
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
|
||||||
@@ -75,6 +76,10 @@ function resolveVersion(options: Pick<ChangelogOptions, 'cwd' | 'version' | 'dep
|
|||||||
return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync));
|
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(
|
function verifyRequestedVersionMatchesPackageVersion(
|
||||||
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
|
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
|
||||||
): void {
|
): void {
|
||||||
@@ -314,8 +319,15 @@ export function resolveChangelogOutputPaths(options?: { cwd?: string }): string[
|
|||||||
return [path.join(cwd, 'CHANGELOG.md')];
|
return [path.join(cwd, 'CHANGELOG.md')];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderReleaseNotes(changes: string): string {
|
function renderReleaseNotes(
|
||||||
|
changes: string,
|
||||||
|
options?: {
|
||||||
|
disclaimer?: string;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
||||||
return [
|
return [
|
||||||
|
...prefix,
|
||||||
'## Highlights',
|
'## Highlights',
|
||||||
changes,
|
changes,
|
||||||
'',
|
'',
|
||||||
@@ -334,13 +346,21 @@ function renderReleaseNotes(changes: string): string {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeReleaseNotesFile(cwd: string, changes: string, deps?: ChangelogFsDeps): string {
|
function writeReleaseNotesFile(
|
||||||
|
cwd: string,
|
||||||
|
changes: string,
|
||||||
|
deps?: ChangelogFsDeps,
|
||||||
|
options?: {
|
||||||
|
disclaimer?: string;
|
||||||
|
outputPath?: string;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||||
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
|
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
|
||||||
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
|
const releaseNotesPath = path.join(cwd, options?.outputPath ?? RELEASE_NOTES_PATH);
|
||||||
|
|
||||||
mkdirSync(path.dirname(releaseNotesPath), { recursive: true });
|
mkdirSync(path.dirname(releaseNotesPath), { recursive: true });
|
||||||
writeFileSync(releaseNotesPath, renderReleaseNotes(changes), 'utf8');
|
writeFileSync(releaseNotesPath, renderReleaseNotes(changes, options), 'utf8');
|
||||||
return releaseNotesPath;
|
return releaseNotesPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +633,30 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
|
|||||||
return writeReleaseNotesFile(cwd, changes, options?.deps);
|
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[]): {
|
function parseCliArgs(argv: string[]): {
|
||||||
baseRef?: string;
|
baseRef?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -710,6 +754,11 @@ function main(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command === 'prerelease-notes') {
|
||||||
|
writePrereleaseNotesForVersion(options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (command === 'docs') {
|
if (command === 'docs') {
|
||||||
generateDocsChangelog(options);
|
generateDocsChangelog(options);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
param(
|
param(
|
||||||
[ValidateSet('geometry')]
|
[ValidateSet('geometry', 'foreground-process', 'bind-overlay', 'lower-overlay', 'set-owner', 'clear-owner')]
|
||||||
[string]$Mode = 'geometry',
|
[string]$Mode = 'geometry',
|
||||||
[string]$SocketPath
|
[string]$SocketPath,
|
||||||
|
[string]$OverlayWindowHandle
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
@@ -35,19 +36,89 @@ public static class SubMinerWindowsHelper {
|
|||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
public static extern IntPtr GetForegroundWindow();
|
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)]
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
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)]
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
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")]
|
[DllImport("dwmapi.dll")]
|
||||||
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
|
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
|
||||||
}
|
}
|
||||||
"@
|
"@
|
||||||
|
|
||||||
$DWMWA_EXTENDED_FRAME_BOUNDS = 9
|
$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 {
|
function Get-WindowBounds {
|
||||||
param([IntPtr]$hWnd)
|
param([IntPtr]$hWnd)
|
||||||
@@ -90,6 +161,7 @@ public static class SubMinerWindowsHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$mpvMatches = New-Object System.Collections.Generic.List[object]
|
$mpvMatches = New-Object System.Collections.Generic.List[object]
|
||||||
|
$targetWindowState = 'not-found'
|
||||||
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
|
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
|
||||||
$callback = [SubMinerWindowsHelper+EnumWindowsProc]{
|
$callback = [SubMinerWindowsHelper+EnumWindowsProc]{
|
||||||
param([IntPtr]$hWnd, [IntPtr]$lParam)
|
param([IntPtr]$hWnd, [IntPtr]$lParam)
|
||||||
@@ -98,10 +170,6 @@ public static class SubMinerWindowsHelper {
|
|||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
if ([SubMinerWindowsHelper]::IsIconic($hWnd)) {
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
|
|
||||||
[uint32]$windowProcessId = 0
|
[uint32]$windowProcessId = 0
|
||||||
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($hWnd, [ref]$windowProcessId)
|
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($hWnd, [ref]$windowProcessId)
|
||||||
if ($windowProcessId -eq 0) {
|
if ($windowProcessId -eq 0) {
|
||||||
@@ -131,11 +199,22 @@ 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
|
$bounds = Get-WindowBounds -hWnd $hWnd
|
||||||
if ($null -eq $bounds) {
|
if ($null -eq $bounds) {
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
|
||||||
|
$targetWindowState = 'visible'
|
||||||
|
}
|
||||||
|
|
||||||
$mpvMatches.Add([PSCustomObject]@{
|
$mpvMatches.Add([PSCustomObject]@{
|
||||||
HWnd = $hWnd
|
HWnd = $hWnd
|
||||||
X = $bounds.X
|
X = $bounds.X
|
||||||
@@ -151,12 +230,45 @@ public static class SubMinerWindowsHelper {
|
|||||||
|
|
||||||
[void][SubMinerWindowsHelper]::EnumWindows($callback, [IntPtr]::Zero)
|
[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
|
$focusedMatch = $mpvMatches | Where-Object { $_.IsForeground } | Select-Object -First 1
|
||||||
if ($null -ne $focusedMatch) {
|
if ($null -ne $focusedMatch) {
|
||||||
[Console]::Error.WriteLine('focus=focused')
|
[Console]::Error.WriteLine('focus=focused')
|
||||||
} else {
|
} else {
|
||||||
[Console]::Error.WriteLine('focus=not-focused')
|
[Console]::Error.WriteLine('focus=not-focused')
|
||||||
}
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
|
||||||
|
[Console]::Error.WriteLine("state=$targetWindowState")
|
||||||
|
}
|
||||||
|
|
||||||
if ($mpvMatches.Count -eq 0) {
|
if ($mpvMatches.Count -eq 0) {
|
||||||
Write-Output 'not-found'
|
Write-Output 'not-found'
|
||||||
@@ -168,6 +280,68 @@ public static class SubMinerWindowsHelper {
|
|||||||
} else {
|
} else {
|
||||||
$mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1
|
$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)"
|
Write-Output "$($bestMatch.X),$($bestMatch.Y),$($bestMatch.Width),$($bestMatch.Height)"
|
||||||
} catch {
|
} catch {
|
||||||
[Console]::Error.WriteLine($_.Exception.Message)
|
[Console]::Error.WriteLine($_.Exception.Message)
|
||||||
|
|||||||
@@ -166,20 +166,14 @@ const TRENDS_DASHBOARD = {
|
|||||||
ratios: {
|
ratios: {
|
||||||
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
|
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
|
||||||
},
|
},
|
||||||
librarySummary: [
|
animePerDay: {
|
||||||
{
|
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
|
||||||
title: 'Little Witch Academia',
|
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
|
||||||
watchTimeMin: 25,
|
cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
|
||||||
videos: 1,
|
words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }],
|
||||||
sessions: 1,
|
lookups: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 15 }],
|
||||||
cards: 5,
|
lookupsPerHundred: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
|
||||||
words: 300,
|
},
|
||||||
lookups: 15,
|
|
||||||
lookupsPerHundred: 5,
|
|
||||||
firstWatched: 20_000,
|
|
||||||
lastWatched: 20_000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
animeCumulative: {
|
animeCumulative: {
|
||||||
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
|
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
|
||||||
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
|
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
|
||||||
@@ -604,23 +598,7 @@ describe('stats server API routes', () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
assert.deepEqual(seenArgs, ['90d', 'month']);
|
assert.deepEqual(seenArgs, ['90d', 'month']);
|
||||||
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
|
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
|
||||||
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
|
assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime);
|
||||||
});
|
|
||||||
|
|
||||||
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 () => {
|
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTrendsDashboard(
|
async getTrendsDashboard(
|
||||||
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
range: '7d' | '30d' | '90d' | 'all' = '30d',
|
||||||
groupBy: 'day' | 'month' = 'day',
|
groupBy: 'day' | 'month' = 'day',
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return getTrendsDashboard(this.db, range, groupBy);
|
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.watchTime[1]?.value, 75);
|
||||||
assert.equal(dashboard.progress.lookups[1]?.value, 18);
|
assert.equal(dashboard.progress.lookups[1]?.value, 18);
|
||||||
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1));
|
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1));
|
||||||
assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime');
|
assert.equal(dashboard.animePerDay.watchTime[0]?.animeTitle, 'Trend Dashboard Anime');
|
||||||
assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75);
|
assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
dashboard.patterns.watchTimeByDayOfWeek.reduce((sum, point) => sum + point.value, 0),
|
dashboard.patterns.watchTimeByDayOfWeek.reduce((sum, point) => sum + point.value, 0),
|
||||||
@@ -835,65 +835,6 @@ 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', () => {
|
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
@@ -3725,224 +3666,3 @@ test('deleteSession removes zero-session media from library and trends', () => {
|
|||||||
cleanupDbPath(dbPath);
|
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from './query-shared';
|
} from './query-shared';
|
||||||
import { getDailyRollups, getMonthlyRollups } from './query-sessions';
|
import { getDailyRollups, getMonthlyRollups } from './query-sessions';
|
||||||
|
|
||||||
type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
type TrendRange = '7d' | '30d' | '90d' | 'all';
|
||||||
type TrendGroupBy = 'day' | 'month';
|
type TrendGroupBy = 'day' | 'month';
|
||||||
|
|
||||||
interface TrendChartPoint {
|
interface TrendChartPoint {
|
||||||
@@ -27,19 +27,6 @@ interface TrendPerAnimePoint {
|
|||||||
value: number;
|
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 {
|
interface TrendSessionMetricRow {
|
||||||
startedAtMs: number;
|
startedAtMs: number;
|
||||||
epochDay: number;
|
epochDay: number;
|
||||||
@@ -74,6 +61,14 @@ export interface TrendsDashboardQueryResult {
|
|||||||
ratios: {
|
ratios: {
|
||||||
lookupsPerHundred: TrendChartPoint[];
|
lookupsPerHundred: TrendChartPoint[];
|
||||||
};
|
};
|
||||||
|
animePerDay: {
|
||||||
|
episodes: TrendPerAnimePoint[];
|
||||||
|
watchTime: TrendPerAnimePoint[];
|
||||||
|
cards: TrendPerAnimePoint[];
|
||||||
|
words: TrendPerAnimePoint[];
|
||||||
|
lookups: TrendPerAnimePoint[];
|
||||||
|
lookupsPerHundred: TrendPerAnimePoint[];
|
||||||
|
};
|
||||||
animeCumulative: {
|
animeCumulative: {
|
||||||
watchTime: TrendPerAnimePoint[];
|
watchTime: TrendPerAnimePoint[];
|
||||||
episodes: TrendPerAnimePoint[];
|
episodes: TrendPerAnimePoint[];
|
||||||
@@ -84,14 +79,12 @@ export interface TrendsDashboardQueryResult {
|
|||||||
watchTimeByDayOfWeek: TrendChartPoint[];
|
watchTimeByDayOfWeek: TrendChartPoint[];
|
||||||
watchTimeByHour: TrendChartPoint[];
|
watchTimeByHour: TrendChartPoint[];
|
||||||
};
|
};
|
||||||
librarySummary: LibrarySummaryRow[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
|
const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
|
||||||
'7d': 7,
|
'7d': 7,
|
||||||
'30d': 30,
|
'30d': 30,
|
||||||
'90d': 90,
|
'90d': 90,
|
||||||
'365d': 365,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
@@ -307,6 +300,61 @@ 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[] {
|
function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
|
||||||
const byAnime = new Map<string, Map<number, number>>();
|
const byAnime = new Map<string, Map<number, number>>();
|
||||||
const allDays = new Set<number>();
|
const allDays = new Set<number>();
|
||||||
@@ -342,89 +390,6 @@ function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoi
|
|||||||
return result;
|
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(
|
function getVideoAnimeTitleMap(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
videoIds: Array<number | null>,
|
videoIds: Array<number | null>,
|
||||||
@@ -697,6 +662,8 @@ export function getTrendsDashboard(
|
|||||||
titlesByVideoId,
|
titlesByVideoId,
|
||||||
(rollup) => rollup.totalTokensSeen,
|
(rollup) => rollup.totalTokensSeen,
|
||||||
),
|
),
|
||||||
|
lookups: buildPerAnimeFromSessions(sessions, (session) => session.yomitanLookupCount),
|
||||||
|
lookupsPerHundred: buildLookupsPerHundredPerAnime(sessions),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -723,6 +690,7 @@ export function getTrendsDashboard(
|
|||||||
ratios: {
|
ratios: {
|
||||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
||||||
},
|
},
|
||||||
|
animePerDay,
|
||||||
animeCumulative: {
|
animeCumulative: {
|
||||||
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
|
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
|
||||||
episodes: buildCumulativePerAnime(animePerDay.episodes),
|
episodes: buildCumulativePerAnime(animePerDay.episodes),
|
||||||
@@ -733,6 +701,5 @@ export function getTrendsDashboard(
|
|||||||
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
|
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
|
||||||
watchTimeByHour: buildWatchTimeByHour(sessions),
|
watchTimeByHour: buildWatchTimeByHour(sessions),
|
||||||
},
|
},
|
||||||
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export {
|
|||||||
createOverlayWindow,
|
createOverlayWindow,
|
||||||
enforceOverlayLayerOrder,
|
enforceOverlayLayerOrder,
|
||||||
ensureOverlayWindowLevel,
|
ensureOverlayWindowLevel,
|
||||||
|
isOverlayWindowContentReady,
|
||||||
syncOverlayWindowLayer,
|
syncOverlayWindowLayer,
|
||||||
updateOverlayWindowBounds,
|
updateOverlayWindowBounds,
|
||||||
} from './overlay-window';
|
} from './overlay-window';
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
buildMpvLoadfileCommands,
|
buildMpvLoadfileCommands,
|
||||||
|
buildMpvSubtitleAddCommands,
|
||||||
|
collectDroppedSubtitlePaths,
|
||||||
collectDroppedVideoPaths,
|
collectDroppedVideoPaths,
|
||||||
parseClipboardVideoPath,
|
parseClipboardVideoPath,
|
||||||
type DropDataTransferLike,
|
type DropDataTransferLike,
|
||||||
@@ -41,6 +43,33 @@ test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates',
|
|||||||
assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']);
|
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', () => {
|
test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => {
|
||||||
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false);
|
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false);
|
||||||
|
|
||||||
@@ -59,6 +88,15 @@ 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', () => {
|
test('parseClipboardVideoPath accepts quoted local paths', () => {
|
||||||
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
|
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const VIDEO_EXTENSIONS = new Set([
|
|||||||
'.wmv',
|
'.wmv',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const SUBTITLE_EXTENSIONS = new Set(['.ass', '.srt', '.ssa', '.sub', '.vtt']);
|
||||||
|
|
||||||
function getPathExtension(pathValue: string): string {
|
function getPathExtension(pathValue: string): string {
|
||||||
const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
|
const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
|
||||||
const dot = normalized.lastIndexOf('.');
|
const dot = normalized.lastIndexOf('.');
|
||||||
@@ -32,7 +34,11 @@ function isSupportedVideoPath(pathValue: string): boolean {
|
|||||||
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
|
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseUriList(data: string): string[] {
|
function isSupportedSubtitlePath(pathValue: string): boolean {
|
||||||
|
return SUBTITLE_EXTENSIONS.has(getPathExtension(pathValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUriList(data: string, isSupportedPath: (pathValue: string) => boolean): string[] {
|
||||||
if (!data.trim()) return [];
|
if (!data.trim()) return [];
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
|
|
||||||
@@ -47,7 +53,7 @@ function parseUriList(data: string): string[] {
|
|||||||
if (/^\/[A-Za-z]:\//.test(filePath)) {
|
if (/^\/[A-Za-z]:\//.test(filePath)) {
|
||||||
filePath = filePath.slice(1);
|
filePath = filePath.slice(1);
|
||||||
}
|
}
|
||||||
if (filePath && isSupportedVideoPath(filePath)) {
|
if (filePath && isSupportedPath(filePath)) {
|
||||||
out.push(filePath);
|
out.push(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -87,6 +93,19 @@ export function parseClipboardVideoPath(text: string): string | null {
|
|||||||
|
|
||||||
export function collectDroppedVideoPaths(
|
export function collectDroppedVideoPaths(
|
||||||
dataTransfer: DropDataTransferLike | null | undefined,
|
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[] {
|
): string[] {
|
||||||
if (!dataTransfer) return [];
|
if (!dataTransfer) return [];
|
||||||
|
|
||||||
@@ -96,7 +115,7 @@ export function collectDroppedVideoPaths(
|
|||||||
const addPath = (candidate: string | null | undefined): void => {
|
const addPath = (candidate: string | null | undefined): void => {
|
||||||
if (!candidate) return;
|
if (!candidate) return;
|
||||||
const trimmed = candidate.trim();
|
const trimmed = candidate.trim();
|
||||||
if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return;
|
if (!trimmed || !isSupportedPath(trimmed) || seen.has(trimmed)) return;
|
||||||
seen.add(trimmed);
|
seen.add(trimmed);
|
||||||
out.push(trimmed);
|
out.push(trimmed);
|
||||||
};
|
};
|
||||||
@@ -109,7 +128,7 @@ export function collectDroppedVideoPaths(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof dataTransfer.getData === 'function') {
|
if (typeof dataTransfer.getData === 'function') {
|
||||||
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'))) {
|
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'), isSupportedPath)) {
|
||||||
addPath(pathValue);
|
addPath(pathValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,3 +149,9 @@ export function buildMpvLoadfileCommands(
|
|||||||
index === 0 ? 'replace' : 'append',
|
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,6 +2,23 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init';
|
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', () => {
|
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
|
||||||
let createdIntegrations = 0;
|
let createdIntegrations = 0;
|
||||||
let startedIntegrations = 0;
|
let startedIntegrations = 0;
|
||||||
@@ -443,3 +460,216 @@ test('initializeOverlayRuntime refreshes visible overlay when tracker focus chan
|
|||||||
|
|
||||||
assert.equal(visibilityRefreshCalls, 2);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export function initializeOverlayRuntime(options: {
|
|||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
isVisibleOverlayVisible: () => boolean;
|
isVisibleOverlayVisible: () => boolean;
|
||||||
updateVisibleOverlayVisibility: () => void;
|
updateVisibleOverlayVisibility: () => void;
|
||||||
|
refreshCurrentSubtitle?: () => void;
|
||||||
getOverlayWindows: () => BrowserWindow[];
|
getOverlayWindows: () => BrowserWindow[];
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||||
@@ -78,6 +79,8 @@ export function initializeOverlayRuntime(options: {
|
|||||||
override?: string | null,
|
override?: string | null,
|
||||||
targetMpvSocketPath?: string | null,
|
targetMpvSocketPath?: string | null,
|
||||||
) => BaseWindowTracker | null;
|
) => BaseWindowTracker | null;
|
||||||
|
bindOverlayOwner?: () => void;
|
||||||
|
releaseOverlayOwner?: () => void;
|
||||||
}): void {
|
}): void {
|
||||||
options.createMainWindow();
|
options.createMainWindow();
|
||||||
options.registerGlobalShortcuts();
|
options.registerGlobalShortcuts();
|
||||||
@@ -94,11 +97,23 @@ export function initializeOverlayRuntime(options: {
|
|||||||
};
|
};
|
||||||
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
|
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
|
||||||
options.updateVisibleOverlayBounds(geometry);
|
options.updateVisibleOverlayBounds(geometry);
|
||||||
|
options.bindOverlayOwner?.();
|
||||||
if (options.isVisibleOverlayVisible()) {
|
if (options.isVisibleOverlayVisible()) {
|
||||||
options.updateVisibleOverlayVisibility();
|
options.updateVisibleOverlayVisibility();
|
||||||
|
options.refreshCurrentSubtitle?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
windowTracker.onWindowLost = () => {
|
windowTracker.onWindowLost = () => {
|
||||||
|
options.releaseOverlayOwner?.();
|
||||||
|
if (
|
||||||
|
process.platform === 'win32' &&
|
||||||
|
typeof windowTracker.isTargetWindowMinimized === 'function' &&
|
||||||
|
!windowTracker.isTargetWindowMinimized()
|
||||||
|
) {
|
||||||
|
options.syncOverlayShortcuts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const window of options.getOverlayWindows()) {
|
for (const window of options.getOverlayWindows()) {
|
||||||
window.hide();
|
window.hide();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,27 +6,59 @@ import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './over
|
|||||||
type WindowTrackerStub = {
|
type WindowTrackerStub = {
|
||||||
isTracking: () => boolean;
|
isTracking: () => boolean;
|
||||||
getGeometry: () => { x: number; y: number; width: number; height: number } | null;
|
getGeometry: () => { x: number; y: number; width: number; height: number } | null;
|
||||||
|
isTargetWindowFocused?: () => boolean;
|
||||||
|
isTargetWindowMinimized?: () => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createMainWindowRecorder() {
|
function createMainWindowRecorder() {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
let visible = false;
|
||||||
|
let focused = false;
|
||||||
|
let opacity = 1;
|
||||||
const window = {
|
const window = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
|
isVisible: () => visible,
|
||||||
|
isFocused: () => focused,
|
||||||
hide: () => {
|
hide: () => {
|
||||||
|
visible = false;
|
||||||
|
focused = false;
|
||||||
calls.push('hide');
|
calls.push('hide');
|
||||||
},
|
},
|
||||||
show: () => {
|
show: () => {
|
||||||
|
visible = true;
|
||||||
calls.push('show');
|
calls.push('show');
|
||||||
},
|
},
|
||||||
|
showInactive: () => {
|
||||||
|
visible = true;
|
||||||
|
calls.push('show-inactive');
|
||||||
|
},
|
||||||
focus: () => {
|
focus: () => {
|
||||||
|
focused = true;
|
||||||
calls.push('focus');
|
calls.push('focus');
|
||||||
},
|
},
|
||||||
|
setAlwaysOnTop: (flag: boolean) => {
|
||||||
|
calls.push(`always-on-top:${flag}`);
|
||||||
|
},
|
||||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
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 };
|
return {
|
||||||
|
window,
|
||||||
|
calls,
|
||||||
|
getOpacity: () => opacity,
|
||||||
|
setFocused: (nextFocused: boolean) => {
|
||||||
|
focused = nextFocused;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => {
|
test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => {
|
||||||
@@ -163,7 +195,286 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
|
|||||||
assert.ok(!calls.includes('osd'));
|
assert.ok(!calls.includes('osd'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Windows visible overlay stays click-through and does not steal focus while tracked', () => {
|
test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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('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 { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
isTracking: () => true,
|
isTracking: () => true,
|
||||||
@@ -191,13 +502,209 @@ test('Windows visible overlay stays click-through and does not steal focus while
|
|||||||
syncOverlayShortcuts: () => {
|
syncOverlayShortcuts: () => {
|
||||||
calls.push('sync-shortcuts');
|
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,
|
isMacOSPlatform: false,
|
||||||
isWindowsPlatform: true,
|
isWindowsPlatform: true,
|
||||||
} as never);
|
} 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('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('show'));
|
assert.ok(calls.includes('sync-windows-z-order'));
|
||||||
assert.ok(!calls.includes('focus'));
|
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', () => {
|
test('visible overlay stays hidden while a modal window is active', () => {
|
||||||
@@ -355,6 +862,157 @@ test('Windows keeps visible overlay hidden while tracker is not ready', () => {
|
|||||||
assert.ok(!calls.includes('update-bounds'));
|
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', () => {
|
test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
let trackerWarning = false;
|
let trackerWarning = false;
|
||||||
|
|||||||
@@ -2,16 +2,67 @@ import type { BrowserWindow } from 'electron';
|
|||||||
import { BaseWindowTracker } from '../../window-trackers';
|
import { BaseWindowTracker } from '../../window-trackers';
|
||||||
import { WindowGeometry } from '../../types';
|
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: {
|
export function updateVisibleOverlayVisibility(args: {
|
||||||
visibleOverlayVisible: boolean;
|
visibleOverlayVisible: boolean;
|
||||||
modalActive?: boolean;
|
modalActive?: boolean;
|
||||||
forceMousePassthrough?: boolean;
|
forceMousePassthrough?: boolean;
|
||||||
mainWindow: BrowserWindow | null;
|
mainWindow: BrowserWindow | null;
|
||||||
windowTracker: BaseWindowTracker | null;
|
windowTracker: BaseWindowTracker | null;
|
||||||
|
lastKnownWindowsForegroundProcessName?: string | null;
|
||||||
|
windowsOverlayProcessName?: string | null;
|
||||||
|
windowsFocusHandoffGraceActive?: boolean;
|
||||||
trackerNotReadyWarningShown: boolean;
|
trackerNotReadyWarningShown: boolean;
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||||
|
syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void;
|
||||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||||
enforceOverlayLayerOrder: () => void;
|
enforceOverlayLayerOrder: () => void;
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
@@ -30,6 +81,10 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
const mainWindow = args.mainWindow;
|
const mainWindow = args.mainWindow;
|
||||||
|
|
||||||
if (args.modalActive) {
|
if (args.modalActive) {
|
||||||
|
if (args.isWindowsPlatform) {
|
||||||
|
clearPendingWindowsOverlayReveal(mainWindow);
|
||||||
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
|
}
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
return;
|
return;
|
||||||
@@ -37,13 +92,92 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
|
|
||||||
const showPassiveVisibleOverlay = (): void => {
|
const showPassiveVisibleOverlay = (): void => {
|
||||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||||
if (args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough) {
|
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) {
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
args.ensureOverlayWindowLevel(mainWindow);
|
|
||||||
mainWindow.show();
|
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) {
|
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
}
|
}
|
||||||
@@ -63,12 +197,27 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
if (!args.visibleOverlayVisible) {
|
if (!args.visibleOverlayVisible) {
|
||||||
args.setTrackerNotReadyWarningShown(false);
|
args.setTrackerNotReadyWarningShown(false);
|
||||||
args.resetOverlayLoadingOsdSuppression?.();
|
args.resetOverlayLoadingOsdSuppression?.();
|
||||||
|
if (args.isWindowsPlatform) {
|
||||||
|
clearPendingWindowsOverlayReveal(mainWindow);
|
||||||
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
|
}
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.windowTracker && args.windowTracker.isTracking()) {
|
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);
|
args.setTrackerNotReadyWarningShown(false);
|
||||||
const geometry = args.windowTracker.getGeometry();
|
const geometry = args.windowTracker.getGeometry();
|
||||||
if (geometry) {
|
if (geometry) {
|
||||||
@@ -76,7 +225,9 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
}
|
}
|
||||||
args.syncPrimaryOverlayWindowLayer('visible');
|
args.syncPrimaryOverlayWindowLayer('visible');
|
||||||
showPassiveVisibleOverlay();
|
showPassiveVisibleOverlay();
|
||||||
args.enforceOverlayLayerOrder();
|
if (!args.forceMousePassthrough && !args.isWindowsPlatform) {
|
||||||
|
args.enforceOverlayLayerOrder();
|
||||||
|
}
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -87,6 +238,10 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
args.setTrackerNotReadyWarningShown(true);
|
args.setTrackerNotReadyWarningShown(true);
|
||||||
maybeShowOverlayLoadingOsd();
|
maybeShowOverlayLoadingOsd();
|
||||||
}
|
}
|
||||||
|
if (args.isWindowsPlatform) {
|
||||||
|
clearPendingWindowsOverlayReveal(mainWindow);
|
||||||
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
|
}
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
return;
|
return;
|
||||||
@@ -99,11 +254,32 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
return;
|
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) {
|
if (!args.trackerNotReadyWarningShown) {
|
||||||
args.setTrackerNotReadyWarningShown(true);
|
args.setTrackerNotReadyWarningShown(true);
|
||||||
maybeShowOverlayLoadingOsd();
|
maybeShowOverlayLoadingOsd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.isWindowsPlatform) {
|
||||||
|
clearPendingWindowsOverlayReveal(mainWindow);
|
||||||
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
|
}
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,32 @@ test('overlay window config explicitly disables renderer sandbox for preload com
|
|||||||
yomitanSession: null,
|
yomitanSession: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
assert.equal(options.backgroundColor, '#00000000');
|
||||||
assert.equal(options.webPreferences?.sandbox, false);
|
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', () => {
|
test('overlay window config uses the provided Yomitan session when available', () => {
|
||||||
|
|||||||
@@ -66,7 +66,14 @@ export function handleOverlayWindowBlurred(options: {
|
|||||||
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
||||||
ensureOverlayWindowLevel: () => void;
|
ensureOverlayWindowLevel: () => void;
|
||||||
moveWindowTop: () => void;
|
moveWindowTop: () => void;
|
||||||
|
onWindowsVisibleOverlayBlur?: () => void;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
|
if ((options.platform ?? process.platform) === 'win32' && options.kind === 'visible') {
|
||||||
|
options.onWindowsVisibleOverlayBlur?.();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
|
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function buildOverlayWindowOptions(
|
|||||||
},
|
},
|
||||||
): BrowserWindowConstructorOptions {
|
): BrowserWindowConstructorOptions {
|
||||||
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
||||||
|
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
show: false,
|
show: false,
|
||||||
@@ -18,8 +19,9 @@ export function buildOverlayWindowOptions(
|
|||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
|
backgroundColor: '#00000000',
|
||||||
frame: false,
|
frame: false,
|
||||||
alwaysOnTop: true,
|
alwaysOnTop: shouldStartAlwaysOnTop,
|
||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
hasShadow: false,
|
hasShadow: false,
|
||||||
@@ -31,6 +33,7 @@ export function buildOverlayWindowOptions(
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
|
backgroundThrottling: false,
|
||||||
webSecurity: true,
|
webSecurity: true,
|
||||||
session: options.yomitanSession ?? undefined,
|
session: options.yomitanSession ?? undefined,
|
||||||
additionalArguments: [`--overlay-layer=${kind}`],
|
additionalArguments: [`--overlay-layer=${kind}`],
|
||||||
|
|||||||
@@ -103,6 +103,49 @@ test('handleOverlayWindowBlurred skips visible overlay restacking after manual h
|
|||||||
assert.deepEqual(calls, []);
|
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', () => {
|
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
@@ -117,6 +160,7 @@ test('handleOverlayWindowBlurred preserves active visible/modal window stacking'
|
|||||||
moveWindowTop: () => {
|
moveWindowTop: () => {
|
||||||
calls.push('move-visible');
|
calls.push('move-visible');
|
||||||
},
|
},
|
||||||
|
platform: 'linux',
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds
|
|||||||
|
|
||||||
const logger = createLogger('main:overlay-window');
|
const logger = createLogger('main:overlay-window');
|
||||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
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 {
|
function getOverlayWindowHtmlPath(): string {
|
||||||
return path.join(__dirname, '..', '..', 'renderer', 'index.html');
|
return path.join(__dirname, '..', '..', 'renderer', 'index.html');
|
||||||
@@ -76,13 +87,17 @@ export function createOverlayWindow(
|
|||||||
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
|
onVisibleWindowBlurred?: () => void;
|
||||||
|
onWindowContentReady?: () => void;
|
||||||
onWindowClosed: (kind: OverlayWindowKind) => void;
|
onWindowClosed: (kind: OverlayWindowKind) => void;
|
||||||
yomitanSession?: Session | null;
|
yomitanSession?: Session | null;
|
||||||
},
|
},
|
||||||
): BrowserWindow {
|
): BrowserWindow {
|
||||||
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
|
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
|
||||||
|
|
||||||
options.ensureOverlayWindowLevel(window);
|
if (!(process.platform === 'win32' && kind === 'visible')) {
|
||||||
|
options.ensureOverlayWindowLevel(window);
|
||||||
|
}
|
||||||
loadOverlayWindowLayer(window, kind);
|
loadOverlayWindowLayer(window, kind);
|
||||||
|
|
||||||
window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
||||||
@@ -93,6 +108,14 @@ export function createOverlayWindow(
|
|||||||
options.onRuntimeOptionsChanged();
|
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') {
|
if (kind === 'visible') {
|
||||||
window.webContents.on('devtools-opened', () => {
|
window.webContents.on('devtools-opened', () => {
|
||||||
options.setOverlayDebugVisualizationEnabled(true);
|
options.setOverlayDebugVisualizationEnabled(true);
|
||||||
@@ -136,6 +159,8 @@ export function createOverlayWindow(
|
|||||||
moveWindowTop: () => {
|
moveWindowTop: () => {
|
||||||
window.moveTop();
|
window.moveTop();
|
||||||
},
|
},
|
||||||
|
onWindowsVisibleOverlayBlur:
|
||||||
|
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,8 @@ function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: num
|
|||||||
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
|
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
|
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | 'all' {
|
||||||
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
|
return raw === '7d' || raw === '30d' || raw === '90d' || raw === 'all' ? raw : '30d';
|
||||||
? raw
|
|
||||||
: '30d';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
||||||
|
|||||||
285
src/main.ts
285
src/main.ts
@@ -130,6 +130,15 @@ import {
|
|||||||
type LogLevelSource,
|
type LogLevelSource,
|
||||||
} from './logger';
|
} from './logger';
|
||||||
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
||||||
|
import {
|
||||||
|
bindWindowsOverlayAboveMpvNative,
|
||||||
|
clearWindowsOverlayOwnerNative,
|
||||||
|
ensureWindowsOverlayTransparencyNative,
|
||||||
|
getWindowsForegroundProcessNameNative,
|
||||||
|
queryWindowsForegroundProcessName,
|
||||||
|
setWindowsOverlayOwnerNative,
|
||||||
|
syncWindowsOverlayToMpvZOrder,
|
||||||
|
} from './window-trackers/windows-helper';
|
||||||
import {
|
import {
|
||||||
commandNeedsOverlayStartupPrereqs,
|
commandNeedsOverlayStartupPrereqs,
|
||||||
commandNeedsOverlayRuntime,
|
commandNeedsOverlayRuntime,
|
||||||
@@ -1835,6 +1844,9 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
|||||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
||||||
getWindowTracker: () => appState.windowTracker,
|
getWindowTracker: () => appState.windowTracker,
|
||||||
|
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
||||||
|
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
|
||||||
|
getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(),
|
||||||
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
|
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||||
appState.trackerNotReadyWarningShown = shown;
|
appState.trackerNotReadyWarningShown = shown;
|
||||||
@@ -1843,6 +1855,9 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
|||||||
ensureOverlayWindowLevel: (window) => {
|
ensureOverlayWindowLevel: (window) => {
|
||||||
ensureOverlayWindowLevel(window);
|
ensureOverlayWindowLevel(window);
|
||||||
},
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: (_window) => {
|
||||||
|
requestWindowsVisibleOverlayZOrderSync();
|
||||||
|
},
|
||||||
syncPrimaryOverlayWindowLayer: (layer) => {
|
syncPrimaryOverlayWindowLayer: (layer) => {
|
||||||
syncPrimaryOverlayWindowLayer(layer);
|
syncPrimaryOverlayWindowLayer(layer);
|
||||||
},
|
},
|
||||||
@@ -1870,6 +1885,223 @@ 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(
|
const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler(
|
||||||
{
|
{
|
||||||
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||||
@@ -3674,6 +3906,12 @@ function applyOverlayRegions(geometry: WindowGeometry): void {
|
|||||||
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
|
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
|
||||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
||||||
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
||||||
|
afterSetOverlayWindowBounds: () => {
|
||||||
|
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleWindowsVisibleOverlayZOrderSyncBurst();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
|
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
|
||||||
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
||||||
@@ -3796,7 +4034,14 @@ function createModalWindow(): BrowserWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createMainWindow(): BrowserWindow {
|
function createMainWindow(): BrowserWindow {
|
||||||
return createMainWindowHandler();
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureTray(): void {
|
function ensureTray(): void {
|
||||||
@@ -4595,6 +4840,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
|||||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||||
|
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
|
||||||
|
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||||
onWindowClosed: (windowKind) => {
|
onWindowClosed: (windowKind) => {
|
||||||
if (windowKind === 'visible') {
|
if (windowKind === 'visible') {
|
||||||
overlayManager.setMainWindow(null);
|
overlayManager.setMainWindow(null);
|
||||||
@@ -4696,6 +4943,9 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
updateVisibleOverlayVisibility: () =>
|
updateVisibleOverlayVisibility: () =>
|
||||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||||
},
|
},
|
||||||
|
refreshCurrentSubtitle: () => {
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||||
|
},
|
||||||
overlayShortcutsRuntime: {
|
overlayShortcutsRuntime: {
|
||||||
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||||
},
|
},
|
||||||
@@ -4719,6 +4969,39 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
},
|
},
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||||
updateVisibleOverlayBounds(geometry),
|
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(),
|
getOverlayWindows: () => getOverlayWindows(),
|
||||||
getResolvedConfig: () => getResolvedConfig(),
|
getResolvedConfig: () => getResolvedConfig(),
|
||||||
showDesktopNotification,
|
showDesktopNotification,
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ export interface OverlayVisibilityRuntimeDeps {
|
|||||||
getVisibleOverlayVisible: () => boolean;
|
getVisibleOverlayVisible: () => boolean;
|
||||||
getForceMousePassthrough: () => boolean;
|
getForceMousePassthrough: () => boolean;
|
||||||
getWindowTracker: () => BaseWindowTracker | null;
|
getWindowTracker: () => BaseWindowTracker | null;
|
||||||
|
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
||||||
|
getWindowsOverlayProcessName?: () => string | null;
|
||||||
|
getWindowsFocusHandoffGraceActive?: () => boolean;
|
||||||
getTrackerNotReadyWarningShown: () => boolean;
|
getTrackerNotReadyWarningShown: () => boolean;
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||||
|
syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void;
|
||||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||||
enforceOverlayLayerOrder: () => void;
|
enforceOverlayLayerOrder: () => void;
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
@@ -36,12 +40,20 @@ export function createOverlayVisibilityRuntimeService(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
updateVisibleOverlayVisibility(): void {
|
updateVisibleOverlayVisibility(): void {
|
||||||
|
const visibleOverlayVisible = deps.getVisibleOverlayVisible();
|
||||||
|
const forceMousePassthrough = deps.getForceMousePassthrough();
|
||||||
|
const windowTracker = deps.getWindowTracker();
|
||||||
|
const mainWindow = deps.getMainWindow();
|
||||||
|
|
||||||
updateVisibleOverlayVisibility({
|
updateVisibleOverlayVisibility({
|
||||||
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
|
visibleOverlayVisible,
|
||||||
modalActive: deps.getModalActive(),
|
modalActive: deps.getModalActive(),
|
||||||
forceMousePassthrough: deps.getForceMousePassthrough(),
|
forceMousePassthrough,
|
||||||
mainWindow: deps.getMainWindow(),
|
mainWindow,
|
||||||
windowTracker: deps.getWindowTracker(),
|
windowTracker,
|
||||||
|
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
||||||
|
windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null,
|
||||||
|
windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
||||||
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
|
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||||
deps.setTrackerNotReadyWarningShown(shown);
|
deps.setTrackerNotReadyWarningShown(shown);
|
||||||
@@ -49,6 +61,8 @@ export function createOverlayVisibilityRuntimeService(
|
|||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||||
deps.updateVisibleOverlayBounds(geometry),
|
deps.updateVisibleOverlayBounds(geometry),
|
||||||
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
||||||
|
syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) =>
|
||||||
|
deps.syncWindowsOverlayToMpvZOrder?.(window),
|
||||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') =>
|
syncPrimaryOverlayWindowLayer: (layer: 'visible') =>
|
||||||
deps.syncPrimaryOverlayWindowLayer(layer),
|
deps.syncPrimaryOverlayWindowLayer(layer),
|
||||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type InitializeOverlayRuntimeCore = (options: {
|
|||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
|
bindOverlayOwner?: () => void;
|
||||||
|
releaseOverlayOwner?: () => void;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
export function createInitializeOverlayRuntimeHandler(deps: {
|
export function createInitializeOverlayRuntimeHandler(deps: {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
|||||||
overlayVisibilityRuntime: {
|
overlayVisibilityRuntime: {
|
||||||
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
||||||
},
|
},
|
||||||
|
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
||||||
overlayShortcutsRuntime: {
|
overlayShortcutsRuntime: {
|
||||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||||
},
|
},
|
||||||
@@ -53,6 +54,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
|||||||
deps.registerGlobalShortcuts();
|
deps.registerGlobalShortcuts();
|
||||||
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||||
deps.updateVisibleOverlayVisibility();
|
deps.updateVisibleOverlayVisibility();
|
||||||
|
deps.refreshCurrentSubtitle?.();
|
||||||
deps.syncOverlayShortcuts();
|
deps.syncOverlayShortcuts();
|
||||||
deps.showDesktopNotification('title', {});
|
deps.showDesktopNotification('title', {});
|
||||||
|
|
||||||
@@ -68,6 +70,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
|||||||
'register-shortcuts',
|
'register-shortcuts',
|
||||||
'visible-bounds',
|
'visible-bounds',
|
||||||
'update-visible',
|
'update-visible',
|
||||||
|
'refresh-subtitle',
|
||||||
'sync-shortcuts',
|
'sync-shortcuts',
|
||||||
'notify',
|
'notify',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
overlayVisibilityRuntime: {
|
overlayVisibilityRuntime: {
|
||||||
updateVisibleOverlayVisibility: () => void;
|
updateVisibleOverlayVisibility: () => void;
|
||||||
};
|
};
|
||||||
|
refreshCurrentSubtitle?: () => void;
|
||||||
overlayShortcutsRuntime: {
|
overlayShortcutsRuntime: {
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
};
|
};
|
||||||
@@ -39,6 +40,8 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
|
bindOverlayOwner?: () => void;
|
||||||
|
releaseOverlayOwner?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (): OverlayRuntimeOptionsMainDeps => ({
|
return (): OverlayRuntimeOptionsMainDeps => ({
|
||||||
getBackendOverride: () => deps.appState.backendOverride,
|
getBackendOverride: () => deps.appState.backendOverride,
|
||||||
@@ -53,6 +56,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
|
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
|
||||||
updateVisibleOverlayVisibility: () =>
|
updateVisibleOverlayVisibility: () =>
|
||||||
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||||
|
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
|
||||||
getOverlayWindows: () => deps.getOverlayWindows(),
|
getOverlayWindows: () => deps.getOverlayWindows(),
|
||||||
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||||
setWindowTracker: (tracker) => {
|
setWindowTracker: (tracker) => {
|
||||||
@@ -71,5 +75,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
||||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||||
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
||||||
|
bindOverlayOwner: deps.bindOverlayOwner,
|
||||||
|
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
|||||||
updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'),
|
updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'),
|
||||||
isVisibleOverlayVisible: () => true,
|
isVisibleOverlayVisible: () => true,
|
||||||
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
||||||
|
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
||||||
getOverlayWindows: () => [],
|
getOverlayWindows: () => [],
|
||||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||||
setWindowTracker: () => calls.push('set-tracker'),
|
setWindowTracker: () => calls.push('set-tracker'),
|
||||||
@@ -41,6 +42,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
|||||||
options.registerGlobalShortcuts();
|
options.registerGlobalShortcuts();
|
||||||
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||||
options.updateVisibleOverlayVisibility();
|
options.updateVisibleOverlayVisibility();
|
||||||
|
options.refreshCurrentSubtitle?.();
|
||||||
options.syncOverlayShortcuts();
|
options.syncOverlayShortcuts();
|
||||||
options.setWindowTracker(null);
|
options.setWindowTracker(null);
|
||||||
options.setAnkiIntegration(null);
|
options.setAnkiIntegration(null);
|
||||||
@@ -51,6 +53,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
|||||||
'register-shortcuts',
|
'register-shortcuts',
|
||||||
'update-visible-bounds',
|
'update-visible-bounds',
|
||||||
'update-visible',
|
'update-visible',
|
||||||
|
'refresh-subtitle',
|
||||||
'sync-shortcuts',
|
'sync-shortcuts',
|
||||||
'set-tracker',
|
'set-tracker',
|
||||||
'set-anki',
|
'set-anki',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type OverlayRuntimeOptions = {
|
|||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
isVisibleOverlayVisible: () => boolean;
|
isVisibleOverlayVisible: () => boolean;
|
||||||
updateVisibleOverlayVisibility: () => void;
|
updateVisibleOverlayVisibility: () => void;
|
||||||
|
refreshCurrentSubtitle?: () => void;
|
||||||
getOverlayWindows: () => BrowserWindow[];
|
getOverlayWindows: () => BrowserWindow[];
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||||
@@ -35,6 +36,8 @@ type OverlayRuntimeOptions = {
|
|||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
|
bindOverlayOwner?: () => void;
|
||||||
|
releaseOverlayOwner?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||||
@@ -44,6 +47,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
isVisibleOverlayVisible: () => boolean;
|
isVisibleOverlayVisible: () => boolean;
|
||||||
updateVisibleOverlayVisibility: () => void;
|
updateVisibleOverlayVisibility: () => void;
|
||||||
|
refreshCurrentSubtitle?: () => void;
|
||||||
getOverlayWindows: () => BrowserWindow[];
|
getOverlayWindows: () => BrowserWindow[];
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||||
@@ -65,6 +69,8 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
|
bindOverlayOwner?: () => void;
|
||||||
|
releaseOverlayOwner?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (): OverlayRuntimeOptions => ({
|
return (): OverlayRuntimeOptions => ({
|
||||||
backendOverride: deps.getBackendOverride(),
|
backendOverride: deps.getBackendOverride(),
|
||||||
@@ -73,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds,
|
updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds,
|
||||||
isVisibleOverlayVisible: deps.isVisibleOverlayVisible,
|
isVisibleOverlayVisible: deps.isVisibleOverlayVisible,
|
||||||
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
|
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
|
||||||
|
refreshCurrentSubtitle: deps.refreshCurrentSubtitle,
|
||||||
getOverlayWindows: deps.getOverlayWindows,
|
getOverlayWindows: deps.getOverlayWindows,
|
||||||
syncOverlayShortcuts: deps.syncOverlayShortcuts,
|
syncOverlayShortcuts: deps.syncOverlayShortcuts,
|
||||||
setWindowTracker: deps.setWindowTracker,
|
setWindowTracker: deps.setWindowTracker,
|
||||||
@@ -87,5 +94,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||||
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||||
|
bindOverlayOwner: deps.bindOverlayOwner,
|
||||||
|
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
getVisibleOverlayVisible: () => true,
|
getVisibleOverlayVisible: () => true,
|
||||||
getForceMousePassthrough: () => true,
|
getForceMousePassthrough: () => true,
|
||||||
getWindowTracker: () => tracker,
|
getWindowTracker: () => tracker,
|
||||||
|
getLastKnownWindowsForegroundProcessName: () => 'mpv',
|
||||||
|
getWindowsOverlayProcessName: () => 'subminer',
|
||||||
|
getWindowsFocusHandoffGraceActive: () => true,
|
||||||
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
|
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
|
||||||
setTrackerNotReadyWarningShown: (shown) => {
|
setTrackerNotReadyWarningShown: (shown) => {
|
||||||
trackerNotReadyWarningShown = shown;
|
trackerNotReadyWarningShown = shown;
|
||||||
@@ -23,6 +26,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
},
|
},
|
||||||
updateVisibleOverlayBounds: () => calls.push('visible-bounds'),
|
updateVisibleOverlayBounds: () => calls.push('visible-bounds'),
|
||||||
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
|
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => calls.push('sync-windows-z-order'),
|
||||||
syncPrimaryOverlayWindowLayer: (layer) => calls.push(`primary-layer:${layer}`),
|
syncPrimaryOverlayWindowLayer: (layer) => calls.push(`primary-layer:${layer}`),
|
||||||
enforceOverlayLayerOrder: () => calls.push('enforce-order'),
|
enforceOverlayLayerOrder: () => calls.push('enforce-order'),
|
||||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||||
@@ -36,10 +40,14 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
assert.equal(deps.getModalActive(), true);
|
assert.equal(deps.getModalActive(), true);
|
||||||
assert.equal(deps.getVisibleOverlayVisible(), true);
|
assert.equal(deps.getVisibleOverlayVisible(), true);
|
||||||
assert.equal(deps.getForceMousePassthrough(), 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);
|
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
|
||||||
deps.setTrackerNotReadyWarningShown(true);
|
deps.setTrackerNotReadyWarningShown(true);
|
||||||
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||||
deps.ensureOverlayWindowLevel(mainWindow);
|
deps.ensureOverlayWindowLevel(mainWindow);
|
||||||
|
deps.syncWindowsOverlayToMpvZOrder?.(mainWindow);
|
||||||
deps.syncPrimaryOverlayWindowLayer('visible');
|
deps.syncPrimaryOverlayWindowLayer('visible');
|
||||||
deps.enforceOverlayLayerOrder();
|
deps.enforceOverlayLayerOrder();
|
||||||
deps.syncOverlayShortcuts();
|
deps.syncOverlayShortcuts();
|
||||||
@@ -52,6 +60,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
'tracker-warning:true',
|
'tracker-warning:true',
|
||||||
'visible-bounds',
|
'visible-bounds',
|
||||||
'ensure-level',
|
'ensure-level',
|
||||||
|
'sync-windows-z-order',
|
||||||
'primary-layer:visible',
|
'primary-layer:visible',
|
||||||
'enforce-order',
|
'enforce-order',
|
||||||
'sync-shortcuts',
|
'sync-shortcuts',
|
||||||
|
|||||||
@@ -11,11 +11,17 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
|||||||
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
||||||
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
||||||
getWindowTracker: () => deps.getWindowTracker(),
|
getWindowTracker: () => deps.getWindowTracker(),
|
||||||
|
getLastKnownWindowsForegroundProcessName: () =>
|
||||||
|
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
|
||||||
|
getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null,
|
||||||
|
getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
||||||
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
|
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
|
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||||
deps.updateVisibleOverlayBounds(geometry),
|
deps.updateVisibleOverlayBounds(geometry),
|
||||||
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
||||||
|
syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) =>
|
||||||
|
deps.syncWindowsOverlayToMpvZOrder?.(window),
|
||||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer),
|
syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer),
|
||||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||||
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
|
onVisibleWindowBlurred?: () => void;
|
||||||
|
onWindowContentReady?: () => void;
|
||||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||||
yomitanSession?: Session | null;
|
yomitanSession?: Session | null;
|
||||||
},
|
},
|
||||||
@@ -22,6 +24,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
|
onVisibleWindowBlurred?: () => void;
|
||||||
|
onWindowContentReady?: () => void;
|
||||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||||
getYomitanSession?: () => Session | null;
|
getYomitanSession?: () => Session | null;
|
||||||
}) {
|
}) {
|
||||||
@@ -34,6 +38,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
isOverlayVisible: deps.isOverlayVisible,
|
isOverlayVisible: deps.isOverlayVisible,
|
||||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||||
forwardTabToMpv: deps.forwardTabToMpv,
|
forwardTabToMpv: deps.forwardTabToMpv,
|
||||||
|
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||||
|
onWindowContentReady: deps.onWindowContentReady,
|
||||||
onWindowClosed: deps.onWindowClosed,
|
onWindowClosed: deps.onWindowClosed,
|
||||||
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
|||||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
|
onVisibleWindowBlurred?: () => void;
|
||||||
|
onWindowContentReady?: () => void;
|
||||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||||
yomitanSession?: Session | null;
|
yomitanSession?: Session | null;
|
||||||
},
|
},
|
||||||
@@ -24,6 +26,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
|||||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
|
onVisibleWindowBlurred?: () => void;
|
||||||
|
onWindowContentReady?: () => void;
|
||||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||||
getYomitanSession?: () => Session | null;
|
getYomitanSession?: () => Session | null;
|
||||||
}) {
|
}) {
|
||||||
@@ -36,6 +40,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
|||||||
isOverlayVisible: deps.isOverlayVisible,
|
isOverlayVisible: deps.isOverlayVisible,
|
||||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||||
forwardTabToMpv: deps.forwardTabToMpv,
|
forwardTabToMpv: deps.forwardTabToMpv,
|
||||||
|
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||||
|
onWindowContentReady: deps.onWindowContentReady,
|
||||||
onWindowClosed: deps.onWindowClosed,
|
onWindowClosed: deps.onWindowClosed,
|
||||||
yomitanSession: deps.getYomitanSession?.() ?? null,
|
yomitanSession: deps.getYomitanSession?.() ?? null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
|
|||||||
) {
|
) {
|
||||||
return (): UpdateVisibleOverlayBoundsMainDeps => ({
|
return (): UpdateVisibleOverlayBoundsMainDeps => ({
|
||||||
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
|
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
|
||||||
|
afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ test('visible bounds handler writes visible layer geometry', () => {
|
|||||||
assert.deepEqual(calls, [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', () => {
|
test('ensure overlay window level handler delegates to core', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const ensureLevel = createEnsureOverlayWindowLevelHandler({
|
const ensureLevel = createEnsureOverlayWindowLevelHandler({
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import type { WindowGeometry } from '../../types';
|
|||||||
|
|
||||||
export function createUpdateVisibleOverlayBoundsHandler(deps: {
|
export function createUpdateVisibleOverlayBoundsHandler(deps: {
|
||||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||||
|
afterSetOverlayWindowBounds?: (geometry: WindowGeometry) => void;
|
||||||
}) {
|
}) {
|
||||||
return (geometry: WindowGeometry): void => {
|
return (geometry: WindowGeometry): void => {
|
||||||
deps.setOverlayWindowBounds(geometry);
|
deps.setOverlayWindowBounds(geometry);
|
||||||
|
deps.afterSetOverlayWindowBounds?.(geometry);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
61
src/prerelease-workflow.test.ts
Normal file
61
src/prerelease-workflow.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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/);
|
||||||
|
});
|
||||||
@@ -22,6 +22,12 @@ test('publish release leaves prerelease unset so gh creates a normal release', (
|
|||||||
assert.ok(!releaseWorkflow.includes('--prerelease'));
|
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', () => {
|
test('publish release forces an existing draft tag release to become public', () => {
|
||||||
assert.ok(releaseWorkflow.includes('--draft=false'));
|
assert.ok(releaseWorkflow.includes('--draft=false'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { createRendererRecoveryController } from './error-recovery.js';
|
import { createRendererRecoveryController } from './error-recovery.js';
|
||||||
import {
|
import {
|
||||||
|
YOMITAN_POPUP_HOST_SELECTOR,
|
||||||
YOMITAN_POPUP_IFRAME_SELECTOR,
|
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||||
|
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||||
hasYomitanPopupIframe,
|
hasYomitanPopupIframe,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
isYomitanPopupVisible,
|
isYomitanPopupVisible,
|
||||||
@@ -228,6 +230,42 @@ 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', () => {
|
test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => {
|
||||||
const createElement = (options: {
|
const createElement = (options: {
|
||||||
tagName: string;
|
tagName: string;
|
||||||
@@ -284,9 +322,25 @@ test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
|
|||||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_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', () => {
|
test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
||||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
let selector = '';
|
const selectors: string[] = [];
|
||||||
const visibleFrame = {
|
const visibleFrame = {
|
||||||
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
||||||
} as unknown as HTMLIFrameElement;
|
} as unknown as HTMLIFrameElement;
|
||||||
@@ -309,18 +363,40 @@ test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
|||||||
try {
|
try {
|
||||||
const root = {
|
const root = {
|
||||||
querySelectorAll: (value: string) => {
|
querySelectorAll: (value: string) => {
|
||||||
selector = value;
|
selectors.push(value);
|
||||||
|
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || value === YOMITAN_POPUP_HOST_SELECTOR) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [hiddenFrame, visibleFrame];
|
return [hiddenFrame, visibleFrame];
|
||||||
},
|
},
|
||||||
} as unknown as ParentNode;
|
} as unknown as ParentNode;
|
||||||
|
|
||||||
assert.equal(isYomitanPopupVisible(root), true);
|
assert.equal(isYomitanPopupVisible(root), true);
|
||||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
assert.deepEqual(selectors, [
|
||||||
|
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||||
|
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
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', () => {
|
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
|
||||||
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
|
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
|
||||||
const activeItem = {
|
const activeItem = {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { SPECIAL_COMMANDS } from '../../config/definitions';
|
|||||||
import type { Keybinding, ShortcutsConfig } from '../../types';
|
import type { Keybinding, ShortcutsConfig } from '../../types';
|
||||||
import type { RendererContext } from '../context';
|
import type { RendererContext } from '../context';
|
||||||
import {
|
import {
|
||||||
|
YOMITAN_POPUP_HOST_SELECTOR,
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
YOMITAN_POPUP_COMMAND_EVENT,
|
YOMITAN_POPUP_COMMAND_EVENT,
|
||||||
@@ -61,6 +62,9 @@ export function createKeyboardHandlers(
|
|||||||
if (target.closest('.modal')) return true;
|
if (target.closest('.modal')) return true;
|
||||||
if (ctx.dom.subtitleContainer.contains(target)) return true;
|
if (ctx.dom.subtitleContainer.contains(target)) return true;
|
||||||
if (isYomitanPopupIframe(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"]'))
|
if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]'))
|
||||||
return true;
|
return true;
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import type { SubtitleSidebarConfig } from '../../types';
|
import type { SubtitleSidebarConfig } from '../../types';
|
||||||
import { createMouseHandlers } from './mouse.js';
|
import { createMouseHandlers } from './mouse.js';
|
||||||
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
|
import {
|
||||||
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
|
YOMITAN_POPUP_HOST_SELECTOR,
|
||||||
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
|
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||||
|
} from '../yomitan-popup.js';
|
||||||
|
|
||||||
function createClassList() {
|
function createClassList() {
|
||||||
const classes = new Set<string>();
|
const classes = new Set<string>();
|
||||||
@@ -78,11 +83,13 @@ function createMouseTestContext() {
|
|||||||
},
|
},
|
||||||
platform: {
|
platform: {
|
||||||
shouldToggleMouseIgnore: false,
|
shouldToggleMouseIgnore: false,
|
||||||
|
isLinuxPlatform: false,
|
||||||
isMacOSPlatform: false,
|
isMacOSPlatform: false,
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
isOverSubtitle: false,
|
isOverSubtitle: false,
|
||||||
isOverSubtitleSidebar: false,
|
isOverSubtitleSidebar: false,
|
||||||
|
yomitanPopupVisible: false,
|
||||||
subtitleSidebarModalOpen: false,
|
subtitleSidebarModalOpen: false,
|
||||||
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
|
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
@@ -712,6 +719,257 @@ 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', () => {
|
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
|
||||||
const ctx = createMouseTestContext();
|
const ctx = createMouseTestContext();
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
@@ -783,6 +1041,361 @@ 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', () => {
|
test('pointer tracking enables overlay interaction as soon as the cursor reaches subtitles', () => {
|
||||||
const ctx = createMouseTestContext();
|
const ctx = createMouseTestContext();
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
@@ -916,10 +1529,8 @@ test('pointer tracking restores click-through after the cursor leaves subtitles'
|
|||||||
|
|
||||||
assert.equal(ctx.state.isOverSubtitle, false);
|
assert.equal(ctx.state.isOverSubtitle, false);
|
||||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
||||||
assert.deepEqual(ignoreCalls, [
|
assert.equal(ignoreCalls[0]?.ignore, false);
|
||||||
{ ignore: false, forward: undefined },
|
assert.deepEqual(ignoreCalls.at(-1), { ignore: true, forward: true });
|
||||||
{ ignore: true, forward: true },
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ import type { ModalStateReader, RendererContext } from '../context';
|
|||||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
|
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||||
|
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
isYomitanPopupVisible,
|
isYomitanPopupVisible,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
} from '../yomitan-popup.js';
|
} from '../yomitan-popup.js';
|
||||||
|
|
||||||
|
const VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE = 'visibility-recovery';
|
||||||
|
const WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE = 'window-resize';
|
||||||
|
|
||||||
export function createMouseHandlers(
|
export function createMouseHandlers(
|
||||||
ctx: RendererContext,
|
ctx: RendererContext,
|
||||||
options: {
|
options: {
|
||||||
@@ -33,6 +38,61 @@ export function createMouseHandlers(
|
|||||||
let pausedByYomitanPopup = false;
|
let pausedByYomitanPopup = false;
|
||||||
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
||||||
let pendingPointerResync = false;
|
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 {
|
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@@ -86,6 +146,7 @@ export function createMouseHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suppressDirectHoverEnterSource = null;
|
||||||
const wasOverSubtitle = ctx.state.isOverSubtitle;
|
const wasOverSubtitle = ctx.state.isOverSubtitle;
|
||||||
const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains(
|
const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains(
|
||||||
'secondary-sub-hover-active',
|
'secondary-sub-hover-active',
|
||||||
@@ -93,7 +154,7 @@ export function createMouseHandlers(
|
|||||||
const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY);
|
const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY);
|
||||||
|
|
||||||
if (!wasOverSubtitle && hoverState.isOverSubtitle) {
|
if (!wasOverSubtitle && hoverState.isOverSubtitle) {
|
||||||
void handleMouseEnter(undefined, hoverState.overSecondarySubtitle);
|
void handleMouseEnter(undefined, hoverState.overSecondarySubtitle, 'tracked-pointer');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,9 +171,13 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restorePointerInteractionState(): void {
|
function resyncPointerInteractionState(options: {
|
||||||
|
allowInteractiveFallback: boolean;
|
||||||
|
suppressDirectHoverEnterSource?: string | null;
|
||||||
|
}): void {
|
||||||
const pointerPosition = lastPointerPosition;
|
const pointerPosition = lastPointerPosition;
|
||||||
pendingPointerResync = false;
|
pendingPointerResync = false;
|
||||||
|
suppressDirectHoverEnterSource = options.suppressDirectHoverEnterSource ?? null;
|
||||||
if (pointerPosition) {
|
if (pointerPosition) {
|
||||||
syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY);
|
syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY);
|
||||||
} else {
|
} else {
|
||||||
@@ -121,7 +186,11 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
|
||||||
if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isOverSubtitle) {
|
if (
|
||||||
|
!options.allowInteractiveFallback ||
|
||||||
|
!ctx.platform.shouldToggleMouseIgnore ||
|
||||||
|
ctx.state.isOverSubtitle
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +199,10 @@ export function createMouseHandlers(
|
|||||||
window.electronAPI.setIgnoreMouseEvents(false);
|
window.electronAPI.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restorePointerInteractionState(): void {
|
||||||
|
resyncPointerInteractionState({ allowInteractiveFallback: true });
|
||||||
|
}
|
||||||
|
|
||||||
function maybeResyncPointerHoverState(event: MouseEvent | PointerEvent): void {
|
function maybeResyncPointerHoverState(event: MouseEvent | PointerEvent): void {
|
||||||
if (!pendingPointerResync) {
|
if (!pendingPointerResync) {
|
||||||
return;
|
return;
|
||||||
@@ -205,18 +278,14 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enablePopupInteraction(): void {
|
function enablePopupInteraction(): void {
|
||||||
yomitanPopupVisible = true;
|
sustainPopupInteraction();
|
||||||
ctx.state.yomitanPopupVisible = true;
|
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
|
||||||
if (ctx.platform.isMacOSPlatform) {
|
if (ctx.platform.isMacOSPlatform) {
|
||||||
window.focus();
|
window.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function disablePopupInteractionIfIdle(): void {
|
function disablePopupInteractionIfIdle(): void {
|
||||||
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
|
if (reconcilePopupInteraction({ reclaimFocus: true })) {
|
||||||
yomitanPopupVisible = true;
|
|
||||||
ctx.state.yomitanPopupVisible = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +297,15 @@ export function createMouseHandlers(
|
|||||||
syncOverlayMouseIgnoreState(ctx);
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMouseEnter(_event?: MouseEvent, showSecondaryHover = false): Promise<void> {
|
async function handleMouseEnter(
|
||||||
|
_event?: MouseEvent,
|
||||||
|
showSecondaryHover = false,
|
||||||
|
source: 'direct' | 'tracked-pointer' = 'direct',
|
||||||
|
): Promise<void> {
|
||||||
|
if (source === 'direct' && suppressDirectHoverEnterSource !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.state.isOverSubtitle = true;
|
ctx.state.isOverSubtitle = true;
|
||||||
if (showSecondaryHover) {
|
if (showSecondaryHover) {
|
||||||
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active');
|
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active');
|
||||||
@@ -326,6 +403,10 @@ export function createMouseHandlers(
|
|||||||
function setupResizeHandler(): void {
|
function setupResizeHandler(): void {
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
options.applyYPercent(options.getCurrentYPercent());
|
options.applyYPercent(options.getCurrentYPercent());
|
||||||
|
resyncPointerInteractionState({
|
||||||
|
allowInteractiveFallback: false,
|
||||||
|
suppressDirectHoverEnterSource: WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +421,15 @@ export function createMouseHandlers(
|
|||||||
syncHoverStateFromTrackedPointer(event);
|
syncHoverStateFromTrackedPointer(event);
|
||||||
maybeResyncPointerHoverState(event);
|
maybeResyncPointerHoverState(event);
|
||||||
});
|
});
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resyncPointerInteractionState({
|
||||||
|
allowInteractiveFallback: false,
|
||||||
|
suppressDirectHoverEnterSource: VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupSelectionObserver(): void {
|
function setupSelectionObserver(): void {
|
||||||
@@ -356,19 +446,37 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupYomitanObserver(): void {
|
function setupYomitanObserver(): void {
|
||||||
yomitanPopupVisible = isYomitanPopupVisible(document);
|
reconcilePopupInteraction({ allowPause: true });
|
||||||
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
|
|
||||||
void maybePauseForYomitanPopup();
|
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||||
enablePopupInteraction();
|
reconcilePopupInteraction({ assumeVisible: true, allowPause: true });
|
||||||
void maybePauseForYomitanPopup();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
disablePopupInteractionIfIdle();
|
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[]) => {
|
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
mutation.addedNodes.forEach((node) => {
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
|||||||
@@ -15,6 +15,53 @@ 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', () => {
|
test('youtube picker keeps overlay interactive even when subtitle hover is inactive', () => {
|
||||||
const classList = createClassList();
|
const classList = createClassList();
|
||||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
@@ -61,3 +108,62 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact
|
|||||||
Object.assign(globalThis, { window: originalWindow });
|
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,5 +1,6 @@
|
|||||||
import type { RendererContext } from './context';
|
import type { RendererContext } from './context';
|
||||||
import type { RendererState } from './state';
|
import type { RendererState } from './state';
|
||||||
|
import { isYomitanPopupVisible } from './yomitan-popup.js';
|
||||||
|
|
||||||
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
@@ -14,11 +15,21 @@ 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 {
|
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
||||||
const shouldStayInteractive =
|
const shouldStayInteractive =
|
||||||
ctx.state.isOverSubtitle ||
|
ctx.state.isOverSubtitle ||
|
||||||
ctx.state.isOverSubtitleSidebar ||
|
ctx.state.isOverSubtitleSidebar ||
|
||||||
ctx.state.yomitanPopupVisible ||
|
isYomitanPopupInteractionActive(ctx.state) ||
|
||||||
isBlockingOverlayModalOpen(ctx.state);
|
isBlockingOverlayModalOpen(ctx.state);
|
||||||
|
|
||||||
if (shouldStayInteractive) {
|
if (shouldStayInteractive) {
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ import { resolveRendererDom } from './utils/dom.js';
|
|||||||
import { resolvePlatformInfo } from './utils/platform.js';
|
import { resolvePlatformInfo } from './utils/platform.js';
|
||||||
import {
|
import {
|
||||||
buildMpvLoadfileCommands,
|
buildMpvLoadfileCommands,
|
||||||
|
buildMpvSubtitleAddCommands,
|
||||||
|
collectDroppedSubtitlePaths,
|
||||||
collectDroppedVideoPaths,
|
collectDroppedVideoPaths,
|
||||||
} from '../core/services/overlay-drop.js';
|
} from '../core/services/overlay-drop.js';
|
||||||
|
|
||||||
@@ -527,6 +529,12 @@ async function init(): Promise<void> {
|
|||||||
if (ctx.platform.isMacOSPlatform) {
|
if (ctx.platform.isMacOSPlatform) {
|
||||||
document.body.classList.add('platform-macos');
|
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) => {
|
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||||
runGuarded('subtitle:update', () => {
|
runGuarded('subtitle:update', () => {
|
||||||
@@ -654,10 +662,6 @@ async function init(): Promise<void> {
|
|||||||
);
|
);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
|
|
||||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
measurementReporter.emitNow();
|
measurementReporter.emitNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,18 +710,28 @@ function setupDragDropToMpvQueue(): void {
|
|||||||
if (!event.dataTransfer) return;
|
if (!event.dataTransfer) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const droppedPaths = collectDroppedVideoPaths(event.dataTransfer);
|
const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer);
|
||||||
const loadCommands = buildMpvLoadfileCommands(droppedPaths, event.shiftKey);
|
const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer);
|
||||||
|
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, event.shiftKey);
|
||||||
|
const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths);
|
||||||
for (const command of loadCommands) {
|
for (const command of loadCommands) {
|
||||||
window.electronAPI.sendMpvCommand(command);
|
window.electronAPI.sendMpvCommand(command);
|
||||||
}
|
}
|
||||||
|
for (const command of subtitleCommands) {
|
||||||
|
window.electronAPI.sendMpvCommand(command);
|
||||||
|
}
|
||||||
|
const osdParts: string[] = [];
|
||||||
if (loadCommands.length > 0) {
|
if (loadCommands.length > 0) {
|
||||||
const action = event.shiftKey ? 'Queued' : 'Loaded';
|
const action = event.shiftKey ? 'Queued' : 'Loaded';
|
||||||
window.electronAPI.sendMpvCommand([
|
osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`);
|
||||||
'show-text',
|
}
|
||||||
`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`,
|
if (subtitleCommands.length > 0) {
|
||||||
'1500',
|
osdParts.push(
|
||||||
]);
|
`Loaded ${subtitleCommands.length} subtitle file${subtitleCommands.length === 1 ? '' : 's'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (osdParts.length > 0) {
|
||||||
|
window.electronAPI.sendMpvCommand(['show-text', osdParts.join(' | '), '1500']);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearDropInteractive();
|
clearDropInteractive();
|
||||||
|
|||||||
@@ -684,7 +684,8 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.settings-modal-open iframe.yomitan-popup,
|
body.settings-modal-open iframe.yomitan-popup,
|
||||||
body.settings-modal-open iframe[id^='yomitan-popup'] {
|
body.settings-modal-open iframe[id^='yomitan-popup'],
|
||||||
|
body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
@@ -1130,6 +1131,11 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.platform-windows #secondarySubContainer.secondary-sub-hover {
|
||||||
|
top: 40px;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#secondarySubContainer.secondary-sub-hover #secondarySubRoot {
|
#secondarySubContainer.secondary-sub-hover #secondarySubRoot {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
@@ -1151,7 +1157,8 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
iframe.yomitan-popup,
|
iframe.yomitan-popup,
|
||||||
iframe[id^='yomitan-popup'] {
|
iframe[id^='yomitan-popup'],
|
||||||
|
[data-subminer-yomitan-popup-host='true'] {
|
||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
z-index: 2147483647 !important;
|
z-index: 2147483647 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -989,6 +989,13 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
|||||||
/transform:\s*translateX\(calc\(var\(--subtitle-sidebar-reserved-width\)\s*\*\s*-0\.5\)\);/,
|
/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');
|
const subtitleSidebarListBlock = extractClassBlock(cssText, '.subtitle-sidebar-list');
|
||||||
assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/);
|
assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type PlatformInfo = {
|
|||||||
isModalLayer: boolean;
|
isModalLayer: boolean;
|
||||||
isLinuxPlatform: boolean;
|
isLinuxPlatform: boolean;
|
||||||
isMacOSPlatform: boolean;
|
isMacOSPlatform: boolean;
|
||||||
|
isWindowsPlatform: boolean;
|
||||||
shouldToggleMouseIgnore: boolean;
|
shouldToggleMouseIgnore: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,12 +25,15 @@ export function resolvePlatformInfo(): PlatformInfo {
|
|||||||
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
|
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
|
||||||
const isMacOSPlatform =
|
const isMacOSPlatform =
|
||||||
navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent);
|
navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent);
|
||||||
|
const isWindowsPlatform =
|
||||||
|
navigator.platform.toLowerCase().includes('win') || /windows/i.test(navigator.userAgent);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
overlayLayer,
|
overlayLayer,
|
||||||
isModalLayer,
|
isModalLayer,
|
||||||
isLinuxPlatform,
|
isLinuxPlatform,
|
||||||
isMacOSPlatform,
|
isMacOSPlatform,
|
||||||
|
isWindowsPlatform,
|
||||||
shouldToggleMouseIgnore: !isLinuxPlatform && !isModalLayer,
|
shouldToggleMouseIgnore: !isLinuxPlatform && !isModalLayer,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
export const YOMITAN_POPUP_IFRAME_SELECTOR = 'iframe.yomitan-popup, iframe[id^="yomitan-popup"]';
|
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_SHOWN_EVENT = 'yomitan-popup-shown';
|
||||||
export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
|
export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
|
||||||
export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
|
export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
|
||||||
@@ -29,21 +33,56 @@ export function isYomitanPopupIframe(element: Element | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
||||||
return root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null;
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
|
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
|
||||||
const popupIframes = root.querySelectorAll<HTMLIFrameElement>(YOMITAN_POPUP_IFRAME_SELECTOR);
|
const visiblePopupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
|
||||||
for (const iframe of popupIframes) {
|
if (visiblePopupHosts.length > 0) {
|
||||||
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const popupIframes = queryPopupElements<HTMLIFrameElement>(root, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||||
|
for (const iframe of popupIframes) {
|
||||||
|
if (isVisiblePopupElement(iframe)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const popupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_HOST_SELECTOR);
|
||||||
|
for (const host of popupHosts) {
|
||||||
|
if (isMarkedVisiblePopupHost(host)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export abstract class BaseWindowTracker {
|
|||||||
return this.targetWindowFocused;
|
return this.targetWindowFocused;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isTargetWindowMinimized(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
protected updateTargetWindowFocused(focused: boolean): void {
|
protected updateTargetWindowFocused(focused: boolean): void {
|
||||||
if (this.targetWindowFocused === focused) {
|
if (this.targetWindowFocused === focused) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
250
src/window-trackers/win32.ts
Normal file
250
src/window-trackers/win32.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
lowerWindowsOverlayInZOrder,
|
||||||
|
parseWindowTrackerHelperForegroundProcess,
|
||||||
parseWindowTrackerHelperFocusState,
|
parseWindowTrackerHelperFocusState,
|
||||||
parseWindowTrackerHelperOutput,
|
parseWindowTrackerHelperOutput,
|
||||||
|
parseWindowTrackerHelperState,
|
||||||
|
queryWindowsForegroundProcessName,
|
||||||
resolveWindowsTrackerHelper,
|
resolveWindowsTrackerHelper,
|
||||||
|
syncWindowsOverlayToMpvZOrder,
|
||||||
} from './windows-helper';
|
} from './windows-helper';
|
||||||
|
|
||||||
test('parseWindowTrackerHelperOutput parses helper geometry output', () => {
|
test('parseWindowTrackerHelperOutput parses helper geometry output', () => {
|
||||||
@@ -28,6 +33,105 @@ test('parseWindowTrackerHelperFocusState parses helper stderr metadata', () => {
|
|||||||
assert.equal(parseWindowTrackerHelperFocusState(''), null);
|
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', () => {
|
test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => {
|
||||||
const helper = resolveWindowsTrackerHelper({
|
const helper = resolveWindowsTrackerHelper({
|
||||||
dirname: 'C:\\repo\\dist\\window-trackers',
|
dirname: 'C:\\repo\\dist\\window-trackers',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import { execFile, type ExecFileException } from 'child_process';
|
||||||
import type { WindowGeometry } from '../types';
|
import type { WindowGeometry } from '../types';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
|
|
||||||
@@ -26,6 +27,13 @@ const log = createLogger('tracker').child('windows-helper');
|
|||||||
|
|
||||||
export type WindowsTrackerHelperKind = 'powershell' | 'native';
|
export type WindowsTrackerHelperKind = 'powershell' | 'native';
|
||||||
export type WindowsTrackerHelperMode = 'auto' | '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 = {
|
export type WindowsTrackerHelperLaunchSpec = {
|
||||||
kind: WindowsTrackerHelperKind;
|
kind: WindowsTrackerHelperKind;
|
||||||
@@ -219,6 +227,197 @@ export function parseWindowTrackerHelperFocusState(output: string): boolean | nu
|
|||||||
return null;
|
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(
|
export function resolveWindowsTrackerHelper(
|
||||||
options: ResolveWindowsTrackerHelperOptions = {},
|
options: ResolveWindowsTrackerHelperOptions = {},
|
||||||
): WindowsTrackerHelperLaunchSpec | null {
|
): WindowsTrackerHelperLaunchSpec | null {
|
||||||
|
|||||||
@@ -1,56 +1,62 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { WindowsWindowTracker } from './windows-tracker';
|
import { WindowsWindowTracker } from './windows-tracker';
|
||||||
|
import type { MpvPollResult } from './win32';
|
||||||
|
|
||||||
test('WindowsWindowTracker skips overlapping polls while helper is in flight', async () => {
|
function mpvVisible(
|
||||||
let helperCalls = 0;
|
overrides: Partial<MpvPollResult & { x?: number; y?: number; width?: number; height?: number; focused?: boolean }> = {},
|
||||||
let release: (() => void) | undefined;
|
): MpvPollResult {
|
||||||
const gate = new Promise<void>((resolve) => {
|
return {
|
||||||
release = resolve;
|
matches: [
|
||||||
});
|
{
|
||||||
|
hwnd: 12345,
|
||||||
|
bounds: {
|
||||||
|
x: overrides.x ?? 0,
|
||||||
|
y: overrides.y ?? 0,
|
||||||
|
width: overrides.width ?? 1280,
|
||||||
|
height: overrides.height ?? 720,
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mpvMinimized: MpvPollResult = {
|
||||||
|
matches: [],
|
||||||
|
focusState: false,
|
||||||
|
windowState: 'minimized',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('WindowsWindowTracker skips overlapping polls while poll is in flight', () => {
|
||||||
|
let pollCalls = 0;
|
||||||
const tracker = new WindowsWindowTracker(undefined, {
|
const tracker = new WindowsWindowTracker(undefined, {
|
||||||
resolveHelper: () => ({
|
pollMpvWindows: () => {
|
||||||
kind: 'powershell',
|
pollCalls += 1;
|
||||||
command: 'powershell.exe',
|
return mpvVisible();
|
||||||
args: ['-File', 'helper.ps1'],
|
|
||||||
helperPath: 'helper.ps1',
|
|
||||||
}),
|
|
||||||
runHelper: async () => {
|
|
||||||
helperCalls += 1;
|
|
||||||
await gate;
|
|
||||||
return {
|
|
||||||
stdout: '0,0,640,360',
|
|
||||||
stderr: 'focus=focused',
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
assert.equal(helperCalls, 1);
|
assert.equal(pollCalls, 2);
|
||||||
|
|
||||||
assert.ok(release);
|
|
||||||
release();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('WindowsWindowTracker updates geometry from helper output', async () => {
|
test('WindowsWindowTracker updates geometry from poll output', () => {
|
||||||
const tracker = new WindowsWindowTracker(undefined, {
|
const tracker = new WindowsWindowTracker(undefined, {
|
||||||
resolveHelper: () => ({
|
pollMpvWindows: () => mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
|
||||||
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();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
assert.deepEqual(tracker.getGeometry(), {
|
assert.deepEqual(tracker.getGeometry(), {
|
||||||
x: 10,
|
x: 10,
|
||||||
@@ -61,59 +67,180 @@ test('WindowsWindowTracker updates geometry from helper output', async () => {
|
|||||||
assert.equal(tracker.isTargetWindowFocused(), true);
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('WindowsWindowTracker clears geometry for helper misses', async () => {
|
test('WindowsWindowTracker clears geometry for poll misses', () => {
|
||||||
const tracker = new WindowsWindowTracker(undefined, {
|
const tracker = new WindowsWindowTracker(undefined, {
|
||||||
resolveHelper: () => ({
|
pollMpvWindows: () => mpvNotFound,
|
||||||
kind: 'powershell',
|
trackingLossGraceMs: 0,
|
||||||
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();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
assert.equal(tracker.getGeometry(), null);
|
assert.equal(tracker.getGeometry(), null);
|
||||||
assert.equal(tracker.isTargetWindowFocused(), false);
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('WindowsWindowTracker retries without socket filter when filtered helper lookup misses', async () => {
|
test('WindowsWindowTracker keeps the last geometry through a single poll miss', () => {
|
||||||
const helperCalls: Array<string | null> = [];
|
let callIndex = 0;
|
||||||
const tracker = new WindowsWindowTracker('\\\\.\\pipe\\subminer-socket', {
|
const outputs = [
|
||||||
resolveHelper: () => ({
|
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
|
||||||
kind: 'powershell',
|
mpvNotFound,
|
||||||
command: 'powershell.exe',
|
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
|
||||||
args: ['-File', 'helper.ps1'],
|
];
|
||||||
helperPath: 'helper.ps1',
|
|
||||||
}),
|
const tracker = new WindowsWindowTracker(undefined, {
|
||||||
runHelper: async (_spec, _mode, targetMpvSocketPath) => {
|
pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!,
|
||||||
helperCalls.push(targetMpvSocketPath);
|
trackingLossGraceMs: 0,
|
||||||
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();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
|
||||||
|
|
||||||
assert.deepEqual(helperCalls, ['\\\\.\\pipe\\subminer-socket', null]);
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
assert.deepEqual(tracker.getGeometry(), {
|
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
|
||||||
x: 25,
|
|
||||||
y: 30,
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
width: 1440,
|
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
|
||||||
height: 810,
|
});
|
||||||
});
|
|
||||||
assert.equal(tracker.isTargetWindowFocused(), true);
|
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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,80 +16,50 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
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 { BaseWindowTracker } from './base-tracker';
|
||||||
import {
|
import type { WindowGeometry } from '../types';
|
||||||
parseWindowTrackerHelperFocusState,
|
import type { MpvPollResult } from './win32';
|
||||||
parseWindowTrackerHelperOutput,
|
|
||||||
resolveWindowsTrackerHelper,
|
|
||||||
type WindowsTrackerHelperLaunchSpec,
|
|
||||||
} from './windows-helper';
|
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
|
|
||||||
const log = createLogger('tracker').child('windows');
|
const log = createLogger('tracker').child('windows');
|
||||||
|
|
||||||
type WindowsTrackerRunnerResult = {
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WindowsTrackerDeps = {
|
type WindowsTrackerDeps = {
|
||||||
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
|
pollMpvWindows?: () => MpvPollResult;
|
||||||
runHelper?: (
|
maxConsecutiveMisses?: number;
|
||||||
spec: WindowsTrackerHelperLaunchSpec,
|
trackingLossGraceMs?: number;
|
||||||
mode: 'geometry',
|
minimizedTrackingLossGraceMs?: number;
|
||||||
targetMpvSocketPath: string | null,
|
now?: () => number;
|
||||||
) => Promise<WindowsTrackerRunnerResult>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function runHelperWithExecFile(
|
function defaultPollMpvWindows(): MpvPollResult {
|
||||||
spec: WindowsTrackerHelperLaunchSpec,
|
const win32 = require('./win32') as typeof import('./win32');
|
||||||
mode: 'geometry',
|
return win32.findMpvWindows();
|
||||||
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 {
|
export class WindowsWindowTracker extends BaseWindowTracker {
|
||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private pollInFlight = false;
|
private pollInFlight = false;
|
||||||
private helperSpec: WindowsTrackerHelperLaunchSpec | null;
|
private readonly pollMpvWindows: () => MpvPollResult;
|
||||||
private readonly targetMpvSocketPath: string | null;
|
private readonly maxConsecutiveMisses: number;
|
||||||
private readonly runHelper: (
|
private readonly trackingLossGraceMs: number;
|
||||||
spec: WindowsTrackerHelperLaunchSpec,
|
private readonly minimizedTrackingLossGraceMs: number;
|
||||||
mode: 'geometry',
|
private readonly now: () => number;
|
||||||
targetMpvSocketPath: string | null,
|
private lastPollErrorFingerprint: string | null = null;
|
||||||
) => Promise<WindowsTrackerRunnerResult>;
|
private lastPollErrorLoggedAtMs = 0;
|
||||||
private lastExecErrorFingerprint: string | null = null;
|
private consecutiveMisses = 0;
|
||||||
private lastExecErrorLoggedAtMs = 0;
|
private trackingLossStartedAtMs: number | null = null;
|
||||||
|
private targetWindowMinimized = false;
|
||||||
|
|
||||||
constructor(targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
|
constructor(_targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
|
||||||
super();
|
super();
|
||||||
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
this.pollMpvWindows = deps.pollMpvWindows ?? defaultPollMpvWindows;
|
||||||
this.helperSpec = deps.resolveHelper ? deps.resolveHelper() : resolveWindowsTrackerHelper();
|
this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2));
|
||||||
this.runHelper = deps.runHelper ?? runHelperWithExecFile;
|
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -104,72 +74,99 @@ export class WindowsWindowTracker extends BaseWindowTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private maybeLogExecError(error: Error, stderr: string): void {
|
override isTargetWindowMinimized(): boolean {
|
||||||
const now = Date.now();
|
return this.targetWindowMinimized;
|
||||||
const fingerprint = `${error.message}|${stderr.trim()}`;
|
|
||||||
const shouldLog =
|
|
||||||
this.lastExecErrorFingerprint !== fingerprint || now - this.lastExecErrorLoggedAtMs >= 5000;
|
|
||||||
if (!shouldLog) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 async runHelperWithSocketFallback(): Promise<WindowsTrackerRunnerResult> {
|
private maybeLogPollError(error: Error): void {
|
||||||
if (!this.helperSpec) {
|
const now = Date.now();
|
||||||
return { stdout: 'not-found', stderr: '' };
|
const fingerprint = error.message;
|
||||||
}
|
const shouldLog =
|
||||||
|
this.lastPollErrorFingerprint !== fingerprint || now - this.lastPollErrorLoggedAtMs >= 5000;
|
||||||
|
if (!shouldLog) return;
|
||||||
|
|
||||||
try {
|
this.lastPollErrorFingerprint = fingerprint;
|
||||||
const primary = await this.runHelper(this.helperSpec, 'geometry', this.targetMpvSocketPath);
|
this.lastPollErrorLoggedAtMs = now;
|
||||||
const primaryGeometry = parseWindowTrackerHelperOutput(primary.stdout);
|
log.warn('Windows native poll failed', { error: error.message });
|
||||||
if (primaryGeometry || !this.targetMpvSocketPath) {
|
}
|
||||||
return primary;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!this.targetMpvSocketPath) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.runHelper(this.helperSpec, 'geometry', null);
|
private resetTrackingLossState(): void {
|
||||||
|
this.consecutiveMisses = 0;
|
||||||
|
this.trackingLossStartedAtMs = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 registerTrackingMiss(graceMs = this.trackingLossGraceMs): void {
|
||||||
|
this.consecutiveMisses += 1;
|
||||||
|
if (this.shouldDropTracking(graceMs)) {
|
||||||
|
this.updateGeometry(null);
|
||||||
|
this.resetTrackingLossState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private pollGeometry(): void {
|
private pollGeometry(): void {
|
||||||
if (this.pollInFlight || !this.helperSpec) {
|
if (this.pollInFlight) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pollInFlight = true;
|
this.pollInFlight = true;
|
||||||
void this.runHelperWithSocketFallback()
|
|
||||||
.then(({ stdout, stderr }) => {
|
try {
|
||||||
const geometry = parseWindowTrackerHelperOutput(stdout);
|
const result = this.pollMpvWindows();
|
||||||
const focusState = parseWindowTrackerHelperFocusState(stderr);
|
const best = this.selectBestMatch(result);
|
||||||
this.updateTargetWindowFocused(focusState ?? Boolean(geometry));
|
|
||||||
this.updateGeometry(geometry);
|
if (best) {
|
||||||
})
|
this.resetTrackingLossState();
|
||||||
.catch((error: unknown) => {
|
this.targetWindowMinimized = false;
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
this.updateTargetWindowFocused(best.focused);
|
||||||
const stderr =
|
this.updateGeometry(best.geometry);
|
||||||
typeof error === 'object' &&
|
return;
|
||||||
error !== null &&
|
}
|
||||||
'stderr' in error &&
|
|
||||||
typeof (error as { stderr?: unknown }).stderr === 'string'
|
if (result.windowState === 'minimized') {
|
||||||
? (error as { stderr: string }).stderr
|
this.targetWindowMinimized = true;
|
||||||
: '';
|
this.updateTargetWindowFocused(false);
|
||||||
this.maybeLogExecError(err, stderr);
|
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
|
||||||
this.updateGeometry(null);
|
return;
|
||||||
})
|
}
|
||||||
.finally(() => {
|
|
||||||
this.pollInFlight = false;
|
this.targetWindowMinimized = false;
|
||||||
});
|
this.updateTargetWindowFocused(false);
|
||||||
|
this.registerTrackingMiss();
|
||||||
|
} 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 {
|
||||||
|
this.pollInFlight = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function AnimeTab({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search library..."
|
placeholder="Search anime..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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"
|
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>
|
||||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||||
{filtered.length} titles · {formatDuration(totalMs)}
|
{filtered.length} anime · {formatDuration(totalMs)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="text-sm text-ctp-overlay2 p-4">No titles found</div>
|
<div className="text-sm text-ctp-overlay2 p-4">No anime found</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
|
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
|
||||||
{filtered.map((item) => (
|
{filtered.map((item) => (
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
@@ -16,32 +16,10 @@ interface NoteInfo {
|
|||||||
expression: string;
|
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) {
|
export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {
|
||||||
const [data, setData] = useState<EpisodeDetailData | null>(null);
|
const [data, setData] = useState<EpisodeDetailData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
|
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
|
||||||
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -63,14 +41,8 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
map.set(note.noteId, { noteId: note.noteId, expression: expr });
|
map.set(note.noteId, { noteId: note.noteId, expression: expr });
|
||||||
}
|
}
|
||||||
setNoteInfos(map);
|
setNoteInfos(map);
|
||||||
setNoteInfosLoaded(true);
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => console.warn('Failed to fetch Anki note info:', err));
|
||||||
console.warn('Failed to fetch Anki note info:', err);
|
|
||||||
if (!cancelled) setNoteInfosLoaded(true);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (!cancelled) setNoteInfosLoaded(true);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -100,16 +72,6 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
|
|
||||||
const { sessions, cardEvents } = data;
|
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 (
|
return (
|
||||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
|
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
|
||||||
{sessions.length > 0 && (
|
{sessions.length > 0 && (
|
||||||
@@ -144,11 +106,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredCardEvents.length > 0 && (
|
{cardEvents.length > 0 && (
|
||||||
<div className="p-3 border-b border-ctp-surface1">
|
<div className="p-3 border-b border-ctp-surface1">
|
||||||
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
|
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{filteredCardEvents.map((ev) => (
|
{cardEvents.map((ev) => (
|
||||||
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
|
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
|
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
|
||||||
{ev.noteIds.length > 0 ? (
|
{ev.noteIds.length > 0 ? (
|
||||||
@@ -182,12 +144,6 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
120
stats/src/components/library/LibraryTab.tsx
Normal file
120
stats/src/components/library/LibraryTab.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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,8 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { getRelatedCollectionLabel } from './MediaDetailView';
|
||||||
import { createElement } from 'react';
|
|
||||||
import { getRelatedCollectionLabel, buildDeleteEpisodeHandler } from './MediaDetailView';
|
|
||||||
|
|
||||||
test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => {
|
test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -43,85 +41,3 @@ test('getRelatedCollectionLabel returns View Anime for non-youtube media', () =>
|
|||||||
'View Anime',
|
'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,48 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMediaDetail } from '../../hooks/useMediaDetail';
|
import { useMediaDetail } from '../../hooks/useMediaDetail';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { confirmSessionDelete, confirmEpisodeDelete } from '../../lib/delete-confirm';
|
import { confirmSessionDelete } from '../../lib/delete-confirm';
|
||||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||||
import { MediaHeader } from './MediaHeader';
|
import { MediaHeader } from './MediaHeader';
|
||||||
import { MediaSessionList } from './MediaSessionList';
|
import { MediaSessionList } from './MediaSessionList';
|
||||||
import type { MediaDetailData, SessionSummary } from '../../types/stats';
|
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 {
|
export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string {
|
||||||
if (detail?.channelName?.trim()) {
|
if (detail?.channelName?.trim()) {
|
||||||
return 'View Channel';
|
return 'View Channel';
|
||||||
@@ -71,8 +35,6 @@ export function MediaDetailView({
|
|||||||
const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null);
|
const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||||
const [isDeletingEpisode, setIsDeletingEpisode] = useState(false);
|
|
||||||
const isDeletingEpisodeRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalSessions(data?.sessions ?? null);
|
setLocalSessions(data?.sessions ?? null);
|
||||||
@@ -117,17 +79,6 @@ export function MediaDetailView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteEpisode = buildDeleteEpisodeHandler({
|
|
||||||
videoId,
|
|
||||||
title: detail.canonicalTitle,
|
|
||||||
apiClient,
|
|
||||||
confirmFn: confirmEpisodeDelete,
|
|
||||||
onBack,
|
|
||||||
setDeleteError,
|
|
||||||
isDeletingRef: isDeletingEpisodeRef,
|
|
||||||
setIsDeleting: setIsDeletingEpisode,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -148,11 +99,7 @@ export function MediaDetailView({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<MediaHeader
|
<MediaHeader detail={detail} />
|
||||||
detail={detail}
|
|
||||||
onDeleteEpisode={handleDeleteEpisode}
|
|
||||||
isDeletingEpisode={isDeletingEpisode}
|
|
||||||
/>
|
|
||||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||||
<MediaSessionList
|
<MediaSessionList
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
|
|||||||
@@ -12,16 +12,9 @@ interface MediaHeaderProps {
|
|||||||
totalUniqueWords: number;
|
totalUniqueWords: number;
|
||||||
knownWordCount: number;
|
knownWordCount: number;
|
||||||
} | null;
|
} | null;
|
||||||
onDeleteEpisode?: () => void;
|
|
||||||
isDeletingEpisode?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaHeader({
|
export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) {
|
||||||
detail,
|
|
||||||
initialKnownWordsSummary = null,
|
|
||||||
onDeleteEpisode,
|
|
||||||
isDeletingEpisode = false,
|
|
||||||
}: MediaHeaderProps) {
|
|
||||||
const knownTokenRate =
|
const knownTokenRate =
|
||||||
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
|
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
|
||||||
const avgSessionMs =
|
const avgSessionMs =
|
||||||
@@ -57,21 +50,7 @@ export function MediaHeader({
|
|||||||
className="w-32 h-44 rounded-lg shrink-0"
|
className="w-32 h-44 rounded-lg shrink-0"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
|
||||||
<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 ? (
|
{detail.channelName ? (
|
||||||
<div className="mt-1 text-sm text-ctp-subtext1 truncate">
|
<div className="mt-1 text-sm text-ctp-subtext1 truncate">
|
||||||
{detail.channelUrl ? (
|
{detail.channelUrl ? (
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function HeroStats({ summary, sessions }: HeroStatsProps) {
|
|||||||
/>
|
/>
|
||||||
<StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" />
|
<StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" />
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Active Titles"
|
label="Active Anime"
|
||||||
value={formatNumber(summary.activeAnimeCount)}
|
value={formatNumber(summary.activeAnimeCount)}
|
||||||
color="text-ctp-mauve"
|
color="text-ctp-mauve"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function TrackingSnapshot({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip text="Total unique videos watched across all titles in your library">
|
<Tooltip text="Total unique episodes (videos) watched across all anime">
|
||||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div>
|
<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">
|
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
|
||||||
@@ -79,9 +79,9 @@ export function TrackingSnapshot({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip text="Number of titles fully completed">
|
<Tooltip text="Number of anime series fully completed">
|
||||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Titles</div>
|
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime</div>
|
||||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
|
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
|
||||||
{formatNumber(summary.totalAnimeCompleted)}
|
{formatNumber(summary.totalAnimeCompleted)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { BarChart, Bar, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import { epochDayToDate } from '../../lib/formatters';
|
import { epochDayToDate } from '../../lib/formatters';
|
||||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
import { CHART_THEME } from '../../lib/chart-theme';
|
||||||
import type { DailyRollup } from '../../types/stats';
|
import type { DailyRollup } from '../../types/stats';
|
||||||
|
|
||||||
interface WatchTimeChartProps {
|
interface WatchTimeChartProps {
|
||||||
@@ -52,23 +52,28 @@ export function WatchTimeChart({ rollups }: WatchTimeChartProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
<BarChart data={chartData} margin={CHART_DEFAULTS.margin}>
|
<BarChart data={chartData}>
|
||||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
width={32}
|
width={30}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={TOOLTIP_CONTENT_STYLE}
|
contentStyle={{
|
||||||
|
background: CHART_THEME.tooltipBg,
|
||||||
|
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
color: CHART_THEME.tooltipText,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
||||||
formatter={formatActiveMinutes}
|
formatter={formatActiveMinutes}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -125,13 +125,14 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
|
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
|
||||||
const hasKnownWords = knownWordsMap.size > 0;
|
const hasKnownWords = knownWordsMap.size > 0;
|
||||||
|
|
||||||
const { cardEvents, yomitanLookupEvents, pauseRegions, markers } =
|
const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } =
|
||||||
buildSessionChartEvents(events);
|
buildSessionChartEvents(events);
|
||||||
const lookupRate = buildLookupRateDisplay(
|
const lookupRate = buildLookupRateDisplay(
|
||||||
session.yomitanLookupCount,
|
session.yomitanLookupCount,
|
||||||
getSessionDisplayWordCount(session),
|
getSessionDisplayWordCount(session),
|
||||||
);
|
);
|
||||||
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
|
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
|
||||||
|
const seekCount = seekEvents.length;
|
||||||
const cardEventCount = cardEvents.length;
|
const cardEventCount = cardEvents.length;
|
||||||
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
|
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
|
||||||
const activeMarker = useMemo<SessionChartMarker | null>(
|
const activeMarker = useMemo<SessionChartMarker | null>(
|
||||||
@@ -229,6 +230,7 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
sorted={sorted}
|
sorted={sorted}
|
||||||
knownWordsMap={knownWordsMap}
|
knownWordsMap={knownWordsMap}
|
||||||
cardEvents={cardEvents}
|
cardEvents={cardEvents}
|
||||||
|
seekEvents={seekEvents}
|
||||||
yomitanLookupEvents={yomitanLookupEvents}
|
yomitanLookupEvents={yomitanLookupEvents}
|
||||||
pauseRegions={pauseRegions}
|
pauseRegions={pauseRegions}
|
||||||
markers={markers}
|
markers={markers}
|
||||||
@@ -240,6 +242,7 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
loadingNoteIds={loadingNoteIds}
|
loadingNoteIds={loadingNoteIds}
|
||||||
onOpenNote={handleOpenNote}
|
onOpenNote={handleOpenNote}
|
||||||
pauseCount={pauseCount}
|
pauseCount={pauseCount}
|
||||||
|
seekCount={seekCount}
|
||||||
cardEventCount={cardEventCount}
|
cardEventCount={cardEventCount}
|
||||||
lookupRate={lookupRate}
|
lookupRate={lookupRate}
|
||||||
session={session}
|
session={session}
|
||||||
@@ -251,6 +254,7 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
<FallbackView
|
<FallbackView
|
||||||
sorted={sorted}
|
sorted={sorted}
|
||||||
cardEvents={cardEvents}
|
cardEvents={cardEvents}
|
||||||
|
seekEvents={seekEvents}
|
||||||
yomitanLookupEvents={yomitanLookupEvents}
|
yomitanLookupEvents={yomitanLookupEvents}
|
||||||
pauseRegions={pauseRegions}
|
pauseRegions={pauseRegions}
|
||||||
markers={markers}
|
markers={markers}
|
||||||
@@ -262,6 +266,7 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
loadingNoteIds={loadingNoteIds}
|
loadingNoteIds={loadingNoteIds}
|
||||||
onOpenNote={handleOpenNote}
|
onOpenNote={handleOpenNote}
|
||||||
pauseCount={pauseCount}
|
pauseCount={pauseCount}
|
||||||
|
seekCount={seekCount}
|
||||||
cardEventCount={cardEventCount}
|
cardEventCount={cardEventCount}
|
||||||
lookupRate={lookupRate}
|
lookupRate={lookupRate}
|
||||||
session={session}
|
session={session}
|
||||||
@@ -275,6 +280,7 @@ function RatioView({
|
|||||||
sorted,
|
sorted,
|
||||||
knownWordsMap,
|
knownWordsMap,
|
||||||
cardEvents,
|
cardEvents,
|
||||||
|
seekEvents,
|
||||||
yomitanLookupEvents,
|
yomitanLookupEvents,
|
||||||
pauseRegions,
|
pauseRegions,
|
||||||
markers,
|
markers,
|
||||||
@@ -286,6 +292,7 @@ function RatioView({
|
|||||||
loadingNoteIds,
|
loadingNoteIds,
|
||||||
onOpenNote,
|
onOpenNote,
|
||||||
pauseCount,
|
pauseCount,
|
||||||
|
seekCount,
|
||||||
cardEventCount,
|
cardEventCount,
|
||||||
lookupRate,
|
lookupRate,
|
||||||
session,
|
session,
|
||||||
@@ -293,6 +300,7 @@ function RatioView({
|
|||||||
sorted: TimelineEntry[];
|
sorted: TimelineEntry[];
|
||||||
knownWordsMap: Map<number, number>;
|
knownWordsMap: Map<number, number>;
|
||||||
cardEvents: SessionEvent[];
|
cardEvents: SessionEvent[];
|
||||||
|
seekEvents: SessionEvent[];
|
||||||
yomitanLookupEvents: SessionEvent[];
|
yomitanLookupEvents: SessionEvent[];
|
||||||
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
||||||
markers: SessionChartMarker[];
|
markers: SessionChartMarker[];
|
||||||
@@ -304,6 +312,7 @@ function RatioView({
|
|||||||
loadingNoteIds: Set<number>;
|
loadingNoteIds: Set<number>;
|
||||||
onOpenNote: (noteId: number) => void;
|
onOpenNote: (noteId: number) => void;
|
||||||
pauseCount: number;
|
pauseCount: number;
|
||||||
|
seekCount: number;
|
||||||
cardEventCount: number;
|
cardEventCount: number;
|
||||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
@@ -441,6 +450,22 @@ 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 */}
|
{/* Yomitan lookup markers */}
|
||||||
{yomitanLookupEvents.map((e, i) => (
|
{yomitanLookupEvents.map((e, i) => (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
@@ -524,6 +549,7 @@ function RatioView({
|
|||||||
<StatsBar
|
<StatsBar
|
||||||
hasKnownWords
|
hasKnownWords
|
||||||
pauseCount={pauseCount}
|
pauseCount={pauseCount}
|
||||||
|
seekCount={seekCount}
|
||||||
cardEventCount={cardEventCount}
|
cardEventCount={cardEventCount}
|
||||||
session={session}
|
session={session}
|
||||||
lookupRate={lookupRate}
|
lookupRate={lookupRate}
|
||||||
@@ -537,6 +563,7 @@ function RatioView({
|
|||||||
function FallbackView({
|
function FallbackView({
|
||||||
sorted,
|
sorted,
|
||||||
cardEvents,
|
cardEvents,
|
||||||
|
seekEvents,
|
||||||
yomitanLookupEvents,
|
yomitanLookupEvents,
|
||||||
pauseRegions,
|
pauseRegions,
|
||||||
markers,
|
markers,
|
||||||
@@ -548,12 +575,14 @@ function FallbackView({
|
|||||||
loadingNoteIds,
|
loadingNoteIds,
|
||||||
onOpenNote,
|
onOpenNote,
|
||||||
pauseCount,
|
pauseCount,
|
||||||
|
seekCount,
|
||||||
cardEventCount,
|
cardEventCount,
|
||||||
lookupRate,
|
lookupRate,
|
||||||
session,
|
session,
|
||||||
}: {
|
}: {
|
||||||
sorted: TimelineEntry[];
|
sorted: TimelineEntry[];
|
||||||
cardEvents: SessionEvent[];
|
cardEvents: SessionEvent[];
|
||||||
|
seekEvents: SessionEvent[];
|
||||||
yomitanLookupEvents: SessionEvent[];
|
yomitanLookupEvents: SessionEvent[];
|
||||||
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
||||||
markers: SessionChartMarker[];
|
markers: SessionChartMarker[];
|
||||||
@@ -565,6 +594,7 @@ function FallbackView({
|
|||||||
loadingNoteIds: Set<number>;
|
loadingNoteIds: Set<number>;
|
||||||
onOpenNote: (noteId: number) => void;
|
onOpenNote: (noteId: number) => void;
|
||||||
pauseCount: number;
|
pauseCount: number;
|
||||||
|
seekCount: number;
|
||||||
cardEventCount: number;
|
cardEventCount: number;
|
||||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
@@ -650,6 +680,20 @@ function FallbackView({
|
|||||||
strokeOpacity={0.8}
|
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) => (
|
{yomitanLookupEvents.map((e, i) => (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
key={`yomitan-${i}`}
|
key={`yomitan-${i}`}
|
||||||
@@ -691,6 +735,7 @@ function FallbackView({
|
|||||||
<StatsBar
|
<StatsBar
|
||||||
hasKnownWords={false}
|
hasKnownWords={false}
|
||||||
pauseCount={pauseCount}
|
pauseCount={pauseCount}
|
||||||
|
seekCount={seekCount}
|
||||||
cardEventCount={cardEventCount}
|
cardEventCount={cardEventCount}
|
||||||
session={session}
|
session={session}
|
||||||
lookupRate={lookupRate}
|
lookupRate={lookupRate}
|
||||||
@@ -704,12 +749,14 @@ function FallbackView({
|
|||||||
function StatsBar({
|
function StatsBar({
|
||||||
hasKnownWords,
|
hasKnownWords,
|
||||||
pauseCount,
|
pauseCount,
|
||||||
|
seekCount,
|
||||||
cardEventCount,
|
cardEventCount,
|
||||||
session,
|
session,
|
||||||
lookupRate,
|
lookupRate,
|
||||||
}: {
|
}: {
|
||||||
hasKnownWords: boolean;
|
hasKnownWords: boolean;
|
||||||
pauseCount: number;
|
pauseCount: number;
|
||||||
|
seekCount: number;
|
||||||
cardEventCount: number;
|
cardEventCount: number;
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||||
@@ -744,7 +791,12 @@ function StatsBar({
|
|||||||
{pauseCount !== 1 ? 's' : ''}
|
{pauseCount !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{pauseCount > 0 && <span className="text-ctp-surface2">|</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>}
|
||||||
|
|
||||||
{/* Group 3: Learning events */}
|
{/* Group 3: Learning events */}
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ function markerLabel(marker: SessionChartMarker): string {
|
|||||||
switch (marker.kind) {
|
switch (marker.kind) {
|
||||||
case 'pause':
|
case 'pause':
|
||||||
return '||';
|
return '||';
|
||||||
|
case 'seek':
|
||||||
|
return marker.direction === 'backward' ? '<<' : '>>';
|
||||||
case 'card':
|
case 'card':
|
||||||
return '\u26CF';
|
return '\u26CF';
|
||||||
}
|
}
|
||||||
@@ -42,6 +44,10 @@ function markerColors(marker: SessionChartMarker): { border: string; bg: string;
|
|||||||
switch (marker.kind) {
|
switch (marker.kind) {
|
||||||
case 'pause':
|
case 'pause':
|
||||||
return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' };
|
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':
|
case 'card':
|
||||||
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
|
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,35 @@ test('SessionEventPopover renders formatted card-mine details with fetched note
|
|||||||
assert.match(markup, /Open in Anki/);
|
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', () => {
|
test('SessionEventPopover renders a cleaner fallback when AnkiConnect provides no preview fields', () => {
|
||||||
const marker: SessionChartMarker = {
|
const marker: SessionChartMarker = {
|
||||||
key: 'card-9000',
|
key: 'card-9000',
|
||||||
|
|||||||
@@ -31,12 +31,18 @@ export function SessionEventPopover({
|
|||||||
onClose,
|
onClose,
|
||||||
onOpenNote,
|
onOpenNote,
|
||||||
}: SessionEventPopoverProps) {
|
}: SessionEventPopoverProps) {
|
||||||
|
const seekDurationLabel =
|
||||||
|
marker.kind === 'seek' && marker.fromMs !== null && marker.toMs !== null
|
||||||
|
? formatEventSeconds(Math.abs(marker.toMs - marker.fromMs))?.replace(/\.0s$/, 's')
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
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="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 className="mb-2 flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-ctp-text">
|
<div className="text-xs font-semibold text-ctp-text">
|
||||||
{marker.kind === 'pause' && 'Paused'}
|
{marker.kind === 'pause' && 'Paused'}
|
||||||
|
{marker.kind === 'seek' && `Seek ${marker.direction}`}
|
||||||
{marker.kind === 'card' && 'Card mined'}
|
{marker.kind === 'card' && 'Card mined'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
|
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
|
||||||
@@ -66,6 +72,7 @@ export function SessionEventPopover({
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{marker.kind === 'pause' && '||'}
|
{marker.kind === 'pause' && '||'}
|
||||||
|
{marker.kind === 'seek' && (marker.direction === 'backward' ? '<<' : '>>')}
|
||||||
{marker.kind === 'card' && '\u26CF'}
|
{marker.kind === 'card' && '\u26CF'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,6 +84,19 @@ export function SessionEventPopover({
|
|||||||
</div>
|
</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' && (
|
{marker.kind === 'card' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs text-ctp-cards-mined">
|
<div className="text-xs text-ctp-cards-mined">
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function SessionRow({
|
|||||||
}}
|
}}
|
||||||
aria-label={`View overview for ${session.canonicalTitle ?? 'Unknown Media'}`}
|
aria-label={`View overview for ${session.canonicalTitle ?? 'Unknown Media'}`}
|
||||||
className="absolute right-10 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue hover:bg-ctp-blue/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center"
|
className="absolute right-10 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue hover:bg-ctp-blue/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center"
|
||||||
title="View in Library"
|
title="View anime overview"
|
||||||
>
|
>
|
||||||
{'\u2197'}
|
{'\u2197'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import type { SessionBucket } from '../../lib/session-grouping';
|
|
||||||
import type { SessionSummary } from '../../types/stats';
|
|
||||||
import { buildBucketDeleteHandler } from './SessionsTab';
|
|
||||||
|
|
||||||
function makeSession(over: Partial<SessionSummary>): SessionSummary {
|
|
||||||
return {
|
|
||||||
sessionId: 1,
|
|
||||||
videoId: 100,
|
|
||||||
canonicalTitle: 'Episode 1',
|
|
||||||
startedAtMs: 1_000_000,
|
|
||||||
endedAtMs: 1_060_000,
|
|
||||||
activeWatchedMs: 60_000,
|
|
||||||
cardsMined: 1,
|
|
||||||
linesSeen: 10,
|
|
||||||
lookupCount: 5,
|
|
||||||
lookupHits: 3,
|
|
||||||
knownWordsSeen: 5,
|
|
||||||
...over,
|
|
||||||
} as SessionSummary;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeBucket(sessions: SessionSummary[]): SessionBucket {
|
|
||||||
const sorted = [...sessions].sort((a, b) => b.startedAtMs - a.startedAtMs);
|
|
||||||
return {
|
|
||||||
key: `v-${sorted[0]!.videoId}`,
|
|
||||||
videoId: sorted[0]!.videoId ?? null,
|
|
||||||
sessions: sorted,
|
|
||||||
totalActiveMs: sorted.reduce((s, x) => s + x.activeWatchedMs, 0),
|
|
||||||
totalCardsMined: sorted.reduce((s, x) => s + x.cardsMined, 0),
|
|
||||||
representativeSession: sorted[0]!,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('buildBucketDeleteHandler deletes every session in the bucket when confirm returns true', async () => {
|
|
||||||
let deleted: number[] | null = null;
|
|
||||||
let onSuccessCalledWith: number[] | null = null;
|
|
||||||
let onErrorCalled = false;
|
|
||||||
|
|
||||||
const bucket = makeBucket([
|
|
||||||
makeSession({ sessionId: 11, startedAtMs: 2_000_000 }),
|
|
||||||
makeSession({ sessionId: 22, startedAtMs: 3_000_000 }),
|
|
||||||
makeSession({ sessionId: 33, startedAtMs: 4_000_000 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handler = buildBucketDeleteHandler({
|
|
||||||
bucket,
|
|
||||||
apiClient: {
|
|
||||||
deleteSessions: async (ids: number[]) => {
|
|
||||||
deleted = ids;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
confirm: (title, count) => {
|
|
||||||
assert.equal(title, 'Episode 1');
|
|
||||||
assert.equal(count, 3);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
onSuccess: (ids) => {
|
|
||||||
onSuccessCalledWith = ids;
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
onErrorCalled = true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler();
|
|
||||||
|
|
||||||
assert.deepEqual(deleted, [33, 22, 11]);
|
|
||||||
assert.deepEqual(onSuccessCalledWith, [33, 22, 11]);
|
|
||||||
assert.equal(onErrorCalled, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => {
|
|
||||||
let deleteCalled = false;
|
|
||||||
let successCalled = false;
|
|
||||||
|
|
||||||
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
|
|
||||||
|
|
||||||
const handler = buildBucketDeleteHandler({
|
|
||||||
bucket,
|
|
||||||
apiClient: {
|
|
||||||
deleteSessions: async () => {
|
|
||||||
deleteCalled = true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
confirm: () => false,
|
|
||||||
onSuccess: () => {
|
|
||||||
successCalled = true;
|
|
||||||
},
|
|
||||||
onError: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler();
|
|
||||||
|
|
||||||
assert.equal(deleteCalled, false);
|
|
||||||
assert.equal(successCalled, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildBucketDeleteHandler reports errors via onError without calling onSuccess', async () => {
|
|
||||||
let errorMessage: string | null = null;
|
|
||||||
let successCalled = false;
|
|
||||||
|
|
||||||
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
|
|
||||||
|
|
||||||
const handler = buildBucketDeleteHandler({
|
|
||||||
bucket,
|
|
||||||
apiClient: {
|
|
||||||
deleteSessions: async () => {
|
|
||||||
throw new Error('boom');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
confirm: () => true,
|
|
||||||
onSuccess: () => {
|
|
||||||
successCalled = true;
|
|
||||||
},
|
|
||||||
onError: (message) => {
|
|
||||||
errorMessage = message;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler();
|
|
||||||
|
|
||||||
assert.equal(errorMessage, 'boom');
|
|
||||||
assert.equal(successCalled, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildBucketDeleteHandler falls back to a generic title when canonicalTitle is null', async () => {
|
|
||||||
let seenTitle: string | null = null;
|
|
||||||
|
|
||||||
const bucket = makeBucket([
|
|
||||||
makeSession({ sessionId: 1, canonicalTitle: null }),
|
|
||||||
makeSession({ sessionId: 2, canonicalTitle: null }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handler = buildBucketDeleteHandler({
|
|
||||||
bucket,
|
|
||||||
apiClient: { deleteSessions: async () => {} },
|
|
||||||
confirm: (title) => {
|
|
||||||
seenTitle = title;
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
onSuccess: () => {},
|
|
||||||
onError: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler();
|
|
||||||
|
|
||||||
assert.equal(seenTitle, 'this episode');
|
|
||||||
});
|
|
||||||
@@ -3,9 +3,8 @@ import { useSessions } from '../../hooks/useSessions';
|
|||||||
import { SessionRow } from './SessionRow';
|
import { SessionRow } from './SessionRow';
|
||||||
import { SessionDetail } from './SessionDetail';
|
import { SessionDetail } from './SessionDetail';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
|
import { confirmSessionDelete } from '../../lib/delete-confirm';
|
||||||
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
|
import { formatSessionDayLabel } from '../../lib/formatters';
|
||||||
import { groupSessionsByVideo, type SessionBucket } from '../../lib/session-grouping';
|
|
||||||
import type { SessionSummary } from '../../types/stats';
|
import type { SessionSummary } from '../../types/stats';
|
||||||
|
|
||||||
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
||||||
@@ -24,35 +23,6 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSumm
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BucketDeleteDeps {
|
|
||||||
bucket: SessionBucket;
|
|
||||||
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
|
|
||||||
confirm: (title: string, count: number) => boolean;
|
|
||||||
onSuccess: (deletedIds: number[]) => void;
|
|
||||||
onError: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a handler that deletes every session in a bucket after confirmation.
|
|
||||||
*
|
|
||||||
* Extracted as a pure factory so the deletion flow can be unit-tested without
|
|
||||||
* rendering the full SessionsTab or mocking React state.
|
|
||||||
*/
|
|
||||||
export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> {
|
|
||||||
const { bucket, apiClient: client, confirm, onSuccess, onError } = deps;
|
|
||||||
return async () => {
|
|
||||||
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
|
||||||
const ids = bucket.sessions.map((s) => s.sessionId);
|
|
||||||
if (!confirm(title, ids.length)) return;
|
|
||||||
try {
|
|
||||||
await client.deleteSessions(ids);
|
|
||||||
onSuccess(ids);
|
|
||||||
} catch (err) {
|
|
||||||
onError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionsTabProps {
|
interface SessionsTabProps {
|
||||||
initialSessionId?: number | null;
|
initialSessionId?: number | null;
|
||||||
onClearInitialSession?: () => void;
|
onClearInitialSession?: () => void;
|
||||||
@@ -66,12 +36,10 @@ export function SessionsTab({
|
|||||||
}: SessionsTabProps = {}) {
|
}: SessionsTabProps = {}) {
|
||||||
const { sessions, loading, error } = useSessions();
|
const { sessions, loading, error } = useSessions();
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
const [expandedBuckets, setExpandedBuckets] = useState<Set<string>>(() => new Set());
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
|
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||||
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisibleSessions(sessions);
|
setVisibleSessions(sessions);
|
||||||
@@ -108,16 +76,7 @@ export function SessionsTab({
|
|||||||
return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q));
|
return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q));
|
||||||
}, [visibleSessions, search]);
|
}, [visibleSessions, search]);
|
||||||
|
|
||||||
const dayGroups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
|
const groups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
|
||||||
|
|
||||||
const toggleBucket = (key: string) => {
|
|
||||||
setExpandedBuckets((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(key)) next.delete(key);
|
|
||||||
else next.add(key);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteSession = async (session: SessionSummary) => {
|
const handleDeleteSession = async (session: SessionSummary) => {
|
||||||
if (!confirmSessionDelete()) return;
|
if (!confirmSessionDelete()) return;
|
||||||
@@ -135,33 +94,6 @@ export function SessionsTab({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteBucket = async (bucket: SessionBucket) => {
|
|
||||||
setDeleteError(null);
|
|
||||||
setDeletingBucketKey(bucket.key);
|
|
||||||
const handler = buildBucketDeleteHandler({
|
|
||||||
bucket,
|
|
||||||
apiClient,
|
|
||||||
confirm: confirmBucketDelete,
|
|
||||||
onSuccess: (ids) => {
|
|
||||||
const deleted = new Set(ids);
|
|
||||||
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
|
|
||||||
setExpandedId((prev) => (prev != null && deleted.has(prev) ? null : prev));
|
|
||||||
setExpandedBuckets((prev) => {
|
|
||||||
if (!prev.has(bucket.key)) return prev;
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(bucket.key);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (message) => setDeleteError(message),
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await handler();
|
|
||||||
} finally {
|
|
||||||
setDeletingBucketKey(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
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>;
|
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||||
|
|
||||||
@@ -178,120 +110,39 @@ export function SessionsTab({
|
|||||||
|
|
||||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||||
|
|
||||||
{Array.from(dayGroups.entries()).map(([dayLabel, daySessions]) => {
|
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => (
|
||||||
const buckets = groupSessionsByVideo(daySessions);
|
<div key={dayLabel}>
|
||||||
return (
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div key={dayLabel}>
|
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
{dayLabel}
|
||||||
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
|
</h3>
|
||||||
{dayLabel}
|
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
||||||
</h3>
|
|
||||||
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{buckets.map((bucket) => {
|
|
||||||
if (bucket.sessions.length === 1) {
|
|
||||||
const s = bucket.sessions[0]!;
|
|
||||||
const detailsId = `session-details-${s.sessionId}`;
|
|
||||||
return (
|
|
||||||
<div key={bucket.key}>
|
|
||||||
<SessionRow
|
|
||||||
session={s}
|
|
||||||
isExpanded={expandedId === s.sessionId}
|
|
||||||
detailsId={detailsId}
|
|
||||||
onToggle={() =>
|
|
||||||
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
|
||||||
}
|
|
||||||
onDelete={() => void handleDeleteSession(s)}
|
|
||||||
deleteDisabled={deletingSessionId === s.sessionId}
|
|
||||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
|
||||||
/>
|
|
||||||
{expandedId === s.sessionId && (
|
|
||||||
<div id={detailsId}>
|
|
||||||
<SessionDetail session={s} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bucketBodyId = `session-bucket-${bucket.key}`;
|
|
||||||
const isExpanded = expandedBuckets.has(bucket.key);
|
|
||||||
const title = bucket.representativeSession.canonicalTitle ?? 'Unknown Media';
|
|
||||||
const deleteDisabled = deletingBucketKey === bucket.key;
|
|
||||||
return (
|
|
||||||
<div key={bucket.key}>
|
|
||||||
<div className="relative group flex items-stretch gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleBucket(bucket.key)}
|
|
||||||
aria-expanded={isExpanded}
|
|
||||||
aria-controls={bucketBodyId}
|
|
||||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className={`text-ctp-overlay2 text-xs shrink-0 transition-transform ${
|
|
||||||
isExpanded ? 'rotate-90' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{'\u25B6'}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium text-ctp-text truncate">{title}</div>
|
|
||||||
<div className="text-xs text-ctp-overlay2">
|
|
||||||
{bucket.sessions.length} session
|
|
||||||
{bucket.sessions.length === 1 ? '' : 's'} ·{' '}
|
|
||||||
{formatDuration(bucket.totalActiveMs)} active ·{' '}
|
|
||||||
{formatNumber(bucket.totalCardsMined)} cards
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleDeleteBucket(bucket)}
|
|
||||||
disabled={deleteDisabled}
|
|
||||||
aria-label={`Delete all ${bucket.sessions.length} sessions of ${title}`}
|
|
||||||
title="Delete all sessions in this group"
|
|
||||||
className="shrink-0 w-8 rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-overlay2 hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed opacity-0 group-hover:opacity-100 focus:opacity-100"
|
|
||||||
>
|
|
||||||
{'\u2715'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{isExpanded && (
|
|
||||||
<div id={bucketBodyId} className="mt-2 ml-6 space-y-2">
|
|
||||||
{bucket.sessions.map((s) => {
|
|
||||||
const detailsId = `session-details-${s.sessionId}`;
|
|
||||||
return (
|
|
||||||
<div key={s.sessionId}>
|
|
||||||
<SessionRow
|
|
||||||
session={s}
|
|
||||||
isExpanded={expandedId === s.sessionId}
|
|
||||||
detailsId={detailsId}
|
|
||||||
onToggle={() =>
|
|
||||||
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
|
||||||
}
|
|
||||||
onDelete={() => void handleDeleteSession(s)}
|
|
||||||
deleteDisabled={deletingSessionId === s.sessionId}
|
|
||||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
|
||||||
/>
|
|
||||||
{expandedId === s.sessionId && (
|
|
||||||
<div id={detailsId}>
|
|
||||||
<SessionDetail session={s} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="space-y-2">
|
||||||
})}
|
{daySessions.map((s) => {
|
||||||
|
const detailsId = `session-details-${s.sessionId}`;
|
||||||
|
return (
|
||||||
|
<div key={s.sessionId}>
|
||||||
|
<SessionRow
|
||||||
|
session={s}
|
||||||
|
isExpanded={expandedId === s.sessionId}
|
||||||
|
detailsId={detailsId}
|
||||||
|
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
|
||||||
|
onDelete={() => void handleDeleteSession(s)}
|
||||||
|
deleteDisabled={deletingSessionId === s.sessionId}
|
||||||
|
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||||
|
/>
|
||||||
|
{expandedId === s.sessionId && (
|
||||||
|
<div id={detailsId}>
|
||||||
|
<SessionDetail session={s} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<div className="text-ctp-overlay2 text-sm">
|
<div className="text-ctp-overlay2 text-sm">
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function DateRangeSelector({
|
|||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
label="Range"
|
label="Range"
|
||||||
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
|
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
|
||||||
value={range}
|
value={range}
|
||||||
onChange={onRangeChange}
|
onChange={onRangeChange}
|
||||||
formatLabel={(r) => (r === 'all' ? 'All' : r)}
|
formatLabel={(r) => (r === 'all' ? 'All' : r)}
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
|
||||||
import type { LibrarySummaryRow } from '../../types/stats';
|
|
||||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
|
||||||
import { epochDayToDate, formatDuration, formatNumber } from '../../lib/formatters';
|
|
||||||
|
|
||||||
interface LibrarySummarySectionProps {
|
|
||||||
rows: LibrarySummaryRow[];
|
|
||||||
hiddenTitles: ReadonlySet<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LEADERBOARD_LIMIT = 10;
|
|
||||||
const LEADERBOARD_HEIGHT = 260;
|
|
||||||
const LEADERBOARD_BAR_COLOR = '#8aadf4';
|
|
||||||
const TABLE_MAX_HEIGHT = 480;
|
|
||||||
|
|
||||||
type SortColumn =
|
|
||||||
| 'title'
|
|
||||||
| 'watchTimeMin'
|
|
||||||
| 'videos'
|
|
||||||
| 'sessions'
|
|
||||||
| 'cards'
|
|
||||||
| 'words'
|
|
||||||
| 'lookups'
|
|
||||||
| 'lookupsPerHundred'
|
|
||||||
| 'firstWatched';
|
|
||||||
|
|
||||||
type SortDirection = 'asc' | 'desc';
|
|
||||||
|
|
||||||
interface ColumnDef {
|
|
||||||
id: SortColumn;
|
|
||||||
label: string;
|
|
||||||
align: 'left' | 'right';
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMNS: ColumnDef[] = [
|
|
||||||
{ id: 'title', label: 'Title', align: 'left' },
|
|
||||||
{ id: 'watchTimeMin', label: 'Watch Time', align: 'right' },
|
|
||||||
{ id: 'videos', label: 'Videos', align: 'right' },
|
|
||||||
{ id: 'sessions', label: 'Sessions', align: 'right' },
|
|
||||||
{ id: 'cards', label: 'Cards', align: 'right' },
|
|
||||||
{ id: 'words', label: 'Words', align: 'right' },
|
|
||||||
{ id: 'lookups', label: 'Lookups', align: 'right' },
|
|
||||||
{ id: 'lookupsPerHundred', label: 'Lookups/100w', align: 'right' },
|
|
||||||
{ id: 'firstWatched', label: 'Date Range', align: 'right' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function truncateTitle(title: string, maxChars: number): string {
|
|
||||||
if (title.length <= maxChars) return title;
|
|
||||||
return `${title.slice(0, maxChars - 1)}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateRange(firstEpochDay: number, lastEpochDay: number): string {
|
|
||||||
const fmt = (epochDay: number) =>
|
|
||||||
epochDayToDate(epochDay).toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
if (firstEpochDay === lastEpochDay) return fmt(firstEpochDay);
|
|
||||||
return `${fmt(firstEpochDay)} → ${fmt(lastEpochDay)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatWatchTime(min: number): string {
|
|
||||||
return formatDuration(min * 60_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareRows(
|
|
||||||
a: LibrarySummaryRow,
|
|
||||||
b: LibrarySummaryRow,
|
|
||||||
column: SortColumn,
|
|
||||||
direction: SortDirection,
|
|
||||||
): number {
|
|
||||||
const sign = direction === 'asc' ? 1 : -1;
|
|
||||||
|
|
||||||
if (column === 'title') {
|
|
||||||
return a.title.localeCompare(b.title) * sign;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (column === 'firstWatched') {
|
|
||||||
return (a.firstWatched - b.firstWatched) * sign;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (column === 'lookupsPerHundred') {
|
|
||||||
const aVal = a.lookupsPerHundred;
|
|
||||||
const bVal = b.lookupsPerHundred;
|
|
||||||
if (aVal === null && bVal === null) return 0;
|
|
||||||
if (aVal === null) return 1;
|
|
||||||
if (bVal === null) return -1;
|
|
||||||
return (aVal - bVal) * sign;
|
|
||||||
}
|
|
||||||
|
|
||||||
const aVal = a[column] as number;
|
|
||||||
const bVal = b[column] as number;
|
|
||||||
return (aVal - bVal) * sign;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySectionProps) {
|
|
||||||
const [sortColumn, setSortColumn] = useState<SortColumn>('watchTimeMin');
|
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
|
||||||
|
|
||||||
const visibleRows = useMemo(
|
|
||||||
() => rows.filter((row) => !hiddenTitles.has(row.title)),
|
|
||||||
[rows, hiddenTitles],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sortedRows = useMemo(
|
|
||||||
() => [...visibleRows].sort((a, b) => compareRows(a, b, sortColumn, sortDirection)),
|
|
||||||
[visibleRows, sortColumn, sortDirection],
|
|
||||||
);
|
|
||||||
|
|
||||||
const leaderboard = useMemo(
|
|
||||||
() =>
|
|
||||||
[...visibleRows]
|
|
||||||
.sort((a, b) => b.watchTimeMin - a.watchTimeMin)
|
|
||||||
.slice(0, LEADERBOARD_LIMIT)
|
|
||||||
.map((row) => ({
|
|
||||||
title: row.title,
|
|
||||||
displayTitle: truncateTitle(row.title, 24),
|
|
||||||
watchTimeMin: row.watchTimeMin,
|
|
||||||
})),
|
|
||||||
[visibleRows],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (visibleRows.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
|
||||||
<div className="text-xs text-ctp-overlay2">No library activity in the selected window.</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleHeaderClick = (column: SortColumn) => {
|
|
||||||
if (column === sortColumn) {
|
|
||||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
|
||||||
} else {
|
|
||||||
setSortColumn(column);
|
|
||||||
setSortDirection(column === 'title' ? 'asc' : 'desc');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
|
||||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">Top Titles by Watch Time (min)</h3>
|
|
||||||
<ResponsiveContainer width="100%" height={LEADERBOARD_HEIGHT}>
|
|
||||||
<BarChart
|
|
||||||
data={leaderboard}
|
|
||||||
layout="vertical"
|
|
||||||
margin={{ top: 8, right: 16, bottom: 8, left: 8 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
|
||||||
<XAxis
|
|
||||||
type="number"
|
|
||||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
|
||||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
|
||||||
tickLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
type="category"
|
|
||||||
dataKey="displayTitle"
|
|
||||||
width={160}
|
|
||||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
|
||||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
|
||||||
tickLine={false}
|
|
||||||
interval={0}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={TOOLTIP_CONTENT_STYLE}
|
|
||||||
formatter={(value: number) => [`${value} min`, 'Watch Time']}
|
|
||||||
labelFormatter={(_label, payload) => {
|
|
||||||
const datum = payload?.[0]?.payload as { title?: string } | undefined;
|
|
||||||
return datum?.title ?? '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="watchTimeMin" fill={LEADERBOARD_BAR_COLOR} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
|
||||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">Per-Title Summary</h3>
|
|
||||||
<div className="overflow-auto" style={{ maxHeight: TABLE_MAX_HEIGHT }}>
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead className="sticky top-0 bg-ctp-surface0">
|
|
||||||
<tr className="border-b border-ctp-surface1 text-ctp-subtext0">
|
|
||||||
{COLUMNS.map((column) => {
|
|
||||||
const isActive = column.id === sortColumn;
|
|
||||||
const indicator = isActive ? (sortDirection === 'asc' ? ' ▲' : ' ▼') : '';
|
|
||||||
return (
|
|
||||||
<th
|
|
||||||
key={column.id}
|
|
||||||
scope="col"
|
|
||||||
className={`px-2 py-2 font-medium select-none cursor-pointer hover:text-ctp-text ${
|
|
||||||
column.align === 'right' ? 'text-right' : 'text-left'
|
|
||||||
} ${isActive ? 'text-ctp-text' : ''}`}
|
|
||||||
onClick={() => handleHeaderClick(column.id)}
|
|
||||||
>
|
|
||||||
{column.label}
|
|
||||||
{indicator}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sortedRows.map((row) => (
|
|
||||||
<tr
|
|
||||||
key={row.title}
|
|
||||||
className="border-b border-ctp-surface1 last:border-b-0 hover:bg-ctp-surface1/40"
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
className="px-2 py-2 text-left text-ctp-text max-w-[240px] truncate"
|
|
||||||
title={row.title}
|
|
||||||
>
|
|
||||||
{row.title}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
|
||||||
{formatWatchTime(row.watchTimeMin)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
|
||||||
{formatNumber(row.videos)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
|
||||||
{formatNumber(row.sessions)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
|
||||||
{formatNumber(row.cards)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
|
||||||
{formatNumber(row.words)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
|
||||||
{formatNumber(row.lookups)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
|
||||||
{row.lookupsPerHundred === null ? '—' : row.lookupsPerHundred.toFixed(1)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-2 text-right text-ctp-subtext0 tabular-nums">
|
|
||||||
{formatDateRange(row.firstWatched, row.lastWatched)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,4 @@
|
|||||||
import {
|
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
CartesianGrid,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from 'recharts';
|
|
||||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
|
||||||
import { epochDayToDate } from '../../lib/formatters';
|
import { epochDayToDate } from '../../lib/formatters';
|
||||||
|
|
||||||
export interface PerAnimeDataPoint {
|
export interface PerAnimeDataPoint {
|
||||||
@@ -73,6 +64,14 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
|
|||||||
const { points, seriesKeys } = buildLineData(data);
|
const { points, seriesKeys } = buildLineData(data);
|
||||||
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
|
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
background: '#363a4f',
|
||||||
|
border: '1px solid #494d64',
|
||||||
|
borderRadius: 6,
|
||||||
|
color: '#cad3f5',
|
||||||
|
fontSize: 12,
|
||||||
|
};
|
||||||
|
|
||||||
if (points.length === 0) {
|
if (points.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||||
@@ -85,22 +84,21 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
|
|||||||
return (
|
return (
|
||||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||||
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
<AreaChart data={points} margin={CHART_DEFAULTS.margin}>
|
<AreaChart data={points}>
|
||||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
width={32}
|
width={28}
|
||||||
/>
|
/>
|
||||||
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} />
|
<Tooltip contentStyle={tooltipStyle} />
|
||||||
{seriesKeys.map((key, i) => (
|
{seriesKeys.map((key, i) => (
|
||||||
<Area
|
<Area
|
||||||
key={key}
|
key={key}
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
CartesianGrid,
|
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
|
||||||
|
|
||||||
interface TrendChartProps {
|
interface TrendChartProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -21,29 +19,35 @@ interface TrendChartProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) {
|
export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) {
|
||||||
|
const tooltipStyle = {
|
||||||
|
background: '#363a4f',
|
||||||
|
border: '1px solid #494d64',
|
||||||
|
borderRadius: 6,
|
||||||
|
color: '#cad3f5',
|
||||||
|
fontSize: 12,
|
||||||
|
};
|
||||||
|
|
||||||
const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]);
|
const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||||
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
{type === 'bar' ? (
|
{type === 'bar' ? (
|
||||||
<BarChart data={data} margin={CHART_DEFAULTS.margin}>
|
<BarChart data={data}>
|
||||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
width={32}
|
width={28}
|
||||||
tickFormatter={formatter}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
|
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
fill={color}
|
fill={color}
|
||||||
@@ -55,22 +59,20 @@ export function TrendChart({ title, data, color, type, formatter, onBarClick }:
|
|||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
) : (
|
) : (
|
||||||
<LineChart data={data} margin={CHART_DEFAULTS.margin}>
|
<LineChart data={data}>
|
||||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
width={32}
|
width={28}
|
||||||
tickFormatter={formatter}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
|
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||||
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
|
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
|
||||||
import { AnimeVisibilityFilter } from './TrendsTab';
|
|
||||||
|
|
||||||
test('AnimeVisibilityFilter uses title visibility wording', () => {
|
|
||||||
const markup = renderToStaticMarkup(
|
|
||||||
<AnimeVisibilityFilter
|
|
||||||
animeTitles={['KonoSuba']}
|
|
||||||
hiddenAnime={new Set()}
|
|
||||||
onShowAll={() => {}}
|
|
||||||
onHideAll={() => {}}
|
|
||||||
onToggleAnime={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(markup, /Title Visibility/);
|
|
||||||
assert.doesNotMatch(markup, /Anime Visibility/);
|
|
||||||
});
|
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
filterHiddenAnimeData,
|
filterHiddenAnimeData,
|
||||||
pruneHiddenAnime,
|
pruneHiddenAnime,
|
||||||
} from './anime-visibility';
|
} from './anime-visibility';
|
||||||
import { LibrarySummarySection } from './LibrarySummarySection';
|
|
||||||
|
|
||||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +28,7 @@ interface AnimeVisibilityFilterProps {
|
|||||||
onToggleAnime: (title: string) => void;
|
onToggleAnime: (title: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnimeVisibilityFilter({
|
function AnimeVisibilityFilter({
|
||||||
animeTitles,
|
animeTitles,
|
||||||
hiddenAnime,
|
hiddenAnime,
|
||||||
onShowAll,
|
onShowAll,
|
||||||
@@ -45,7 +44,7 @@ export function AnimeVisibilityFilter({
|
|||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
|
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
|
||||||
Title Visibility
|
Anime Visibility
|
||||||
</h4>
|
</h4>
|
||||||
<p className="mt-1 text-xs text-ctp-overlay1">
|
<p className="mt-1 text-xs text-ctp-overlay1">
|
||||||
Shared across all anime trend charts. Default: show everything.
|
Shared across all anime trend charts. Default: show everything.
|
||||||
@@ -115,6 +114,11 @@ export function TrendsTab() {
|
|||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const animeTitles = buildAnimeVisibilityOptions([
|
const animeTitles = buildAnimeVisibilityOptions([
|
||||||
|
data.animePerDay.episodes,
|
||||||
|
data.animePerDay.watchTime,
|
||||||
|
data.animePerDay.cards,
|
||||||
|
data.animePerDay.words,
|
||||||
|
data.animePerDay.lookups,
|
||||||
data.animeCumulative.episodes,
|
data.animeCumulative.episodes,
|
||||||
data.animeCumulative.cards,
|
data.animeCumulative.cards,
|
||||||
data.animeCumulative.words,
|
data.animeCumulative.words,
|
||||||
@@ -122,6 +126,24 @@ export function TrendsTab() {
|
|||||||
]);
|
]);
|
||||||
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
|
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
|
||||||
|
|
||||||
|
const filteredEpisodesPerAnime = filterHiddenAnimeData(
|
||||||
|
data.animePerDay.episodes,
|
||||||
|
activeHiddenAnime,
|
||||||
|
);
|
||||||
|
const filteredWatchTimePerAnime = filterHiddenAnimeData(
|
||||||
|
data.animePerDay.watchTime,
|
||||||
|
activeHiddenAnime,
|
||||||
|
);
|
||||||
|
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
|
||||||
|
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
|
||||||
|
const filteredLookupsPerAnime = filterHiddenAnimeData(
|
||||||
|
data.animePerDay.lookups,
|
||||||
|
activeHiddenAnime,
|
||||||
|
);
|
||||||
|
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
|
||||||
|
data.animePerDay.lookupsPerHundred,
|
||||||
|
activeHiddenAnime,
|
||||||
|
);
|
||||||
const filteredAnimeProgress = filterHiddenAnimeData(
|
const filteredAnimeProgress = filterHiddenAnimeData(
|
||||||
data.animeCumulative.episodes,
|
data.animeCumulative.episodes,
|
||||||
activeHiddenAnime,
|
activeHiddenAnime,
|
||||||
@@ -163,18 +185,6 @@ export function TrendsTab() {
|
|||||||
/>
|
/>
|
||||||
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
|
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
|
||||||
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
||||||
<TrendChart
|
|
||||||
title="Watch Time by Day of Week (min)"
|
|
||||||
data={data.patterns.watchTimeByDayOfWeek}
|
|
||||||
color="#8aadf4"
|
|
||||||
type="bar"
|
|
||||||
/>
|
|
||||||
<TrendChart
|
|
||||||
title="Watch Time by Hour (min)"
|
|
||||||
data={data.patterns.watchTimeByHour}
|
|
||||||
color="#c6a0f6"
|
|
||||||
type="bar"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SectionHeader>Period Trends</SectionHeader>
|
<SectionHeader>Period Trends</SectionHeader>
|
||||||
<TrendChart
|
<TrendChart
|
||||||
@@ -211,7 +221,7 @@ export function TrendsTab() {
|
|||||||
type="line"
|
type="line"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionHeader>Library — Cumulative</SectionHeader>
|
<SectionHeader>Anime — Per Day</SectionHeader>
|
||||||
<AnimeVisibilityFilter
|
<AnimeVisibilityFilter
|
||||||
animeTitles={animeTitles}
|
animeTitles={animeTitles}
|
||||||
hiddenAnime={activeHiddenAnime}
|
hiddenAnime={activeHiddenAnime}
|
||||||
@@ -229,6 +239,21 @@ export function TrendsTab() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<StackedTrendChart title="Episodes per Anime" data={filteredEpisodesPerAnime} />
|
||||||
|
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
|
||||||
|
<StackedTrendChart
|
||||||
|
title="Cards Mined per Anime"
|
||||||
|
data={filteredCardsPerAnime}
|
||||||
|
colorPalette={cardsMinedStackedColors}
|
||||||
|
/>
|
||||||
|
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
|
||||||
|
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
|
||||||
|
<StackedTrendChart
|
||||||
|
title="Lookups/100w per Anime"
|
||||||
|
data={filteredLookupsPerHundredPerAnime}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionHeader>Anime — Cumulative</SectionHeader>
|
||||||
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
|
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
|
||||||
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
||||||
<StackedTrendChart
|
<StackedTrendChart
|
||||||
@@ -238,8 +263,19 @@ export function TrendsTab() {
|
|||||||
/>
|
/>
|
||||||
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
|
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
|
||||||
|
|
||||||
<SectionHeader>Library — Summary</SectionHeader>
|
<SectionHeader>Patterns</SectionHeader>
|
||||||
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
|
<TrendChart
|
||||||
|
title="Watch Time by Day of Week (min)"
|
||||||
|
data={data.patterns.watchTimeByDayOfWeek}
|
||||||
|
color="#8aadf4"
|
||||||
|
type="bar"
|
||||||
|
/>
|
||||||
|
<TrendChart
|
||||||
|
title="Watch Time by Hour (min)"
|
||||||
|
data={data.patterns.watchTimeByHour}
|
||||||
|
color="#c6a0f6"
|
||||||
|
type="bar"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function CrossAnimeWordsTable({
|
|||||||
>
|
>
|
||||||
{'\u25B6'}
|
{'\u25B6'}
|
||||||
</span>
|
</span>
|
||||||
Words Across Multiple Titles
|
Words In Multiple Anime
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{hasKnownData && (
|
{hasKnownData && (
|
||||||
@@ -97,8 +97,8 @@ export function CrossAnimeWordsTable({
|
|||||||
{collapsed ? null : ranked.length === 0 ? (
|
{collapsed ? null : ranked.length === 0 ? (
|
||||||
<div className="text-xs text-ctp-overlay2 mt-3">
|
<div className="text-xs text-ctp-overlay2 mt-3">
|
||||||
{hideKnown
|
{hideKnown
|
||||||
? 'All words that span multiple titles are already known!'
|
? 'All multi-anime words are already known!'
|
||||||
: 'No words found across multiple titles.'}
|
: 'No words found across multiple anime.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -109,7 +109,7 @@ export function CrossAnimeWordsTable({
|
|||||||
<th className="text-left py-2 pr-3 font-medium">Word</th>
|
<th className="text-left py-2 pr-3 font-medium">Word</th>
|
||||||
<th className="text-left py-2 pr-3 font-medium">Reading</th>
|
<th className="text-left py-2 pr-3 font-medium">Reading</th>
|
||||||
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
|
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
|
||||||
<th className="text-right py-2 pr-3 font-medium w-16">Titles</th>
|
<th className="text-right py-2 pr-3 font-medium w-16">Anime</th>
|
||||||
<th className="text-right py-2 font-medium w-16">Seen</th>
|
<th className="text-right py-2 font-medium w-16">Seen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
|
||||||
import { FrequencyRankTable } from './FrequencyRankTable';
|
|
||||||
import type { VocabularyEntry } from '../../types/stats';
|
|
||||||
|
|
||||||
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
|
|
||||||
return {
|
|
||||||
wordId: 1,
|
|
||||||
headword: '日本語',
|
|
||||||
word: '日本語',
|
|
||||||
reading: 'にほんご',
|
|
||||||
frequency: 5,
|
|
||||||
frequencyRank: 100,
|
|
||||||
animeCount: 1,
|
|
||||||
partOfSpeech: null,
|
|
||||||
firstSeen: 0,
|
|
||||||
lastSeen: 0,
|
|
||||||
...over,
|
|
||||||
} as VocabularyEntry;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('renders headword and reading inline in a single column (no separate Reading header)', () => {
|
|
||||||
const entry = makeEntry({});
|
|
||||||
const markup = renderToStaticMarkup(
|
|
||||||
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
|
|
||||||
);
|
|
||||||
assert.ok(!markup.includes('>Reading<'), 'should not have a Reading column header');
|
|
||||||
assert.ok(markup.includes('日本語'), 'should include the headword');
|
|
||||||
assert.ok(markup.includes('にほんご'), 'should include the reading inline');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('omits reading when reading equals headword', () => {
|
|
||||||
const entry = makeEntry({ headword: 'カレー', word: 'カレー', reading: 'カレー' });
|
|
||||||
const markup = renderToStaticMarkup(
|
|
||||||
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
|
|
||||||
);
|
|
||||||
assert.ok(markup.includes('カレー'), 'should include the headword');
|
|
||||||
assert.ok(
|
|
||||||
!markup.includes('【'),
|
|
||||||
'should not render any bracketed reading when equal to headword',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -113,6 +113,7 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
|||||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
||||||
<th className="text-left py-2 pr-3 font-medium w-16">Rank</th>
|
<th className="text-left py-2 pr-3 font-medium w-16">Rank</th>
|
||||||
<th className="text-left py-2 pr-3 font-medium">Word</th>
|
<th className="text-left py-2 pr-3 font-medium">Word</th>
|
||||||
|
<th className="text-left py-2 pr-3 font-medium">Reading</th>
|
||||||
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
|
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
|
||||||
<th className="text-right py-2 font-medium w-20">Seen</th>
|
<th className="text-right py-2 font-medium w-20">Seen</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -127,19 +128,9 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
|||||||
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
|
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
|
||||||
#{w.frequencyRank!.toLocaleString()}
|
#{w.frequencyRank!.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pr-3">
|
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
|
||||||
<span className="text-ctp-text font-medium">{w.headword}</span>
|
<td className="py-1.5 pr-3 text-ctp-subtext0">
|
||||||
{(() => {
|
{fullReading(w.headword, w.reading) || w.headword}
|
||||||
const reading = fullReading(w.headword, w.reading);
|
|
||||||
// `fullReading` normalizes katakana to hiragana, so we normalize the
|
|
||||||
// headword the same way before comparing — otherwise katakana-only
|
|
||||||
// entries like `カレー` would render `【かれー】`.
|
|
||||||
const normalizedHeadword = fullReading(w.headword, w.headword);
|
|
||||||
if (!reading || reading === normalizedHeadword) return null;
|
|
||||||
return (
|
|
||||||
<span className="text-ctp-subtext0 text-xs ml-1.5">【{reading}】</span>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pr-3">
|
<td className="py-1.5 pr-3">
|
||||||
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
||||||
|
|||||||
57
stats/src/hooks/useMediaLibrary.test.ts
Normal file
57
stats/src/hooks/useMediaLibrary.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { MediaLibraryItem } from '../types/stats';
|
||||||
|
import { shouldRefreshMediaLibraryRows } from './useMediaLibrary';
|
||||||
|
|
||||||
|
const baseItem: MediaLibraryItem = {
|
||||||
|
videoId: 1,
|
||||||
|
canonicalTitle: 'watch?v=abc123',
|
||||||
|
totalSessions: 1,
|
||||||
|
totalActiveMs: 60_000,
|
||||||
|
totalCards: 0,
|
||||||
|
totalTokensSeen: 10,
|
||||||
|
lastWatchedMs: 1_000,
|
||||||
|
hasCoverArt: 0,
|
||||||
|
youtubeVideoId: 'abc123',
|
||||||
|
videoUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
videoTitle: null,
|
||||||
|
videoThumbnailUrl: 'https://i.ytimg.com/vi/abc123/hqdefault.jpg',
|
||||||
|
channelId: null,
|
||||||
|
channelName: null,
|
||||||
|
channelUrl: null,
|
||||||
|
channelThumbnailUrl: null,
|
||||||
|
uploaderId: null,
|
||||||
|
uploaderUrl: null,
|
||||||
|
description: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('shouldRefreshMediaLibraryRows requests a follow-up fetch for incomplete youtube metadata', () => {
|
||||||
|
assert.equal(shouldRefreshMediaLibraryRows([baseItem]), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRefreshMediaLibraryRows skips follow-up fetch when youtube metadata is complete', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRefreshMediaLibraryRows([
|
||||||
|
{
|
||||||
|
...baseItem,
|
||||||
|
videoTitle: 'Video Name',
|
||||||
|
channelName: 'Creator Name',
|
||||||
|
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-avatar=s88',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRefreshMediaLibraryRows ignores non-youtube rows', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRefreshMediaLibraryRows([
|
||||||
|
{
|
||||||
|
...baseItem,
|
||||||
|
youtubeVideoId: null,
|
||||||
|
videoUrl: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
65
stats/src/hooks/useMediaLibrary.ts
Normal file
65
stats/src/hooks/useMediaLibrary.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getStatsClient } from './useStatsApi';
|
||||||
|
import type { MediaLibraryItem } from '../types/stats';
|
||||||
|
|
||||||
|
const MEDIA_LIBRARY_REFRESH_DELAY_MS = 1_500;
|
||||||
|
const MEDIA_LIBRARY_MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
export function shouldRefreshMediaLibraryRows(rows: MediaLibraryItem[]): boolean {
|
||||||
|
return rows.some((row) => {
|
||||||
|
if (!row.youtubeVideoId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !row.videoTitle?.trim() || !row.channelName?.trim() || !row.channelThumbnailUrl?.trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMediaLibrary() {
|
||||||
|
const [media, setMedia] = useState<MediaLibraryItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let retryCount = 0;
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const load = (isInitial = false) => {
|
||||||
|
if (isInitial) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
getStatsClient()
|
||||||
|
.getMediaLibrary()
|
||||||
|
.then((rows) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setMedia(rows);
|
||||||
|
if (shouldRefreshMediaLibraryRows(rows) && retryCount < MEDIA_LIBRARY_MAX_RETRIES) {
|
||||||
|
retryCount += 1;
|
||||||
|
retryTimer = setTimeout(() => {
|
||||||
|
retryTimer = null;
|
||||||
|
load(false);
|
||||||
|
}, MEDIA_LIBRARY_REFRESH_DELAY_MS);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(err.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled || !isInitial) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
load(true);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { media, loading, error };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user