mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-11 04:19:26 -07:00
Compare commits
10 Commits
v0.2.3
...
5436e0cd49
| Author | SHA1 | Date | |
|---|---|---|---|
|
5436e0cd49
|
|||
|
beeeee5ebd
|
|||
|
fdbf769760
|
|||
|
0a36d1aa99
|
|||
|
69ab87c25f
|
|||
|
9a30419a23
|
|||
|
092c56f98f
|
|||
|
10ef535f9a
|
|||
|
6c80bd5843
|
|||
|
f0bd0ba355
|
85
.github/workflows/release.yml
vendored
85
.github/workflows/release.yml
vendored
@@ -278,45 +278,70 @@ jobs:
|
|||||||
echo "$CHANGES" >> $GITHUB_OUTPUT
|
echo "$CHANGES" >> $GITHUB_OUTPUT
|
||||||
echo "EOF" >> $GITHUB_OUTPUT
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Create Release
|
- name: Publish Release
|
||||||
uses: softprops/action-gh-release@v2
|
env:
|
||||||
with:
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
name: ${{ steps.version.outputs.VERSION }}
|
run: |
|
||||||
body: |
|
set -euo pipefail
|
||||||
## Changes
|
|
||||||
${{ steps.changelog.outputs.CHANGES }}
|
|
||||||
|
|
||||||
## Installation
|
cat > release-body.md <<'EOF'
|
||||||
|
## Changes
|
||||||
|
${{ steps.changelog.outputs.CHANGES }}
|
||||||
|
|
||||||
### AppImage (Recommended)
|
## Installation
|
||||||
1. Download the AppImage below
|
|
||||||
2. Make it executable: `chmod +x SubMiner.AppImage`
|
|
||||||
3. Run: `./SubMiner.AppImage`
|
|
||||||
|
|
||||||
### macOS
|
### AppImage (Recommended)
|
||||||
1. Download `subminer-*.dmg`
|
1. Download the AppImage below
|
||||||
2. Open the DMG and drag `SubMiner.app` into `/Applications`
|
2. Make it executable: `chmod +x SubMiner.AppImage`
|
||||||
3. If needed, use the ZIP artifact as an alternative
|
3. Run: `./SubMiner.AppImage`
|
||||||
|
|
||||||
### Manual Installation
|
### macOS
|
||||||
See the [README](https://github.com/${{ github.repository }}#installation) for manual installation instructions.
|
1. Download `subminer-*.dmg`
|
||||||
|
2. Open the DMG and drag `SubMiner.app` into `/Applications`
|
||||||
|
3. If needed, use the ZIP artifact as an alternative
|
||||||
|
|
||||||
### Optional Assets (config example + mpv plugin + rofi theme)
|
### Manual Installation
|
||||||
1. Download `subminer-assets.tar.gz`
|
See the [README](https://github.com/${{ github.repository }}#installation) for manual installation instructions.
|
||||||
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
|
|
||||||
3. Copy `plugin/subminer/` directory contents to `~/.config/mpv/scripts/`
|
|
||||||
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
|
|
||||||
5. Copy `assets/themes/subminer.rasi` to:
|
|
||||||
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
|
|
||||||
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
|
|
||||||
|
|
||||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
### Optional Assets (config example + mpv plugin + rofi theme)
|
||||||
files: |
|
1. Download `subminer-assets.tar.gz`
|
||||||
|
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
|
||||||
|
3. Copy `plugin/subminer/` directory contents to `~/.config/mpv/scripts/`
|
||||||
|
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
|
||||||
|
5. Copy `assets/themes/subminer.rasi` to:
|
||||||
|
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
|
||||||
|
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
|
||||||
|
|
||||||
|
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
|
||||||
|
gh release edit "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--title "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--notes-file release-body.md \
|
||||||
|
--prerelease false
|
||||||
|
else
|
||||||
|
gh release create "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--title "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--notes-file release-body.md \
|
||||||
|
--prerelease false
|
||||||
|
fi
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
artifacts=(
|
||||||
release/*.AppImage
|
release/*.AppImage
|
||||||
release/*.dmg
|
release/*.dmg
|
||||||
release/*.zip
|
release/*.zip
|
||||||
release/*.tar.gz
|
release/*.tar.gz
|
||||||
release/SHA256SUMS.txt
|
release/SHA256SUMS.txt
|
||||||
dist/launcher/subminer
|
dist/launcher/subminer
|
||||||
draft: false
|
)
|
||||||
prerelease: false
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ title: 'Subtitle hover: auto-pause playback with config toggle'
|
|||||||
status: Done
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-02-28 22:43'
|
created_date: '2026-02-28 22:43'
|
||||||
updated_date: '2026-02-28 22:43'
|
updated_date: '2026-03-04 12:07'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: medium
|
priority: medium
|
||||||
@@ -43,4 +43,11 @@ Scope:
|
|||||||
|
|
||||||
Implemented `subtitleStyle.autoPauseVideoOnHover` with default `true`, wired through config defaults/resolution/types, renderer state/style, and mouse hover handlers. Added playback pause-state IPC (`getPlaybackPaused`) to avoid false resume when media was already paused. Added renderer hover behavior tests (including race/cancel case) and config/resolve tests. Updated config examples and docs (`README`, usage, shortcuts, mining workflow, configuration) to document default hover pause/resume behavior and disable path.
|
Implemented `subtitleStyle.autoPauseVideoOnHover` with default `true`, wired through config defaults/resolution/types, renderer state/style, and mouse hover handlers. Added playback pause-state IPC (`getPlaybackPaused`) to avoid false resume when media was already paused. Added renderer hover behavior tests (including race/cancel case) and config/resolve tests. Updated config examples and docs (`README`, usage, shortcuts, mining workflow, configuration) to document default hover pause/resume behavior and disable path.
|
||||||
|
|
||||||
|
Follow-up adjustments (2026-03-04):
|
||||||
|
|
||||||
|
- Hover pause now resumes immediately when leaving subtitle text (no Yomitan-popup hover retention).
|
||||||
|
- Added `subtitleStyle.autoPauseVideoOnYomitanPopup` (default `false`) to optionally keep playback paused while Yomitan popup is open, with auto-resume on close only when SubMiner initiated the popup pause.
|
||||||
|
- Yomitan popup control keybinds added while popup is open: `J/K` scroll, `M` mine, `P` audio play, `[` previous audio variant, `]` next audio variant (within selected source).
|
||||||
|
- Extension copy drift detection widened so popup runtime changes are reliably re-copied on launch (`popup.js`, `popup-main.js`, `display.js`, `display-audio.js`).
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
id: TASK-84
|
||||||
|
title: 'Docs Plausible endpoint uses /api/event path'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-03 00:00'
|
||||||
|
updated_date: '2026-03-03 00:00'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
ordinal: 12000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Fix VitePress docs Plausible tracker config to post to hosted worker API event endpoint instead of worker root URL.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Docs theme Plausible `endpoint` points to `https://worker.subminer.moe/api/event`.
|
||||||
|
- [x] #2 Plausible docs test asserts `/api/event` endpoint path.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Updated docs Plausible tracker endpoint to `https://worker.subminer.moe/api/event` and updated regression test expectation accordingly.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
id: TASK-84
|
||||||
|
title: Migrate AniSkip metadata+lookup orchestration to launcher/Electron
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- Codex
|
||||||
|
created_date: '2026-03-03 08:31'
|
||||||
|
updated_date: '2026-03-03 08:35'
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
- aniskip
|
||||||
|
- launcher
|
||||||
|
- mpv-plugin
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- launcher/aniskip-metadata.ts
|
||||||
|
- launcher/mpv.ts
|
||||||
|
- plugin/subminer/aniskip.lua
|
||||||
|
- plugin/subminer/options.lua
|
||||||
|
- plugin/subminer/state.lua
|
||||||
|
- plugin/subminer/lifecycle.lua
|
||||||
|
- plugin/subminer/messages.lua
|
||||||
|
- plugin/subminer.conf
|
||||||
|
- launcher/aniskip-metadata.test.ts
|
||||||
|
documentation:
|
||||||
|
- docs/mpv-plugin.md
|
||||||
|
- launcher/aniskip-metadata.ts
|
||||||
|
- plugin/subminer/aniskip.lua
|
||||||
|
- docs/architecture.md
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Move AniSkip MAL/title-to-MAL lookup and intro payload resolution from mpv Lua to launcher Electron flow, while keeping mpv-side intro skip UX and chapter/chapter prompt behavior in plugin. Launcher should infer/analyze file metadata, fetch AniSkip payload when launching files, and pass resolved skip window via script options; plugin should trust launcher payload and fall back only when absent.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Launcher infers AniSkip metadata for file targets using existing guessit/fallback logic and performs AniSkip MAL + payload resolution during mpv startup.
|
||||||
|
- [x] #2 Launcher injects script options containing resolved MAL id and intro window fields (or explicit lookup-failure status) into mpv startup.
|
||||||
|
- [x] #3 Lua plugin consumes launcher-provided AniSkip intro data and skips all network lookups when payload is present.
|
||||||
|
- [x] #4 Standalone mpv/plugin usage without launcher payload continues to function using existing async in-plugin lookup path.
|
||||||
|
- [x] #5 Docs and defaults are updated to document new script-option contract.
|
||||||
|
- [x] #6 Launcher tests cover payload generation contract and fallback behavior where metadata is unavailable.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1) Add launcher-side AniSkip payload resolution helpers in launcher/aniskip-metadata.ts (MAL prefix lookup + AniSkip payload fetch + result normalization).
|
||||||
|
2) Wire launcher/mpv.ts + buildSubminerScriptOpts to pass resolved AniSkip fields/mode in --script-opts for file playback.
|
||||||
|
3) Update plugin/subminer/aniskip.lua plus options/state to consume injected payload: if intro_start/end present, apply immediately and skip network lookup; otherwise retain existing async behavior.
|
||||||
|
4) Ensure fallback for standalone mpv usage remains intact for no-launcher/manual refresh.
|
||||||
|
5) Add/update tests/docs/config references for new script-opt contract and edge cases.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Executed end-to-end migration so launcher resolves AniSkip title/MAL/payload before mpv start and injects it via --script-opts. Plugin now parses and consumes launcher payload (JSON/url/base64), applies OP intro from payload, tracks payload metadata in state, and keeps legacy async lookup path for non-launcher/absent payload playback. Added launcher config key aniskip_payload and updated launcher/aniskip-metadata tests for resolve/payload behavior and contract validation.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
id: TASK-85
|
||||||
|
title: 'Remove docs Plausible analytics integration'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-03 00:00'
|
||||||
|
updated_date: '2026-03-03 00:00'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
ordinal: 12001
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Remove Plausible analytics integration from docs theme and dependency graph. Keep docs build/runtime analytics-free.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Docs theme no longer imports or initializes Plausible tracker.
|
||||||
|
- [x] #2 `@plausible-analytics/tracker` removed from dependencies and lockfile.
|
||||||
|
- [x] #3 Docs analytics test reflects absence of Plausible wiring.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Deleted Plausible runtime wiring from VitePress theme, removed tracker package via `bun remove`, and updated docs test to assert no Plausible integration remains.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
3
bun.lock
3
bun.lock
@@ -6,7 +6,6 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@catppuccin/vitepress": "^0.1.2",
|
"@catppuccin/vitepress": "^0.1.2",
|
||||||
"@plausible-analytics/tracker": "^0.4.4",
|
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
@@ -189,8 +188,6 @@
|
|||||||
|
|
||||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
"@plausible-analytics/tracker": ["@plausible-analytics/tracker@0.4.4", "", {}, "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||||
|
"replace": true, // Replace active subtitle file when synchronization succeeds.
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -5,25 +5,8 @@ import '@catppuccin/vitepress/theme/macchiato/mauve.css';
|
|||||||
import './mermaid-modal.css';
|
import './mermaid-modal.css';
|
||||||
|
|
||||||
let mermaidLoader: Promise<any> | null = null;
|
let mermaidLoader: Promise<any> | null = null;
|
||||||
let plausibleTrackerInitialized = false;
|
|
||||||
const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
|
const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
|
||||||
|
|
||||||
async function initPlausibleTracker() {
|
|
||||||
if (typeof window === 'undefined' || plausibleTrackerInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { init } = await import('@plausible-analytics/tracker');
|
|
||||||
init({
|
|
||||||
domain: 'subminer.moe',
|
|
||||||
endpoint: 'https://worker.subminer.moe',
|
|
||||||
outboundLinks: true,
|
|
||||||
fileDownloads: true,
|
|
||||||
formSubmissions: true,
|
|
||||||
});
|
|
||||||
plausibleTrackerInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMermaidModal() {
|
function closeMermaidModal() {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -206,9 +189,6 @@ export default {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initPlausibleTracker().catch((error) => {
|
|
||||||
console.error('Failed to initialize Plausible tracker:', error);
|
|
||||||
});
|
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
watch(() => route.path, render);
|
watch(() => route.path, render);
|
||||||
|
|||||||
@@ -258,7 +258,8 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
|
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
|
||||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text; resume after leaving subtitle area (`true` by default). |
|
||||||
|
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while Yomitan popup is open; resume when popup closes (`false` by default). |
|
||||||
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
||||||
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
|
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
|
||||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||||
@@ -771,7 +772,8 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
|
|||||||
"defaultMode": "auto",
|
"defaultMode": "auto",
|
||||||
"alass_path": "",
|
"alass_path": "",
|
||||||
"ffsubsync_path": "",
|
"ffsubsync_path": "",
|
||||||
"ffmpeg_path": ""
|
"ffmpeg_path": "",
|
||||||
|
"replace": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -782,6 +784,7 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
|
|||||||
| `alass_path` | string path | Path to `alass` executable. Empty or `null` falls back to `/usr/bin/alass`. |
|
| `alass_path` | string path | Path to `alass` executable. Empty or `null` falls back to `/usr/bin/alass`. |
|
||||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. |
|
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. |
|
||||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
||||||
|
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
||||||
|
|
||||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||||
Customize it there, or set it to `null` to disable.
|
Customize it there, or set it to `null` to disable.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ The visible overlay renders subtitles as tokenized, clickable word spans. Each w
|
|||||||
|
|
||||||
- Word-level click targets for Yomitan lookup
|
- Word-level click targets for Yomitan lookup
|
||||||
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
||||||
|
- Optional auto-pause while Yomitan popup is open (`subtitleStyle.autoPauseVideoOnYomitanPopup`)
|
||||||
- Right-click to pause/resume
|
- Right-click to pause/resume
|
||||||
- Right-click + drag to reposition subtitles
|
- Right-click + drag to reposition subtitles
|
||||||
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ aniskip_button_duration=3
|
|||||||
| `aniskip_season` | `""` | numeric season | Optional season hint |
|
| `aniskip_season` | `""` | numeric season | Optional season hint |
|
||||||
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
|
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
|
||||||
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
|
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
|
||||||
|
| `aniskip_payload` | `""` | JSON / base64-encoded JSON | Optional pre-fetched AniSkip payload for this media. When set, plugin skips network lookup |
|
||||||
| `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt |
|
| `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt |
|
||||||
| `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) |
|
| `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) |
|
||||||
| `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) |
|
| `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) |
|
||||||
@@ -208,7 +209,8 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
|||||||
- You explicitly call `script-message subminer-aniskip-refresh`.
|
- You explicitly call `script-message subminer-aniskip-refresh`.
|
||||||
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
||||||
- MAL/title resolution is cached for the current mpv session.
|
- MAL/title resolution is cached for the current mpv session.
|
||||||
- When launched via `subminer`, launcher runs `guessit` first (file targets) and passes title/season/episode to the plugin; fallback is filename-derived title.
|
- When launched via `subminer`, launcher can pass `aniskip_payload` (pre-fetched AniSkip `skip-times` payload) and the plugin applies it directly without making API calls.
|
||||||
|
- If the payload is absent or invalid, lookup falls back to title/MAL-based async fetch.
|
||||||
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
||||||
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
||||||
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default).
|
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default).
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { readFileSync } from 'node:fs';
|
|||||||
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
||||||
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
|
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
|
||||||
|
|
||||||
test('docs theme configures plausible tracker for subminer.moe via worker.subminer.moe', () => {
|
test('docs theme has no plausible analytics wiring', () => {
|
||||||
expect(docsThemeContents).toContain('@plausible-analytics/tracker');
|
expect(docsThemeContents).not.toContain('@plausible-analytics/tracker');
|
||||||
expect(docsThemeContents).toContain('const { init } = await import');
|
expect(docsThemeContents).not.toContain('initPlausibleTracker');
|
||||||
expect(docsThemeContents).toContain("domain: 'subminer.moe'");
|
expect(docsThemeContents).not.toContain('worker.subminer.moe');
|
||||||
expect(docsThemeContents).toContain("endpoint: 'https://worker.subminer.moe'");
|
expect(docsThemeContents).not.toContain('domain:');
|
||||||
expect(docsThemeContents).toContain('outboundLinks: true');
|
expect(docsThemeContents).not.toContain('outboundLinks: true');
|
||||||
expect(docsThemeContents).toContain('fileDownloads: true');
|
expect(docsThemeContents).not.toContain('fileDownloads: true');
|
||||||
expect(docsThemeContents).toContain('formSubmissions: true');
|
expect(docsThemeContents).not.toContain('formSubmissions: true');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||||
|
"replace": true, // Replace active subtitle file when synchronization succeeds.
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -106,7 +107,8 @@
|
|||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text; resume after leaving subtitle area. Values: true | false
|
||||||
|
"autoPauseVideoOnYomitanPopup": false, // Automatically pause mpv playback while Yomitan popup is open; resume when popup closes. Values: true | false
|
||||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||||
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
||||||
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
|
|||||||
@@ -58,7 +58,18 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
|
|
||||||
These keybindings can be overridden or disabled via the `keybindings` config array.
|
These keybindings can be overridden or disabled via the `keybindings` config array.
|
||||||
|
|
||||||
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
|
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover; resume after leaving subtitle area). Optional popup behavior: set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true` to keep playback paused while Yomitan popup is open.
|
||||||
|
|
||||||
|
When a Yomitan popup is open, SubMiner also provides popup control shortcuts:
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| ------------- | -------------------------------------- |
|
||||||
|
| `J` | Scroll definitions down |
|
||||||
|
| `K` | Scroll definitions up |
|
||||||
|
| `M` | Mine/add selected term |
|
||||||
|
| `P` | Play selected term audio |
|
||||||
|
| `[` | Play previous available audio (selected source) |
|
||||||
|
| `]` | Play next available audio (selected source) |
|
||||||
|
|
||||||
## Subtitle & Feature Shortcuts
|
## Subtitle & Feature Shortcuts
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,11 @@ Notes:
|
|||||||
|
|
||||||
These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization.
|
These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization.
|
||||||
|
|
||||||
By default, hovering over subtitle text pauses mpv playback and leaving the subtitle area resumes playback. Set `subtitleStyle.autoPauseVideoOnHover` to `false` to disable this behavior.
|
By default, hovering over subtitle text pauses mpv playback. Playback resumes as soon as the cursor leaves subtitle text. Set `subtitleStyle.autoPauseVideoOnHover` to `false` to disable this behavior.
|
||||||
|
|
||||||
|
If you want playback to stay paused while a Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true`. When enabled, SubMiner auto-resumes on popup close only if SubMiner paused playback for that popup.
|
||||||
|
|
||||||
|
If the Yomitan popup is open, you can control it directly from the overlay: `J/K` scroll definitions, `M` mines/adds the selected term, `P` plays term audio, `[` plays the previous available audio, and `]` plays the next available audio in the selected source.
|
||||||
|
|
||||||
### Drag-and-drop Queueing
|
### Drag-and-drop Queueing
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,38 @@ import {
|
|||||||
inferAniSkipMetadataForFile,
|
inferAniSkipMetadataForFile,
|
||||||
buildSubminerScriptOpts,
|
buildSubminerScriptOpts,
|
||||||
parseAniSkipGuessitJson,
|
parseAniSkipGuessitJson,
|
||||||
|
resolveAniSkipMetadataForFile,
|
||||||
} from './aniskip-metadata';
|
} from './aniskip-metadata';
|
||||||
|
|
||||||
|
function makeMockResponse(payload: unknown): Response {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => payload,
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFetchInput(input: string | URL | Request): string {
|
||||||
|
if (typeof input === 'string') return input;
|
||||||
|
if (input instanceof URL) return input.toString();
|
||||||
|
return input.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withMockFetch(
|
||||||
|
handler: (input: string | URL | Request) => Promise<Response>,
|
||||||
|
fn: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const original = globalThis.fetch;
|
||||||
|
(globalThis as { fetch: typeof fetch }).fetch = (async (input: string | URL | Request) => {
|
||||||
|
return handler(input);
|
||||||
|
}) as typeof fetch;
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
(globalThis as { fetch: typeof fetch }).fetch = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test('parseAniSkipGuessitJson extracts title season and episode', () => {
|
test('parseAniSkipGuessitJson extracts title season and episode', () => {
|
||||||
const parsed = parseAniSkipGuessitJson(
|
const parsed = parseAniSkipGuessitJson(
|
||||||
JSON.stringify({ title: 'My Show', season: 2, episode: 7 }),
|
JSON.stringify({ title: 'My Show', season: 2, episode: 7 }),
|
||||||
@@ -16,6 +46,10 @@ test('parseAniSkipGuessitJson extracts title season and episode', () => {
|
|||||||
season: 2,
|
season: 2,
|
||||||
episode: 7,
|
episode: 7,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
|
malId: null,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'lookup_failed',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,6 +68,10 @@ test('parseAniSkipGuessitJson prefers series over episode title', () => {
|
|||||||
season: 1,
|
season: 1,
|
||||||
episode: 10,
|
episode: 10,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
|
malId: null,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'lookup_failed',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,16 +98,78 @@ test('inferAniSkipMetadataForFile falls back to anime directory title when filen
|
|||||||
assert.equal(parsed.source, 'fallback');
|
assert.equal(parsed.source, 'fallback');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildSubminerScriptOpts includes aniskip metadata fields', () => {
|
test('resolveAniSkipMetadataForFile resolves MAL id and intro payload', async () => {
|
||||||
|
await withMockFetch(
|
||||||
|
async (input) => {
|
||||||
|
const url = normalizeFetchInput(input);
|
||||||
|
if (url.includes('myanimelist.net/search/prefix.json')) {
|
||||||
|
return makeMockResponse({
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{ id: '9876', name: 'Wrong Match' },
|
||||||
|
{ id: '1234', name: 'My Show' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('api.aniskip.com/v1/skip-times/1234/7')) {
|
||||||
|
return makeMockResponse({
|
||||||
|
found: true,
|
||||||
|
results: [{ skip_type: 'op', interval: { start_time: 12.5, end_time: 54.2 } }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected url: ${url}`);
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const resolved = await resolveAniSkipMetadataForFile('/media/Anime.My.Show.S01E07.mkv');
|
||||||
|
assert.equal(resolved.malId, 1234);
|
||||||
|
assert.equal(resolved.introStart, 12.5);
|
||||||
|
assert.equal(resolved.introEnd, 54.2);
|
||||||
|
assert.equal(resolved.lookupStatus, 'ready');
|
||||||
|
assert.equal(resolved.title, 'Anime My Show');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses', async () => {
|
||||||
|
await withMockFetch(
|
||||||
|
async () => makeMockResponse({ categories: [] }),
|
||||||
|
async () => {
|
||||||
|
const resolved = await resolveAniSkipMetadataForFile('/media/NopeShow.S01E03.mkv');
|
||||||
|
assert.equal(resolved.malId, null);
|
||||||
|
assert.equal(resolved.lookupStatus, 'missing_mal_id');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSubminerScriptOpts includes aniskip payload fields', () => {
|
||||||
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
|
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
|
||||||
title: "Frieren: Beyond Journey's End",
|
title: "Frieren: Beyond Journey's End",
|
||||||
season: 1,
|
season: 1,
|
||||||
episode: 5,
|
episode: 5,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
|
malId: 1234,
|
||||||
|
introStart: 30.5,
|
||||||
|
introEnd: 62,
|
||||||
|
lookupStatus: 'ready',
|
||||||
});
|
});
|
||||||
|
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
|
||||||
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
|
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
|
||||||
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
|
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
|
||||||
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
|
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
|
||||||
assert.match(opts, /subminer-aniskip_season=1/);
|
assert.match(opts, /subminer-aniskip_season=1/);
|
||||||
assert.match(opts, /subminer-aniskip_episode=5/);
|
assert.match(opts, /subminer-aniskip_episode=5/);
|
||||||
|
assert.match(opts, /subminer-aniskip_mal_id=1234/);
|
||||||
|
assert.match(opts, /subminer-aniskip_intro_start=30.5/);
|
||||||
|
assert.match(opts, /subminer-aniskip_intro_end=62/);
|
||||||
|
assert.match(opts, /subminer-aniskip_lookup_status=ready/);
|
||||||
|
assert.ok(payloadMatch !== null);
|
||||||
|
const payload = JSON.parse(decodeURIComponent(payloadMatch[1]));
|
||||||
|
assert.equal(payload.found, true);
|
||||||
|
const first = payload.results?.[0];
|
||||||
|
assert.equal(first.skip_type, 'op');
|
||||||
|
assert.equal(first.interval.start_time, 30.5);
|
||||||
|
assert.equal(first.interval.end_time, 62);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,22 @@ import path from 'node:path';
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { commandExists } from './util.js';
|
import { commandExists } from './util.js';
|
||||||
|
|
||||||
|
export type AniSkipLookupStatus =
|
||||||
|
| 'ready'
|
||||||
|
| 'missing_mal_id'
|
||||||
|
| 'missing_episode'
|
||||||
|
| 'missing_payload'
|
||||||
|
| 'lookup_failed';
|
||||||
|
|
||||||
export interface AniSkipMetadata {
|
export interface AniSkipMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
season: number | null;
|
season: number | null;
|
||||||
episode: number | null;
|
episode: number | null;
|
||||||
source: 'guessit' | 'fallback';
|
source: 'guessit' | 'fallback';
|
||||||
|
malId: number | null;
|
||||||
|
introStart: number | null;
|
||||||
|
introEnd: number | null;
|
||||||
|
lookupStatus?: AniSkipLookupStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InferAniSkipDeps {
|
interface InferAniSkipDeps {
|
||||||
@@ -14,6 +25,50 @@ interface InferAniSkipDeps {
|
|||||||
runGuessit: (mediaPath: string) => string | null;
|
runGuessit: (mediaPath: string) => string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MalSearchResult {
|
||||||
|
id?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MalSearchCategory {
|
||||||
|
items?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MalSearchResponse {
|
||||||
|
categories?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AniSkipIntervalPayload {
|
||||||
|
start_time?: unknown;
|
||||||
|
end_time?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AniSkipSkipItemPayload {
|
||||||
|
skip_type?: unknown;
|
||||||
|
interval?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AniSkipPayloadResponse {
|
||||||
|
found?: unknown;
|
||||||
|
results?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAL_PREFIX_API = 'https://myanimelist.net/search/prefix.json?type=anime&keyword=';
|
||||||
|
const ANISKIP_PAYLOAD_API = 'https://api.aniskip.com/v1/skip-times/';
|
||||||
|
const MAL_USER_AGENT = 'SubMiner-launcher/ani-skip';
|
||||||
|
const MAL_MATCH_STOPWORDS = new Set([
|
||||||
|
'the',
|
||||||
|
'this',
|
||||||
|
'that',
|
||||||
|
'world',
|
||||||
|
'animated',
|
||||||
|
'series',
|
||||||
|
'season',
|
||||||
|
'no',
|
||||||
|
'on',
|
||||||
|
'and',
|
||||||
|
]);
|
||||||
|
|
||||||
function toPositiveInt(value: unknown): number | null {
|
function toPositiveInt(value: unknown): number | null {
|
||||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
return Math.floor(value);
|
return Math.floor(value);
|
||||||
@@ -27,6 +82,217 @@ function toPositiveInt(value: unknown): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPositiveNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForMatch(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeMatchWords(value: string): string[] {
|
||||||
|
const words = normalizeForMatch(value)
|
||||||
|
.split(' ')
|
||||||
|
.filter((word) => word.length >= 3);
|
||||||
|
return words.filter((word) => !MAL_MATCH_STOPWORDS.has(word));
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleOverlapScore(expectedTitle: string, candidateTitle: string): number {
|
||||||
|
const expected = normalizeForMatch(expectedTitle);
|
||||||
|
const candidate = normalizeForMatch(candidateTitle);
|
||||||
|
|
||||||
|
if (!expected || !candidate) return 0;
|
||||||
|
|
||||||
|
if (candidate.includes(expected)) return 120;
|
||||||
|
|
||||||
|
const expectedTokens = tokenizeMatchWords(expectedTitle);
|
||||||
|
if (expectedTokens.length === 0) return 0;
|
||||||
|
|
||||||
|
const candidateSet = new Set(tokenizeMatchWords(candidateTitle));
|
||||||
|
let score = 0;
|
||||||
|
let matched = 0;
|
||||||
|
|
||||||
|
for (const token of expectedTokens) {
|
||||||
|
if (candidateSet.has(token)) {
|
||||||
|
score += 30;
|
||||||
|
matched += 1;
|
||||||
|
} else {
|
||||||
|
score -= 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched === 0) {
|
||||||
|
score -= 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverage = matched / expectedTokens.length;
|
||||||
|
if (expectedTokens.length >= 2) {
|
||||||
|
if (coverage >= 0.8) score += 30;
|
||||||
|
else if (coverage >= 0.6) score += 10;
|
||||||
|
else score -= 50;
|
||||||
|
} else if (coverage >= 1) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnySequelMarker(candidateTitle: string): boolean {
|
||||||
|
const normalized = ` ${normalizeForMatch(candidateTitle)} `;
|
||||||
|
if (!normalized.trim()) return false;
|
||||||
|
|
||||||
|
const markers = [
|
||||||
|
'season 2',
|
||||||
|
'season 3',
|
||||||
|
'season 4',
|
||||||
|
'2nd season',
|
||||||
|
'3rd season',
|
||||||
|
'4th season',
|
||||||
|
'second season',
|
||||||
|
'third season',
|
||||||
|
'fourth season',
|
||||||
|
' ii ',
|
||||||
|
' iii ',
|
||||||
|
' iv ',
|
||||||
|
];
|
||||||
|
return markers.some((marker) => normalized.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
function seasonSignalScore(requestedSeason: number | null, candidateTitle: string): number {
|
||||||
|
const season = toPositiveInt(requestedSeason);
|
||||||
|
if (!season || season < 1) return 0;
|
||||||
|
|
||||||
|
const normalized = ` ${normalizeForMatch(candidateTitle)} `;
|
||||||
|
if (!normalized.trim()) return 0;
|
||||||
|
|
||||||
|
if (season === 1) {
|
||||||
|
return hasAnySequelMarker(candidateTitle) ? -60 : 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericMarker = ` season ${season} `;
|
||||||
|
const ordinalMarker = ` ${season}th season `;
|
||||||
|
if (normalized.includes(numericMarker) || normalized.includes(ordinalMarker)) {
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
const romanAliases = {
|
||||||
|
2: [' ii ', ' second season ', ' 2nd season '],
|
||||||
|
3: [' iii ', ' third season ', ' 3rd season '],
|
||||||
|
4: [' iv ', ' fourth season ', ' 4th season '],
|
||||||
|
5: [' v ', ' fifth season ', ' 5th season '],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const aliases = romanAliases[season] ?? [];
|
||||||
|
return aliases.some((alias) => normalized.includes(alias)) ? 40 : hasAnySequelMarker(candidateTitle) ? -20 : 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMalSearchItems(payload: unknown): MalSearchResult[] {
|
||||||
|
const parsed = payload as MalSearchResponse;
|
||||||
|
const categories = Array.isArray(parsed?.categories) ? parsed.categories : null;
|
||||||
|
if (!categories) return [];
|
||||||
|
|
||||||
|
const items: MalSearchResult[] = [];
|
||||||
|
for (const category of categories) {
|
||||||
|
const typedCategory = category as MalSearchCategory;
|
||||||
|
const rawItems = Array.isArray(typedCategory?.items) ? typedCategory.items : [];
|
||||||
|
for (const rawItem of rawItems) {
|
||||||
|
const item = rawItem as Record<string, unknown>;
|
||||||
|
items.push({
|
||||||
|
id: item?.id,
|
||||||
|
name: item?.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEpisodePayload(value: unknown): number | null {
|
||||||
|
return toPositiveNumber(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAniSkipPayload(payload: unknown): { start: number; end: number } | null {
|
||||||
|
const parsed = payload as AniSkipPayloadResponse;
|
||||||
|
const results = Array.isArray(parsed?.results) ? parsed.results : null;
|
||||||
|
if (!results) return null;
|
||||||
|
|
||||||
|
for (const rawResult of results) {
|
||||||
|
const result = rawResult as AniSkipSkipItemPayload;
|
||||||
|
if (result.skip_type !== 'op' || typeof result.interval !== 'object' || result.interval === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const interval = result.interval as AniSkipIntervalPayload;
|
||||||
|
const start = normalizeEpisodePayload(interval?.start_time);
|
||||||
|
const end = normalizeEpisodePayload(interval?.end_time);
|
||||||
|
if (start !== null && end !== null && end > start) {
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T | null> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': MAL_USER_AGENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
try {
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveMalIdFromTitle(title: string, season: number | null): Promise<number | null> {
|
||||||
|
const lookup = season && season > 1 ? `${title} Season ${season}` : title;
|
||||||
|
const payload = await fetchJson<unknown>(`${MAL_PREFIX_API}${encodeURIComponent(lookup)}`);
|
||||||
|
const items = toMalSearchItems(payload);
|
||||||
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
let bestScore = Number.NEGATIVE_INFINITY;
|
||||||
|
let bestMalId: number | null = null;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const id = toPositiveInt(item.id);
|
||||||
|
if (!id) continue;
|
||||||
|
const name = typeof item.name === 'string' ? item.name : '';
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
const score = titleOverlapScore(title, name) + seasonSignalScore(season, name);
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestMalId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAniSkipPayload(
|
||||||
|
malId: number,
|
||||||
|
episode: number,
|
||||||
|
): Promise<{ start: number; end: number } | null> {
|
||||||
|
const payload = await fetchJson<unknown>(`${ANISKIP_PAYLOAD_API}${malId}/${episode}?types=op&types=ed`);
|
||||||
|
const parsed = payload as AniSkipPayloadResponse;
|
||||||
|
if (!parsed || parsed.found !== true) return null;
|
||||||
|
return parseAniSkipPayload(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
function detectEpisodeFromName(baseName: string): number | null {
|
function detectEpisodeFromName(baseName: string): number | null {
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/[Ss]\d+[Ee](\d{1,3})/,
|
/[Ss]\d+[Ee](\d{1,3})/,
|
||||||
@@ -133,6 +399,10 @@ export function parseAniSkipGuessitJson(stdout: string, mediaPath: string): AniS
|
|||||||
season,
|
season,
|
||||||
episode: episodeFromDirect ?? episodeFromList,
|
episode: episodeFromDirect ?? episodeFromList,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
|
malId: null,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'lookup_failed',
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -171,9 +441,70 @@ export function inferAniSkipMetadataForFile(
|
|||||||
season: detectSeasonFromNameOrDir(mediaPath),
|
season: detectSeasonFromNameOrDir(mediaPath),
|
||||||
episode: detectEpisodeFromName(baseName),
|
episode: detectEpisodeFromName(baseName),
|
||||||
source: 'fallback',
|
source: 'fallback',
|
||||||
|
malId: null,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'lookup_failed',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveAniSkipMetadataForFile(mediaPath: string): Promise<AniSkipMetadata> {
|
||||||
|
const inferred = inferAniSkipMetadataForFile(mediaPath);
|
||||||
|
if (!inferred.title) {
|
||||||
|
return { ...inferred, lookupStatus: 'lookup_failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const malId = await resolveMalIdFromTitle(inferred.title, inferred.season);
|
||||||
|
if (!malId) {
|
||||||
|
return {
|
||||||
|
...inferred,
|
||||||
|
malId: null,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'missing_mal_id',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inferred.episode) {
|
||||||
|
return {
|
||||||
|
...inferred,
|
||||||
|
malId,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'missing_episode',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await fetchAniSkipPayload(malId, inferred.episode);
|
||||||
|
if (!payload) {
|
||||||
|
return {
|
||||||
|
...inferred,
|
||||||
|
malId,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'missing_payload',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...inferred,
|
||||||
|
malId,
|
||||||
|
introStart: payload.start,
|
||||||
|
introEnd: payload.end,
|
||||||
|
lookupStatus: 'ready',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
...inferred,
|
||||||
|
malId: inferred.malId,
|
||||||
|
introStart: inferred.introStart,
|
||||||
|
introEnd: inferred.introEnd,
|
||||||
|
lookupStatus: 'lookup_failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeScriptOptValue(value: string): string {
|
function sanitizeScriptOptValue(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/,/g, ' ')
|
.replace(/,/g, ' ')
|
||||||
@@ -182,6 +513,28 @@ function sanitizeScriptOptValue(value: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildLauncherAniSkipPayload(aniSkipMetadata: AniSkipMetadata): string | null {
|
||||||
|
if (!aniSkipMetadata.malId || !aniSkipMetadata.introStart || !aniSkipMetadata.introEnd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (aniSkipMetadata.introEnd <= aniSkipMetadata.introStart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
found: true,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
skip_type: 'op',
|
||||||
|
interval: {
|
||||||
|
start_time: aniSkipMetadata.introStart,
|
||||||
|
end_time: aniSkipMetadata.introEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return encodeURIComponent(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSubminerScriptOpts(
|
export function buildSubminerScriptOpts(
|
||||||
appPath: string,
|
appPath: string,
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
@@ -200,5 +553,21 @@ export function buildSubminerScriptOpts(
|
|||||||
if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) {
|
if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) {
|
||||||
parts.push(`subminer-aniskip_episode=${aniSkipMetadata.episode}`);
|
parts.push(`subminer-aniskip_episode=${aniSkipMetadata.episode}`);
|
||||||
}
|
}
|
||||||
|
if (aniSkipMetadata && aniSkipMetadata.malId && aniSkipMetadata.malId > 0) {
|
||||||
|
parts.push(`subminer-aniskip_mal_id=${aniSkipMetadata.malId}`);
|
||||||
|
}
|
||||||
|
if (aniSkipMetadata && aniSkipMetadata.introStart !== null && aniSkipMetadata.introStart > 0) {
|
||||||
|
parts.push(`subminer-aniskip_intro_start=${aniSkipMetadata.introStart}`);
|
||||||
|
}
|
||||||
|
if (aniSkipMetadata && aniSkipMetadata.introEnd !== null && aniSkipMetadata.introEnd > 0) {
|
||||||
|
parts.push(`subminer-aniskip_intro_end=${aniSkipMetadata.introEnd}`);
|
||||||
|
}
|
||||||
|
if (aniSkipMetadata?.lookupStatus) {
|
||||||
|
parts.push(`subminer-aniskip_lookup_status=${sanitizeScriptOptValue(aniSkipMetadata.lookupStatus)}`);
|
||||||
|
}
|
||||||
|
const aniskipPayload = aniSkipMetadata ? buildLauncherAniSkipPayload(aniSkipMetadata) : null;
|
||||||
|
if (aniskipPayload) {
|
||||||
|
parts.push(`subminer-aniskip_payload=${sanitizeScriptOptValue(aniskipPayload)}`);
|
||||||
|
}
|
||||||
return parts.join(',');
|
return parts.join(',');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
|
log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
startMpv(
|
await startMpv(
|
||||||
selectedTarget.target,
|
selectedTarget.target,
|
||||||
selectedTarget.kind,
|
selectedTarget.kind,
|
||||||
args,
|
args,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|||||||
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
||||||
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
||||||
import { log, fail, getMpvLogPath } from './log.js';
|
import { log, fail, getMpvLogPath } from './log.js';
|
||||||
import { buildSubminerScriptOpts, inferAniSkipMetadataForFile } from './aniskip-metadata.js';
|
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||||
import {
|
import {
|
||||||
commandExists,
|
commandExists,
|
||||||
isExecutable,
|
isExecutable,
|
||||||
@@ -419,7 +419,7 @@ export async function loadSubtitleIntoMpv(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startMpv(
|
export async function startMpv(
|
||||||
target: string,
|
target: string,
|
||||||
targetKind: 'file' | 'url',
|
targetKind: 'file' | 'url',
|
||||||
args: Args,
|
args: Args,
|
||||||
@@ -479,7 +479,8 @@ export function startMpv(
|
|||||||
if (options?.startPaused) {
|
if (options?.startPaused) {
|
||||||
mpvArgs.push('--pause=yes');
|
mpvArgs.push('--pause=yes');
|
||||||
}
|
}
|
||||||
const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
|
const aniSkipMetadata =
|
||||||
|
targetKind === 'file' ? await resolveAniSkipMetadataForFile(target) : null;
|
||||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
||||||
if (aniSkipMetadata) {
|
if (aniSkipMetadata) {
|
||||||
log(
|
log(
|
||||||
|
|||||||
@@ -58,7 +58,6 @@
|
|||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@catppuccin/vitepress": "^0.1.2",
|
"@catppuccin/vitepress": "^0.1.2",
|
||||||
"@plausible-analytics/tracker": "^0.4.4",
|
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ aniskip_mal_id=
|
|||||||
# Force episode number (optional). Leave blank for filename/title detection.
|
# Force episode number (optional). Leave blank for filename/title detection.
|
||||||
aniskip_episode=
|
aniskip_episode=
|
||||||
|
|
||||||
|
# Optional pre-fetched AniSkip payload for this media (JSON or base64 JSON). When set, the plugin uses this directly and skips network lookup.
|
||||||
|
aniskip_payload=
|
||||||
|
|
||||||
# Show intro skip OSD button while inside OP range.
|
# Show intro skip OSD button while inside OP range.
|
||||||
aniskip_show_button=yes
|
aniskip_show_button=yes
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ function M.create(ctx)
|
|||||||
local mal_lookup_cache = {}
|
local mal_lookup_cache = {}
|
||||||
local payload_cache = {}
|
local payload_cache = {}
|
||||||
local title_context_cache = {}
|
local title_context_cache = {}
|
||||||
|
local base64_reverse = {}
|
||||||
|
local base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
|
||||||
|
for i = 1, #base64_chars do
|
||||||
|
base64_reverse[base64_chars:sub(i, i)] = i - 1
|
||||||
|
end
|
||||||
|
|
||||||
local function url_encode(text)
|
local function url_encode(text)
|
||||||
if type(text) ~= "string" then
|
if type(text) ~= "string" then
|
||||||
@@ -25,6 +31,109 @@ function M.create(ctx)
|
|||||||
return encoded:gsub(" ", "%%20")
|
return encoded:gsub(" ", "%%20")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function parse_json_payload(text)
|
||||||
|
if type(text) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local parsed, parse_error = utils.parse_json(text)
|
||||||
|
if type(parsed) == "table" then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
return nil, parse_error
|
||||||
|
end
|
||||||
|
|
||||||
|
local function decode_base64(input)
|
||||||
|
if type(input) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local cleaned = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/")
|
||||||
|
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
|
||||||
|
if cleaned == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #cleaned % 4 == 1 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #cleaned % 4 ~= 0 then
|
||||||
|
cleaned = cleaned .. string.rep("=", 4 - (#cleaned % 4))
|
||||||
|
end
|
||||||
|
if not cleaned:match("^[A-Za-z0-9+/%=]+$") then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
local out_len = 0
|
||||||
|
for index = 1, #cleaned, 4 do
|
||||||
|
local c1 = cleaned:sub(index, index)
|
||||||
|
local c2 = cleaned:sub(index + 1, index + 1)
|
||||||
|
local c3 = cleaned:sub(index + 2, index + 2)
|
||||||
|
local c4 = cleaned:sub(index + 3, index + 3)
|
||||||
|
local v1 = base64_reverse[c1]
|
||||||
|
local v2 = base64_reverse[c2]
|
||||||
|
if not v1 or not v2 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local v3 = c3 == "=" and 0 or base64_reverse[c3]
|
||||||
|
local v4 = c4 == "=" and 0 or base64_reverse[c4]
|
||||||
|
if (c3 ~= "=" and not v3) or (c4 ~= "=" and not v4) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local n = (((v1 * 64 + v2) * 64 + v3) * 64 + v4)
|
||||||
|
local b1 = math.floor(n / 65536)
|
||||||
|
local remaining = n % 65536
|
||||||
|
local b2 = math.floor(remaining / 256)
|
||||||
|
local b3 = remaining % 256
|
||||||
|
out_len = out_len + 1
|
||||||
|
out[out_len] = string.char(b1)
|
||||||
|
if c3 ~= "=" then
|
||||||
|
out_len = out_len + 1
|
||||||
|
out[out_len] = string.char(b2)
|
||||||
|
end
|
||||||
|
if c4 ~= "=" then
|
||||||
|
out_len = out_len + 1
|
||||||
|
out[out_len] = string.char(b3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_launcher_payload()
|
||||||
|
local raw_payload = type(opts.aniskip_payload) == "string" and opts.aniskip_payload or ""
|
||||||
|
local trimmed = raw_payload:match("^%s*(.-)%s*$") or ""
|
||||||
|
if trimmed == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local parsed, parse_error = parse_json_payload(trimmed)
|
||||||
|
if type(parsed) == "table" then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
|
||||||
|
local url_decoded = trimmed:gsub("%%(%x%x)", function(hex)
|
||||||
|
local value = tonumber(hex, 16)
|
||||||
|
if value then
|
||||||
|
return string.char(value)
|
||||||
|
end
|
||||||
|
return "%"
|
||||||
|
end)
|
||||||
|
if url_decoded ~= trimmed then
|
||||||
|
parsed, parse_error = parse_json_payload(url_decoded)
|
||||||
|
if type(parsed) == "table" then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local b64_decoded = decode_base64(trimmed)
|
||||||
|
if type(b64_decoded) == "string" and b64_decoded ~= "" then
|
||||||
|
parsed, parse_error = parse_json_payload(b64_decoded)
|
||||||
|
if type(parsed) == "table" then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
subminer_log("warn", "aniskip", "Invalid launcher AniSkip payload: " .. tostring(parse_error or "unparseable"))
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
local function run_json_curl_async(url, callback)
|
local function run_json_curl_async(url, callback)
|
||||||
mp.command_native_async({
|
mp.command_native_async({
|
||||||
name = "subprocess",
|
name = "subprocess",
|
||||||
@@ -296,6 +405,8 @@ function M.create(ctx)
|
|||||||
state.aniskip.episode = nil
|
state.aniskip.episode = nil
|
||||||
state.aniskip.intro_start = nil
|
state.aniskip.intro_start = nil
|
||||||
state.aniskip.intro_end = nil
|
state.aniskip.intro_end = nil
|
||||||
|
state.aniskip.payload = nil
|
||||||
|
state.aniskip.payload_source = nil
|
||||||
remove_aniskip_chapters()
|
remove_aniskip_chapters()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -366,7 +477,17 @@ function M.create(ctx)
|
|||||||
state.aniskip.intro_end = intro_end
|
state.aniskip.intro_end = intro_end
|
||||||
state.aniskip.prompt_shown = false
|
state.aniskip.prompt_shown = false
|
||||||
set_intro_chapters(intro_start, intro_end)
|
set_intro_chapters(intro_start, intro_end)
|
||||||
subminer_log("info", "aniskip", string.format("Intro window %.3f -> %.3f (MAL %d, ep %d)", intro_start, intro_end, mal_id, episode))
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
"Intro window %.3f -> %.3f (MAL %s, ep %s)",
|
||||||
|
intro_start,
|
||||||
|
intro_end,
|
||||||
|
tostring(mal_id or "-"),
|
||||||
|
tostring(episode or "-")
|
||||||
|
)
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -374,6 +495,10 @@ function M.create(ctx)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function has_launcher_payload()
|
||||||
|
return type(opts.aniskip_payload) == "string" and opts.aniskip_payload:match("%S") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
local function is_launcher_context()
|
local function is_launcher_context()
|
||||||
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||||
if forced_title ~= "" then
|
if forced_title ~= "" then
|
||||||
@@ -391,6 +516,9 @@ function M.create(ctx)
|
|||||||
if forced_season and forced_season > 0 then
|
if forced_season and forced_season > 0 then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
if has_launcher_payload() then
|
||||||
|
return true
|
||||||
|
end
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -500,6 +628,18 @@ function M.create(ctx)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function fetch_payload_from_launcher(payload, mal_id, title, episode)
|
||||||
|
if not payload then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
state.aniskip.payload = payload
|
||||||
|
state.aniskip.payload_source = "launcher"
|
||||||
|
state.aniskip.mal_id = mal_id
|
||||||
|
state.aniskip.title = title
|
||||||
|
state.aniskip.episode = episode
|
||||||
|
return apply_aniskip_payload(mal_id, title, episode, payload)
|
||||||
|
end
|
||||||
|
|
||||||
local function fetch_aniskip_for_current_media(trigger_source)
|
local function fetch_aniskip_for_current_media(trigger_source)
|
||||||
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
||||||
if not opts.aniskip_enabled then
|
if not opts.aniskip_enabled then
|
||||||
@@ -518,6 +658,28 @@ function M.create(ctx)
|
|||||||
reset_aniskip_fields()
|
reset_aniskip_fields()
|
||||||
local title, episode, season = resolve_title_and_episode()
|
local title, episode, season = resolve_title_and_episode()
|
||||||
local lookup_titles = resolve_lookup_titles(title)
|
local lookup_titles = resolve_lookup_titles(title)
|
||||||
|
local launcher_payload = resolve_launcher_payload()
|
||||||
|
if launcher_payload then
|
||||||
|
local launcher_mal_id = tonumber(opts.aniskip_mal_id)
|
||||||
|
if not launcher_mal_id then
|
||||||
|
launcher_mal_id = nil
|
||||||
|
end
|
||||||
|
if fetch_payload_from_launcher(launcher_payload, launcher_mal_id, title, episode) then
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
"Using launcher-provided AniSkip payload (title=%s, season=%s, episode=%s)",
|
||||||
|
tostring(title or ""),
|
||||||
|
tostring(season or "-"),
|
||||||
|
tostring(episode or "-")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
subminer_log("info", "aniskip", "Launcher payload present but no OP interval was available")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
subminer_log(
|
subminer_log(
|
||||||
"info",
|
"info",
|
||||||
@@ -558,6 +720,8 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
state.aniskip.payload = payload
|
||||||
|
state.aniskip.payload_source = "remote"
|
||||||
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
||||||
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function M.load(options_lib, default_socket_path)
|
|||||||
aniskip_season = "",
|
aniskip_season = "",
|
||||||
aniskip_mal_id = "",
|
aniskip_mal_id = "",
|
||||||
aniskip_episode = "",
|
aniskip_episode = "",
|
||||||
|
aniskip_payload = "",
|
||||||
aniskip_show_button = true,
|
aniskip_show_button = true,
|
||||||
aniskip_button_text = "You can skip by pressing %s",
|
aniskip_button_text = "You can skip by pressing %s",
|
||||||
aniskip_button_key = "y-k",
|
aniskip_button_key = "y-k",
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ function M.new()
|
|||||||
episode = nil,
|
episode = nil,
|
||||||
intro_start = nil,
|
intro_start = nil,
|
||||||
intro_end = nil,
|
intro_end = nil,
|
||||||
|
payload = nil,
|
||||||
|
payload_source = nil,
|
||||||
found = false,
|
found = false,
|
||||||
prompt_shown = false,
|
prompt_shown = false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
||||||
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
|
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
|
||||||
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
||||||
|
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, false);
|
||||||
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||||
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
|
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -160,6 +161,44 @@ test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses subtitleStyle.autoPauseVideoOnYomitanPopup and warns on invalid values', () => {
|
||||||
|
const validDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(validDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"autoPauseVideoOnYomitanPopup": true
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const validService = new ConfigService(validDir);
|
||||||
|
assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup, true);
|
||||||
|
|
||||||
|
const invalidDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(invalidDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"autoPauseVideoOnYomitanPopup": "yes"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidService = new ConfigService(invalidDir);
|
||||||
|
assert.equal(
|
||||||
|
invalidService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup,
|
||||||
|
DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnYomitanPopup,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
invalidService
|
||||||
|
.getWarnings()
|
||||||
|
.some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnYomitanPopup'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
|
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
|
||||||
const validDir = makeTempDir();
|
const validDir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
alass_path: '',
|
alass_path: '',
|
||||||
ffsubsync_path: '',
|
ffsubsync_path: '',
|
||||||
ffmpeg_path: '',
|
ffmpeg_path: '',
|
||||||
|
replace: true,
|
||||||
},
|
},
|
||||||
startupWarmups: {
|
startupWarmups: {
|
||||||
lowPowerMode: false,
|
lowPowerMode: false,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
|||||||
enableJlpt: false,
|
enableJlpt: false,
|
||||||
preserveLineBreaks: false,
|
preserveLineBreaks: false,
|
||||||
autoPauseVideoOnHover: true,
|
autoPauseVideoOnHover: true,
|
||||||
|
autoPauseVideoOnYomitanPopup: false,
|
||||||
hoverTokenColor: '#f4dbd6',
|
hoverTokenColor: '#f4dbd6',
|
||||||
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
|
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
'logging.level',
|
'logging.level',
|
||||||
'startupWarmups.lowPowerMode',
|
'startupWarmups.lowPowerMode',
|
||||||
'subtitleStyle.enableJlpt',
|
'subtitleStyle.enableJlpt',
|
||||||
|
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
'ankiConnect.enabled',
|
'ankiConnect.enabled',
|
||||||
'immersionTracking.enabled',
|
'immersionTracking.enabled',
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.subsync.defaultMode,
|
defaultValue: defaultConfig.subsync.defaultMode,
|
||||||
description: 'Subsync default mode.',
|
description: 'Subsync default mode.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subsync.replace',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.subsync.replace,
|
||||||
|
description: 'Replace the active subtitle file when sync completes.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'startupWarmups.lowPowerMode',
|
path: 'startupWarmups.lowPowerMode',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Automatically pause mpv playback while hovering subtitle text, then resume on leave.',
|
'Automatically pause mpv playback while hovering subtitle text, then resume on leave.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.subtitleStyle.autoPauseVideoOnYomitanPopup,
|
||||||
|
description:
|
||||||
|
'Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'subtitleStyle.hoverTokenColor',
|
path: 'subtitleStyle.hoverTokenColor',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -173,6 +173,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync;
|
if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync;
|
||||||
const ffmpeg = asString(src.subsync.ffmpeg_path);
|
const ffmpeg = asString(src.subsync.ffmpeg_path);
|
||||||
if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg;
|
if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg;
|
||||||
|
const replace = asBoolean(src.subsync.replace);
|
||||||
|
if (replace !== undefined) {
|
||||||
|
resolved.subsync.replace = replace;
|
||||||
|
} else if (src.subsync.replace !== undefined) {
|
||||||
|
warn('subsync.replace', src.subsync.replace, resolved.subsync.replace, 'Expected boolean.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isObject(src.subtitlePosition)) {
|
if (isObject(src.subtitlePosition)) {
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
|
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
|
||||||
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
|
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
|
||||||
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
|
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
|
||||||
|
const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup =
|
||||||
|
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup;
|
||||||
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
||||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||||
@@ -171,6 +173,26 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoPauseVideoOnYomitanPopup = asBoolean(
|
||||||
|
(src.subtitleStyle as { autoPauseVideoOnYomitanPopup?: unknown }).autoPauseVideoOnYomitanPopup,
|
||||||
|
);
|
||||||
|
if (autoPauseVideoOnYomitanPopup !== undefined) {
|
||||||
|
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup = autoPauseVideoOnYomitanPopup;
|
||||||
|
} else if (
|
||||||
|
(src.subtitleStyle as { autoPauseVideoOnYomitanPopup?: unknown })
|
||||||
|
.autoPauseVideoOnYomitanPopup !== undefined
|
||||||
|
) {
|
||||||
|
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup =
|
||||||
|
fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup;
|
||||||
|
warn(
|
||||||
|
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
|
(src.subtitleStyle as { autoPauseVideoOnYomitanPopup?: unknown })
|
||||||
|
.autoPauseVideoOnYomitanPopup,
|
||||||
|
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const hoverTokenColor = asColor(
|
const hoverTokenColor = asColor(
|
||||||
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
|
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,6 +47,25 @@ test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', () => {
|
||||||
|
const { context, warnings } = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
autoPauseVideoOnYomitanPopup: 'invalid' as unknown as boolean,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applySubtitleDomainConfig(context);
|
||||||
|
|
||||||
|
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnYomitanPopup, false);
|
||||||
|
assert.ok(
|
||||||
|
warnings.some(
|
||||||
|
(warning) =>
|
||||||
|
warning.path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' &&
|
||||||
|
warning.message === 'Expected boolean.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
|
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
|
||||||
const valid = createResolveContext({
|
const valid = createResolveContext({
|
||||||
subtitleStyle: {
|
subtitleStyle: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BrowserWindow } from 'electron';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { WindowGeometry } from '../../types';
|
import { WindowGeometry } from '../../types';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
|
||||||
const logger = createLogger('main:overlay-window');
|
const logger = createLogger('main:overlay-window');
|
||||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||||
@@ -24,6 +25,24 @@ function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind)
|
|||||||
|
|
||||||
export type OverlayWindowKind = 'visible' | 'modal';
|
export type OverlayWindowKind = 'visible' | 'modal';
|
||||||
|
|
||||||
|
function isLookupWindowToggleInput(input: Electron.Input): boolean {
|
||||||
|
if (input.type !== 'keyDown') return false;
|
||||||
|
if (input.alt) return false;
|
||||||
|
if (!input.control && !input.meta) return false;
|
||||||
|
if (input.shift) return false;
|
||||||
|
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
|
||||||
|
return input.code === 'KeyY' || normalizedKey === 'y';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeyboardModeToggleInput(input: Electron.Input): boolean {
|
||||||
|
if (input.type !== 'keyDown') return false;
|
||||||
|
if (input.alt) return false;
|
||||||
|
if (!input.control && !input.meta) return false;
|
||||||
|
if (!input.shift) return false;
|
||||||
|
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
|
||||||
|
return input.code === 'KeyY' || normalizedKey === 'y';
|
||||||
|
}
|
||||||
|
|
||||||
export function updateOverlayWindowBounds(
|
export function updateOverlayWindowBounds(
|
||||||
geometry: WindowGeometry,
|
geometry: WindowGeometry,
|
||||||
window: BrowserWindow | null,
|
window: BrowserWindow | null,
|
||||||
@@ -118,6 +137,16 @@ export function createOverlayWindow(
|
|||||||
window.webContents.on('before-input-event', (event, input) => {
|
window.webContents.on('before-input-event', (event, input) => {
|
||||||
if (kind === 'modal') return;
|
if (kind === 'modal') return;
|
||||||
if (!window.isVisible()) return;
|
if (!window.isVisible()) return;
|
||||||
|
if (isKeyboardModeToggleInput(input)) {
|
||||||
|
event.preventDefault();
|
||||||
|
window.webContents.send(IPC_CHANNELS.event.keyboardModeToggleRequested);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isLookupWindowToggleInput(input)) {
|
||||||
|
event.preventDefault();
|
||||||
|
window.webContents.send(IPC_CHANNELS.event.lookupWindowToggleRequested);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
|
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -209,10 +209,73 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
|
|||||||
assert.ok(ffArgs.includes(primaryPath));
|
assert.ok(ffArgs.includes(primaryPath));
|
||||||
assert.ok(ffArgs.includes('--reference-stream'));
|
assert.ok(ffArgs.includes('--reference-stream'));
|
||||||
assert.ok(ffArgs.includes('0:2'));
|
assert.ok(ffArgs.includes('0:2'));
|
||||||
|
const ffOutputFlagIndex = ffArgs.indexOf('-o');
|
||||||
|
assert.equal(ffOutputFlagIndex >= 0, true);
|
||||||
|
assert.equal(ffArgs[ffOutputFlagIndex + 1], primaryPath);
|
||||||
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
||||||
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runSubsyncManual writes deterministic _retimed filename when replace is false', async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-ffsubsync-no-replace-'));
|
||||||
|
const ffsubsyncLogPath = path.join(tmpDir, 'ffsubsync-args.log');
|
||||||
|
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||||
|
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
|
||||||
|
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||||
|
const videoPath = path.join(tmpDir, 'video.mkv');
|
||||||
|
const primaryPath = path.join(tmpDir, 'episode.ja.srt');
|
||||||
|
|
||||||
|
fs.writeFileSync(videoPath, 'video');
|
||||||
|
fs.writeFileSync(primaryPath, 'sub');
|
||||||
|
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
writeExecutableScript(
|
||||||
|
ffsubsyncPath,
|
||||||
|
`#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: () => {},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return videoPath;
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return null;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'sub',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
'external-filename': primaryPath,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
defaultMode: 'manual',
|
||||||
|
alassPath,
|
||||||
|
ffsubsyncPath,
|
||||||
|
ffmpegPath,
|
||||||
|
replace: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n');
|
||||||
|
const ffOutputFlagIndex = ffArgs.indexOf('-o');
|
||||||
|
assert.equal(ffOutputFlagIndex >= 0, true);
|
||||||
|
const outputPath = ffArgs[ffOutputFlagIndex + 1];
|
||||||
|
assert.equal(outputPath, path.join(tmpDir, 'episode.ja_retimed.srt'));
|
||||||
|
});
|
||||||
|
|
||||||
test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => {
|
test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-'));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-'));
|
||||||
const alassLogPath = path.join(tmpDir, 'alass-args.log');
|
const alassLogPath = path.join(tmpDir, 'alass-args.log');
|
||||||
@@ -281,6 +344,76 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
|
|||||||
assert.equal(alassArgs[1], primaryPath);
|
assert.equal(alassArgs[1], primaryPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-'));
|
||||||
|
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||||
|
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
|
||||||
|
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||||
|
const videoPath = path.join(tmpDir, 'video.mkv');
|
||||||
|
const primaryPath = path.join(tmpDir, 'primary.srt');
|
||||||
|
|
||||||
|
fs.writeFileSync(videoPath, 'video');
|
||||||
|
fs.writeFileSync(primaryPath, 'sub');
|
||||||
|
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
writeExecutableScript(
|
||||||
|
ffmpegPath,
|
||||||
|
'#!/bin/sh\nout=""\nfor arg in "$@"; do out="$arg"; done\nif [ -n "$out" ]; then : > "$out"; fi\nexit 0\n',
|
||||||
|
);
|
||||||
|
writeExecutableScript(
|
||||||
|
alassPath,
|
||||||
|
'#!/bin/sh\nsleep 0.2\nif [ ! -f "$1" ]; then echo "missing reference subtitle" >&2; exit 1; fi\nif [ ! -f "$2" ]; then echo "missing input subtitle" >&2; exit 1; fi\n: > "$3"\nexit 0\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const sentCommands: Array<Array<string | number>> = [];
|
||||||
|
const deps = makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: (payload) => {
|
||||||
|
sentCommands.push(payload.command);
|
||||||
|
},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return videoPath;
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return null;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'sub',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
'external-filename': primaryPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'sub',
|
||||||
|
selected: false,
|
||||||
|
external: false,
|
||||||
|
'ff-index': 2,
|
||||||
|
codec: 'ass',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
defaultMode: 'manual',
|
||||||
|
alassPath,
|
||||||
|
ffsubsyncPath,
|
||||||
|
ffmpegPath,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.message, 'Subtitle synchronized with alass');
|
||||||
|
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
||||||
|
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
||||||
|
});
|
||||||
|
|
||||||
test('runSubsyncManual resolves string sid values from mpv stream properties', async () => {
|
test('runSubsyncManual resolves string sid values from mpv stream properties', async () => {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-'));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-'));
|
||||||
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||||
|
|||||||
@@ -215,10 +215,10 @@ function cleanupTemporaryFile(extraction: FileExtractionResult): void {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRetimedPath(subPath: string): string {
|
function buildRetimedPath(subPath: string, replace: boolean): string {
|
||||||
|
if (replace) return subPath;
|
||||||
const parsed = path.parse(subPath);
|
const parsed = path.parse(subPath);
|
||||||
const suffix = `_retimed_${Date.now()}`;
|
return path.join(parsed.dir, `${parsed.name}_retimed${parsed.ext || '.srt'}`);
|
||||||
return path.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext || '.srt'}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAlassSync(
|
async function runAlassSync(
|
||||||
@@ -265,7 +265,8 @@ async function subsyncToReference(
|
|||||||
context.videoPath,
|
context.videoPath,
|
||||||
context.primaryTrack,
|
context.primaryTrack,
|
||||||
);
|
);
|
||||||
const outputPath = buildRetimedPath(primaryExtraction.path);
|
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
|
||||||
|
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: CommandResult;
|
let result: CommandResult;
|
||||||
@@ -389,7 +390,7 @@ export async function runSubsyncManual(
|
|||||||
let sourceExtraction: FileExtractionResult | null = null;
|
let sourceExtraction: FileExtractionResult | null = null;
|
||||||
try {
|
try {
|
||||||
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
|
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
|
||||||
return subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
|
return await subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
|
||||||
} finally {
|
} finally {
|
||||||
if (sourceExtraction) {
|
if (sourceExtraction) {
|
||||||
cleanupTemporaryFile(sourceExtraction);
|
cleanupTemporaryFile(sourceExtraction);
|
||||||
|
|||||||
@@ -972,6 +972,34 @@ test('tokenizeSubtitle skips frequency rank when Yomitan token is enriched as pa
|
|||||||
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
|
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tokenizeSubtitle keeps frequency rank when mecab tags classify token as content-bearing', async () => {
|
||||||
|
const result = await tokenizeSubtitle(
|
||||||
|
'ふふ',
|
||||||
|
makeDepsFromYomitanTokens([{ surface: 'ふふ', reading: '', headword: 'ふふ' }], {
|
||||||
|
getFrequencyDictionaryEnabled: () => true,
|
||||||
|
getFrequencyRank: (text) => (text === 'ふふ' ? 3014 : null),
|
||||||
|
tokenizeWithMecab: async () => [
|
||||||
|
{
|
||||||
|
headword: 'ふふ',
|
||||||
|
surface: 'ふふ',
|
||||||
|
reading: 'フフ',
|
||||||
|
startPos: 0,
|
||||||
|
endPos: 2,
|
||||||
|
partOfSpeech: PartOfSpeech.verb,
|
||||||
|
pos1: '動詞',
|
||||||
|
pos2: '自立',
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: false,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.tokens?.length, 1);
|
||||||
|
assert.equal(result.tokens?.[0]?.frequencyRank, 3014);
|
||||||
|
});
|
||||||
|
|
||||||
test('tokenizeSubtitle ignores invalid frequency ranks', async () => {
|
test('tokenizeSubtitle ignores invalid frequency ranks', async () => {
|
||||||
const result = await tokenizeSubtitle(
|
const result = await tokenizeSubtitle(
|
||||||
'猫',
|
'猫',
|
||||||
@@ -2400,7 +2428,7 @@ test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and freque
|
|||||||
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
|
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tokenizeSubtitle keeps merged token when overlap contains at least one content pos1 tag', async () => {
|
test('tokenizeSubtitle excludes merged function/content token from frequency highlighting but keeps N+1', async () => {
|
||||||
const result = await tokenizeSubtitle(
|
const result = await tokenizeSubtitle(
|
||||||
'になれば',
|
'になれば',
|
||||||
makeDepsFromYomitanTokens([{ surface: 'になれば', reading: 'になれば', headword: 'なる' }], {
|
makeDepsFromYomitanTokens([{ surface: 'になれば', reading: 'になれば', headword: 'なる' }], {
|
||||||
@@ -2453,7 +2481,7 @@ test('tokenizeSubtitle keeps merged token when overlap contains at least one con
|
|||||||
|
|
||||||
assert.equal(result.tokens?.length, 1);
|
assert.equal(result.tokens?.length, 1);
|
||||||
assert.equal(result.tokens?.[0]?.pos1, '助詞|動詞');
|
assert.equal(result.tokens?.[0]?.pos1, '助詞|動詞');
|
||||||
assert.equal(result.tokens?.[0]?.frequencyRank, 13);
|
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
|
||||||
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, true);
|
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -314,6 +314,26 @@ test('annotateTokens excludes likely kana SFX tokens from frequency when POS tag
|
|||||||
assert.equal(result[0]?.frequencyRank, undefined);
|
assert.equal(result[0]?.frequencyRank, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('annotateTokens keeps frequency when mecab tags classify token as content-bearing', () => {
|
||||||
|
const tokens = [
|
||||||
|
makeToken({
|
||||||
|
surface: 'ふふ',
|
||||||
|
headword: 'ふふ',
|
||||||
|
pos1: '動詞',
|
||||||
|
pos2: '自立',
|
||||||
|
frequencyRank: 3014,
|
||||||
|
startPos: 0,
|
||||||
|
endPos: 2,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = annotateTokens(tokens, makeDeps(), {
|
||||||
|
minSentenceWordsForNPlusOne: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result[0]?.frequencyRank, 3014);
|
||||||
|
});
|
||||||
|
|
||||||
test('annotateTokens allows previously default-excluded pos2 when removed from effective set', () => {
|
test('annotateTokens allows previously default-excluded pos2 when removed from effective set', () => {
|
||||||
const tokens = [
|
const tokens = [
|
||||||
makeToken({
|
makeToken({
|
||||||
@@ -337,7 +357,7 @@ test('annotateTokens allows previously default-excluded pos2 when removed from e
|
|||||||
assert.equal(result[0]?.isNPlusOneTarget, true);
|
assert.equal(result[0]?.isNPlusOneTarget, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('annotateTokens keeps composite tokens when any component pos tag is content-bearing', () => {
|
test('annotateTokens excludes composite function/content tokens from frequency but keeps N+1 eligible', () => {
|
||||||
const tokens = [
|
const tokens = [
|
||||||
makeToken({
|
makeToken({
|
||||||
surface: 'になれば',
|
surface: 'になれば',
|
||||||
@@ -354,7 +374,7 @@ test('annotateTokens keeps composite tokens when any component pos tag is conten
|
|||||||
minSentenceWordsForNPlusOne: 1,
|
minSentenceWordsForNPlusOne: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result[0]?.frequencyRank, 5);
|
assert.equal(result[0]?.frequencyRank, undefined);
|
||||||
assert.equal(result[0]?.isNPlusOneTarget, true);
|
assert.equal(result[0]?.isNPlusOneTarget, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,9 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet<strin
|
|||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Composite tags like "助詞|名詞" stay eligible unless every component is excluded.
|
// Frequency highlighting should be conservative: if any merged component is excluded,
|
||||||
return parts.every((part) => exclusions.has(part));
|
// skip highlighting the whole token to avoid noisy merged fragments.
|
||||||
|
return parts.some((part) => exclusions.has(part));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet<string> {
|
function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet<string> {
|
||||||
|
|||||||
@@ -39,6 +39,30 @@ test('enrichTokensWithMecabPos1 fills missing pos1 using surface-sequence fallba
|
|||||||
assert.equal(enriched[0]?.pos1, '助詞');
|
assert.equal(enriched[0]?.pos1, '助詞');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('enrichTokensWithMecabPos1 keeps partOfSpeech unchanged and only enriches POS tags', () => {
|
||||||
|
const tokens = [makeToken({ surface: 'これは', startPos: 0, endPos: 3 })];
|
||||||
|
const mecabTokens = [
|
||||||
|
makeToken({
|
||||||
|
surface: 'これ',
|
||||||
|
startPos: 0,
|
||||||
|
endPos: 2,
|
||||||
|
pos1: '名詞',
|
||||||
|
partOfSpeech: PartOfSpeech.noun,
|
||||||
|
}),
|
||||||
|
makeToken({
|
||||||
|
surface: 'は',
|
||||||
|
startPos: 2,
|
||||||
|
endPos: 3,
|
||||||
|
pos1: '助詞',
|
||||||
|
partOfSpeech: PartOfSpeech.particle,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens);
|
||||||
|
assert.equal(enriched[0]?.pos1, '名詞|助詞');
|
||||||
|
assert.equal(enriched[0]?.partOfSpeech, PartOfSpeech.other);
|
||||||
|
});
|
||||||
|
|
||||||
test('enrichTokensWithMecabPos1 passes through unchanged when mecab tokens are null or empty', () => {
|
test('enrichTokensWithMecabPos1 passes through unchanged when mecab tokens are null or empty', () => {
|
||||||
const tokens = [makeToken({ surface: '猫', startPos: 0, endPos: 1 })];
|
const tokens = [makeToken({ surface: '猫', startPos: 0, endPos: 1 })];
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ test('syncYomitanDefaultAnkiServer updates default profile server when script re
|
|||||||
assert.equal(updated, true);
|
assert.equal(updated, true);
|
||||||
assert.match(scriptValue, /optionsGetFull/);
|
assert.match(scriptValue, /optionsGetFull/);
|
||||||
assert.match(scriptValue, /setAllSettings/);
|
assert.match(scriptValue, /setAllSettings/);
|
||||||
|
assert.match(scriptValue, /profileCurrent/);
|
||||||
|
assert.match(scriptValue, /forceOverride = false/);
|
||||||
assert.equal(infoLogs.length, 1);
|
assert.equal(infoLogs.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,6 +61,45 @@ test('syncYomitanDefaultAnkiServer returns true when script reports no change',
|
|||||||
assert.equal(infoLogCount, 0);
|
assert.equal(infoLogCount, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('syncYomitanDefaultAnkiServer returns false when existing non-default server blocks update', async () => {
|
||||||
|
const deps = createDeps(async () => ({
|
||||||
|
updated: false,
|
||||||
|
matched: false,
|
||||||
|
reason: 'blocked-existing-server',
|
||||||
|
}));
|
||||||
|
const infoLogs: string[] = [];
|
||||||
|
|
||||||
|
const synced = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, {
|
||||||
|
error: () => undefined,
|
||||||
|
info: (message) => infoLogs.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(synced, false);
|
||||||
|
assert.equal(infoLogs.length, 1);
|
||||||
|
assert.match(infoLogs[0] ?? '', /blocked-existing-server/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncYomitanDefaultAnkiServer injects force override when enabled', async () => {
|
||||||
|
let scriptValue = '';
|
||||||
|
const deps = createDeps(async (script) => {
|
||||||
|
scriptValue = script;
|
||||||
|
return { updated: false, matched: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
const synced = await syncYomitanDefaultAnkiServer(
|
||||||
|
'http://127.0.0.1:8766',
|
||||||
|
deps,
|
||||||
|
{
|
||||||
|
error: () => undefined,
|
||||||
|
info: () => undefined,
|
||||||
|
},
|
||||||
|
{ forceOverride: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(synced, true);
|
||||||
|
assert.match(scriptValue, /forceOverride = true/);
|
||||||
|
});
|
||||||
|
|
||||||
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
|
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
|
||||||
const deps = createDeps(async () => {
|
const deps = createDeps(async () => {
|
||||||
throw new Error('execute failed');
|
throw new Error('execute failed');
|
||||||
|
|||||||
@@ -848,11 +848,15 @@ export async function syncYomitanDefaultAnkiServer(
|
|||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
deps: YomitanParserRuntimeDeps,
|
deps: YomitanParserRuntimeDeps,
|
||||||
logger: LoggerLike,
|
logger: LoggerLike,
|
||||||
|
options?: {
|
||||||
|
forceOverride?: boolean;
|
||||||
|
},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const normalizedTargetServer = serverUrl.trim();
|
const normalizedTargetServer = serverUrl.trim();
|
||||||
if (!normalizedTargetServer) {
|
if (!normalizedTargetServer) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const forceOverride = options?.forceOverride === true;
|
||||||
|
|
||||||
const isReady = await ensureYomitanParserWindow(deps, logger);
|
const isReady = await ensureYomitanParserWindow(deps, logger);
|
||||||
const parserWindow = deps.getYomitanParserWindow();
|
const parserWindow = deps.getYomitanParserWindow();
|
||||||
@@ -882,35 +886,42 @@ export async function syncYomitanDefaultAnkiServer(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const targetServer = ${JSON.stringify(normalizedTargetServer)};
|
const targetServer = ${JSON.stringify(normalizedTargetServer)};
|
||||||
|
const forceOverride = ${forceOverride ? 'true' : 'false'};
|
||||||
const optionsFull = await invoke("optionsGetFull", undefined);
|
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||||
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||||
if (profiles.length === 0) {
|
if (profiles.length === 0) {
|
||||||
return { updated: false, reason: "no-profiles" };
|
return { updated: false, reason: "no-profiles" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultProfile = profiles[0];
|
const profileCurrent = Number.isInteger(optionsFull.profileCurrent)
|
||||||
if (!defaultProfile || typeof defaultProfile !== "object") {
|
? optionsFull.profileCurrent
|
||||||
|
: 0;
|
||||||
|
const targetProfile = profiles[profileCurrent];
|
||||||
|
if (!targetProfile || typeof targetProfile !== "object") {
|
||||||
return { updated: false, reason: "invalid-default-profile" };
|
return { updated: false, reason: "invalid-default-profile" };
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultProfile.options = defaultProfile.options && typeof defaultProfile.options === "object"
|
targetProfile.options = targetProfile.options && typeof targetProfile.options === "object"
|
||||||
? defaultProfile.options
|
? targetProfile.options
|
||||||
: {};
|
: {};
|
||||||
defaultProfile.options.anki = defaultProfile.options.anki && typeof defaultProfile.options.anki === "object"
|
targetProfile.options.anki = targetProfile.options.anki && typeof targetProfile.options.anki === "object"
|
||||||
? defaultProfile.options.anki
|
? targetProfile.options.anki
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const currentServerRaw = defaultProfile.options.anki.server;
|
const currentServerRaw = targetProfile.options.anki.server;
|
||||||
const currentServer = typeof currentServerRaw === "string" ? currentServerRaw.trim() : "";
|
const currentServer = typeof currentServerRaw === "string" ? currentServerRaw.trim() : "";
|
||||||
const canReplaceDefault =
|
if (currentServer === targetServer) {
|
||||||
currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
|
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer };
|
||||||
if (!canReplaceDefault || currentServer === targetServer) {
|
}
|
||||||
return { updated: false, reason: "no-change", currentServer, targetServer };
|
const canReplaceCurrent =
|
||||||
|
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
|
||||||
|
if (!canReplaceCurrent) {
|
||||||
|
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultProfile.options.anki.server = targetServer;
|
targetProfile.options.anki.server = targetServer;
|
||||||
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
|
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
|
||||||
return { updated: true, currentServer, targetServer };
|
return { updated: true, matched: true, currentServer, targetServer };
|
||||||
})();
|
})();
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -924,6 +935,24 @@ export async function syncYomitanDefaultAnkiServer(
|
|||||||
logger.info?.(`Updated Yomitan default profile Anki server to ${normalizedTargetServer}`);
|
logger.info?.(`Updated Yomitan default profile Anki server to ${normalizedTargetServer}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
const matchedWithoutUpdate =
|
||||||
|
isObject(result) &&
|
||||||
|
result.updated === false &&
|
||||||
|
(result as { matched?: unknown }).matched === true;
|
||||||
|
if (matchedWithoutUpdate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const blockedByExistingServer =
|
||||||
|
isObject(result) &&
|
||||||
|
result.updated === false &&
|
||||||
|
(result as { matched?: unknown }).matched === false &&
|
||||||
|
typeof (result as { reason?: unknown }).reason === 'string';
|
||||||
|
if (blockedByExistingServer) {
|
||||||
|
logger.info?.(
|
||||||
|
`Skipped syncing Yomitan Anki server (reason=${String((result as { reason: string }).reason)})`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const checkedWithoutUpdate =
|
const checkedWithoutUpdate =
|
||||||
typeof result === 'object' &&
|
typeof result === 'object' &&
|
||||||
result !== null &&
|
result !== null &&
|
||||||
|
|||||||
53
src/core/services/yomitan-extension-copy.ts
Normal file
53
src/core/services/yomitan-extension-copy.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const YOMITAN_SYNC_SCRIPT_PATHS = [
|
||||||
|
path.join('js', 'app', 'popup.js'),
|
||||||
|
path.join('js', 'display', 'popup-main.js'),
|
||||||
|
path.join('js', 'display', 'display.js'),
|
||||||
|
path.join('js', 'display', 'display-audio.js'),
|
||||||
|
];
|
||||||
|
|
||||||
|
function readManifestVersion(manifestPath: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { version?: unknown };
|
||||||
|
return typeof parsed.version === 'string' ? parsed.version : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function areFilesEqual(sourcePath: string, targetPath: string): boolean {
|
||||||
|
if (!fs.existsSync(sourcePath) || !fs.existsSync(targetPath)) return false;
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(sourcePath).equals(fs.readFileSync(targetPath));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string): boolean {
|
||||||
|
if (!fs.existsSync(targetDir)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceManifest = path.join(sourceDir, 'manifest.json');
|
||||||
|
const targetManifest = path.join(targetDir, 'manifest.json');
|
||||||
|
if (!fs.existsSync(sourceManifest) || !fs.existsSync(targetManifest)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceVersion = readManifestVersion(sourceManifest);
|
||||||
|
const targetVersion = readManifestVersion(targetManifest);
|
||||||
|
if (sourceVersion === null || targetVersion === null || sourceVersion !== targetVersion) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const relativePath of YOMITAN_SYNC_SCRIPT_PATHS) {
|
||||||
|
if (!areFilesEqual(path.join(sourceDir, relativePath), path.join(targetDir, relativePath))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
52
src/core/services/yomitan-extension-loader.test.ts
Normal file
52
src/core/services/yomitan-extension-loader.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
|
||||||
|
|
||||||
|
function writeFile(filePath: string, content: string): void {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, content, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
|
||||||
|
const sourceDir = path.join(tempRoot, 'source');
|
||||||
|
const targetDir = path.join(tempRoot, 'target');
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
|
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||||
|
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'source-popup-main');
|
||||||
|
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'target-popup-main');
|
||||||
|
|
||||||
|
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldCopyYomitanExtension skips copy when versions and watched scripts match', () => {
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
|
||||||
|
const sourceDir = path.join(tempRoot, 'source');
|
||||||
|
const targetDir = path.join(tempRoot, 'target');
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
|
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||||
|
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
|
||||||
|
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'js', 'display', 'display.js'), 'same-display');
|
||||||
|
writeFile(path.join(targetDir, 'js', 'display', 'display.js'), 'same-display');
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
|
||||||
|
writeFile(path.join(targetDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
|
||||||
|
|
||||||
|
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), false);
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { BrowserWindow, Extension, session } from 'electron';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
|
||||||
|
|
||||||
const logger = createLogger('main:yomitan-extension-loader');
|
const logger = createLogger('main:yomitan-extension-loader');
|
||||||
|
|
||||||
@@ -22,27 +23,7 @@ function ensureExtensionCopy(sourceDir: string, userDataPath: string): string {
|
|||||||
const extensionsRoot = path.join(userDataPath, 'extensions');
|
const extensionsRoot = path.join(userDataPath, 'extensions');
|
||||||
const targetDir = path.join(extensionsRoot, 'yomitan');
|
const targetDir = path.join(extensionsRoot, 'yomitan');
|
||||||
|
|
||||||
const sourceManifest = path.join(sourceDir, 'manifest.json');
|
const shouldCopy = shouldCopyYomitanExtension(sourceDir, targetDir);
|
||||||
const targetManifest = path.join(targetDir, 'manifest.json');
|
|
||||||
|
|
||||||
let shouldCopy = !fs.existsSync(targetDir);
|
|
||||||
if (!shouldCopy && fs.existsSync(sourceManifest) && fs.existsSync(targetManifest)) {
|
|
||||||
try {
|
|
||||||
const sourceVersion = (
|
|
||||||
JSON.parse(fs.readFileSync(sourceManifest, 'utf-8')) as {
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
).version;
|
|
||||||
const targetVersion = (
|
|
||||||
JSON.parse(fs.readFileSync(targetManifest, 'utf-8')) as {
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
).version;
|
|
||||||
shouldCopy = sourceVersion !== targetVersion;
|
|
||||||
} catch {
|
|
||||||
shouldCopy = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldCopy) {
|
if (shouldCopy) {
|
||||||
fs.mkdirSync(extensionsRoot, { recursive: true });
|
fs.mkdirSync(extensionsRoot, { recursive: true });
|
||||||
|
|||||||
@@ -2663,6 +2663,9 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
|||||||
logger.info(message, ...args);
|
logger.info(message, ...args);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
forceOverride: getResolvedConfig().ankiConnect.proxy?.enabled === true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (synced) {
|
if (synced) {
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ function createQueuedIpcListenerWithPayload<T>(
|
|||||||
|
|
||||||
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
||||||
const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
|
const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
|
||||||
|
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
|
||||||
|
IPC_CHANNELS.event.keyboardModeToggleRequested,
|
||||||
|
);
|
||||||
|
const onLookupWindowToggleRequestedEvent = createQueuedIpcListener(
|
||||||
|
IPC_CHANNELS.event.lookupWindowToggleRequested,
|
||||||
|
);
|
||||||
const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManualPayload>(
|
const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManualPayload>(
|
||||||
IPC_CHANNELS.event.subsyncOpenManual,
|
IPC_CHANNELS.event.subsyncOpenManual,
|
||||||
(payload) => payload as SubsyncManualPayload,
|
(payload) => payload as SubsyncManualPayload,
|
||||||
@@ -282,6 +288,8 @@ const electronAPI: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
||||||
onOpenJimaku: onOpenJimakuEvent,
|
onOpenJimaku: onOpenJimakuEvent,
|
||||||
|
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
||||||
|
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
||||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
YOMITAN_POPUP_IFRAME_SELECTOR,
|
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||||
hasYomitanPopupIframe,
|
hasYomitanPopupIframe,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
|
isYomitanPopupVisible,
|
||||||
} from './yomitan-popup.js';
|
} from './yomitan-popup.js';
|
||||||
import { scrollActiveRuntimeOptionIntoView } from './modals/runtime-options.js';
|
import { scrollActiveRuntimeOptionIntoView } from './modals/runtime-options.js';
|
||||||
import { resolvePlatformInfo } from './utils/platform.js';
|
import { resolvePlatformInfo } from './utils/platform.js';
|
||||||
@@ -283,6 +284,43 @@ test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
|
|||||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
let selector = '';
|
||||||
|
const visibleFrame = {
|
||||||
|
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
||||||
|
} as unknown as HTMLIFrameElement;
|
||||||
|
const hiddenFrame = {
|
||||||
|
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
||||||
|
} as unknown as HTMLIFrameElement;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getComputedStyle: (element: Element) => {
|
||||||
|
if (element === hiddenFrame) {
|
||||||
|
return { visibility: 'hidden', display: 'block', opacity: '1' } as CSSStyleDeclaration;
|
||||||
|
}
|
||||||
|
return { visibility: 'visible', display: 'block', opacity: '1' } as CSSStyleDeclaration;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const root = {
|
||||||
|
querySelectorAll: (value: string) => {
|
||||||
|
selector = value;
|
||||||
|
return [hiddenFrame, visibleFrame];
|
||||||
|
},
|
||||||
|
} as unknown as ParentNode;
|
||||||
|
|
||||||
|
assert.equal(isYomitanPopupVisible(root), true);
|
||||||
|
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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 = {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { Keybinding } from '../../types';
|
import type { Keybinding } from '../../types';
|
||||||
import type { RendererContext } from '../context';
|
import type { RendererContext } from '../context';
|
||||||
import { hasYomitanPopupIframe, isYomitanPopupIframe } from '../yomitan-popup.js';
|
import {
|
||||||
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
|
YOMITAN_POPUP_COMMAND_EVENT,
|
||||||
|
isYomitanPopupVisible,
|
||||||
|
isYomitanPopupIframe,
|
||||||
|
} from '../yomitan-popup.js';
|
||||||
|
|
||||||
export function createKeyboardHandlers(
|
export function createKeyboardHandlers(
|
||||||
ctx: RendererContext,
|
ctx: RendererContext,
|
||||||
@@ -20,6 +25,7 @@ export function createKeyboardHandlers(
|
|||||||
) {
|
) {
|
||||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||||
const CHORD_TIMEOUT_MS = 1000;
|
const CHORD_TIMEOUT_MS = 1000;
|
||||||
|
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
||||||
|
|
||||||
const CHORD_MAP = new Map<
|
const CHORD_MAP = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -55,6 +61,293 @@ export function createKeyboardHandlers(
|
|||||||
return parts.join('+');
|
return parts.join('+');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchYomitanPopupKeydown(
|
||||||
|
key: string,
|
||||||
|
code: string,
|
||||||
|
modifiers: Array<'alt' | 'ctrl' | 'shift' | 'meta'>,
|
||||||
|
repeat: boolean,
|
||||||
|
) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
|
detail: {
|
||||||
|
type: 'forwardKeyDown',
|
||||||
|
key,
|
||||||
|
code,
|
||||||
|
modifiers,
|
||||||
|
repeat,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchYomitanPopupVisibility(visible: boolean) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
|
detail: {
|
||||||
|
type: 'setVisible',
|
||||||
|
visible,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchYomitanPopupMineSelected() {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
|
detail: {
|
||||||
|
type: 'mineSelected',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchYomitanFrontendScanSelectedText() {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
|
detail: {
|
||||||
|
type: 'scanSelectedText',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
|
||||||
|
return e.ctrlKey || e.metaKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeyboardDrivenModeToggle(e: KeyboardEvent): boolean {
|
||||||
|
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
|
||||||
|
return isPrimaryModifierPressed(e) && !e.altKey && e.shiftKey && isYKey && !e.repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLookupWindowToggle(e: KeyboardEvent): boolean {
|
||||||
|
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
|
||||||
|
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubtitleWordNodes(): HTMLElement[] {
|
||||||
|
return Array.from(ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncKeyboardTokenSelection(): void {
|
||||||
|
const wordNodes = getSubtitleWordNodes();
|
||||||
|
for (const wordNode of wordNodes) {
|
||||||
|
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
|
||||||
|
ctx.state.keyboardSelectedWordIndex = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIndex = Math.min(
|
||||||
|
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||||
|
wordNodes.length - 1,
|
||||||
|
);
|
||||||
|
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
||||||
|
const selectedWordNode = wordNodes[selectedIndex];
|
||||||
|
if (selectedWordNode) {
|
||||||
|
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
ctx.state.keyboardSelectedWordIndex = null;
|
||||||
|
}
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleKeyboardDrivenMode(): void {
|
||||||
|
setKeyboardDrivenModeEnabled(!ctx.state.keyboardDrivenModeEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveKeyboardSelection(delta: -1 | 1): boolean {
|
||||||
|
const wordNodes = getSubtitleWordNodes();
|
||||||
|
if (wordNodes.length === 0) {
|
||||||
|
ctx.state.keyboardSelectedWordIndex = null;
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = ctx.state.keyboardSelectedWordIndex ?? 0;
|
||||||
|
const nextIndex = Math.min(Math.max(currentIndex + delta, 0), wordNodes.length - 1);
|
||||||
|
ctx.state.keyboardSelectedWordIndex = nextIndex;
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScanModifierState = {
|
||||||
|
shiftKey?: boolean;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
altKey?: boolean;
|
||||||
|
metaKey?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function emitSyntheticScanEvents(
|
||||||
|
target: Element,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
modifiers: ScanModifierState = {},
|
||||||
|
): void {
|
||||||
|
if (typeof PointerEvent !== 'undefined') {
|
||||||
|
const pointerEventInit = {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
pointerType: 'mouse',
|
||||||
|
isPrimary: true,
|
||||||
|
button: 0,
|
||||||
|
buttons: 0,
|
||||||
|
shiftKey: modifiers.shiftKey ?? false,
|
||||||
|
ctrlKey: modifiers.ctrlKey ?? false,
|
||||||
|
altKey: modifiers.altKey ?? false,
|
||||||
|
metaKey: modifiers.metaKey ?? false,
|
||||||
|
} satisfies PointerEventInit;
|
||||||
|
|
||||||
|
target.dispatchEvent(new PointerEvent('pointerover', pointerEventInit));
|
||||||
|
target.dispatchEvent(new PointerEvent('pointermove', pointerEventInit));
|
||||||
|
target.dispatchEvent(new PointerEvent('pointerdown', { ...pointerEventInit, buttons: 1 }));
|
||||||
|
target.dispatchEvent(new PointerEvent('pointerup', pointerEventInit));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mouseEventInit = {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
button: 0,
|
||||||
|
buttons: 0,
|
||||||
|
shiftKey: modifiers.shiftKey ?? false,
|
||||||
|
ctrlKey: modifiers.ctrlKey ?? false,
|
||||||
|
altKey: modifiers.altKey ?? false,
|
||||||
|
metaKey: modifiers.metaKey ?? false,
|
||||||
|
} satisfies MouseEventInit;
|
||||||
|
target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit));
|
||||||
|
target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 }));
|
||||||
|
target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit));
|
||||||
|
target.dispatchEvent(new MouseEvent('click', mouseEventInit));
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitLookupScanFallback(target: Element, clientX: number, clientY: number): void {
|
||||||
|
emitSyntheticScanEvents(target, clientX, clientY, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWordNodeText(wordNode: HTMLElement): void {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(wordNode);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
ctx.dom.subtitleRoot.classList.add('has-selection');
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerLookupForSelectedWord(): boolean {
|
||||||
|
const wordNodes = getSubtitleWordNodes();
|
||||||
|
if (wordNodes.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIndex = Math.min(
|
||||||
|
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||||
|
wordNodes.length - 1,
|
||||||
|
);
|
||||||
|
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
||||||
|
const selectedWordNode = wordNodes[selectedIndex];
|
||||||
|
if (!selectedWordNode) return false;
|
||||||
|
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
|
selectWordNodeText(selectedWordNode);
|
||||||
|
|
||||||
|
const rect = selectedWordNode.getBoundingClientRect();
|
||||||
|
const clientX = rect.left + rect.width / 2;
|
||||||
|
const clientY = rect.top + rect.height / 2;
|
||||||
|
|
||||||
|
dispatchYomitanFrontendScanSelectedText();
|
||||||
|
// Fallback only if the explicit scan path did not open popup quickly.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Dispatch directly on the selected token span; when overlay pointer-events are disabled,
|
||||||
|
// elementFromPoint may resolve to the underlying video surface instead.
|
||||||
|
emitLookupScanFallback(selectedWordNode, clientX, clientY);
|
||||||
|
}, 60);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyboardModeToggleRequested(): void {
|
||||||
|
toggleKeyboardDrivenMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLookupWindowToggleRequested(): void {
|
||||||
|
if (ctx.state.yomitanPopupVisible) {
|
||||||
|
dispatchYomitanPopupVisibility(false);
|
||||||
|
if (ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
restoreOverlayKeyboardFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerLookupForSelectedWord();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreOverlayKeyboardFocus(): void {
|
||||||
|
void window.electronAPI.focusMainWindow();
|
||||||
|
window.focus();
|
||||||
|
ctx.dom.overlay.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyboardDrivenModeNavigation(e: KeyboardEvent): boolean {
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = e.code;
|
||||||
|
if (key === 'ArrowLeft' || key === 'ArrowUp' || key === 'KeyH' || key === 'KeyK') {
|
||||||
|
return moveKeyboardSelection(-1);
|
||||||
|
}
|
||||||
|
if (key === 'ArrowRight' || key === 'ArrowDown' || key === 'KeyL' || key === 'KeyJ') {
|
||||||
|
return moveKeyboardSelection(1);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleYomitanPopupKeybind(e: KeyboardEvent): boolean {
|
||||||
|
if (e.repeat) return false;
|
||||||
|
const modifierOnlyCodes = new Set([
|
||||||
|
'ShiftLeft',
|
||||||
|
'ShiftRight',
|
||||||
|
'ControlLeft',
|
||||||
|
'ControlRight',
|
||||||
|
'AltLeft',
|
||||||
|
'AltRight',
|
||||||
|
'MetaLeft',
|
||||||
|
'MetaRight',
|
||||||
|
]);
|
||||||
|
if (modifierOnlyCodes.has(e.code)) return false;
|
||||||
|
|
||||||
|
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.code === 'KeyM') {
|
||||||
|
dispatchYomitanPopupMineSelected();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiers: Array<'alt' | 'ctrl' | 'shift' | 'meta'> = [];
|
||||||
|
if (e.altKey) modifiers.push('alt');
|
||||||
|
if (e.ctrlKey) modifiers.push('ctrl');
|
||||||
|
if (e.shiftKey) modifiers.push('shift');
|
||||||
|
if (e.metaKey) modifiers.push('meta');
|
||||||
|
|
||||||
|
dispatchYomitanPopupKeydown(e.key, e.code, modifiers, e.repeat);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSessionHelpChordBinding(): {
|
function resolveSessionHelpChordBinding(): {
|
||||||
bindingKey: 'KeyH' | 'KeyK';
|
bindingKey: 'KeyH' | 'KeyK';
|
||||||
fallbackUsed: boolean;
|
fallbackUsed: boolean;
|
||||||
@@ -106,9 +399,42 @@ export function createKeyboardHandlers(
|
|||||||
|
|
||||||
async function setupMpvInputForwarding(): Promise<void> {
|
async function setupMpvInputForwarding(): Promise<void> {
|
||||||
updateKeybindings(await window.electronAPI.getKeybindings());
|
updateKeybindings(await window.electronAPI.getKeybindings());
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
|
|
||||||
|
const subtitleMutationObserver = new MutationObserver(() => {
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
|
});
|
||||||
|
subtitleMutationObserver.observe(ctx.dom.subtitleRoot, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreOverlayKeyboardFocus();
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
if (hasYomitanPopupIframe(document)) return;
|
if (isKeyboardDrivenModeToggle(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleKeyboardModeToggleRequested();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLookupWindowToggle(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleLookupWindowToggleRequested();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||||
|
if (handleYomitanPopupKeybind(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ctx.state.runtimeOptionsModalOpen) {
|
if (ctx.state.runtimeOptionsModalOpen) {
|
||||||
options.handleRuntimeOptionsKeydown(e);
|
options.handleRuntimeOptionsKeydown(e);
|
||||||
@@ -131,6 +457,11 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ctx.state.chordPending) {
|
if (ctx.state.chordPending) {
|
||||||
const modifierKeys = [
|
const modifierKeys = [
|
||||||
'ShiftLeft',
|
'ShiftLeft',
|
||||||
@@ -211,5 +542,8 @@ export function createKeyboardHandlers(
|
|||||||
return {
|
return {
|
||||||
setupMpvInputForwarding,
|
setupMpvInputForwarding,
|
||||||
updateKeybindings,
|
updateKeybindings,
|
||||||
|
syncKeyboardTokenSelection,
|
||||||
|
handleKeyboardModeToggleRequested,
|
||||||
|
handleLookupWindowToggleRequested,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { createMouseHandlers } from './mouse.js';
|
import { createMouseHandlers } from './mouse.js';
|
||||||
|
import {
|
||||||
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
|
} from '../yomitan-popup.js';
|
||||||
|
|
||||||
function createClassList() {
|
function createClassList() {
|
||||||
const classes = new Set<string>();
|
const classes = new Set<string>();
|
||||||
@@ -28,6 +32,12 @@ function createDeferred<T>() {
|
|||||||
return { promise, resolve };
|
return { promise, resolve };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitForNextTick(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createMouseTestContext() {
|
function createMouseTestContext() {
|
||||||
const overlayClassList = createClassList();
|
const overlayClassList = createClassList();
|
||||||
const subtitleRootClassList = createClassList();
|
const subtitleRootClassList = createClassList();
|
||||||
@@ -78,6 +88,7 @@ test('auto-pause on subtitle hover pauses on enter and resumes on leave when ena
|
|||||||
getCurrentYPercent: () => 10,
|
getCurrentYPercent: () => 10,
|
||||||
persistSubtitlePositionPatch: () => {},
|
persistSubtitlePositionPatch: () => {},
|
||||||
getSubtitleHoverAutoPauseEnabled: () => true,
|
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
getPlaybackPaused: async () => false,
|
getPlaybackPaused: async () => false,
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
mpvCommands.push(command);
|
mpvCommands.push(command);
|
||||||
@@ -106,6 +117,7 @@ test('auto-pause on subtitle hover skips when playback is already paused', async
|
|||||||
getCurrentYPercent: () => 10,
|
getCurrentYPercent: () => 10,
|
||||||
persistSubtitlePositionPatch: () => {},
|
persistSubtitlePositionPatch: () => {},
|
||||||
getSubtitleHoverAutoPauseEnabled: () => true,
|
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
getPlaybackPaused: async () => true,
|
getPlaybackPaused: async () => true,
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
mpvCommands.push(command);
|
mpvCommands.push(command);
|
||||||
@@ -131,6 +143,7 @@ test('auto-pause on subtitle hover is skipped when disabled in config', async ()
|
|||||||
getCurrentYPercent: () => 10,
|
getCurrentYPercent: () => 10,
|
||||||
persistSubtitlePositionPatch: () => {},
|
persistSubtitlePositionPatch: () => {},
|
||||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
getPlaybackPaused: async () => false,
|
getPlaybackPaused: async () => false,
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
mpvCommands.push(command);
|
mpvCommands.push(command);
|
||||||
@@ -157,6 +170,7 @@ test('pending hover pause check is ignored when mouse leaves before pause state
|
|||||||
getCurrentYPercent: () => 10,
|
getCurrentYPercent: () => 10,
|
||||||
persistSubtitlePositionPatch: () => {},
|
persistSubtitlePositionPatch: () => {},
|
||||||
getSubtitleHoverAutoPauseEnabled: () => true,
|
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
getPlaybackPaused: async () => deferred.promise,
|
getPlaybackPaused: async () => deferred.promise,
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
mpvCommands.push(command);
|
mpvCommands.push(command);
|
||||||
@@ -170,3 +184,273 @@ test('pending hover pause check is ignored when mouse leaves before pause state
|
|||||||
|
|
||||||
assert.deepEqual(mpvCommands, []);
|
assert.deepEqual(mpvCommands, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('hover pause resumes immediately on subtitle leave even when yomitan popup is visible', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
|
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>>();
|
||||||
|
|
||||||
|
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: () => {},
|
||||||
|
},
|
||||||
|
focus: () => {},
|
||||||
|
innerHeight: 1000,
|
||||||
|
getSelection: () => null,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
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: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupYomitanObserver();
|
||||||
|
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
await handlers.handleMouseEnter();
|
||||||
|
await handlers.handleMouseLeave();
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
} 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('auto-pause still works when yomitan popup is already visible', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
|
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>>();
|
||||||
|
|
||||||
|
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: () => {},
|
||||||
|
},
|
||||||
|
focus: () => {},
|
||||||
|
innerHeight: 1000,
|
||||||
|
getSelection: () => null,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
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: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupYomitanObserver();
|
||||||
|
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
await handlers.handleMouseEnter();
|
||||||
|
await handlers.handleMouseLeave();
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
} 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('popup open pauses and popup close resumes when yomitan popup auto-pause is enabled', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
|
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>>();
|
||||||
|
|
||||||
|
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: () => {},
|
||||||
|
},
|
||||||
|
focus: () => {},
|
||||||
|
innerHeight: 1000,
|
||||||
|
getSelection: () => null,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
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: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => true,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: (command: (string | number)[]) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupYomitanObserver();
|
||||||
|
|
||||||
|
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
await waitForNextTick();
|
||||||
|
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
} 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ModalStateReader, RendererContext } from '../context';
|
|||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
hasYomitanPopupIframe,
|
isYomitanPopupVisible,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
} from '../yomitan-popup.js';
|
} from '../yomitan-popup.js';
|
||||||
|
|
||||||
@@ -14,16 +14,72 @@ export function createMouseHandlers(
|
|||||||
getCurrentYPercent: () => number;
|
getCurrentYPercent: () => number;
|
||||||
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
|
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
|
||||||
getSubtitleHoverAutoPauseEnabled: () => boolean;
|
getSubtitleHoverAutoPauseEnabled: () => boolean;
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => boolean;
|
||||||
getPlaybackPaused: () => Promise<boolean | null>;
|
getPlaybackPaused: () => Promise<boolean | null>;
|
||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
sendMpvCommand: (command: (string | number)[]) => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
let yomitanPopupVisible = false;
|
let yomitanPopupVisible = false;
|
||||||
let hoverPauseRequestId = 0;
|
let hoverPauseRequestId = 0;
|
||||||
|
let popupPauseRequestId = 0;
|
||||||
let pausedBySubtitleHover = false;
|
let pausedBySubtitleHover = false;
|
||||||
|
let pausedByYomitanPopup = false;
|
||||||
|
|
||||||
|
function maybeResumeHoverPause(): void {
|
||||||
|
if (!pausedBySubtitleHover) return;
|
||||||
|
if (pausedByYomitanPopup) return;
|
||||||
|
if (ctx.state.isOverSubtitle) return;
|
||||||
|
pausedBySubtitleHover = false;
|
||||||
|
options.sendMpvCommand(['set_property', 'pause', 'no']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeResumeYomitanPopupPause(): void {
|
||||||
|
if (!pausedByYomitanPopup) return;
|
||||||
|
pausedByYomitanPopup = false;
|
||||||
|
if (ctx.state.isOverSubtitle && options.getSubtitleHoverAutoPauseEnabled()) {
|
||||||
|
pausedBySubtitleHover = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.sendMpvCommand(['set_property', 'pause', 'no']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybePauseForYomitanPopup(): Promise<void> {
|
||||||
|
if (!yomitanPopupVisible || !options.getYomitanPopupAutoPauseEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++popupPauseRequestId;
|
||||||
|
if (pausedByYomitanPopup) return;
|
||||||
|
|
||||||
|
if (pausedBySubtitleHover) {
|
||||||
|
pausedBySubtitleHover = false;
|
||||||
|
pausedByYomitanPopup = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let paused: boolean | null = null;
|
||||||
|
try {
|
||||||
|
paused = await options.getPlaybackPaused();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
requestId !== popupPauseRequestId ||
|
||||||
|
!yomitanPopupVisible ||
|
||||||
|
!options.getYomitanPopupAutoPauseEnabled()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (paused !== false) return;
|
||||||
|
|
||||||
|
options.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||||
|
pausedByYomitanPopup = true;
|
||||||
|
}
|
||||||
|
|
||||||
function enablePopupInteraction(): void {
|
function enablePopupInteraction(): void {
|
||||||
yomitanPopupVisible = true;
|
yomitanPopupVisible = true;
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
ctx.dom.overlay.classList.add('interactive');
|
ctx.dom.overlay.classList.add('interactive');
|
||||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||||
window.electronAPI.setIgnoreMouseEvents(false);
|
window.electronAPI.setIgnoreMouseEvents(false);
|
||||||
@@ -34,12 +90,17 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function disablePopupInteractionIfIdle(): void {
|
function disablePopupInteractionIfIdle(): void {
|
||||||
if (typeof document !== 'undefined' && hasYomitanPopupIframe(document)) {
|
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
|
||||||
yomitanPopupVisible = true;
|
yomitanPopupVisible = true;
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
yomitanPopupVisible = false;
|
yomitanPopupVisible = false;
|
||||||
|
ctx.state.yomitanPopupVisible = false;
|
||||||
|
popupPauseRequestId += 1;
|
||||||
|
maybeResumeYomitanPopupPause();
|
||||||
|
maybeResumeHoverPause();
|
||||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||||
ctx.dom.overlay.classList.remove('interactive');
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||||
@@ -55,6 +116,10 @@ export function createMouseHandlers(
|
|||||||
window.electronAPI.setIgnoreMouseEvents(false);
|
window.electronAPI.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (yomitanPopupVisible && options.getYomitanPopupAutoPauseEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.getSubtitleHoverAutoPauseEnabled()) {
|
if (!options.getSubtitleHoverAutoPauseEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -79,10 +144,7 @@ export function createMouseHandlers(
|
|||||||
async function handleMouseLeave(): Promise<void> {
|
async function handleMouseLeave(): Promise<void> {
|
||||||
ctx.state.isOverSubtitle = false;
|
ctx.state.isOverSubtitle = false;
|
||||||
hoverPauseRequestId += 1;
|
hoverPauseRequestId += 1;
|
||||||
if (pausedBySubtitleHover) {
|
maybeResumeHoverPause();
|
||||||
pausedBySubtitleHover = false;
|
|
||||||
options.sendMpvCommand(['set_property', 'pause', 'no']);
|
|
||||||
}
|
|
||||||
if (yomitanPopupVisible) return;
|
if (yomitanPopupVisible) return;
|
||||||
disablePopupInteractionIfIdle();
|
disablePopupInteractionIfIdle();
|
||||||
}
|
}
|
||||||
@@ -143,10 +205,13 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupYomitanObserver(): void {
|
function setupYomitanObserver(): void {
|
||||||
yomitanPopupVisible = hasYomitanPopupIframe(document);
|
yomitanPopupVisible = isYomitanPopupVisible(document);
|
||||||
|
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
|
||||||
|
void maybePauseForYomitanPopup();
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||||
enablePopupInteraction();
|
enablePopupInteraction();
|
||||||
|
void maybePauseForYomitanPopup();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
@@ -160,6 +225,7 @@ export function createMouseHandlers(
|
|||||||
const element = node as Element;
|
const element = node as Element;
|
||||||
if (isYomitanPopupIframe(element)) {
|
if (isYomitanPopupIframe(element)) {
|
||||||
enablePopupInteraction();
|
enablePopupInteraction();
|
||||||
|
void maybePauseForYomitanPopup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ const mouseHandlers = createMouseHandlers(ctx, {
|
|||||||
getCurrentYPercent: positioning.getCurrentYPercent,
|
getCurrentYPercent: positioning.getCurrentYPercent,
|
||||||
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
|
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
|
||||||
getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover,
|
getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => ctx.state.autoPauseVideoOnYomitanPopup,
|
||||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
window.electronAPI.sendMpvCommand(command);
|
window.electronAPI.sendMpvCommand(command);
|
||||||
@@ -139,6 +140,16 @@ function truncateForErrorLog(text: string): string {
|
|||||||
return `${normalized.slice(0, 177)}...`;
|
return `${normalized.slice(0, 177)}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSubtitleTextForPreview(data: SubtitleData | string): string {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (data && typeof data.text === 'string') {
|
||||||
|
return data.text;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function getActiveModal(): string | null {
|
function getActiveModal(): string | null {
|
||||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
||||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||||
@@ -244,6 +255,20 @@ function registerModalOpenHandlers(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerKeyboardCommandHandlers(): void {
|
||||||
|
window.electronAPI.onKeyboardModeToggleRequested(() => {
|
||||||
|
runGuarded('keyboard-mode-toggle:requested', () => {
|
||||||
|
keyboardHandlers.handleKeyboardModeToggleRequested();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electronAPI.onLookupWindowToggleRequested(() => {
|
||||||
|
runGuarded('lookup-window-toggle:requested', () => {
|
||||||
|
keyboardHandlers.handleLookupWindowToggleRequested();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function runGuarded(action: string, fn: () => void): void {
|
function runGuarded(action: string, fn: () => void): void {
|
||||||
try {
|
try {
|
||||||
fn();
|
fn();
|
||||||
@@ -261,6 +286,7 @@ function runGuardedAsync(action: string, fn: () => Promise<void> | void): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerModalOpenHandlers();
|
registerModalOpenHandlers();
|
||||||
|
registerKeyboardCommandHandlers();
|
||||||
|
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<void> {
|
||||||
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
|
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
|
||||||
@@ -270,11 +296,7 @@ async function init(): Promise<void> {
|
|||||||
|
|
||||||
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||||
runGuarded('subtitle:update', () => {
|
runGuarded('subtitle:update', () => {
|
||||||
if (typeof data === 'string') {
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
||||||
lastSubtitlePreview = truncateForErrorLog(data);
|
|
||||||
} else if (data && typeof data.text === 'string') {
|
|
||||||
lastSubtitlePreview = truncateForErrorLog(data.text);
|
|
||||||
}
|
|
||||||
subtitleRenderer.renderSubtitle(data);
|
subtitleRenderer.renderSubtitle(data);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
});
|
});
|
||||||
@@ -287,8 +309,13 @@ async function init(): Promise<void> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
let initialSubtitle: SubtitleData | string = '';
|
||||||
lastSubtitlePreview = truncateForErrorLog(initialSubtitle);
|
try {
|
||||||
|
initialSubtitle = await window.electronAPI.getCurrentSubtitle();
|
||||||
|
} catch {
|
||||||
|
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
||||||
|
}
|
||||||
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
|
||||||
subtitleRenderer.renderSubtitle(initialSubtitle);
|
subtitleRenderer.renderSubtitle(initialSubtitle);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export type RendererState = {
|
|||||||
jlptN5Color: string;
|
jlptN5Color: string;
|
||||||
preserveSubtitleLineBreaks: boolean;
|
preserveSubtitleLineBreaks: boolean;
|
||||||
autoPauseVideoOnSubtitleHover: boolean;
|
autoPauseVideoOnSubtitleHover: boolean;
|
||||||
|
autoPauseVideoOnYomitanPopup: boolean;
|
||||||
frequencyDictionaryEnabled: boolean;
|
frequencyDictionaryEnabled: boolean;
|
||||||
frequencyDictionaryTopX: number;
|
frequencyDictionaryTopX: number;
|
||||||
frequencyDictionaryMode: 'single' | 'banded';
|
frequencyDictionaryMode: 'single' | 'banded';
|
||||||
@@ -78,6 +79,9 @@ export type RendererState = {
|
|||||||
keybindingsMap: Map<string, (string | number)[]>;
|
keybindingsMap: Map<string, (string | number)[]>;
|
||||||
chordPending: boolean;
|
chordPending: boolean;
|
||||||
chordTimeout: ReturnType<typeof setTimeout> | null;
|
chordTimeout: ReturnType<typeof setTimeout> | null;
|
||||||
|
keyboardDrivenModeEnabled: boolean;
|
||||||
|
keyboardSelectedWordIndex: number | null;
|
||||||
|
yomitanPopupVisible: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createRendererState(): RendererState {
|
export function createRendererState(): RendererState {
|
||||||
@@ -128,6 +132,7 @@ export function createRendererState(): RendererState {
|
|||||||
jlptN5Color: '#8aadf4',
|
jlptN5Color: '#8aadf4',
|
||||||
preserveSubtitleLineBreaks: false,
|
preserveSubtitleLineBreaks: false,
|
||||||
autoPauseVideoOnSubtitleHover: false,
|
autoPauseVideoOnSubtitleHover: false,
|
||||||
|
autoPauseVideoOnYomitanPopup: false,
|
||||||
frequencyDictionaryEnabled: false,
|
frequencyDictionaryEnabled: false,
|
||||||
frequencyDictionaryTopX: 1000,
|
frequencyDictionaryTopX: 1000,
|
||||||
frequencyDictionaryMode: 'single',
|
frequencyDictionaryMode: 'single',
|
||||||
@@ -141,5 +146,8 @@ export function createRendererState(): RendererState {
|
|||||||
keybindingsMap: new Map(),
|
keybindingsMap: new Map(),
|
||||||
chordPending: false,
|
chordPending: false,
|
||||||
chordTimeout: null,
|
chordTimeout: null,
|
||||||
|
keyboardDrivenModeEnabled: false,
|
||||||
|
keyboardSelectedWordIndex: null,
|
||||||
|
yomitanPopupVisible: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,6 +340,15 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
-webkit-text-fill-color: currentColor !important;
|
-webkit-text-fill-color: currentColor !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#subtitleRoot .word.keyboard-selected {
|
||||||
|
outline: 2px solid rgba(135, 201, 255, 0.92);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(12, 18, 28, 0.68),
|
||||||
|
0 0 18px rgba(120, 188, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
#subtitleRoot .word[data-frequency-rank]::before {
|
#subtitleRoot .word[data-frequency-rank]::before {
|
||||||
content: attr(data-frequency-rank);
|
content: attr(data-frequency-rank);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -363,7 +372,8 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#subtitleRoot .word[data-frequency-rank]:hover::before {
|
#subtitleRoot .word[data-frequency-rank]:hover::before,
|
||||||
|
#subtitleRoot .word.keyboard-selected[data-frequency-rank]::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
}
|
}
|
||||||
@@ -390,7 +400,8 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#subtitleRoot .word[data-jlpt-level]:hover::after {
|
#subtitleRoot .word[data-jlpt-level]:hover::after,
|
||||||
|
#subtitleRoot .word.keyboard-selected[data-jlpt-level]::after {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,6 +409,11 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
|||||||
'#subtitleRoot .word[data-frequency-rank]:hover::before',
|
'#subtitleRoot .word[data-frequency-rank]:hover::before',
|
||||||
);
|
);
|
||||||
assert.match(frequencyTooltipHoverBlock, /opacity:\s*1;/);
|
assert.match(frequencyTooltipHoverBlock, /opacity:\s*1;/);
|
||||||
|
const frequencyTooltipKeyboardSelectedBlock = extractClassBlock(
|
||||||
|
cssText,
|
||||||
|
'#subtitleRoot .word.keyboard-selected[data-frequency-rank]::before',
|
||||||
|
);
|
||||||
|
assert.match(frequencyTooltipKeyboardSelectedBlock, /opacity:\s*1;/);
|
||||||
|
|
||||||
const jlptTooltipBaseBlock = extractClassBlock(
|
const jlptTooltipBaseBlock = extractClassBlock(
|
||||||
cssText,
|
cssText,
|
||||||
@@ -424,6 +429,11 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
|||||||
'#subtitleRoot .word[data-jlpt-level]:hover::after',
|
'#subtitleRoot .word[data-jlpt-level]:hover::after',
|
||||||
);
|
);
|
||||||
assert.match(jlptTooltipHoverBlock, /opacity:\s*1;/);
|
assert.match(jlptTooltipHoverBlock, /opacity:\s*1;/);
|
||||||
|
const jlptTooltipKeyboardSelectedBlock = extractClassBlock(
|
||||||
|
cssText,
|
||||||
|
'#subtitleRoot .word.keyboard-selected[data-jlpt-level]::after',
|
||||||
|
);
|
||||||
|
assert.match(jlptTooltipKeyboardSelectedBlock, /opacity:\s*1;/);
|
||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
cssText,
|
cssText,
|
||||||
|
|||||||
@@ -610,6 +610,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
|||||||
ctx.state.jlptN5Color = jlptColors.N5;
|
ctx.state.jlptN5Color = jlptColors.N5;
|
||||||
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
|
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
|
||||||
ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false;
|
ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false;
|
||||||
|
ctx.state.autoPauseVideoOnYomitanPopup = style.autoPauseVideoOnYomitanPopup ?? false;
|
||||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
|
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
|
||||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2);
|
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2);
|
||||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3);
|
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
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_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_LEAVE_EVENT = 'yomitan-popup-mouse-leave';
|
||||||
|
export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
|
||||||
|
|
||||||
export function isYomitanPopupIframe(element: Element | null): boolean {
|
export function isYomitanPopupIframe(element: Element | null): boolean {
|
||||||
if (!element) return false;
|
if (!element) return false;
|
||||||
@@ -14,3 +17,19 @@ 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 root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
|
||||||
|
const popupIframes = root.querySelectorAll<HTMLIFrameElement>(YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||||
|
for (const iframe of popupIframes) {
|
||||||
|
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 false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export const IPC_CHANNELS = {
|
|||||||
runtimeOptionsChanged: 'runtime-options:changed',
|
runtimeOptionsChanged: 'runtime-options:changed',
|
||||||
runtimeOptionsOpen: 'runtime-options:open',
|
runtimeOptionsOpen: 'runtime-options:open',
|
||||||
jimakuOpen: 'jimaku:open',
|
jimakuOpen: 'jimaku:open',
|
||||||
|
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||||
|
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||||
configHotReload: 'config:hot-reload',
|
configHotReload: 'config:hot-reload',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { codecToExtension } from './utils';
|
import { codecToExtension, getSubsyncConfig } from './utils';
|
||||||
|
|
||||||
test('codecToExtension maps stream/web formats to ffmpeg extractable extensions', () => {
|
test('codecToExtension maps stream/web formats to ffmpeg extractable extensions', () => {
|
||||||
assert.equal(codecToExtension('subrip'), 'srt');
|
assert.equal(codecToExtension('subrip'), 'srt');
|
||||||
@@ -12,3 +12,13 @@ test('codecToExtension maps stream/web formats to ffmpeg extractable extensions'
|
|||||||
test('codecToExtension returns null for unsupported codecs', () => {
|
test('codecToExtension returns null for unsupported codecs', () => {
|
||||||
assert.equal(codecToExtension('unsupported-codec'), null);
|
assert.equal(codecToExtension('unsupported-codec'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getSubsyncConfig defaults replace to true', () => {
|
||||||
|
assert.equal(getSubsyncConfig(undefined).replace, true);
|
||||||
|
assert.equal(getSubsyncConfig({}).replace, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getSubsyncConfig respects explicit replace value', () => {
|
||||||
|
assert.equal(getSubsyncConfig({ replace: false }).replace, false);
|
||||||
|
assert.equal(getSubsyncConfig({ replace: true }).replace, true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface SubsyncResolvedConfig {
|
|||||||
alassPath: string;
|
alassPath: string;
|
||||||
ffsubsyncPath: string;
|
ffsubsyncPath: string;
|
||||||
ffmpegPath: string;
|
ffmpegPath: string;
|
||||||
|
replace?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = {
|
const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = {
|
||||||
@@ -55,6 +56,7 @@ export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncReso
|
|||||||
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
|
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
|
||||||
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
|
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
|
||||||
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
|
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
|
||||||
|
replace: config?.replace ?? DEFAULT_CONFIG.subsync.replace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export interface SubsyncConfig {
|
|||||||
alass_path?: string;
|
alass_path?: string;
|
||||||
ffsubsync_path?: string;
|
ffsubsync_path?: string;
|
||||||
ffmpeg_path?: string;
|
ffmpeg_path?: string;
|
||||||
|
replace?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartupWarmupsConfig {
|
export interface StartupWarmupsConfig {
|
||||||
@@ -289,6 +290,7 @@ export interface SubtitleStyleConfig {
|
|||||||
enableJlpt?: boolean;
|
enableJlpt?: boolean;
|
||||||
preserveLineBreaks?: boolean;
|
preserveLineBreaks?: boolean;
|
||||||
autoPauseVideoOnHover?: boolean;
|
autoPauseVideoOnHover?: boolean;
|
||||||
|
autoPauseVideoOnYomitanPopup?: boolean;
|
||||||
hoverTokenColor?: string;
|
hoverTokenColor?: string;
|
||||||
hoverTokenBackgroundColor?: string;
|
hoverTokenBackgroundColor?: string;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
@@ -841,6 +843,8 @@ export interface ElectronAPI {
|
|||||||
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
|
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
|
||||||
onOpenRuntimeOptions: (callback: () => void) => void;
|
onOpenRuntimeOptions: (callback: () => void) => void;
|
||||||
onOpenJimaku: (callback: () => void) => void;
|
onOpenJimaku: (callback: () => void) => void;
|
||||||
|
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||||
|
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
||||||
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
||||||
|
|||||||
37
vendor/yomitan/js/app/frontend.js
vendored
37
vendor/yomitan/js/app/frontend.js
vendored
@@ -28,6 +28,40 @@ import {TextSourceGenerator} from '../dom/text-source-generator.js';
|
|||||||
import {TextSourceRange} from '../dom/text-source-range.js';
|
import {TextSourceRange} from '../dom/text-source-range.js';
|
||||||
import {TextScanner} from '../language/text-scanner.js';
|
import {TextScanner} from '../language/text-scanner.js';
|
||||||
|
|
||||||
|
const SUBMINER_FRONTEND_COMMAND_EVENT = 'subminer-yomitan-popup-command';
|
||||||
|
const subminerFrontendInstances = new Set();
|
||||||
|
let subminerFrontendCommandBridgeRegistered = false;
|
||||||
|
|
||||||
|
function getActiveFrontendForSubminerCommand() {
|
||||||
|
/** @type {?Frontend} */
|
||||||
|
let fallback = null;
|
||||||
|
for (const frontend of subminerFrontendInstances) {
|
||||||
|
if (frontend._textScanner?.isEnabled?.()) {
|
||||||
|
return frontend;
|
||||||
|
}
|
||||||
|
if (fallback === null) {
|
||||||
|
fallback = frontend;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSubminerFrontendCommandBridge() {
|
||||||
|
if (subminerFrontendCommandBridgeRegistered) { return; }
|
||||||
|
subminerFrontendCommandBridgeRegistered = true;
|
||||||
|
|
||||||
|
window.addEventListener(SUBMINER_FRONTEND_COMMAND_EVENT, (event) => {
|
||||||
|
const frontend = getActiveFrontendForSubminerCommand();
|
||||||
|
if (frontend === null) { return; }
|
||||||
|
const detail = event.detail;
|
||||||
|
if (typeof detail !== 'object' || detail === null) { return; }
|
||||||
|
|
||||||
|
if (detail.type === 'scanSelectedText') {
|
||||||
|
frontend._onApiScanSelectedText();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the main class responsible for scanning and handling webpage content.
|
* This is the main class responsible for scanning and handling webpage content.
|
||||||
*/
|
*/
|
||||||
@@ -158,6 +192,9 @@ export class Frontend {
|
|||||||
* Prepares the instance for use.
|
* Prepares the instance for use.
|
||||||
*/
|
*/
|
||||||
async prepare() {
|
async prepare() {
|
||||||
|
registerSubminerFrontendCommandBridge();
|
||||||
|
subminerFrontendInstances.add(this);
|
||||||
|
|
||||||
await this.updateOptions();
|
await this.updateOptions();
|
||||||
try {
|
try {
|
||||||
const {zoomFactor} = await this._application.api.getZoom();
|
const {zoomFactor} = await this._application.api.getZoom();
|
||||||
|
|||||||
84
vendor/yomitan/js/app/popup.js
vendored
84
vendor/yomitan/js/app/popup.js
vendored
@@ -28,6 +28,85 @@ import {loadStyle} from '../dom/style-util.js';
|
|||||||
import {checkPopupPreviewURL} from '../pages/settings/popup-preview-controller.js';
|
import {checkPopupPreviewURL} from '../pages/settings/popup-preview-controller.js';
|
||||||
import {ThemeController} from './theme-controller.js';
|
import {ThemeController} from './theme-controller.js';
|
||||||
|
|
||||||
|
const SUBMINER_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
|
||||||
|
const subminerPopupInstances = new Set();
|
||||||
|
let subminerPopupCommandBridgeRegistered = false;
|
||||||
|
|
||||||
|
function getActivePopupForSubminerCommand() {
|
||||||
|
/** @type {?Popup} */
|
||||||
|
let fallback = null;
|
||||||
|
for (const popup of subminerPopupInstances) {
|
||||||
|
if (!popup.isVisibleSync()) { continue; }
|
||||||
|
fallback = popup;
|
||||||
|
if (popup.isPointerOverSelfOrChildren()) {
|
||||||
|
return popup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSubminerPopupCommandBridge() {
|
||||||
|
if (subminerPopupCommandBridgeRegistered) { return; }
|
||||||
|
subminerPopupCommandBridgeRegistered = true;
|
||||||
|
window.addEventListener(SUBMINER_POPUP_COMMAND_EVENT, (event) => {
|
||||||
|
const popup = getActivePopupForSubminerCommand();
|
||||||
|
if (popup === null) { return; }
|
||||||
|
const detail = event.detail;
|
||||||
|
if (typeof detail !== 'object' || detail === null) { return; }
|
||||||
|
|
||||||
|
if (detail.type === 'simulateHotkey') {
|
||||||
|
const key = detail.key;
|
||||||
|
const rawModifiers = detail.modifiers;
|
||||||
|
if (typeof key !== 'string' || !Array.isArray(rawModifiers)) { return; }
|
||||||
|
const modifiers = rawModifiers.filter((modifier) => (
|
||||||
|
modifier === 'alt' ||
|
||||||
|
modifier === 'ctrl' ||
|
||||||
|
modifier === 'shift' ||
|
||||||
|
modifier === 'meta'
|
||||||
|
));
|
||||||
|
void popup._invokeSafe('displaySimulateHotkey', {key, modifiers});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.type === 'forwardKeyDown') {
|
||||||
|
const code = detail.code;
|
||||||
|
const key = detail.key;
|
||||||
|
const rawModifiers = detail.modifiers;
|
||||||
|
if (typeof code !== 'string' || typeof key !== 'string' || !Array.isArray(rawModifiers)) { return; }
|
||||||
|
const modifiers = rawModifiers.filter((modifier) => (
|
||||||
|
modifier === 'alt' ||
|
||||||
|
modifier === 'ctrl' ||
|
||||||
|
modifier === 'shift' ||
|
||||||
|
modifier === 'meta'
|
||||||
|
));
|
||||||
|
void popup._invokeSafe('displayForwardKeyDown', {
|
||||||
|
key,
|
||||||
|
code,
|
||||||
|
modifiers,
|
||||||
|
repeat: detail.repeat === true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.type === 'mineSelected') {
|
||||||
|
void popup._invokeSafe('displayMineSelected', void 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.type === 'cycleAudioSource') {
|
||||||
|
const direction = detail.direction === -1 ? -1 : 1;
|
||||||
|
void popup._invokeSafe('displayAudioCycleSource', {direction});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.type === 'setVisible') {
|
||||||
|
if (detail.visible === false) {
|
||||||
|
popup.hide(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is the container which hosts the display of search results.
|
* This class is the container which hosts the display of search results.
|
||||||
* @augments EventDispatcher<import('popup').Events>
|
* @augments EventDispatcher<import('popup').Events>
|
||||||
@@ -209,6 +288,8 @@ export class Popup extends EventDispatcher {
|
|||||||
* Prepares the popup for use.
|
* Prepares the popup for use.
|
||||||
*/
|
*/
|
||||||
prepare() {
|
prepare() {
|
||||||
|
registerSubminerPopupCommandBridge();
|
||||||
|
subminerPopupInstances.add(this);
|
||||||
this._frame.addEventListener('mouseover', this._onFrameMouseOver.bind(this));
|
this._frame.addEventListener('mouseover', this._onFrameMouseOver.bind(this));
|
||||||
this._frame.addEventListener('mouseout', this._onFrameMouseOut.bind(this));
|
this._frame.addEventListener('mouseout', this._onFrameMouseOut.bind(this));
|
||||||
this._frame.addEventListener('mousedown', (e) => e.stopPropagation());
|
this._frame.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||||
@@ -471,6 +552,7 @@ export class Popup extends EventDispatcher {
|
|||||||
*/
|
*/
|
||||||
_onFrameMouseOver() {
|
_onFrameMouseOver() {
|
||||||
this._isPointerOverPopup = true;
|
this._isPointerOverPopup = true;
|
||||||
|
window.dispatchEvent(new CustomEvent('yomitan-popup-mouse-enter'));
|
||||||
|
|
||||||
this.stopHideDelayed();
|
this.stopHideDelayed();
|
||||||
this.trigger('mouseOver', {});
|
this.trigger('mouseOver', {});
|
||||||
@@ -486,6 +568,7 @@ export class Popup extends EventDispatcher {
|
|||||||
*/
|
*/
|
||||||
_onFrameMouseOut() {
|
_onFrameMouseOut() {
|
||||||
this._isPointerOverPopup = false;
|
this._isPointerOverPopup = false;
|
||||||
|
window.dispatchEvent(new CustomEvent('yomitan-popup-mouse-leave'));
|
||||||
|
|
||||||
this.trigger('mouseOut', {});
|
this.trigger('mouseOut', {});
|
||||||
|
|
||||||
@@ -836,6 +919,7 @@ export class Popup extends EventDispatcher {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_onExtensionUnloaded() {
|
_onExtensionUnloaded() {
|
||||||
|
subminerPopupInstances.delete(this);
|
||||||
this._invokeWindow('displayExtensionUnloaded', void 0);
|
this._invokeWindow('displayExtensionUnloaded', void 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
109
vendor/yomitan/js/display/display-audio.js
vendored
109
vendor/yomitan/js/display/display-audio.js
vendored
@@ -69,6 +69,10 @@ export class DisplayAudio {
|
|||||||
]);
|
]);
|
||||||
/** @type {?boolean} */
|
/** @type {?boolean} */
|
||||||
this._enableDefaultAudioSources = null;
|
this._enableDefaultAudioSources = null;
|
||||||
|
/** @type {?number} */
|
||||||
|
this._audioCycleSourceIndex = null;
|
||||||
|
/** @type {Map<number, number>} */
|
||||||
|
this._audioCycleAudioInfoIndexMap = new Map();
|
||||||
/** @type {(event: MouseEvent) => void} */
|
/** @type {(event: MouseEvent) => void} */
|
||||||
this._onAudioPlayButtonClickBind = this._onAudioPlayButtonClick.bind(this);
|
this._onAudioPlayButtonClickBind = this._onAudioPlayButtonClick.bind(this);
|
||||||
/** @type {(event: MouseEvent) => void} */
|
/** @type {(event: MouseEvent) => void} */
|
||||||
@@ -96,6 +100,7 @@ export class DisplayAudio {
|
|||||||
]);
|
]);
|
||||||
this._display.registerDirectMessageHandlers([
|
this._display.registerDirectMessageHandlers([
|
||||||
['displayAudioClearAutoPlayTimer', this._onMessageClearAutoPlayTimer.bind(this)],
|
['displayAudioClearAutoPlayTimer', this._onMessageClearAutoPlayTimer.bind(this)],
|
||||||
|
['displayAudioCycleSource', this._onMessageCycleAudioSource.bind(this)],
|
||||||
]);
|
]);
|
||||||
/* eslint-enable @stylistic/no-multi-spaces */
|
/* eslint-enable @stylistic/no-multi-spaces */
|
||||||
this._display.on('optionsUpdated', this._onOptionsUpdated.bind(this));
|
this._display.on('optionsUpdated', this._onOptionsUpdated.bind(this));
|
||||||
@@ -186,6 +191,8 @@ export class DisplayAudio {
|
|||||||
/** @type {Map<string, import('display-audio').AudioSource[]>} */
|
/** @type {Map<string, import('display-audio').AudioSource[]>} */
|
||||||
const nameMap = new Map();
|
const nameMap = new Map();
|
||||||
this._audioSources.length = 0;
|
this._audioSources.length = 0;
|
||||||
|
this._audioCycleSourceIndex = null;
|
||||||
|
this._audioCycleAudioInfoIndexMap.clear();
|
||||||
for (const {type, url, voice} of sources) {
|
for (const {type, url, voice} of sources) {
|
||||||
this._addAudioSourceInfo(type, url, voice, true, nameMap);
|
this._addAudioSourceInfo(type, url, voice, true, nameMap);
|
||||||
requiredAudioSources.delete(type);
|
requiredAudioSources.delete(type);
|
||||||
@@ -204,6 +211,8 @@ export class DisplayAudio {
|
|||||||
_onContentClear() {
|
_onContentClear() {
|
||||||
this._entriesToken = {};
|
this._entriesToken = {};
|
||||||
this._cache.clear();
|
this._cache.clear();
|
||||||
|
this._audioCycleSourceIndex = null;
|
||||||
|
this._audioCycleAudioInfoIndexMap.clear();
|
||||||
this.clearAutoPlayTimer();
|
this.clearAutoPlayTimer();
|
||||||
this._eventListeners.removeAllEventListeners();
|
this._eventListeners.removeAllEventListeners();
|
||||||
}
|
}
|
||||||
@@ -273,6 +282,73 @@ export class DisplayAudio {
|
|||||||
this.clearAutoPlayTimer();
|
this.clearAutoPlayTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{direction?: number}} details
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async _onMessageCycleAudioSource({direction}) {
|
||||||
|
/** @type {import('display-audio').AudioSource[]} */
|
||||||
|
const configuredSources = this._audioSources.filter((source) => source.isInOptions);
|
||||||
|
const sources = configuredSources.length > 0 ? configuredSources : this._audioSources;
|
||||||
|
if (sources.length === 0) { return false; }
|
||||||
|
|
||||||
|
const dictionaryEntryIndex = this._display.selectedIndex;
|
||||||
|
const headwordIndex = 0;
|
||||||
|
const headword = this._getHeadword(dictionaryEntryIndex, headwordIndex);
|
||||||
|
if (headword === null) { return false; }
|
||||||
|
const {term, reading} = headword;
|
||||||
|
|
||||||
|
const primaryCardAudio = this._getPrimaryCardAudio(term, reading);
|
||||||
|
let source = null;
|
||||||
|
if (primaryCardAudio !== null) {
|
||||||
|
source = sources.find((item) => item.index === primaryCardAudio.index) ?? null;
|
||||||
|
}
|
||||||
|
if (source === null) {
|
||||||
|
const fallbackIndex = (
|
||||||
|
this._audioCycleSourceIndex !== null &&
|
||||||
|
this._audioCycleSourceIndex >= 0 &&
|
||||||
|
this._audioCycleSourceIndex < sources.length
|
||||||
|
) ? this._audioCycleSourceIndex : 0;
|
||||||
|
source = sources[fallbackIndex] ?? null;
|
||||||
|
this._audioCycleSourceIndex = fallbackIndex;
|
||||||
|
}
|
||||||
|
if (source === null) { return false; }
|
||||||
|
|
||||||
|
const infoList = await this._getSourceAudioInfoList(source, term, reading);
|
||||||
|
const infoListLength = infoList.length;
|
||||||
|
if (infoListLength === 0) { return false; }
|
||||||
|
|
||||||
|
const step = direction === -1 ? -1 : 1;
|
||||||
|
let currentSubIndex = this._audioCycleAudioInfoIndexMap.get(source.index);
|
||||||
|
if (
|
||||||
|
typeof currentSubIndex !== 'number' &&
|
||||||
|
primaryCardAudio !== null &&
|
||||||
|
primaryCardAudio.index === source.index &&
|
||||||
|
primaryCardAudio.subIndex !== null
|
||||||
|
) {
|
||||||
|
currentSubIndex = primaryCardAudio.subIndex;
|
||||||
|
}
|
||||||
|
if (typeof currentSubIndex !== 'number') {
|
||||||
|
currentSubIndex = step > 0 ? -1 : infoListLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < infoListLength; ++i) {
|
||||||
|
currentSubIndex = (currentSubIndex + step + infoListLength) % infoListLength;
|
||||||
|
const {valid} = await this._playAudio(
|
||||||
|
dictionaryEntryIndex,
|
||||||
|
headwordIndex,
|
||||||
|
[source],
|
||||||
|
currentSubIndex,
|
||||||
|
);
|
||||||
|
if (valid) {
|
||||||
|
this._audioCycleAudioInfoIndexMap.set(source.index, currentSubIndex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('settings').AudioSourceType} type
|
* @param {import('settings').AudioSourceType} type
|
||||||
* @param {string} url
|
* @param {string} url
|
||||||
@@ -691,6 +767,39 @@ export class DisplayAudio {
|
|||||||
return infoList.map((info) => ({info, audioPromise: null, audioResolved: false, audio: null}));
|
return infoList.map((info) => ({info, audioPromise: null, audioResolved: false, audio: null}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('display-audio').AudioSource} source
|
||||||
|
* @param {string} term
|
||||||
|
* @param {string} reading
|
||||||
|
* @returns {Promise<import('display-audio').AudioInfoList>}
|
||||||
|
*/
|
||||||
|
async _getSourceAudioInfoList(source, term, reading) {
|
||||||
|
const cacheItem = this._getCacheItem(term, reading, true);
|
||||||
|
if (typeof cacheItem === 'undefined') { return []; }
|
||||||
|
const {sourceMap} = cacheItem;
|
||||||
|
|
||||||
|
let cacheUpdated = false;
|
||||||
|
let sourceInfo = sourceMap.get(source.index);
|
||||||
|
if (typeof sourceInfo === 'undefined') {
|
||||||
|
const infoListPromise = this._getTermAudioInfoList(source, term, reading);
|
||||||
|
sourceInfo = {infoListPromise, infoList: null};
|
||||||
|
sourceMap.set(source.index, sourceInfo);
|
||||||
|
cacheUpdated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {infoList} = sourceInfo;
|
||||||
|
if (infoList === null) {
|
||||||
|
infoList = await sourceInfo.infoListPromise;
|
||||||
|
sourceInfo.infoList = infoList;
|
||||||
|
cacheUpdated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cacheUpdated) {
|
||||||
|
this._updateOpenMenu();
|
||||||
|
}
|
||||||
|
return infoList;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {number} dictionaryEntryIndex
|
* @param {number} dictionaryEntryIndex
|
||||||
* @param {number} headwordIndex
|
* @param {number} headwordIndex
|
||||||
|
|||||||
54
vendor/yomitan/js/display/display.js
vendored
54
vendor/yomitan/js/display/display.js
vendored
@@ -224,6 +224,9 @@ export class Display extends EventDispatcher {
|
|||||||
['displaySetContentScale', this._onMessageSetContentScale.bind(this)],
|
['displaySetContentScale', this._onMessageSetContentScale.bind(this)],
|
||||||
['displayConfigure', this._onMessageConfigure.bind(this)],
|
['displayConfigure', this._onMessageConfigure.bind(this)],
|
||||||
['displayVisibilityChanged', this._onMessageVisibilityChanged.bind(this)],
|
['displayVisibilityChanged', this._onMessageVisibilityChanged.bind(this)],
|
||||||
|
['displaySimulateHotkey', this._onMessageSimulateHotkey.bind(this)],
|
||||||
|
['displayForwardKeyDown', this._onMessageForwardKeyDown.bind(this)],
|
||||||
|
['displayMineSelected', this._onMessageMineSelected.bind(this)],
|
||||||
]);
|
]);
|
||||||
this.registerWindowMessageHandlers([
|
this.registerWindowMessageHandlers([
|
||||||
['displayExtensionUnloaded', this._onMessageExtensionUnloaded.bind(this)],
|
['displayExtensionUnloaded', this._onMessageExtensionUnloaded.bind(this)],
|
||||||
@@ -785,6 +788,57 @@ export class Display extends EventDispatcher {
|
|||||||
this.trigger('frameVisibilityChange', {value});
|
this.trigger('frameVisibilityChange', {value});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{key: string, modifiers: unknown[]}} details
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_onMessageSimulateHotkey({key, modifiers}) {
|
||||||
|
if (typeof key !== 'string' || !Array.isArray(modifiers)) { return false; }
|
||||||
|
const normalizedModifiers = modifiers.filter((modifier) => (
|
||||||
|
modifier === 'alt' ||
|
||||||
|
modifier === 'ctrl' ||
|
||||||
|
modifier === 'shift' ||
|
||||||
|
modifier === 'meta'
|
||||||
|
));
|
||||||
|
return this._hotkeyHandler.simulate(key, normalizedModifiers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{key: string, code: string, modifiers: unknown[], repeat?: boolean}} details
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_onMessageForwardKeyDown({key, code, modifiers, repeat = false}) {
|
||||||
|
if (typeof key !== 'string' || typeof code !== 'string' || !Array.isArray(modifiers)) { return false; }
|
||||||
|
const normalizedModifiers = modifiers.filter((modifier) => (
|
||||||
|
modifier === 'alt' ||
|
||||||
|
modifier === 'ctrl' ||
|
||||||
|
modifier === 'shift' ||
|
||||||
|
modifier === 'meta'
|
||||||
|
));
|
||||||
|
const eventInit = {
|
||||||
|
key,
|
||||||
|
code,
|
||||||
|
repeat,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
altKey: normalizedModifiers.includes('alt'),
|
||||||
|
ctrlKey: normalizedModifiers.includes('ctrl'),
|
||||||
|
shiftKey: normalizedModifiers.includes('shift'),
|
||||||
|
metaKey: normalizedModifiers.includes('meta'),
|
||||||
|
};
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', eventInit));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_onMessageMineSelected() {
|
||||||
|
document.dispatchEvent(new CustomEvent('subminer-display-mine-selected'));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {import('display').WindowApiHandler<'displayExtensionUnloaded'>} */
|
/** @type {import('display').WindowApiHandler<'displayExtensionUnloaded'>} */
|
||||||
_onMessageExtensionUnloaded() {
|
_onMessageExtensionUnloaded() {
|
||||||
this._application.webExtension.triggerUnloaded();
|
this._application.webExtension.triggerUnloaded();
|
||||||
|
|||||||
44
vendor/yomitan/js/display/popup-main.js
vendored
44
vendor/yomitan/js/display/popup-main.js
vendored
@@ -47,6 +47,50 @@ await Application.main(true, async (application) => {
|
|||||||
const displayResizer = new DisplayResizer(display);
|
const displayResizer = new DisplayResizer(display);
|
||||||
displayResizer.prepare();
|
displayResizer.prepare();
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.defaultPrevented || event.repeat) { return; }
|
||||||
|
if (event.ctrlKey || event.metaKey || event.altKey) { return; }
|
||||||
|
|
||||||
|
const target = /** @type {?Element} */ (event.target instanceof Element ? event.target : null);
|
||||||
|
if (target !== null) {
|
||||||
|
if (target.closest('input, textarea, select, [contenteditable="true"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = event.code;
|
||||||
|
if (code === 'KeyJ' || code === 'KeyK') {
|
||||||
|
const scanningOptions = display.getOptions()?.scanning;
|
||||||
|
const scale = Number.isFinite(scanningOptions?.reducedMotionScrollingScale)
|
||||||
|
? scanningOptions.reducedMotionScrollingScale
|
||||||
|
: 1;
|
||||||
|
display._scrollByPopupHeight(code === 'KeyJ' ? 1 : -1, scale);
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 'KeyM') {
|
||||||
|
displayAnki._hotkeySaveAnkiNoteForSelectedEntry('0');
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 'KeyP') {
|
||||||
|
void displayAudio.playAudio(display.selectedIndex, 0);
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 'BracketLeft' || code === 'BracketRight') {
|
||||||
|
displayAudio._onMessageCycleAudioSource({direction: code === 'BracketLeft' ? 1 : -1});
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('subminer-display-mine-selected', () => {
|
||||||
|
displayAnki._hotkeySaveAnkiNoteForSelectedEntry('0');
|
||||||
|
});
|
||||||
|
|
||||||
display.initializeState();
|
display.initializeState();
|
||||||
|
|
||||||
document.documentElement.dataset.loaded = 'true';
|
document.documentElement.dataset.loaded = 'true';
|
||||||
|
|||||||
Reference in New Issue
Block a user