mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
Compare commits
1 Commits
b682f0d37a
...
v0.10.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 35adf8299c |
@@ -8,6 +8,8 @@
|
||||
### Fixed
|
||||
- Stats: Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
|
||||
- Stats: Stats server now falls back to a Node `http` listener in Electron/runtime paths that do not expose Bun.
|
||||
- Overlay: Fixed the macOS visible-overlay toggle path so manual hides stay hidden and the plugin uses the explicit visible-overlay toggle command.
|
||||
- Subtitle Sidebar: Restored macOS mpv passthrough while the overlay subtitle sidebar is open so clicks outside the sidebar can refocus mpv and keep native keybindings working.
|
||||
|
||||
### Internal
|
||||
- Release: Added a maintained source coverage lane that shards Bun coverage one test file at a time and merges LCOV output into `coverage/test-src/lcov.info`.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
id: TASK-247
|
||||
title: Strip inline subtitle markup from subtitle sidebar cues
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-29 10:01'
|
||||
updated_date: '2026-03-29 10:10'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
- src/core/services/subtitle-cue-parser.ts
|
||||
- src/renderer/modals/subtitle-sidebar.ts
|
||||
- src/core/services/subtitle-cue-parser.test.ts
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Subtitle sidebar should display readable subtitle text when loaded subtitle files include inline markup such as HTML-like font tags. Parsed cue text currently preserves markup, causing raw tags to appear in the sidebar instead of clean subtitle content.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Subtitle sidebar cue text omits inline subtitle markup such as HTML-like font tags while preserving visible subtitle content.
|
||||
- [x] #2 Parsed subtitle cues used by the sidebar keep timing order and expected line-break behavior after markup sanitization.
|
||||
- [x] #3 Regression tests cover markup-bearing subtitle cue parsing so raw tags do not reappear in the sidebar.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add regression tests in src/core/services/subtitle-cue-parser.test.ts for subtitle cues containing HTML-like font tags, including multi-line content.
|
||||
2. Verify the new parser test fails against current behavior to confirm the bug is covered.
|
||||
3. Update src/core/services/subtitle-cue-parser.ts to sanitize inline subtitle markup while preserving visible text and expected newline handling.
|
||||
4. Re-run focused parser tests, then run broader verification commands required for handoff as practical.
|
||||
5. Update task notes/acceptance criteria based on verified results and finalize the task record.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
User approved implementation on 2026-03-29.
|
||||
|
||||
Implemented parser-level subtitle cue sanitization for HTML-like tags so loaded sidebar cues render readable text while preserving cue line breaks.
|
||||
|
||||
Added regression coverage for SRT and ASS cue parsing with <font ...> markup.
|
||||
|
||||
Verification: bun test src/core/services/subtitle-cue-parser.test.ts; bun run typecheck; bun run test:fast; bun run test:env; bun run build; bun run test:smoke:dist.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Sanitized parsed subtitle cue text in src/core/services/subtitle-cue-parser.ts so HTML-like inline markup such as <font ...> is removed before cues reach the subtitle sidebar. The sanitizer is shared across SRT/VTT-style parsing and ASS parsing, while existing cue timing and line-break semantics remain intact.
|
||||
|
||||
Added regression tests in src/core/services/subtitle-cue-parser.test.ts covering markup-bearing SRT lines and ASS dialogue lines with \N breaks, and verified the original failure before implementing the fix.
|
||||
|
||||
Tests run: bun test src/core/services/subtitle-cue-parser.test.ts; bun run typecheck; bun run test:fast; bun run test:env; bun run build; bun run test:smoke:dist.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
id: TASK-248
|
||||
title: Fix macOS visible overlay toggle getting immediately restored
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-29 10:03'
|
||||
updated_date: '2026-03-29 22:14'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
- /Users/sudacode/projects/japanese/SubMiner/plugin/subminer/process.lua
|
||||
- /Users/sudacode/projects/japanese/SubMiner/plugin/subminer/ui.lua
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/cli-command.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/main/overlay-visibility-runtime.ts
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Investigate and fix the visible overlay toggle path on macOS so the user can reliably hide the overlay after it has been shown. The current behavior can ignore the toggle or hide the overlay briefly before it is restored immediately.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Pressing the visible-overlay toggle hides the overlay when it is currently shown on macOS.
|
||||
- [x] #2 A manual hide is not immediately undone by startup or readiness flows.
|
||||
- [x] #3 The mpv/plugin toggle path matches the intended visible-overlay toggle behavior.
|
||||
- [x] #4 Regression tests cover the failing toggle path.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Reproduce the toggle/re-show logic from code paths around mpv plugin control commands and auto-play readiness.
|
||||
2. Add regression coverage for manual toggle-off staying hidden through readiness completion.
|
||||
3. Patch the plugin/control path so manual visible-overlay toggles are not undone by readiness auto-show.
|
||||
4. Run targeted tests, then the relevant verification lane.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Root cause: the mpv plugin readiness callback (`subminer-autoplay-ready`) could re-issue `--show-visible-overlay` after a manual toggle/hide. Initial fix only suppressed the next readiness restore, but repeated readiness callbacks in the same media session could still re-show the overlay. The plugin toggle path also still used legacy `--toggle` instead of the explicit visible-overlay command.
|
||||
|
||||
Implemented a session-scoped suppression flag in the Lua plugin so a manual hide/toggle during the pause-until-ready window blocks readiness auto-show for the rest of the current auto-start session, then resets on the next auto-start session.
|
||||
|
||||
Added Lua regression coverage for both behaviors: manual toggle-off stays hidden through readiness completion, repeated readiness callbacks in the same session stay suppressed, and `subminer-toggle` emits `--toggle-visible-overlay` rather than legacy `--toggle`.
|
||||
|
||||
Follow-up investigation found a second issue in `src/core/services/cli-command.ts`: pure visible-overlay toggle commands still ran the MPV connect/start path (`connectMpvClient`) because `--toggle` and `--toggle-visible-overlay` were classified as start-like commands. That side effect could retrigger startup visibility work even after the plugin-side fix.
|
||||
|
||||
Updated CLI command handling so only `--start` reconnects MPV. Pure toggle/show/hide overlay commands still initialize overlay runtime when needed, but they no longer restart/reconnect the MPV control path.
|
||||
|
||||
Renderer/modal follow-ups: restored focused-overlay mpv y-chord proxy in `src/renderer/handlers/keyboard.ts`, added a modal-close guard in `src/main/overlay-runtime.ts` so modal teardown does not re-show a manually hidden overlay, and added a duplicate-toggle debounce in `src/main/runtime/overlay-visibility-actions.ts` to ignore near-simultaneous toggle requests inside the main process.
|
||||
|
||||
2026-03-29: added regression for repeated subminer-autoplay-ready signals after manual y-t hide. Root cause: Lua plugin suppression only blocked the first ready-time restore, so later ready callbacks in the same media session could re-show the visible overlay. Updated plugin suppression to remain active for the full current auto-start session and reset on the next auto-start trigger.
|
||||
|
||||
2026-03-29: live mpv log showed repeated `subminer-autoplay-ready` script messages from Electron during paused startup, each triggering plugin `--show-visible-overlay` and immediate re-show. Fixed `src/main/runtime/autoplay-ready-gate.ts` so plugin readiness is signaled once per media while paused retry loops only re-issue `pause=false` instead of re-signaling readiness.
|
||||
|
||||
2026-03-29: Added window-level guard for stray visible-overlay re-show on macOS. `src/core/services/overlay-window.ts` now immediately re-hides the visible overlay window on `show` if overlay state is false, covering native/Electron re-show paths that bypass normal visibility actions. Regression: `src/core/services/overlay-window.test.ts`. Verified with full gate and rebuilt unsigned mac bundle.
|
||||
|
||||
2026-03-29: added a blur-path guard for the visible overlay window. `src/core/services/overlay-window.ts` now skips topmost restacking when a visible-overlay blur fires after overlay state already flipped off, covering a macOS hide-in-flight path that could immediately reassert the window. Regression coverage added in `src/core/services/overlay-window.test.ts`; verified with targeted overlay tests, full gate, and rebuilt unsigned mac bundle.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Confirmed with user that macOS `y-t` now works. Cleaned the patch set down to the remaining justified fixes: explicit visible-overlay plugin toggle/suppression, pure-toggle CLI no longer reconnects MPV, autoplay-ready signaling only fires once per media, and the final visible-overlay blur guard that stops macOS restacking after a manual hide. Full gate passed again before commit `c939c580` (`fix: stabilize macOS visible overlay toggle`).
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
id: TASK-249
|
||||
title: Fix AniList token persistence on setup login
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-29 10:08'
|
||||
updated_date: '2026-03-29 19:42'
|
||||
labels:
|
||||
- anilist
|
||||
- bug
|
||||
dependencies: []
|
||||
documentation:
|
||||
- src/main/runtime/anilist-setup.ts
|
||||
- src/core/services/anilist/anilist-token-store.ts
|
||||
- src/main/runtime/anilist-token-refresh.ts
|
||||
- docs-site/anilist-integration.md
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
AniList setup can appear successful but the token is not persisted across restarts. Investigate the setup callback and token store path so the app either saves the token reliably or surfaces persistence failure instead of reopening setup on every launch.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 AniList setup login persists a usable token across app restarts when safeStorage works
|
||||
- [ ] #2 If token persistence fails the setup flow reports the failure instead of pretending login succeeded
|
||||
- [ ] #3 Regression coverage exists for the callback/save path and the refresh path that reopens setup when no token is available
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Pinned installed mpv plugin configs to the current SubMiner binary so standalone mpv launches reuse the same app identity that saved AniList tokens. Added startup self-heal for existing blank binary_path configs, install-time binary_path writes for fresh plugin installs, regression tests for both paths, and docs updates describing the new behavior.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
id: TASK-250
|
||||
title: Restore macOS mpv passthrough while overlay subtitle sidebar is open
|
||||
status: Done
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-03-29 10:10'
|
||||
updated_date: '2026-03-29 10:23'
|
||||
labels:
|
||||
- bug
|
||||
- macos
|
||||
- subtitle-sidebar
|
||||
- overlay
|
||||
- mpv
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/renderer/overlay-mouse-ignore.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/handlers/keyboard.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.test.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/renderer/overlay-mouse-ignore.test.ts
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
When the overlay-layout subtitle sidebar is open on macOS, users should still be able to click through outside the sidebar and return keyboard focus to mpv so native mpv keybindings continue to work. The sidebar should stay interactive when hovered or focused, but it must not make the whole visible overlay behave like a blocking modal.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Opening the overlay-layout subtitle sidebar does not keep the entire visible overlay mouse-interactive outside sidebar hover or focus.
|
||||
- [x] #2 With the subtitle sidebar open, clicking outside the sidebar can refocus mpv so native mpv keybindings continue to work.
|
||||
- [x] #3 Focused regression coverage exists for overlay-layout sidebar passthrough behavior on mouse-ignore state changes.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add renderer regression coverage for overlay-layout subtitle sidebar passthrough so open-but-unhovered sidebar no longer holds global mouse interaction.
|
||||
2. Update overlay mouse-ignore gating to keep the subtitle sidebar interactive only while hovered or otherwise actively interacting, instead of treating overlay layout as a blocking modal.
|
||||
3. Run focused renderer tests for subtitle sidebar and mouse-ignore behavior, then update task notes/criteria with the verified outcome.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Confirmed the regression only affects the default overlay-layout subtitle sidebar: open sidebar state was treated as a blocking overlay modal, which prevented click-through outside the sidebar and stranded native mpv keybindings until focus was manually recovered.
|
||||
|
||||
Added a failing regression in src/renderer/modals/subtitle-sidebar.test.ts for overlay-layout passthrough before changing the gate.
|
||||
|
||||
Verification: bun test src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts; bun run typecheck
|
||||
|
||||
User reported the first renderer-only fix did not resolve the macOS issue in practice. Reopening investigation to trace visible-overlay window focus and hit-testing outside the renderer mouse-ignore gate.
|
||||
|
||||
Follow-up root cause: sidebar hover handlers were attached to the full-screen `.subtitle-sidebar-modal` shell instead of the actual sidebar panel. On the transparent visible overlay that shell spans the viewport, so sidebar-active state could persist outside the panel and keep the overlay interactive longer than intended.
|
||||
|
||||
Updated the sidebar modal to track hover/focus on `subtitleSidebarContent` and derive sidebar interaction state from panel hover or focus-within before recomputing mouse passthrough.
|
||||
|
||||
Verification refresh: bun test src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts; bun run typecheck
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Restored overlay subtitle sidebar passthrough in two layers. First, the visible overlay mouse-ignore gate no longer treats the subtitle sidebar as a global blocking modal. Second, the sidebar panel now tracks interaction on the real sidebar content instead of the full-screen modal shell, and keeps itself active only while the panel is hovered or focused. Added regressions for overlay-layout passthrough and focus-within behavior. Verification: `bun test src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts` and `bun run typecheck`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: TASK-251
|
||||
title: 'Docs: add subtitle sidebar and Jimaku integration pages'
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-29 22:36'
|
||||
updated_date: '2026-03-29 22:38'
|
||||
labels:
|
||||
- docs
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Track the docs-site update that adds a dedicated subtitle sidebar page, links Jimaku integration from the homepage/config docs, and refreshes the docs-site theme styling used by those pages.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 docs-site nav includes a Subtitle Sidebar entry
|
||||
- [x] #2 Subtitle Sidebar page documents layout, shortcut, and config options
|
||||
- [x] #3 Jimaku integration page and configuration docs link to the new docs page
|
||||
- [x] #4 Changelog fragment exists for the user-visible docs release note
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Added the subtitle sidebar docs page and nav entry, linked Jimaku integration from the homepage/config docs, refreshed docs-site styling tokens, and recorded the release note fragment. Verified with `bun run changelog:lint`, `bun run docs:test`, `bun run docs:build`, and `bun run build`. Full repo test gate still has pre-existing failures in `bun run test:fast` and `bun run test:env` unrelated to these docs changes.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -1,6 +0,0 @@
|
||||
type: changed
|
||||
area: core
|
||||
|
||||
- Refactored startup, query, and workflow code into focused modules.
|
||||
- Added repo-local workflow plugin shims and updated internal docs and verification helpers.
|
||||
- Expanded tests around launcher, runtime, stats, and immersion-tracker behavior.
|
||||
6
changes/251-docs-site-sidebar.md
Normal file
6
changes/251-docs-site-sidebar.md
Normal file
@@ -0,0 +1,6 @@
|
||||
type: docs
|
||||
area: docs-site
|
||||
|
||||
- Added a dedicated Subtitle Sidebar guide and linked it from the homepage and configuration docs.
|
||||
- Linked Jimaku integration from the homepage to its dedicated docs page.
|
||||
- Refreshed docs-site theme tokens and hover/selection styling for the updated pages.
|
||||
5
changes/252-youtube-playback-socket-path.md
Normal file
5
changes/252-youtube-playback-socket-path.md
Normal file
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: main
|
||||
|
||||
- Resolve the YouTube playback socket path lazily so startup honors CLI and config overrides.
|
||||
- Add regression coverage for the lazy socket-path lookup during Windows mpv startup.
|
||||
@@ -498,6 +498,7 @@
|
||||
// ==========================================
|
||||
"discordPresence": {
|
||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
|
||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||
|
||||
@@ -74,7 +74,9 @@ export default {
|
||||
{ text: 'Configuration', link: '/configuration' },
|
||||
{ text: 'Keyboard Shortcuts', link: '/shortcuts' },
|
||||
{ text: 'Subtitle Annotations', link: '/subtitle-annotations' },
|
||||
{ text: 'Subtitle Sidebar', link: '/subtitle-sidebar' },
|
||||
{ text: 'Immersion Tracking', link: '/immersion-tracking' },
|
||||
{ text: 'JLPT Vocabulary Bundle', link: '/jlpt-vocab-bundle' },
|
||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -34,6 +34,25 @@
|
||||
system-ui,
|
||||
sans-serif;
|
||||
--tui-transition: 180ms ease;
|
||||
|
||||
/* Theme-specific values — overridden in .dark below */
|
||||
--tui-nav-bg: color-mix(in srgb, var(--vp-c-bg-alt) 88%, transparent);
|
||||
--tui-table-hover-bg: color-mix(in srgb, var(--vp-c-bg-soft) 80%, transparent);
|
||||
--tui-link-underline: color-mix(in srgb, var(--vp-c-brand-1) 40%, transparent);
|
||||
--tui-selection-bg: hsla(267, 83%, 45%, 0.14);
|
||||
--tui-hero-glow: hsla(267, 83%, 45%, 0.05);
|
||||
--tui-step-hover-bg: var(--vp-c-bg-alt);
|
||||
--tui-step-hover-glow: color-mix(in srgb, var(--vp-c-brand-1) 30%, transparent);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--tui-nav-bg: hsla(232, 23%, 18%, 0.82);
|
||||
--tui-table-hover-bg: hsla(232, 23%, 18%, 0.4);
|
||||
--tui-link-underline: hsla(267, 83%, 80%, 0.3);
|
||||
--tui-selection-bg: hsla(267, 83%, 80%, 0.22);
|
||||
--tui-hero-glow: hsla(267, 83%, 80%, 0.06);
|
||||
--tui-step-hover-bg: hsla(232, 23%, 18%, 0.6);
|
||||
--tui-step-hover-glow: hsla(267, 83%, 80%, 0.3);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -48,7 +67,7 @@
|
||||
|
||||
/* === Selection === */
|
||||
::selection {
|
||||
background: hsla(267, 83%, 80%, 0.22);
|
||||
background: var(--tui-selection-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@@ -102,7 +121,7 @@ button,
|
||||
}
|
||||
|
||||
.VPNav .VPNavBar:not(.has-sidebar) {
|
||||
background: hsla(232, 23%, 18%, 0.82);
|
||||
background: var(--tui-nav-bg);
|
||||
}
|
||||
|
||||
.VPNav .VPNavBar.has-sidebar .content {
|
||||
@@ -245,13 +264,13 @@ button,
|
||||
}
|
||||
|
||||
.vp-doc table tr:hover td {
|
||||
background: hsla(232, 23%, 18%, 0.4);
|
||||
background: var(--tui-table-hover-bg);
|
||||
}
|
||||
|
||||
/* === Links === */
|
||||
.vp-doc a {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid hsla(267, 83%, 80%, 0.3);
|
||||
border-bottom: 1px solid var(--tui-link-underline);
|
||||
transition: border-color var(--tui-transition), color var(--tui-transition);
|
||||
}
|
||||
|
||||
@@ -653,7 +672,7 @@ body {
|
||||
height: 400px;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
hsla(267, 83%, 80%, 0.06) 0%,
|
||||
var(--tui-hero-glow) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
- Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
|
||||
- Added a Node `http` fallback for Electron/runtime paths that do not expose Bun, so stats keeps working there too.
|
||||
- Updated Discord Rich Presence to the maintained `@xhayper/discord-rpc` wrapper.
|
||||
- Fixed the macOS visible-overlay toggle path so manual hides stay hidden and the plugin uses the explicit visible-overlay toggle command.
|
||||
- Restored macOS mpv passthrough while the overlay subtitle sidebar is open so clicks outside the sidebar can refocus mpv and keep native keybindings working.
|
||||
|
||||
## v0.9.3 (2026-03-25)
|
||||
- Moved YouTube primary subtitle language defaults to `youtube.primarySubLanguages`.
|
||||
|
||||
@@ -390,6 +390,8 @@ The sidebar is only available when the active subtitle source has been parsed in
|
||||
|
||||
`embedded` layout is intended to act like a split-pane view: it reserves player space with a right-side video margin and keeps interaction in both the player area and sidebar. If you see unexpected offset behavior in your environment, switch back to `overlay` to isolate sidebar placement.
|
||||
|
||||
For full details on layout modes, behavior, and the keyboard shortcut, see the [Subtitle Sidebar](/subtitle-sidebar) page.
|
||||
|
||||
`jlptColors` keys are:
|
||||
|
||||
| Key | Default | Description |
|
||||
@@ -1197,30 +1199,38 @@ Discord Rich Presence is optional and disabled by default. When enabled, SubMine
|
||||
{
|
||||
"discordPresence": {
|
||||
"enabled": true,
|
||||
"presenceStyle": "default",
|
||||
"updateIntervalMs": 3000,
|
||||
"debounceMs": 750
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------ | --------------- | ---------------------------------------------------------- |
|
||||
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) |
|
||||
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
|
||||
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
|
||||
| Option | Values | Description |
|
||||
| ------------------ | ------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) |
|
||||
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
|
||||
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
|
||||
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
|
||||
|
||||
Setup steps:
|
||||
|
||||
1. Set `discordPresence.enabled` to `true`.
|
||||
2. Restart SubMiner.
|
||||
2. Optionally set `discordPresence.presenceStyle` to choose a card text preset.
|
||||
3. Restart SubMiner.
|
||||
|
||||
SubMiner uses a fixed official activity card style for all users:
|
||||
#### Presence style presets
|
||||
|
||||
- Details: current media title while playing (fallback: `Mining and crafting (Anki cards)` when idle/disconnected)
|
||||
- State: `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss` (fallback: `Idle`)
|
||||
- Large image key/text: `subminer-logo` / `SubMiner`
|
||||
- Small image key/text: `study` / `Sentence Mining`
|
||||
- No activity button by default
|
||||
While playing media, the **Details** line always shows the current media title and **State** shows `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss`. The preset controls what appears when idle and the tooltip text on images.
|
||||
|
||||
| Preset | Idle details | Small image text | Vibe |
|
||||
| ------------ | ----------------------------------- | ------------------ | --------------------------------------- |
|
||||
| **`default`**| `Sentence Mining` | `日本語学習中` | Clean, bilingual flair |
|
||||
| `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke |
|
||||
| `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese |
|
||||
| `minimal` | `SubMiner` | *(none)* | Bare essentials, no small image overlay |
|
||||
|
||||
All presets use the `subminer-logo` large image with `SubMiner` tooltip. No activity button is shown by default.
|
||||
|
||||
Troubleshooting:
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ features:
|
||||
alt: Subtitle download icon
|
||||
title: Subtitle Download & Sync
|
||||
details: Search and pull subtitles from Jimaku, then auto-sync timing with alass or ffsubsync — all from the overlay.
|
||||
link: /configuration#jimaku
|
||||
link: /jimaku-integration
|
||||
linkText: Jimaku integration
|
||||
- icon:
|
||||
src: /assets/tokenization.svg
|
||||
@@ -223,12 +223,12 @@ const demoAssetVersion = '20260223-2';
|
||||
}
|
||||
|
||||
.workflow-step:hover {
|
||||
background: hsla(232, 23%, 18%, 0.6);
|
||||
background: var(--tui-step-hover-bg);
|
||||
}
|
||||
|
||||
.workflow-step:hover .step-number {
|
||||
color: var(--vp-c-brand-1);
|
||||
text-shadow: 0 0 12px hsla(267, 83%, 80%, 0.3);
|
||||
text-shadow: 0 0 12px var(--tui-step-hover-glow);
|
||||
}
|
||||
|
||||
.workflow-connector {
|
||||
|
||||
@@ -172,7 +172,7 @@ Install `mpv` separately and ensure `mpv.exe` is on `PATH`. `ffmpeg` is still re
|
||||
### Windows Usage Notes
|
||||
|
||||
- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, offer mpv plugin installation, open bundled Yomitan settings, and optionally create `SubMiner mpv` Start Menu/Desktop shortcuts.
|
||||
- If you use the mpv plugin, leave `binary_path` empty unless SubMiner is installed in a non-standard location.
|
||||
- First-run mpv plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is installed in a non-standard location.
|
||||
- Windows plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket`; do not keep `/tmp/subminer-socket` on Windows.
|
||||
- Native window tracking is built in on Windows; no `xdotool`, `xwininfo`, or compositor-specific helper is required.
|
||||
|
||||
@@ -201,6 +201,7 @@ mpv must be launched with `--input-ipc-server=/tmp/subminer-socket` for SubMiner
|
||||
:::
|
||||
|
||||
On Windows, the packaged plugin config is rewritten to `socket_path=\\.\pipe\subminer-socket`.
|
||||
First-run setup also pins `binary_path` to the current app binary so mpv launches the same SubMiner build that installed the plugin.
|
||||
|
||||
```bash
|
||||
# Option 1: install from release assets bundle
|
||||
|
||||
@@ -131,6 +131,6 @@ Verify mpv is running and connected via IPC. SubMiner loads the subtitle by issu
|
||||
|
||||
## Related
|
||||
|
||||
- [Configuration Reference](/configuration#jimaku) — full config section
|
||||
- [Configuration Reference](/configuration#jimaku) — full config options
|
||||
- [Mining Workflow](/mining-workflow#jimaku-subtitle-search) — how Jimaku fits into the sentence mining loop
|
||||
- [Troubleshooting](/troubleshooting#jimaku) — additional error guidance
|
||||
|
||||
@@ -498,6 +498,7 @@
|
||||
// ==========================================
|
||||
"discordPresence": {
|
||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
|
||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||
|
||||
71
docs-site/subtitle-sidebar.md
Normal file
71
docs-site/subtitle-sidebar.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Subtitle Sidebar
|
||||
|
||||
The subtitle sidebar displays the full parsed cue list for the active subtitle file as a scrollable panel alongside mpv. It lets you review past and upcoming lines, click any cue to seek directly to that moment, and follow along without depending on the transient overlay subtitles.
|
||||
|
||||
The sidebar is opt-in and disabled by default. Enable it under `subtitleSidebar.enabled` in your config.
|
||||
|
||||
## How It Works
|
||||
|
||||
When SubMiner parses the active subtitle source into a cue list, the sidebar becomes available. Toggle it with the `\` key (configurable via `subtitleSidebar.toggleKey`). While open:
|
||||
|
||||
- The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`).
|
||||
- Clicking any cue seeks mpv to that timestamp.
|
||||
- The sidebar stays synchronized with the overlay — media transitions and subtitle source changes update both simultaneously.
|
||||
|
||||
The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar.
|
||||
|
||||
## Layout Modes
|
||||
|
||||
Two layout modes are available via `subtitleSidebar.layout`:
|
||||
|
||||
**`overlay`** (default) — The sidebar floats over mpv as a panel. It does not affect the player window size or position.
|
||||
|
||||
**`embedded`** — Reserves space on the right side of the player and shifts the video area to mimic a split-pane layout. Useful if you want the cue list visible without it covering the video. If you see unexpected positioning in your environment, switch back to `overlay` to isolate the issue.
|
||||
|
||||
## Configuration
|
||||
|
||||
Enable and configure the sidebar under `subtitleSidebar` in your config file:
|
||||
|
||||
```json
|
||||
{
|
||||
"subtitleSidebar": {
|
||||
"enabled": false,
|
||||
"autoOpen": false,
|
||||
"layout": "overlay",
|
||||
"toggleKey": "Backslash",
|
||||
"pauseVideoOnHover": false,
|
||||
"autoScroll": true,
|
||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
||||
"fontSize": 16
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------------------------- | ------- | ------------ | -------------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | boolean | `false` | Enable subtitle sidebar support |
|
||||
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
|
||||
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
|
||||
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
|
||||
| `pauseVideoOnHover` | boolean | `false` | Pause playback while hovering the cue list |
|
||||
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
|
||||
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
|
||||
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
|
||||
| `backgroundColor` | string | — | Sidebar shell background color |
|
||||
| `textColor` | string | — | Default cue text color |
|
||||
| `fontFamily` | string | — | CSS `font-family` applied to cue text |
|
||||
| `fontSize` | number | `16` | Base cue font size in CSS pixels |
|
||||
| `timestampColor` | string | — | Cue timestamp color |
|
||||
| `activeLineColor` | string | — | Active cue text color |
|
||||
| `activeLineBackgroundColor` | string | — | Active cue background color |
|
||||
| `hoverLineBackgroundColor` | string | — | Hovered cue background color |
|
||||
|
||||
Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like a solid overlay.
|
||||
|
||||
## Keyboard Shortcut
|
||||
|
||||
| Key | Action | Config key |
|
||||
| --- | ----------------------- | ------------------------------ |
|
||||
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
|
||||
|
||||
The toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source. See [Keyboard Shortcuts](/shortcuts) for the full shortcut reference.
|
||||
@@ -153,6 +153,9 @@ function M.create(ctx)
|
||||
|
||||
local function notify_auto_play_ready()
|
||||
release_auto_play_ready_gate("tokenization-ready")
|
||||
if state.suppress_ready_overlay_restore then
|
||||
return
|
||||
end
|
||||
if state.overlay_running and resolve_visible_overlay_startup() then
|
||||
run_control_command_async("show-visible-overlay", {
|
||||
socket_path = opts.socket_path,
|
||||
@@ -287,6 +290,9 @@ function M.create(ctx)
|
||||
|
||||
local function start_overlay(overrides)
|
||||
overrides = overrides or {}
|
||||
if overrides.auto_start_trigger == true then
|
||||
state.suppress_ready_overlay_restore = false
|
||||
end
|
||||
|
||||
if not binary.ensure_binary_available() then
|
||||
subminer_log("error", "binary", "SubMiner binary not found")
|
||||
@@ -433,6 +439,7 @@ function M.create(ctx)
|
||||
subminer_log("error", "binary", "SubMiner binary not found")
|
||||
return
|
||||
end
|
||||
state.suppress_ready_overlay_restore = true
|
||||
|
||||
run_control_command_async("hide-visible-overlay", nil, function(ok, result)
|
||||
if ok then
|
||||
@@ -456,8 +463,9 @@ function M.create(ctx)
|
||||
show_osd("Error: binary not found")
|
||||
return
|
||||
end
|
||||
state.suppress_ready_overlay_restore = true
|
||||
|
||||
run_control_command_async("toggle", nil, function(ok)
|
||||
run_control_command_async("toggle-visible-overlay", nil, function(ok)
|
||||
if not ok then
|
||||
subminer_log("warn", "process", "Toggle command failed")
|
||||
show_osd("Toggle failed")
|
||||
|
||||
@@ -32,6 +32,7 @@ function M.new()
|
||||
auto_play_ready_gate_armed = false,
|
||||
auto_play_ready_timeout = nil,
|
||||
auto_play_ready_osd_timer = nil,
|
||||
suppress_ready_overlay_restore = false,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -822,6 +822,92 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for manual toggle-off ready scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
|
||||
recorded.script_messages["subminer-toggle"]()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1,
|
||||
"manual toggle should use explicit visible-overlay toggle command"
|
||||
)
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||
"manual toggle-off before readiness should suppress ready-time visible overlay restore"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(
|
||||
recorded ~= nil,
|
||||
"plugin failed to load for repeated ready restore suppression scenario: " .. tostring(err)
|
||||
)
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
|
||||
recorded.script_messages["subminer-toggle"]()
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||
"manual toggle-off should suppress repeated ready-time visible overlay restores for the same session"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
},
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for manual toggle command scenario: " .. tostring(err))
|
||||
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
|
||||
recorded.script_messages["subminer-toggle"]()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1,
|
||||
"script-message toggle should issue explicit visible-overlay toggle command"
|
||||
)
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--toggle") == 0,
|
||||
"script-message toggle should not issue legacy generic toggle command"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
|
||||
@@ -129,6 +129,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
},
|
||||
discordPresence: {
|
||||
enabled: false,
|
||||
presenceStyle: 'default' as const,
|
||||
updateIntervalMs: 3_000,
|
||||
debounceMs: 750,
|
||||
},
|
||||
|
||||
@@ -323,6 +323,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.discordPresence.enabled,
|
||||
description: 'Enable optional Discord Rich Presence updates.',
|
||||
},
|
||||
{
|
||||
path: 'discordPresence.presenceStyle',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.discordPresence.presenceStyle,
|
||||
description:
|
||||
'Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".',
|
||||
},
|
||||
{
|
||||
path: 'discordPresence.updateIntervalMs',
|
||||
kind: 'number',
|
||||
|
||||
@@ -443,13 +443,23 @@ test('handleCliCommand still runs non-start actions on second-instance', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand connects MPV for toggle on second-instance', () => {
|
||||
test('handleCliCommand does not connect MPV for pure toggle on second-instance', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ toggle: true }), 'second-instance', deps);
|
||||
assert.ok(calls.includes('toggleVisibleOverlay'));
|
||||
assert.equal(
|
||||
calls.some((value) => value === 'connectMpvClient'),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand does not connect MPV for explicit visible-overlay toggle', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ toggleVisibleOverlay: true }), 'second-instance', deps);
|
||||
assert.ok(calls.includes('toggleVisibleOverlay'));
|
||||
assert.equal(
|
||||
calls.some((value) => value === 'connectMpvClient'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ export function handleCliCommand(
|
||||
|
||||
const reuseSecondInstanceStart =
|
||||
source === 'second-instance' && args.start && deps.isOverlayRuntimeInitialized();
|
||||
const shouldStart = args.start || args.toggle || args.toggleVisibleOverlay;
|
||||
const shouldConnectMpv = args.start;
|
||||
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
|
||||
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
|
||||
|
||||
@@ -302,7 +302,7 @@ export function handleCliCommand(
|
||||
deps.initializeOverlayRuntime();
|
||||
}
|
||||
|
||||
if (shouldStart && deps.hasMpvClient()) {
|
||||
if (shouldConnectMpv && deps.hasMpvClient()) {
|
||||
const socketPath = deps.getMpvSocketPath();
|
||||
deps.setMpvClientSocketPath(socketPath);
|
||||
deps.connectMpvClient();
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
const baseConfig = {
|
||||
enabled: true,
|
||||
presenceStyle: 'default' as const,
|
||||
updateIntervalMs: 10_000,
|
||||
debounceMs: 200,
|
||||
} as const;
|
||||
@@ -27,24 +28,67 @@ const baseSnapshot: DiscordPresenceSnapshot = {
|
||||
sessionStartedAtMs: BASE_SESSION_STARTED_AT_MS,
|
||||
};
|
||||
|
||||
test('buildDiscordPresenceActivity maps polished payload fields', () => {
|
||||
test('buildDiscordPresenceActivity maps polished payload fields (default style)', () => {
|
||||
const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot);
|
||||
assert.equal(payload.details, 'Sousou no Frieren E01');
|
||||
assert.equal(payload.state, 'Playing 01:35 / 24:10');
|
||||
assert.equal(payload.largeImageKey, 'subminer-logo');
|
||||
assert.equal(payload.smallImageKey, 'study');
|
||||
assert.equal(payload.smallImageText, '日本語学習中');
|
||||
assert.equal(payload.buttons, undefined);
|
||||
assert.equal(payload.startTimestamp, Math.floor(BASE_SESSION_STARTED_AT_MS / 1000));
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity falls back to idle when disconnected', () => {
|
||||
test('buildDiscordPresenceActivity falls back to idle with default style', () => {
|
||||
const payload = buildDiscordPresenceActivity(baseConfig, {
|
||||
...baseSnapshot,
|
||||
connected: false,
|
||||
mediaPath: null,
|
||||
});
|
||||
assert.equal(payload.state, 'Idle');
|
||||
assert.equal(payload.details, 'Sentence Mining');
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity uses meme style fallback', () => {
|
||||
const memeConfig = { ...baseConfig, presenceStyle: 'meme' as const };
|
||||
const payload = buildDiscordPresenceActivity(memeConfig, {
|
||||
...baseSnapshot,
|
||||
connected: false,
|
||||
mediaPath: null,
|
||||
});
|
||||
assert.equal(payload.details, 'Mining and crafting (Anki cards)');
|
||||
assert.equal(payload.smallImageText, 'Sentence Mining');
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity uses japanese style', () => {
|
||||
const jpConfig = { ...baseConfig, presenceStyle: 'japanese' as const };
|
||||
const payload = buildDiscordPresenceActivity(jpConfig, {
|
||||
...baseSnapshot,
|
||||
connected: false,
|
||||
mediaPath: null,
|
||||
});
|
||||
assert.equal(payload.details, '文の採掘中');
|
||||
assert.equal(payload.smallImageText, 'イマージョン学習');
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity uses minimal style', () => {
|
||||
const minConfig = { ...baseConfig, presenceStyle: 'minimal' as const };
|
||||
const payload = buildDiscordPresenceActivity(minConfig, {
|
||||
...baseSnapshot,
|
||||
connected: false,
|
||||
mediaPath: null,
|
||||
});
|
||||
assert.equal(payload.details, 'SubMiner');
|
||||
assert.equal(payload.smallImageKey, undefined);
|
||||
assert.equal(payload.smallImageText, undefined);
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity shows media title regardless of style', () => {
|
||||
for (const presenceStyle of ['default', 'meme', 'japanese', 'minimal'] as const) {
|
||||
const payload = buildDiscordPresenceActivity({ ...baseConfig, presenceStyle }, baseSnapshot);
|
||||
assert.equal(payload.details, 'Sousou no Frieren E01');
|
||||
assert.equal(payload.state, 'Playing 01:35 / 24:10');
|
||||
}
|
||||
});
|
||||
|
||||
test('service deduplicates identical updates and sends changed timeline', async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DiscordPresenceStylePreset } from '../../types/integrations';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
|
||||
export interface DiscordPresenceSnapshot {
|
||||
@@ -33,15 +34,58 @@ type DiscordClient = {
|
||||
|
||||
type TimeoutLike = ReturnType<typeof setTimeout>;
|
||||
|
||||
const DISCORD_PRESENCE_STYLE = {
|
||||
fallbackDetails: 'Mining and crafting (Anki cards)',
|
||||
largeImageKey: 'subminer-logo',
|
||||
largeImageText: 'SubMiner',
|
||||
smallImageKey: 'study',
|
||||
smallImageText: 'Sentence Mining',
|
||||
buttonLabel: '',
|
||||
buttonUrl: '',
|
||||
} as const;
|
||||
interface PresenceStyleDefinition {
|
||||
fallbackDetails: string;
|
||||
largeImageKey: string;
|
||||
largeImageText: string;
|
||||
smallImageKey: string;
|
||||
smallImageText: string;
|
||||
buttonLabel: string;
|
||||
buttonUrl: string;
|
||||
}
|
||||
|
||||
const PRESENCE_STYLES: Record<DiscordPresenceStylePreset, PresenceStyleDefinition> = {
|
||||
default: {
|
||||
fallbackDetails: 'Sentence Mining',
|
||||
largeImageKey: 'subminer-logo',
|
||||
largeImageText: 'SubMiner',
|
||||
smallImageKey: 'study',
|
||||
smallImageText: '日本語学習中',
|
||||
buttonLabel: '',
|
||||
buttonUrl: '',
|
||||
},
|
||||
meme: {
|
||||
fallbackDetails: 'Mining and crafting (Anki cards)',
|
||||
largeImageKey: 'subminer-logo',
|
||||
largeImageText: 'SubMiner',
|
||||
smallImageKey: 'study',
|
||||
smallImageText: 'Sentence Mining',
|
||||
buttonLabel: '',
|
||||
buttonUrl: '',
|
||||
},
|
||||
japanese: {
|
||||
fallbackDetails: '文の採掘中',
|
||||
largeImageKey: 'subminer-logo',
|
||||
largeImageText: 'SubMiner',
|
||||
smallImageKey: 'study',
|
||||
smallImageText: 'イマージョン学習',
|
||||
buttonLabel: '',
|
||||
buttonUrl: '',
|
||||
},
|
||||
minimal: {
|
||||
fallbackDetails: 'SubMiner',
|
||||
largeImageKey: 'subminer-logo',
|
||||
largeImageText: 'SubMiner',
|
||||
smallImageKey: '',
|
||||
smallImageText: '',
|
||||
buttonLabel: '',
|
||||
buttonUrl: '',
|
||||
},
|
||||
};
|
||||
|
||||
function resolvePresenceStyle(preset: DiscordPresenceStylePreset | undefined): PresenceStyleDefinition {
|
||||
return PRESENCE_STYLES[preset ?? 'default'] ?? PRESENCE_STYLES.default;
|
||||
}
|
||||
|
||||
function trimField(value: string, maxLength = 128): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
@@ -79,15 +123,16 @@ function formatClock(totalSeconds: number | null | undefined): string {
|
||||
}
|
||||
|
||||
export function buildDiscordPresenceActivity(
|
||||
_config: DiscordPresenceConfig,
|
||||
config: DiscordPresenceConfig,
|
||||
snapshot: DiscordPresenceSnapshot,
|
||||
): DiscordActivityPayload {
|
||||
const style = resolvePresenceStyle(config.presenceStyle);
|
||||
const status = buildStatus(snapshot);
|
||||
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
||||
const details =
|
||||
snapshot.connected && snapshot.mediaPath
|
||||
? trimField(title)
|
||||
: DISCORD_PRESENCE_STYLE.fallbackDetails;
|
||||
: style.fallbackDetails;
|
||||
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
||||
const state =
|
||||
snapshot.connected && snapshot.mediaPath
|
||||
@@ -100,26 +145,26 @@ export function buildDiscordPresenceActivity(
|
||||
startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000),
|
||||
};
|
||||
|
||||
if (DISCORD_PRESENCE_STYLE.largeImageKey.trim().length > 0) {
|
||||
activity.largeImageKey = DISCORD_PRESENCE_STYLE.largeImageKey.trim();
|
||||
if (style.largeImageKey.trim().length > 0) {
|
||||
activity.largeImageKey = style.largeImageKey.trim();
|
||||
}
|
||||
if (DISCORD_PRESENCE_STYLE.largeImageText.trim().length > 0) {
|
||||
activity.largeImageText = trimField(DISCORD_PRESENCE_STYLE.largeImageText.trim());
|
||||
if (style.largeImageText.trim().length > 0) {
|
||||
activity.largeImageText = trimField(style.largeImageText.trim());
|
||||
}
|
||||
if (DISCORD_PRESENCE_STYLE.smallImageKey.trim().length > 0) {
|
||||
activity.smallImageKey = DISCORD_PRESENCE_STYLE.smallImageKey.trim();
|
||||
if (style.smallImageKey.trim().length > 0) {
|
||||
activity.smallImageKey = style.smallImageKey.trim();
|
||||
}
|
||||
if (DISCORD_PRESENCE_STYLE.smallImageText.trim().length > 0) {
|
||||
activity.smallImageText = trimField(DISCORD_PRESENCE_STYLE.smallImageText.trim());
|
||||
if (style.smallImageText.trim().length > 0) {
|
||||
activity.smallImageText = trimField(style.smallImageText.trim());
|
||||
}
|
||||
if (
|
||||
DISCORD_PRESENCE_STYLE.buttonLabel.trim().length > 0 &&
|
||||
/^https?:\/\//.test(DISCORD_PRESENCE_STYLE.buttonUrl.trim())
|
||||
style.buttonLabel.trim().length > 0 &&
|
||||
/^https?:\/\//.test(style.buttonUrl.trim())
|
||||
) {
|
||||
activity.buttons = [
|
||||
{
|
||||
label: trimField(DISCORD_PRESENCE_STYLE.buttonLabel.trim(), 32),
|
||||
url: DISCORD_PRESENCE_STYLE.buttonUrl.trim(),
|
||||
label: trimField(style.buttonLabel.trim(), 32),
|
||||
url: style.buttonUrl.trim(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -59,3 +59,21 @@ export function handleOverlayWindowBeforeInputEvent(options: {
|
||||
options.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function handleOverlayWindowBlurred(options: {
|
||||
kind: OverlayWindowKind;
|
||||
windowVisible: boolean;
|
||||
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
||||
ensureOverlayWindowLevel: () => void;
|
||||
moveWindowTop: () => void;
|
||||
}): boolean {
|
||||
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
options.ensureOverlayWindowLevel();
|
||||
if (options.kind === 'visible' && options.windowVisible) {
|
||||
options.moveWindowTop();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
handleOverlayWindowBeforeInputEvent,
|
||||
handleOverlayWindowBlurred,
|
||||
isTabInputForMpvForwarding,
|
||||
} from './overlay-window-input';
|
||||
|
||||
@@ -82,3 +83,58 @@ test('handleOverlayWindowBeforeInputEvent leaves modal Tab handling alone', () =
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred skips visible overlay restacking after manual hide', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = handleOverlayWindowBlurred({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
isOverlayVisible: () => false,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
assert.equal(
|
||||
handleOverlayWindowBlurred({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
isOverlayVisible: () => true,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-visible');
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-visible');
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
handleOverlayWindowBlurred({
|
||||
kind: 'modal',
|
||||
windowVisible: true,
|
||||
isOverlayVisible: () => false,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-modal');
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-modal');
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['ensure-visible', 'move-visible', 'ensure-modal']);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLogger } from '../../logger';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
handleOverlayWindowBeforeInputEvent,
|
||||
handleOverlayWindowBlurred,
|
||||
type OverlayWindowKind,
|
||||
} from './overlay-window-input';
|
||||
import { buildOverlayWindowOptions } from './overlay-window-options';
|
||||
@@ -124,12 +125,18 @@ export function createOverlayWindow(
|
||||
});
|
||||
|
||||
window.on('blur', () => {
|
||||
if (!window.isDestroyed()) {
|
||||
options.ensureOverlayWindowLevel(window);
|
||||
if (kind === 'visible' && window.isVisible()) {
|
||||
if (window.isDestroyed()) return;
|
||||
handleOverlayWindowBlurred({
|
||||
kind,
|
||||
windowVisible: window.isVisible(),
|
||||
isOverlayVisible: options.isOverlayVisible,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
options.ensureOverlayWindowLevel(window);
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
window.moveTop();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (options.isDev && kind === 'visible') {
|
||||
|
||||
@@ -35,6 +35,21 @@ test('parseSrtCues handles multi-line subtitle text', () => {
|
||||
assert.equal(cues[0]!.text, 'これは\nテストです');
|
||||
});
|
||||
|
||||
test('parseSrtCues strips HTML-like markup while preserving line breaks', () => {
|
||||
const content = [
|
||||
'1',
|
||||
'00:01:00,000 --> 00:01:05,000',
|
||||
'<font color="japanese">これは</font>',
|
||||
'<font color="japanese">テストです</font>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSrtCues(content);
|
||||
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]!.text, 'これは\nテストです');
|
||||
});
|
||||
|
||||
test('parseSrtCues handles hours in timestamps', () => {
|
||||
const content = ['1', '01:30:00,000 --> 01:30:05,000', 'テスト', ''].join('\n');
|
||||
|
||||
@@ -134,6 +149,18 @@ test('parseAssCues handles \\N line breaks', () => {
|
||||
assert.equal(cues[0]!.text, '一行目\\N二行目');
|
||||
});
|
||||
|
||||
test('parseAssCues strips HTML-like markup while preserving ASS line breaks', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,<font color="japanese">一行目</font>\\N<font color="japanese">二行目</font>',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseAssCues(content);
|
||||
|
||||
assert.equal(cues[0]!.text, '一行目\\N二行目');
|
||||
});
|
||||
|
||||
test('parseAssCues returns empty for content without Events section', () => {
|
||||
const content = ['[Script Info]', 'Title: Test'].join('\n');
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface SubtitleCue {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const HTML_SUBTITLE_TAG_PATTERN = /<\/?[A-Za-z][^>\n]*>/g;
|
||||
|
||||
const SRT_TIMING_PATTERN =
|
||||
/^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/;
|
||||
|
||||
@@ -21,6 +23,10 @@ function parseTimestamp(
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeSubtitleCueText(text: string): string {
|
||||
return text.replace(ASS_OVERRIDE_TAG_PATTERN, '').replace(HTML_SUBTITLE_TAG_PATTERN, '').trim();
|
||||
}
|
||||
|
||||
export function parseSrtCues(content: string): SubtitleCue[] {
|
||||
const cues: SubtitleCue[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
@@ -54,7 +60,7 @@ export function parseSrtCues(content: string): SubtitleCue[] {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const text = textLines.join('\n').trim();
|
||||
const text = sanitizeSubtitleCueText(textLines.join('\n'));
|
||||
if (text) {
|
||||
cues.push({ startTime, endTime, text });
|
||||
}
|
||||
@@ -140,13 +146,9 @@ export function parseAssCues(content: string): SubtitleCue[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawText = fields
|
||||
.slice(textFieldIndex)
|
||||
.join(',')
|
||||
.replace(ASS_OVERRIDE_TAG_PATTERN, '')
|
||||
.trim();
|
||||
if (rawText) {
|
||||
cues.push({ startTime, endTime, text: rawText });
|
||||
const text = sanitizeSubtitleCueText(fields.slice(textFieldIndex).join(','));
|
||||
if (text) {
|
||||
cues.push({ startTime, endTime, text });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
src/main.ts
10
src/main.ts
@@ -356,6 +356,7 @@ import {
|
||||
import {
|
||||
detectInstalledFirstRunPlugin,
|
||||
installFirstRunPluginToDefaultLocation,
|
||||
syncInstalledFirstRunPluginBinaryPath,
|
||||
} from './main/runtime/first-run-setup-plugin';
|
||||
import {
|
||||
applyWindowsMpvShortcuts,
|
||||
@@ -1002,7 +1003,7 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
||||
mpvYtdlFormat: YOUTUBE_MPV_YTDL_FORMAT,
|
||||
autoLaunchTimeoutMs: YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS,
|
||||
connectTimeoutMs: YOUTUBE_MPV_CONNECT_TIMEOUT_MS,
|
||||
socketPath: appState.mpvSocketPath,
|
||||
getSocketPath: () => appState.mpvSocketPath,
|
||||
getMpvConnected: () => Boolean(appState.mpvClient?.connected),
|
||||
invalidatePendingAutoplayReadyFallbacks: () =>
|
||||
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(),
|
||||
@@ -1036,6 +1037,12 @@ const resolveWindowsMpvShortcutRuntimePaths = () =>
|
||||
appDataDir: app.getPath('appData'),
|
||||
desktopDir: app.getPath('desktop'),
|
||||
});
|
||||
syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
binaryPath: process.execPath,
|
||||
});
|
||||
const firstRunSetupService = createFirstRunSetupService({
|
||||
platform: process.platform,
|
||||
configDir: CONFIG_DIR,
|
||||
@@ -1065,6 +1072,7 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
dirname: __dirname,
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
binaryPath: process.execPath,
|
||||
}),
|
||||
detectWindowsMpvShortcuts: () => {
|
||||
if (process.platform !== 'win32') {
|
||||
|
||||
@@ -33,13 +33,66 @@ test('autoplay ready gate suppresses duplicate media signals unless forced while
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const firstScheduled = scheduled.shift();
|
||||
firstScheduled?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands.slice(0, 3), [
|
||||
['script-message', 'subminer-autoplay-ready'],
|
||||
['script-message', 'subminer-autoplay-ready'],
|
||||
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
|
||||
['script-message', 'subminer-autoplay-ready'],
|
||||
]);
|
||||
assert.ok(commands.some((command) => command[0] === 'set_property' && command[1] === 'pause'));
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
),
|
||||
);
|
||||
assert.equal(scheduled.length > 0, true);
|
||||
});
|
||||
|
||||
test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
for (const callback of scheduled.splice(0, 3)) {
|
||||
callback();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
|
||||
['script-message', 'subminer-autoplay-ready'],
|
||||
]);
|
||||
assert.equal(
|
||||
commands.filter(
|
||||
(command) =>
|
||||
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
).length > 0,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,19 +46,6 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
||||
const allowDuplicateWhilePaused =
|
||||
options?.forceWhilePaused === true && deps.getPlaybackPaused() !== false;
|
||||
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (duplicateMediaSignal && allowDuplicateWhilePaused) {
|
||||
deps.signalPluginAutoplayReady();
|
||||
return;
|
||||
}
|
||||
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
deps.signalPluginAutoplayReady();
|
||||
|
||||
const releaseRetryDelayMs = 200;
|
||||
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
||||
forceWhilePaused: options?.forceWhilePaused === true,
|
||||
@@ -88,7 +75,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
return true;
|
||||
};
|
||||
|
||||
const attemptRelease = (attempt: number): void => {
|
||||
const attemptRelease = (playbackGeneration: number, attempt: number): void => {
|
||||
void (async () => {
|
||||
if (
|
||||
autoPlayReadySignalMediaPath !== mediaPath ||
|
||||
@@ -100,7 +87,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (!mpvClient?.connected) {
|
||||
if (attempt < maxReleaseAttempts) {
|
||||
deps.schedule(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
|
||||
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -110,15 +97,27 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.signalPluginAutoplayReady();
|
||||
mpvClient.send({ command: ['set_property', 'pause', false] });
|
||||
if (attempt < maxReleaseAttempts) {
|
||||
deps.schedule(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
|
||||
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
attemptRelease(0);
|
||||
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!duplicateMediaSignal) {
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
deps.signalPluginAutoplayReady();
|
||||
attemptRelease(playbackGeneration, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
attemptRelease(playbackGeneration, 0);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
detectInstalledFirstRunPlugin,
|
||||
installFirstRunPluginToDefaultLocation,
|
||||
resolvePackagedFirstRunPluginAssets,
|
||||
syncInstalledFirstRunPluginBinaryPath,
|
||||
} from './first-run-setup-plugin';
|
||||
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
|
||||
|
||||
@@ -68,13 +69,17 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
|
||||
dirname: path.join(root, 'dist', 'main', 'runtime'),
|
||||
appPath: path.join(root, 'app'),
|
||||
resourcesPath,
|
||||
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.pluginInstallStatus, 'installed');
|
||||
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
|
||||
assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin');
|
||||
assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n');
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'configured=true\nbinary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\n',
|
||||
);
|
||||
|
||||
const scriptsDirEntries = fs.readdirSync(installPaths.scriptsDir);
|
||||
const scriptOptsEntries = fs.readdirSync(installPaths.scriptOptsDir);
|
||||
@@ -113,13 +118,17 @@ test('installFirstRunPluginToDefaultLocation installs plugin to Windows mpv defa
|
||||
dirname: path.join(root, 'dist', 'main', 'runtime'),
|
||||
appPath: path.join(root, 'app'),
|
||||
resourcesPath,
|
||||
binaryPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.pluginInstallStatus, 'installed');
|
||||
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
|
||||
assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin');
|
||||
assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n');
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'configured=true\nbinary_path=C:\\Program Files\\SubMiner\\SubMiner.exe\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,12 +155,70 @@ test('installFirstRunPluginToDefaultLocation rewrites Windows plugin socket_path
|
||||
dirname: path.join(root, 'dist', 'main', 'runtime'),
|
||||
appPath: path.join(root, 'app'),
|
||||
resourcesPath,
|
||||
binaryPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'binary_path=\nsocket_path=\\\\.\\pipe\\subminer-socket\n',
|
||||
'binary_path=C:\\Program Files\\SubMiner\\SubMiner.exe\nsocket_path=\\\\.\\pipe\\subminer-socket\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing installs', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
|
||||
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
|
||||
fs.writeFileSync(installPaths.pluginConfigPath, 'binary_path=\nsocket_path=/tmp/subminer-socket\n');
|
||||
|
||||
const result = syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: 'linux',
|
||||
homeDir,
|
||||
xdgConfigHome,
|
||||
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
updated: true,
|
||||
configPath: installPaths.pluginConfigPath,
|
||||
});
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'binary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('syncInstalledFirstRunPluginBinaryPath preserves explicit binary_path overrides', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
|
||||
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
installPaths.pluginConfigPath,
|
||||
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
|
||||
const result = syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: 'linux',
|
||||
homeDir,
|
||||
xdgConfigHome,
|
||||
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
updated: false,
|
||||
configPath: installPaths.pluginConfigPath,
|
||||
});
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,43 @@ function rewriteInstalledWindowsPluginConfig(configPath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizePluginConfigValue(value: string): string {
|
||||
return value.replace(/[\r\n]/g, '').trim();
|
||||
}
|
||||
|
||||
function upsertPluginConfigLine(content: string, key: string, value: string): string {
|
||||
const normalizedValue = sanitizePluginConfigValue(value);
|
||||
const line = `${key}=${normalizedValue}`;
|
||||
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
||||
if (pattern.test(content)) {
|
||||
return content.replace(pattern, line);
|
||||
}
|
||||
|
||||
const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
|
||||
return `${content}${suffix}${line}\n`;
|
||||
}
|
||||
|
||||
function rewriteInstalledPluginBinaryPath(configPath: string, binaryPath: string): boolean {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const updated = upsertPluginConfigLine(content, 'binary_path', binaryPath);
|
||||
if (updated === content) {
|
||||
return false;
|
||||
}
|
||||
fs.writeFileSync(configPath, updated, 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
function readInstalledPluginBinaryPath(configPath: string): string | null {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const match = content.match(/^binary_path=(.*)$/m);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const rawValue = match[1] ?? '';
|
||||
const value = sanitizePluginConfigValue(rawValue);
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function resolvePackagedFirstRunPluginAssets(deps: {
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
@@ -79,6 +116,7 @@ export function installFirstRunPluginToDefaultLocation(options: {
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
binaryPath: string;
|
||||
}): PluginInstallResult {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
options.platform,
|
||||
@@ -116,6 +154,7 @@ export function installFirstRunPluginToDefaultLocation(options: {
|
||||
backupExistingPath(installPaths.pluginConfigPath);
|
||||
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
|
||||
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
|
||||
rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
|
||||
if (options.platform === 'win32') {
|
||||
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
|
||||
}
|
||||
@@ -127,3 +166,33 @@ export function installFirstRunPluginToDefaultLocation(options: {
|
||||
message: `Installed mpv plugin to ${installPaths.mpvConfigDir}.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncInstalledFirstRunPluginBinaryPath(options: {
|
||||
platform: NodeJS.Platform;
|
||||
homeDir: string;
|
||||
xdgConfigHome?: string;
|
||||
binaryPath: string;
|
||||
}): { updated: boolean; configPath: string | null } {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
options.platform,
|
||||
options.homeDir,
|
||||
options.xdgConfigHome,
|
||||
);
|
||||
if (!installPaths.supported || !fs.existsSync(installPaths.pluginConfigPath)) {
|
||||
return { updated: false, configPath: null };
|
||||
}
|
||||
|
||||
const configuredBinaryPath = readInstalledPluginBinaryPath(installPaths.pluginConfigPath);
|
||||
if (configuredBinaryPath) {
|
||||
return { updated: false, configPath: installPaths.pluginConfigPath };
|
||||
}
|
||||
|
||||
const updated = rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
|
||||
if (options.platform === 'win32') {
|
||||
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
|
||||
}
|
||||
return {
|
||||
updated,
|
||||
configPath: installPaths.pluginConfigPath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ test('youtube playback runtime resets flow ownership after a successful run', as
|
||||
const calls: string[] = [];
|
||||
let appOwnedFlowInFlight = false;
|
||||
let timeoutCallback: (() => void) | null = null;
|
||||
let socketPath = '/tmp/mpv.sock';
|
||||
|
||||
const runtime = createYoutubePlaybackRuntime({
|
||||
platform: 'linux',
|
||||
@@ -13,7 +14,7 @@ test('youtube playback runtime resets flow ownership after a successful run', as
|
||||
mpvYtdlFormat: 'bestvideo+bestaudio',
|
||||
autoLaunchTimeoutMs: 2_000,
|
||||
connectTimeoutMs: 1_000,
|
||||
socketPath: '/tmp/mpv.sock',
|
||||
getSocketPath: () => socketPath,
|
||||
getMpvConnected: () => true,
|
||||
invalidatePendingAutoplayReadyFallbacks: () => {
|
||||
calls.push('invalidate-autoplay');
|
||||
@@ -78,3 +79,70 @@ test('youtube playback runtime resets flow ownership after a successful run', as
|
||||
scheduledCallback();
|
||||
assert.equal(runtime.getQuitOnDisconnectArmed(), true);
|
||||
});
|
||||
|
||||
test('youtube playback runtime resolves the socket path lazily for windows startup', async () => {
|
||||
const calls: string[] = [];
|
||||
let socketPath = '/tmp/initial.sock';
|
||||
|
||||
const runtime = createYoutubePlaybackRuntime({
|
||||
platform: 'win32',
|
||||
directPlaybackFormat: 'best',
|
||||
mpvYtdlFormat: 'bestvideo+bestaudio',
|
||||
autoLaunchTimeoutMs: 2_000,
|
||||
connectTimeoutMs: 1_000,
|
||||
getSocketPath: () => socketPath,
|
||||
getMpvConnected: () => false,
|
||||
invalidatePendingAutoplayReadyFallbacks: () => {
|
||||
calls.push('invalidate-autoplay');
|
||||
},
|
||||
setAppOwnedFlowInFlight: (next) => {
|
||||
calls.push(`app-owned:${next}`);
|
||||
},
|
||||
ensureYoutubePlaybackRuntimeReady: async () => {
|
||||
calls.push('ensure-runtime-ready');
|
||||
},
|
||||
resolveYoutubePlaybackUrl: async (url, format) => {
|
||||
calls.push(`resolve:${url}:${format}`);
|
||||
return 'https://example.com/direct';
|
||||
},
|
||||
launchWindowsMpv: (_playbackUrl, args) => {
|
||||
calls.push(`launch:${args.join(' ')}`);
|
||||
return { ok: true, mpvPath: '/usr/bin/mpv' };
|
||||
},
|
||||
waitForYoutubeMpvConnected: async (timeoutMs) => {
|
||||
calls.push(`wait-connected:${timeoutMs}`);
|
||||
return true;
|
||||
},
|
||||
prepareYoutubePlaybackInMpv: async ({ url }) => {
|
||||
calls.push(`prepare:${url}`);
|
||||
return true;
|
||||
},
|
||||
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
||||
calls.push(`run-flow:${url}:${mode}`);
|
||||
},
|
||||
logInfo: (message) => {
|
||||
calls.push(`info:${message}`);
|
||||
},
|
||||
logWarn: (message) => {
|
||||
calls.push(`warn:${message}`);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
calls.push('schedule-arm');
|
||||
callback();
|
||||
return 1 as never;
|
||||
},
|
||||
clearScheduled: () => {
|
||||
calls.push('clear-scheduled');
|
||||
},
|
||||
});
|
||||
|
||||
socketPath = '/tmp/updated.sock';
|
||||
|
||||
await runtime.runYoutubePlaybackFlow({
|
||||
url: 'https://youtu.be/demo',
|
||||
mode: 'download',
|
||||
source: 'initial',
|
||||
});
|
||||
|
||||
assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock')));
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export type YoutubePlaybackRuntimeDeps = {
|
||||
mpvYtdlFormat: string;
|
||||
autoLaunchTimeoutMs: number;
|
||||
connectTimeoutMs: number;
|
||||
socketPath: string;
|
||||
getSocketPath: () => string;
|
||||
getMpvConnected: () => boolean;
|
||||
invalidatePendingAutoplayReadyFallbacks: () => void;
|
||||
setAppOwnedFlowInFlight: (next: boolean) => void;
|
||||
@@ -76,6 +76,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
}
|
||||
|
||||
if (deps.platform === 'win32' && !deps.getMpvConnected()) {
|
||||
const socketPath = deps.getSocketPath();
|
||||
const launchResult = deps.launchWindowsMpv(playbackUrl, [
|
||||
'--pause=yes',
|
||||
'--ytdl=yes',
|
||||
@@ -87,7 +88,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
'--secondary-sub-visibility=no',
|
||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
`--input-ipc-server=${deps.socketPath}`,
|
||||
`--input-ipc-server=${socketPath}`,
|
||||
]);
|
||||
launchedWindowsMpv = launchResult.ok;
|
||||
if (launchResult.ok && launchResult.mpvPath) {
|
||||
|
||||
@@ -518,6 +518,26 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' });
|
||||
testGlobals.dispatchKeydown({ key: 't', code: 'KeyT' });
|
||||
|
||||
assert.equal(
|
||||
testGlobals.mpvCommands.some(
|
||||
(command) => command[0] === 'script-message' && command[1] === 'subminer-toggle',
|
||||
),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -1241,6 +1241,7 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
|
||||
const previousDocument = globals.document;
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
const modalListeners = new Map<string, Array<() => void>>();
|
||||
const contentListeners = new Map<string, Array<() => void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
@@ -1317,6 +1318,11 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
|
||||
subtitleSidebarContent: {
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ width: 420 }),
|
||||
addEventListener: (type: string, listener: () => void) => {
|
||||
const bucket = contentListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
contentListeners.set(type, bucket);
|
||||
},
|
||||
},
|
||||
subtitleSidebarClose: { addEventListener: () => {} },
|
||||
subtitleSidebarStatus: { textContent: '' },
|
||||
@@ -1333,7 +1339,7 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
|
||||
await modal.openSubtitleSidebarModal();
|
||||
await modal.refreshSubtitleSidebarSnapshot();
|
||||
mpvCommands.length = 0;
|
||||
await modalListeners.get('mouseenter')?.[0]?.();
|
||||
await contentListeners.get('mouseenter')?.[0]?.();
|
||||
|
||||
assert.deepEqual(mpvCommands.at(-1), ['set_property', 'pause', 'yes']);
|
||||
|
||||
@@ -1353,6 +1359,7 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
|
||||
const previousDocument = globals.document;
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
const modalListeners = new Map<string, Array<() => Promise<void> | void>>();
|
||||
const contentListeners = new Map<string, Array<() => Promise<void> | void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
@@ -1431,6 +1438,11 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
|
||||
subtitleSidebarContent: {
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ width: 420 }),
|
||||
addEventListener: (type: string, listener: () => Promise<void> | void) => {
|
||||
const bucket = contentListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
contentListeners.set(type, bucket);
|
||||
},
|
||||
},
|
||||
subtitleSidebarClose: { addEventListener: () => {} },
|
||||
subtitleSidebarStatus: { textContent: '' },
|
||||
@@ -1446,7 +1458,7 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
|
||||
|
||||
await modal.openSubtitleSidebarModal();
|
||||
await assert.doesNotReject(async () => {
|
||||
await modalListeners.get('mouseenter')?.[0]?.();
|
||||
await contentListeners.get('mouseenter')?.[0]?.();
|
||||
});
|
||||
|
||||
assert.equal(state.subtitleSidebarPausedByHover, false);
|
||||
@@ -1744,6 +1756,7 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
|
||||
const modalListeners = new Map<string, Array<() => void>>();
|
||||
const contentListeners = new Map<string, Array<() => void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
@@ -1823,6 +1836,11 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
|
||||
subtitleSidebarContent: {
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ width: 360 }),
|
||||
addEventListener: (type: string, listener: () => void) => {
|
||||
const bucket = contentListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
contentListeners.set(type, bucket);
|
||||
},
|
||||
},
|
||||
subtitleSidebarClose: { addEventListener: () => {} },
|
||||
subtitleSidebarStatus: { textContent: '' },
|
||||
@@ -1842,15 +1860,15 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
|
||||
await modal.openSubtitleSidebarModal();
|
||||
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
|
||||
|
||||
modalListeners.get('mouseenter')?.[0]?.();
|
||||
contentListeners.get('mouseenter')?.[0]?.();
|
||||
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
|
||||
|
||||
modalListeners.get('mouseleave')?.[0]?.();
|
||||
contentListeners.get('mouseleave')?.[0]?.();
|
||||
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
|
||||
|
||||
state.isOverSubtitle = true;
|
||||
modalListeners.get('mouseenter')?.[0]?.();
|
||||
modalListeners.get('mouseleave')?.[0]?.();
|
||||
contentListeners.get('mouseenter')?.[0]?.();
|
||||
contentListeners.get('mouseleave')?.[0]?.();
|
||||
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
|
||||
|
||||
void mpvCommands;
|
||||
@@ -1860,6 +1878,251 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
|
||||
}
|
||||
});
|
||||
|
||||
test('subtitle sidebar overlay layout restores macOS and Windows passthrough outside sidebar hover', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
|
||||
const modalListeners = new Map<string, Array<() => void>>();
|
||||
const contentListeners = new Map<string, Array<() => void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
},
|
||||
currentTimeSec: 1.1,
|
||||
config: {
|
||||
enabled: true,
|
||||
autoOpen: false,
|
||||
layout: 'overlay',
|
||||
toggleKey: 'Backslash',
|
||||
pauseVideoOnHover: false,
|
||||
autoScroll: true,
|
||||
maxWidth: 360,
|
||||
opacity: 0.92,
|
||||
backgroundColor: 'rgba(54, 58, 79, 0.88)',
|
||||
textColor: '#cad3f5',
|
||||
fontFamily: '"Iosevka Aile", sans-serif',
|
||||
fontSize: 17,
|
||||
timestampColor: '#a5adcb',
|
||||
activeLineColor: '#f5bde6',
|
||||
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
innerWidth: 1200,
|
||||
electronAPI: {
|
||||
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||
sendMpvCommand: (command: Array<string | number>) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreMouseCalls.push([ignore, options]);
|
||||
},
|
||||
} as unknown as ElectronAPI,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => createCueRow(),
|
||||
body: {
|
||||
classList: createClassList(),
|
||||
},
|
||||
documentElement: {
|
||||
style: {
|
||||
setProperty: () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
subtitleSidebarModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
style: { setProperty: () => {} },
|
||||
addEventListener: (type: string, listener: () => void) => {
|
||||
const bucket = modalListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
modalListeners.set(type, bucket);
|
||||
},
|
||||
},
|
||||
subtitleSidebarContent: {
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ width: 360 }),
|
||||
addEventListener: (type: string, listener: () => void) => {
|
||||
const bucket = contentListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
contentListeners.set(type, bucket);
|
||||
},
|
||||
},
|
||||
subtitleSidebarClose: { addEventListener: () => {} },
|
||||
subtitleSidebarStatus: { textContent: '' },
|
||||
subtitleSidebarList: createListStub(),
|
||||
},
|
||||
platform: {
|
||||
shouldToggleMouseIgnore: true,
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createSubtitleSidebarModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
});
|
||||
modal.wireDomEvents();
|
||||
|
||||
assert.equal(modalListeners.get('mouseenter')?.length ?? 0, 0);
|
||||
assert.equal(modalListeners.get('mouseleave')?.length ?? 0, 0);
|
||||
assert.equal(contentListeners.get('mouseenter')?.length ?? 0, 1);
|
||||
assert.equal(contentListeners.get('mouseleave')?.length ?? 0, 1);
|
||||
|
||||
await modal.openSubtitleSidebarModal();
|
||||
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
|
||||
|
||||
contentListeners.get('mouseenter')?.[0]?.();
|
||||
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
|
||||
|
||||
contentListeners.get('mouseleave')?.[0]?.();
|
||||
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
|
||||
|
||||
void mpvCommands;
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('subtitle sidebar overlay layout only stays interactive while focus remains inside the sidebar panel', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
|
||||
const contentListeners = new Map<string, Array<(event?: FocusEvent) => void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
},
|
||||
currentTimeSec: 1.1,
|
||||
config: {
|
||||
enabled: true,
|
||||
autoOpen: false,
|
||||
layout: 'overlay',
|
||||
toggleKey: 'Backslash',
|
||||
pauseVideoOnHover: false,
|
||||
autoScroll: true,
|
||||
maxWidth: 360,
|
||||
opacity: 0.92,
|
||||
backgroundColor: 'rgba(54, 58, 79, 0.88)',
|
||||
textColor: '#cad3f5',
|
||||
fontFamily: '"Iosevka Aile", sans-serif',
|
||||
fontSize: 17,
|
||||
timestampColor: '#a5adcb',
|
||||
activeLineColor: '#f5bde6',
|
||||
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
innerWidth: 1200,
|
||||
electronAPI: {
|
||||
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||
sendMpvCommand: () => {},
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreMouseCalls.push([ignore, options]);
|
||||
},
|
||||
} as unknown as ElectronAPI,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => createCueRow(),
|
||||
body: {
|
||||
classList: createClassList(),
|
||||
},
|
||||
documentElement: {
|
||||
style: {
|
||||
setProperty: () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
const sidebarContent = {
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ width: 360 }),
|
||||
addEventListener: (type: string, listener: (event?: FocusEvent) => void) => {
|
||||
const bucket = contentListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
contentListeners.set(type, bucket);
|
||||
},
|
||||
contains: () => false,
|
||||
};
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
subtitleSidebarModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
style: { setProperty: () => {} },
|
||||
addEventListener: () => {},
|
||||
},
|
||||
subtitleSidebarContent: sidebarContent,
|
||||
subtitleSidebarClose: { addEventListener: () => {} },
|
||||
subtitleSidebarStatus: { textContent: '' },
|
||||
subtitleSidebarList: createListStub(),
|
||||
},
|
||||
platform: {
|
||||
shouldToggleMouseIgnore: true,
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createSubtitleSidebarModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
});
|
||||
modal.wireDomEvents();
|
||||
|
||||
await modal.openSubtitleSidebarModal();
|
||||
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
|
||||
|
||||
contentListeners.get('focusin')?.[0]?.();
|
||||
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
|
||||
|
||||
contentListeners.get('focusout')?.[0]?.({ relatedTarget: null } as FocusEvent);
|
||||
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('closing embedded subtitle sidebar recomputes passthrough from remaining subtitle hover state', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
|
||||
@@ -143,11 +143,23 @@ export function createSubtitleSidebarModal(
|
||||
let lastAppliedVideoMarginRatio: number | null = null;
|
||||
let subtitleSidebarHoverRequestId = 0;
|
||||
let disposeDomEvents: (() => void) | null = null;
|
||||
let subtitleSidebarHovered = false;
|
||||
let subtitleSidebarFocusedWithin = false;
|
||||
|
||||
function restoreEmbeddedSidebarPassthrough(): void {
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
function syncSidebarInteractionState(): void {
|
||||
ctx.state.isOverSubtitleSidebar = subtitleSidebarHovered || subtitleSidebarFocusedWithin;
|
||||
}
|
||||
|
||||
function clearSidebarInteractionState(): void {
|
||||
subtitleSidebarHovered = false;
|
||||
subtitleSidebarFocusedWithin = false;
|
||||
syncSidebarInteractionState();
|
||||
}
|
||||
|
||||
function setStatus(message: string): void {
|
||||
ctx.dom.subtitleSidebarStatus.textContent = message;
|
||||
}
|
||||
@@ -379,6 +391,7 @@ export function createSubtitleSidebarModal(
|
||||
applyConfig(snapshot);
|
||||
if (!snapshot.config.enabled) {
|
||||
resumeSubtitleSidebarHoverPause();
|
||||
clearSidebarInteractionState();
|
||||
ctx.state.subtitleSidebarCues = [];
|
||||
ctx.state.subtitleSidebarModalOpen = false;
|
||||
ctx.dom.subtitleSidebarModal.classList.add('hidden');
|
||||
@@ -450,7 +463,7 @@ export function createSubtitleSidebarModal(
|
||||
}
|
||||
|
||||
ctx.state.subtitleSidebarModalOpen = true;
|
||||
ctx.state.isOverSubtitleSidebar = false;
|
||||
clearSidebarInteractionState();
|
||||
ctx.dom.subtitleSidebarModal.classList.remove('hidden');
|
||||
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'false');
|
||||
renderCueList();
|
||||
@@ -478,7 +491,7 @@ export function createSubtitleSidebarModal(
|
||||
return;
|
||||
}
|
||||
resumeSubtitleSidebarHoverPause();
|
||||
ctx.state.isOverSubtitleSidebar = false;
|
||||
clearSidebarInteractionState();
|
||||
ctx.state.subtitleSidebarModalOpen = false;
|
||||
ctx.dom.subtitleSidebarModal.classList.add('hidden');
|
||||
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
|
||||
@@ -536,8 +549,9 @@ export function createSubtitleSidebarModal(
|
||||
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
|
||||
ctx.state.subtitleSidebarManualScrollUntilMs = Date.now() + MANUAL_SCROLL_HOLD_MS;
|
||||
});
|
||||
ctx.dom.subtitleSidebarModal.addEventListener('mouseenter', async () => {
|
||||
ctx.state.isOverSubtitleSidebar = true;
|
||||
ctx.dom.subtitleSidebarContent.addEventListener('mouseenter', async () => {
|
||||
subtitleSidebarHovered = true;
|
||||
syncSidebarInteractionState();
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
if (!ctx.state.subtitleSidebarPauseVideoOnHover || ctx.state.subtitleSidebarPausedByHover) {
|
||||
return;
|
||||
@@ -557,8 +571,36 @@ export function createSubtitleSidebarModal(
|
||||
ctx.state.subtitleSidebarPausedByHover = true;
|
||||
}
|
||||
});
|
||||
ctx.dom.subtitleSidebarModal.addEventListener('mouseleave', () => {
|
||||
ctx.state.isOverSubtitleSidebar = false;
|
||||
ctx.dom.subtitleSidebarContent.addEventListener('mouseleave', () => {
|
||||
subtitleSidebarHovered = false;
|
||||
syncSidebarInteractionState();
|
||||
if (ctx.state.isOverSubtitleSidebar) {
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
return;
|
||||
}
|
||||
resumeSubtitleSidebarHoverPause();
|
||||
});
|
||||
ctx.dom.subtitleSidebarContent.addEventListener('focusin', () => {
|
||||
subtitleSidebarFocusedWithin = true;
|
||||
syncSidebarInteractionState();
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
});
|
||||
ctx.dom.subtitleSidebarContent.addEventListener('focusout', (event: FocusEvent) => {
|
||||
const relatedTarget = event.relatedTarget;
|
||||
if (
|
||||
typeof Node !== 'undefined' &&
|
||||
relatedTarget instanceof Node &&
|
||||
ctx.dom.subtitleSidebarContent.contains(relatedTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
subtitleSidebarFocusedWithin = false;
|
||||
syncSidebarInteractionState();
|
||||
if (ctx.state.isOverSubtitleSidebar) {
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
return;
|
||||
}
|
||||
resumeSubtitleSidebarHoverPause();
|
||||
});
|
||||
const resizeHandler = () => {
|
||||
|
||||
@@ -2,9 +2,6 @@ import type { RendererContext } from './context';
|
||||
import type { RendererState } from './state';
|
||||
|
||||
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||
const embeddedSidebarOpen =
|
||||
state.subtitleSidebarModalOpen && state.subtitleSidebarConfig?.layout === 'embedded';
|
||||
|
||||
return Boolean(
|
||||
state.controllerSelectModalOpen ||
|
||||
state.controllerDebugModalOpen ||
|
||||
@@ -13,8 +10,7 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||
state.kikuModalOpen ||
|
||||
state.runtimeOptionsModalOpen ||
|
||||
state.subsyncModalOpen ||
|
||||
state.sessionHelpModalOpen ||
|
||||
(state.subtitleSidebarModalOpen && !embeddedSidebarOpen),
|
||||
state.sessionHelpModalOpen,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -273,6 +273,7 @@ export interface ResolvedConfig {
|
||||
};
|
||||
discordPresence: {
|
||||
enabled: boolean;
|
||||
presenceStyle: import('./integrations').DiscordPresenceStylePreset;
|
||||
updateIntervalMs: number;
|
||||
debounceMs: number;
|
||||
};
|
||||
|
||||
@@ -101,8 +101,11 @@ export interface JellyfinConfig {
|
||||
transcodeVideoCodec?: string;
|
||||
}
|
||||
|
||||
export type DiscordPresenceStylePreset = 'default' | 'meme' | 'japanese' | 'minimal';
|
||||
|
||||
export interface DiscordPresenceConfig {
|
||||
enabled?: boolean;
|
||||
presenceStyle?: DiscordPresenceStylePreset;
|
||||
updateIntervalMs?: number;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user