Compare commits

..

96 Commits

Author SHA1 Message Date
af74102964 Fix startup autoplay and dictionary progress sequencing
- keep paused startup release retries aligned with the full gate window
- restore dictionary sync progress and reuse merged zips on unchanged revisits
- surface later dictionary OSD updates once tokenization is ready
2026-03-20 02:27:00 -07:00
bae2a49673 fix: restore overlay ownership during plugin auto-start 2026-03-20 01:57:25 -07:00
1342393035 clean up 2026-03-20 00:45:39 -07:00
9d109de8db Restore anime episode progress from subtitle timing
- Fall back to latest retained subtitle/event segment end when `ended_media_ms` is missing
- Tighten stats command tests and add regression coverage for the fallback
2026-03-20 00:45:27 -07:00
bedeee22f5 fix: normalize stats lookup copy from tokens to words 2026-03-20 00:30:56 -07:00
1267085306 fix: flush playback position before media path clear 2026-03-20 00:30:50 -07:00
0ee150ed91 fix(subtitle): exclude explanatory pondering endings 2026-03-20 00:30:41 -07:00
3e5671270e fix: refresh anime cover art on AniList reassignment 2026-03-20 00:17:37 -07:00
24667ad6c9 fix(review): address latest CodeRabbit comments 2026-03-19 23:49:55 -07:00
42028d0a4d fix(subtitle): unify annotation token filtering 2026-03-19 23:48:38 -07:00
4a01cebca6 feat(stats): rename all token display text to words
Replace every user-facing "token(s)" label, tooltip, and message in the
stats UI with "words" so the terminology is consistent and friendlier
(e.g. "Words Seen", "word occurrences", "3.4 / 100 words", "Words Today").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:48:37 -07:00
3995c396f8 fix(review): address latest CodeRabbit comments 2026-03-19 23:13:43 -07:00
544cd8aaa0 fix(stats): address review follow-ups 2026-03-19 22:55:46 -07:00
1932d2e25e fix(stats): format stats navigation helper 2026-03-19 22:21:57 -07:00
2258ededbd Show anime progress from latest session position
- include anime ID in media detail data
- use latest session position for episode progress
- update stats UI and lookup tests
2026-03-19 21:57:04 -07:00
64a88020c9 feat(stats): add 'View Anime' navigation button in MediaDetailView
- Added onNavigateToAnime prop to MediaDetailView
- Show 'View Anime →' button in the top-right when viewing media from
  non-anime origins (overview/sessions)
- Extract animeId from available sessions to enable navigation
- Button is hidden when already viewing from anime origin

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-19 21:43:30 -07:00
0ea1746123 feat(stats): add media-detail navigation from Sessions rows; fix(tokenizer): exclude そうだ auxiliary-stem from annotations
- Added hover-revealed ↗ button on SessionRow that navigates to the
  anime media-detail view for the session's videoId
- Added `sessions` origin type to MediaDetailOrigin and
  openSessionsMediaDetail() / closeMediaDetail() handling so the
  back button returns correctly to the Sessions tab ("Back to Sessions")
- Wired onNavigateToMediaDetail down through SessionsTab → SessionRow
- Excluded tokens with MeCab POS3 = 助動詞語幹 (e.g. そうだ grammar tails)
  from subtitle annotation metadata so frequency, JLPT, and N+1 styling
  no longer apply to grammar-tail tokens
- Added annotation-stage unit test and end-to-end tokenizeSubtitle test
  for the そうだ exclusion path
- Updated docs-site changelog, immersion-tracking, and
  subtitle-annotations pages to reflect both changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 21:42:53 -07:00
59fa3b427d fix: exclude auxiliary grammar tails from subtitle annotations 2026-03-19 21:40:20 -07:00
ff95934f07 fix(launcher): address newest PR review feedback 2026-03-19 21:32:51 -07:00
c27ef90046 test(anki): cover non-blocking proxy enrichment 2026-03-19 21:32:32 -07:00
34ba602405 fix(stats): persist anime episode progress checkpoints 2026-03-19 21:31:47 -07:00
ecb4b07f43 docs: remove release cut note from changelog 2026-03-19 20:07:11 -07:00
1227706ac9 fix: address latest PR review feedback 2026-03-19 20:06:52 -07:00
9ad3ccfa38 fix(stats): address Claude review follow-ups 2026-03-19 19:55:05 -07:00
20f53c0b70 Switch known-word cache to incremental sync and doctor refresh
- Load persisted known-word cache on startup; reconcile adds/deletes/edits on timed sync
- Add `knownWords.addMinedWordsImmediately` (default `true`) for immediate mined-word updates
- Route full rebuild to explicit `subminer doctor --refresh-known-words` and expand tests/docs
2026-03-19 19:29:58 -07:00
72d78ba1ca chore: prepare release v0.7.0 2026-03-19 18:04:02 -07:00
43a0d11446 fix(subtitle): restore known and JLPT token annotations 2026-03-19 18:03:20 -07:00
1b5f0c6999 Normalize formatting in tracking snapshot and session detail test
- Collapse multiline JSX and import statements to single-line style
- No behavior changes; formatting-only cleanup
2026-03-19 17:04:36 -07:00
886c6ef1d7 cleanup 2026-03-19 15:47:05 -07:00
f2d6c70019 Fix stats command flow and tracking metrics regressions
- Route default `subminer stats` through attached `--stats`; keep daemon path for `--background`/`--stop`
- Update overview metrics: lookup rate uses lifetime Yomitan lookups per 100 tokens; new words dedupe by headword
- Suppress repeated macOS `Overlay loading...` OSD during fullscreen tracker flaps and improve session-detail chart scaling
- Add/adjust launcher, tracker query, stats server, IPC, overlay, and stats UI regression tests; add changelog fragments
2026-03-19 15:46:52 -07:00
274b0619ac fix(anki): address latest PR 19 review follow-ups 2026-03-19 08:47:31 -07:00
a954f62f55 Decouple stats daemon and preserve final mine OSD status
- Run `subminer stats -b` as a dedicated daemon process, independent from the overlay app
- Stop Anki progress spinner before showing final `✓`/`x` mine result so it is not overwritten
- Keep grammar/noise subtitle tokens hoverable while stripping annotation metadata
2026-03-18 23:49:27 -07:00
4d96ebf5c0 fix: reduce prefetched subtitle annotation delay 2026-03-18 23:47:33 -07:00
7a0d7a488b docs: redesign README for cleaner layout and scannability
- Condense features into bold-label paragraphs instead of H3 subsections
- Collapse install instructions into <details> sections
- Remove redundant screenshots (annotations-key, stats-vocabulary)
- Add AUR version badge
- Merge first-launch and setup steps into a single paragraph
- Add horizontal rule dividers between major sections
2026-03-18 23:35:17 -07:00
f916b65d7f fix: sync texthooker-ui annotation overrides 2026-03-18 19:32:51 -07:00
36627bf87d fix(anki): avoid unnecessary known-word cache restarts 2026-03-18 19:29:47 -07:00
ad1f66a842 feat: sync animated anki images to sentence audio 2026-03-18 19:21:12 -07:00
f4cce31d4a fix: align texthooker and stats formatting with CI expectations 2026-03-18 19:01:29 -07:00
ec56970646 fix(ci): install stats deps in release builds 2026-03-18 02:37:58 -07:00
48f10dbb03 chore(backlog): maintain task backlog and add changelog fragments
- Move completed tasks (85, 117, 118, 155) to backlog/completed/
- Delete superseded task files (166 verification, 172 drilldown)
- Add stats dashboard milestone m-1
- Add new tasks (190, 194)
- Update task metadata across remaining backlog items
- Add changelog fragments for stats, mpv args, and subtitle filtering
2026-03-18 02:25:07 -07:00
1cb129b0b7 chore: update README, gitignore, and add CLAUDE.md
- Refresh README with feature overview and screenshot embeds
- Add .superpowers/ and clean up duplicate gitignore entries
- Add CLAUDE.md project instructions
- Remove stale release/release-notes.md
2026-03-18 02:24:57 -07:00
af1505fbe6 docs: update config examples, docs site, and add screenshots
- Update config examples with word field, retention, and stats options
- Add immersion tracking documentation for retention presets
- Add Anki integration and configuration docs updates
- Add stats dashboard screenshots
2026-03-18 02:24:46 -07:00
97126caf4e feat(stats): add note ID resolution and session event handling improvements
- Add note ID resolution through merge redirects in stats API
- Build Anki note previews using configured field names
- Add session event helpers for merged note dedup and stable request keys
- Refactor SessionDetail to prevent redundant note info requests
- Add session event popover and API client tests
2026-03-18 02:24:38 -07:00
a0015dc75c feat: add configurable Anki word field with note ID merge tracking
- Extract word field config into reusable anki-field-config module
- Add ankiConnect.fields.word config option (default: "Expression")
- Replace hardcoded "Expression" field references across Anki integration
- Add note ID redirect tracking for merged/moved cards
- Support legacy ankiConnect.wordField migration path
2026-03-18 02:24:26 -07:00
61e1621137 perf: split stats app bundles by route 2026-03-18 00:05:51 -07:00
a5b1c0509d fix(stats): align session event popovers with chart plot area 2026-03-17 23:56:58 -07:00
e694963426 fix(stats): address PR 19 review follow-ups 2026-03-17 23:56:42 -07:00
a69254f976 feat(stats): show seek length in session event tooltip 2026-03-17 23:38:45 -07:00
a1348cf8e4 chore(backlog): add m-1 milestone for remaining stats fixes 2026-03-17 23:38:33 -07:00
f9b582582b fix(stats): load full session timelines by default 2026-03-17 22:37:34 -07:00
8f39416ff5 fix(stats): use yomitan tokens for subtitle counts 2026-03-17 22:33:08 -07:00
ecb41a490b feat(launcher): add mpv args passthrough 2026-03-17 21:51:52 -07:00
b061b265c2 chore(vendor): bump subminer-yomitan 2026-03-17 20:12:42 -07:00
f2b3af17d7 docs: update docs, add backlog tasks and change notes
Update configuration, immersion tracking, and mining workflow docs.
Add backlog tasks for upcoming work items and change notes for recent
features and fixes.
2026-03-17 20:12:42 -07:00
5698121996 chore: minor fixes and cleanup across services and renderer 2026-03-17 20:12:41 -07:00
f8e2ae4887 feat: overhaul stats dashboard with navigation, trends, and anime views
Add navigation state machine for tab/detail routing, anime overview
stats with Yomitan lookup rates, session word count accuracy fixes,
vocabulary tab hook order fix, simplified trends data fetching from
backend-aggregated endpoints, and improved session detail charts.
2026-03-17 20:12:41 -07:00
08a5401a7d feat: add background stats server daemon lifecycle
Implement `subminer stats -b` to start a background stats daemon and
`subminer stats -s` to stop it, with PID-based process lifecycle
management, single-instance lock bypass for daemon mode, and automatic
reuse of running daemon instances.
2026-03-17 20:12:41 -07:00
55ee12e87f feat: refactor immersion tracker queries and session word tracking
Add comprehensive query helpers for session deletion with word aggregate
refresh, known-words-per-session timeline, anime-level word summaries,
and trends dashboard aggregation. Track yomitanLookupCount in session
metrics and support bulk session operations.
2026-03-17 20:12:41 -07:00
a5a6426fe1 feat: add mark-as-watched keybinding and Yomitan lookup tracking
Add configurable keybinding to mark the current video as watched with
IPC plumbing between renderer and main process. Add event listener
infrastructure for tracking Yomitan dictionary lookups per session.
2026-03-17 20:12:41 -07:00
75f2c212c7 refactor: centralize watch threshold constant
Extract DEFAULT_MIN_WATCH_RATIO (0.85) into src/shared/watch-threshold.ts
to replace the hardcoded ANILIST_UPDATE_MIN_WATCH_RATIO in main.ts.
2026-03-17 20:05:08 -07:00
3dd337f518 update backlog tasks 2026-03-17 20:05:08 -07:00
94ec28b48c refactor: isolate character dictionary completion handling 2026-03-17 20:05:08 -07:00
078792e0b2 docs: refresh immersion and stats documentation 2026-03-17 20:05:08 -07:00
390ae1b2f2 feat: optimize stats dashboard data and components 2026-03-17 20:05:08 -07:00
11710f20db feat: stabilize startup sync and overlay/runtime paths 2026-03-17 20:05:08 -07:00
de574c04bd Add isolated typecheck test for get_frequency script
- add `scripts/get_frequency.test.ts` to verify `scripts/get_frequency.ts` typechecks alone
- remove duplicate `yomitanSession` property from runtime state/interface
2026-03-17 20:05:08 -07:00
a9e33618e7 chore: apply remaining workspace formatting and updates 2026-03-17 20:05:08 -07:00
77c35c770d chore: add stats lint/check wiring for CI 2026-03-17 20:05:08 -07:00
64e9821e7a chore(backlog): sync task metadata and archives 2026-03-17 20:05:08 -07:00
5c529802c6 fix(stats): restore cross-anime words table 2026-03-17 20:05:08 -07:00
8123a145c0 fix(plugin): add lowercase linux binary fallbacks 2026-03-17 20:05:07 -07:00
659118c20c docs: document stats page mining, word exclusions, and vocabulary UX improvements 2026-03-17 20:05:07 -07:00
929159bba5 test(renderer): verify excluded interjections remain visible as non-interactive text 2026-03-17 20:05:07 -07:00
a317019bb9 feat(tokenizer): exclude interjections and sound effects from subtitle annotations
- Filter out 感動詞 (interjection) POS1 tokens from annotation payloads
- Exclude common interjection terms (ああ, ええ, はあ, etc.)
- Exclude reduplicated kana SFX with optional trailing と
- shouldExcludeTokenFromSubtitleAnnotations checks both POS1 and term patterns
- filterSubtitleAnnotationTokens applied after annotation stage
2026-03-17 20:05:07 -07:00
5767667d51 feat(stats): add mine card from stats page with Yomitan bridge
- POST /api/stats/mine-card endpoint with mode=word|sentence|audio
- mode=word: creates full Yomitan card (definition/reading/pitch) via hidden search page bridge
- mode=sentence/audio: creates card directly with Lapis/Kiku flags
- Audio + image generated in parallel from source video via ffmpeg
- Respects all AnkiConnect config (AVIF, static, field mappings, metadata pattern)
- addYomitanNoteViaSearch calls window.__subminerAddNote exposed by Yomitan fork
- Syncs AnkiConnect URL to Yomitan before each word mine
2026-03-17 20:05:07 -07:00
a1f30fd482 feat(tracking): store secondary subtitle text and source path in occurrence data
- Add secondary_text column to imm_subtitle_lines with migration
- Pass currentSecondarySubText through recordSubtitleLine flow
- Include secondaryText and sourcePath in word/kanji occurrence queries
- Update all type interfaces (backend + frontend)
2026-03-17 20:05:07 -07:00
5a30446809 feat(stats): add click handler to bar charts for word detail navigation 2026-03-17 20:05:07 -07:00
6634255f43 feat(stats): fix truncated readings and improve word detail UX
- fullReading() reconstructs full word reading from headword + partial stored reading
- FrequencyRankTable always shows reading for every row
- Word highlighted in example sentences with underline style
- Bar chart clicks open word detail panel
2026-03-17 20:05:07 -07:00
a3ed8dcf3d feat(stats): add word exclusion list for vocabulary tab
- useExcludedWords hook manages excluded words in localStorage
- ExclusionManager modal for viewing/removing excluded words
- Exclusion button on WordDetailPanel header
- Filtered words affect stat cards, charts, frequency table, and word list
2026-03-17 20:05:07 -07:00
92c1557e46 refactor: split known words config from n-plus-one 2026-03-17 20:05:07 -07:00
04682a02cc feat: improve stats dashboard and annotation settings 2026-03-17 20:05:07 -07:00
650e95cdc3 Feature/renderer performance (#24) 2026-03-17 20:05:07 -07:00
46fbea902a Harden stats APIs and fix Electron Yomitan debug runtime
- Validate stats session IDs/limits and add AnkiConnect request timeouts
- Stabilize stats window/runtime lifecycle and tighten window security defaults
- Fix Electron CLI debug startup by unsetting `ELECTRON_RUN_AS_NODE` and wiring Yomitan session state
- Expand regression coverage for tracker queries/events ordering and session aggregates
- Update docs for stats dashboard usage and Yomitan lookup troubleshooting
2026-03-17 20:05:07 -07:00
93811ebfde fix(launcher): default stats cleanup to vocab mode 2026-03-17 20:05:07 -07:00
74544d79a7 docs: update stats dashboard docs, config, and releasing checklist
- Update dashboard tab descriptions to include Anime tab and richer session timelines
- Add autoOpenBrowser config option to stats section
- Add subminer stats cleanup command to changelog fragment
- Expand releasing checklist with doc verification, changelog lint, and build gates
2026-03-17 20:05:07 -07:00
536f0a1315 feat(stats): redesign session timeline and clean up vocabulary tab
- Replace cumulative line chart with activity-focused area chart showing per-interval new words
- Add total words as a blue line on a secondary right Y-axis
- Add pause shaded regions, seek markers, and card mined markers with numeric x-axis for reliable rendering
- Add clickable header logo with proper aspect ratio
- Remove unused "Hide particles & single kana" checkbox from vocabulary tab
2026-03-17 20:05:07 -07:00
ff2d9141bc feat(stats): add episodes completed and anime completed to tracking snapshot
- Query watched videos count and anime with all episodes watched
- Display in overview tracking snapshot
- Remove efficiency section from trends
2026-03-17 20:05:07 -07:00
249a7cada8 chore: remove implementation plan documents 2026-03-17 20:05:07 -07:00
9530445a95 feat: add AniList rate limiter and remaining backlog tasks 2026-03-17 20:05:07 -07:00
2d87dae6cc docs: update documentation site for stats dashboard and immersion tracking 2026-03-17 20:05:07 -07:00
0f44107beb feat(stats): build anime-centric stats dashboard frontend
5-tab React dashboard with Catppuccin Mocha theme:
- Overview: hero stats, streak calendar, watch time chart, recent sessions
- Anime: grid with cover art, episode list with completion %, detail view
- Trends: 15 charts across Activity, Efficiency, Anime, and Patterns
- Vocabulary: POS-filtered word/kanji lists with detail panels
- Sessions: expandable session history with event timeline

Features:
- Cross-tab navigation (anime <-> vocabulary)
- Global word detail panel overlay
- Expandable episode detail with Anki card links (Expression field)
- Per-anime multi-line trend charts
- Watch time by day-of-week and hour-of-day
- Collapsible sections with accessibility (aria-expanded)
- Card size selector for anime grid
- Cover art caching via AniList
- HTTP API client with file:// protocol fallback for Electron overlay
2026-03-17 20:05:07 -07:00
950263bd66 feat(stats): add launcher stats command and build integration
- Launcher stats subcommand with cleanup mode
- Stats frontend build integrated into Makefile
- CI workflow updated for stats package
- Config example updated with stats section
- mpv plugin menu entry for stats toggle
2026-03-17 20:05:07 -07:00
26fb5b4162 feat(stats): wire stats server, overlay, and CLI into main process
- Stats server auto-start on immersion tracker init
- Stats overlay toggle via keybinding and IPC
- Stats CLI command (subminer stats) with cleanup mode
- mpv plugin menu integration for stats toggle
- CLI args for --stats, --stats-cleanup, --stats-response-path
2026-03-17 20:04:40 -07:00
ffe5c6e1c6 feat(stats): add stats server, API endpoints, config, and Anki integration
- Hono HTTP server with 20+ REST endpoints for stats data
- Stats overlay BrowserWindow with toggle keybinding
- IPC channel definitions and preload bridge
- Stats config section (toggleKey, serverPort, autoStartServer, autoOpenBrowser)
- Config resolver for stats section
- AnkiConnect proxy endpoints (guiBrowse, notesInfo)
- Note ID passthrough in card mining callback chain
- Stats CLI command with autoOpenBrowser respect
2026-03-17 20:04:40 -07:00
fe8bb167c4 feat(immersion): add anime metadata, occurrence tracking, and schema upgrades
- Add imm_anime table with AniList integration
- Add imm_subtitle_lines, imm_word_line_occurrences, imm_kanji_line_occurrences
- Add POS fields (part_of_speech, pos1, pos2, pos3) to imm_words
- Add anime metadata parsing with guessit fallback
- Add video duration tracking and watched status
- Add episode, streak, trend, and word/kanji detail queries
- Deduplicate subtitle line recording within sessions
- Pass Anki note IDs through card mining callback chain
2026-03-17 20:01:23 -07:00
cc5d270b8e docs: add stats dashboard design docs, plans, and knowledge base
- Stats dashboard redesign design and implementation plans
- Episode detail and Anki card link design
- Internal knowledge base restructure
- Backlog tasks for testing, verification, and occurrence tracking
2026-03-17 20:01:23 -07:00
70 changed files with 427 additions and 5317 deletions

View File

@@ -334,14 +334,6 @@ jobs:
id: version id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Build changelog artifacts for release
run: |
if find changes -maxdepth 1 -name '*.md' -not -name README.md -print -quit | grep -q .; then
bun run changelog:build --version "${{ steps.version.outputs.VERSION }}"
else
echo "No pending changelog fragments found."
fi
- name: Verify changelog is ready for tagged release - name: Verify changelog is ready for tagged release
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}" run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"

View File

@@ -1,21 +1,5 @@
# Changelog # Changelog
## v0.8.0 (2026-03-22)
### Changed
- Docs: Refreshed the vendored Texthooker docs/index.html bundle to match the latest local build artifacts.
### Fixed
- Anki: Known-word cache refreshes now reconcile Anki changes incrementally instead of wiping and rebuilding on startup, mined cards can append their word into the cache immediately through a new default-enabled config flag, and explicit refreshes now run through `subminer doctor --refresh-known-words`.
- Subtitle: Restored known-word coloring and JLPT underlines for subtitle tokens like `大体` when the subtitle token is kanji but the known-word cache only matches the kana reading.
- Stats: Episode progress in the anime page now uses the last ended playback position instead of cumulative active watch time, avoiding distorted percentages after rewatches or repeated sessions.
- Stats: Anime episode progress now keeps the latest known playback position through active-session checkpoints and stale-session recovery, so recently watched episodes no longer lose their progress percentage.
- Stats: Anime episode progress now falls back to the latest retained subtitle/event timing when a session is missing a persisted playback-position checkpoint, so older watch sessions no longer get stuck at `0%` progress.
- Overlay: Kept subtitle sidebar cue tracking stable across transitions by avoiding cue-line regression on subtitle timing edge cases and stale text updates.
- Overlay: Improved sidebar config by documenting and exposing layout mode and typography options (`layout`, `fontFamily`, `fontSize`) in the generated documentation flow.
- Overlay: Added `subtitleSidebar.autoOpen` (default `false`) to open the subtitle sidebar once during overlay startup when the sidebar feature is enabled.
- Overlay: Made subtitle sidebar resume/start positioning jump directly to the first resolved active cue instead of smooth-scrolling through the full list, while keeping smooth auto-follow for later cue changes.
## v0.7.0 (2026-03-19) ## v0.7.0 (2026-03-19)
### Added ### Added

View File

@@ -1,37 +0,0 @@
---
id: TASK-214
title: Jump subtitle sidebar directly to resume position on first resolved cue
status: In Progress
assignee: []
created_date: '2026-03-21 11:15'
updated_date: '2026-03-21 11:15'
labels:
- bug
- ux
- overlay
- subtitles
dependencies: []
references:
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.test.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When playback starts from a resumed timestamp while the subtitle sidebar is open, the sidebar currently smooth-scrolls from the top of the cue list to the resumed cue. Change the first resolved active-cue positioning to jump immediately to the resume location while preserving smooth auto-follow for later playback-driven cue advances.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The first active cue resolved after open/resume uses an instant jump instead of smooth-scrolling through the list.
- [x] #2 Normal subtitle-sidebar auto-follow remains smooth after the first active cue has been positioned.
- [x] #3 Regression coverage distinguishes the initial jump behavior from later smooth auto-follow updates.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-03-21: Fixed by treating the first auto-scroll from `previousActiveCueIndex < 0` as `behavior: 'auto'` in the subtitle sidebar scroll helper. Added renderer regression coverage for initial jump plus later smooth follow.
<!-- SECTION:NOTES:END -->

View File

@@ -1,40 +0,0 @@
---
id: TASK-215
title: Add startup auto-open option for subtitle sidebar
status: In Progress
assignee: []
created_date: '2026-03-21 11:35'
updated_date: '2026-03-21 11:35'
labels:
- feature
- ux
- overlay
- subtitles
dependencies: []
references:
- /Users/sudacode/projects/japanese/SubMiner/src/types.ts
- /Users/sudacode/projects/japanese/SubMiner/src/config/definitions/defaults-subtitle.ts
- /Users/sudacode/projects/japanese/SubMiner/src/config/resolve/subtitle-domains.ts
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/renderer.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a subtitle sidebar config option that auto-opens the sidebar once during overlay startup. The option should default to `false`, only apply when the sidebar feature is enabled, and should not force the sidebar back open later in the same session after manual close or later visibility changes.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `subtitleSidebar.autoOpen` is available in config with default `false`.
- [x] #2 When enabled, overlay startup opens the subtitle sidebar once after initial sidebar config/snapshot load.
- [x] #3 Regression coverage covers config resolution and startup-only auto-open behavior.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-03-21: Added `subtitleSidebar.autoOpen` to types/defaults/config registry and resolver. Renderer bootstrap now calls a startup-only subtitle sidebar helper after the initial snapshot refresh. Modal regression coverage verifies startup auto-open requires both `enabled` and `autoOpen`.
<!-- SECTION:NOTES:END -->

View File

@@ -1,79 +0,0 @@
---
id: TASK-216
title: 'Address PR #28 CodeRabbit follow-ups on subtitle sidebar'
status: Completed
assignee:
- '@codex'
created_date: '2026-03-21 00:00'
updated_date: '2026-03-21 00:00'
labels:
- pr-review
- subtitle-sidebar
- renderer
dependencies: []
references:
- src/main/runtime/subtitle-prefetch-init.ts
- src/main/runtime/subtitle-prefetch-init.test.ts
- src/renderer/handlers/mouse.ts
- src/renderer/handlers/mouse.test.ts
- src/renderer/modals/subtitle-sidebar.ts
- src/renderer/modals/subtitle-sidebar.test.ts
- src/renderer/style.css
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Validate the CodeRabbit follow-ups on PR #28 for the subtitle sidebar workstream, implement the confirmed fixes, and verify the touched runtime and renderer paths.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Review comments that described real regressions are fixed in code
- [x] #2 Focused regression coverage exists for the fixed behaviors
- [x] #3 Targeted typecheck and runtime-compat verification pass
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed follow-up fixes for PR #28:
- Cleared parsed subtitle cues on subtitle prefetch init failure so stale snapshot cache entries do not survive a failed refresh.
- Treated primary and secondary subtitle containers as one hover region so moving between them does not resume playback mid-transition.
- Kept the subtitle sidebar closed when disabled, serialized snapshot polling with timeouts, made cue rows keyboard-activatable, resolved stale cue selection fallback, and resumed hover-paused playback when the modal closes.
Regression coverage added:
- `src/main/runtime/subtitle-prefetch-init.test.ts`
- `src/renderer/handlers/mouse.test.ts`
- `src/renderer/modals/subtitle-sidebar.test.ts`
Verification:
- `bun test src/main/runtime/subtitle-prefetch-init.test.ts`
- `bun test src/renderer/handlers/mouse.test.ts`
- `bun test src/renderer/modals/subtitle-sidebar.test.ts`
- `bun run typecheck`
- `bun run test:runtime:compat`
2026-03-21: Reopened to assess a newer CodeRabbit review pass on PR #28 and address any remaining valid action items before push/reply.
2026-03-21: Addressed the latest CodeRabbit follow-up pass in commit d70c6448 after rebasing onto the updated remote branch tip.
2026-03-21: Reopened for the latest CodeRabbit round on commit d70c6448; current actionable item is the invalid ctx.state.isOverSubtitleSidebar assignment in subtitle-sidebar.ts.
2026-03-22: Addressed the live hover-state and startup mouse-ignore follow-ups from the latest CodeRabbit pass. `handleMouseLeave()` now clears `isOverSubtitle` and drops `secondary-sub-hover-active` when leaving the secondary subtitle container toward the primary container, and renderer startup now calls `syncOverlayMouseIgnoreState(ctx)` instead of forcing `setIgnoreMouseEvents(true, { forward: true })`. The sidebar IPC hover catch and CSS spacing comments were already satisfied in the current tree.
2026-03-22: Regenerated `bun.lock` from a clean install so the `electron-builder-squirrel-windows` override now resolves at `26.8.2` in the lockfile alongside `app-builder-lib@26.8.2`.
2026-03-21: Finished the remaining cleanup pass from the latest review. `subtitleSidebar.layout` now uses enum validation, `SubtitleCue` is re-exported from `src/types.ts` as the single public type path, and the subtitle sidebar resize listener now has unload cleanup wired through the renderer.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented the confirmed PR #28 CodeRabbit follow-ups for subtitle sidebar behavior and added regression coverage plus verification for the touched renderer and runtime paths.
Handled the latest CodeRabbit review pass for PR #28: accepted zero sidebar opacity, closed/inerted the sidebar when refresh sees config disabled, moved poll rescheduling out of finally, caught hover pause IPC failures, and fixed the stylelint spacing issue.
Verification: bun test src/config/resolve/subtitle-sidebar.test.ts; bun test src/renderer/modals/subtitle-sidebar.test.ts; bun test src/renderer/handlers/mouse.test.ts; bun run typecheck; bun run test:fast; bun run test:env; bun run build; SubMiner verifier lanes config + runtime-compat (including test:runtime:compat and test:smoke:dist).
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,69 +0,0 @@
---
id: TASK-217
title: Fix embedded overlay passthrough sync between subtitle and sidebar
status: Done
assignee:
- codex
created_date: '2026-03-21 23:16'
updated_date: '2026-03-21 23:28'
labels:
- bug
- overlay
- macos
dependencies: []
references:
- src/renderer/handlers/mouse.ts
- src/renderer/modals/subtitle-sidebar.ts
- src/renderer/renderer.ts
documentation:
- docs/workflow/verification.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
On macOS, when both the subtitle overlay and embedded subtitle sidebar are visible, mouse passthrough to mpv can remain stale until the user hovers the sidebar. After closing the sidebar, passthrough can likewise remain stale until the user hovers the subtitle again. Fix the overlay input-state synchronization so passthrough reflects the current hover/open state immediately instead of relying on the last hover target.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 When the embedded subtitle sidebar is open and the pointer is not over subtitle or sidebar content, the overlay returns to mouse passthrough immediately without requiring a sidebar hover cycle.
- [x] #2 When transitioning between subtitle hover and sidebar hover states on macOS embedded sidebar mode, mouse ignore state stays in sync with the currently interactive region.
- [x] #3 Closing the embedded subtitle sidebar restores the correct passthrough state based on remaining subtitle hover/modal state without requiring an additional hover.
- [x] #4 Regression tests cover the passthrough synchronization behavior.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a shared renderer-side passthrough sync helper that derives whether the overlay should ignore mouse events from subtitle hover, embedded sidebar visibility/hover, popup visibility, and modal state.
2. Replace direct embedded-sidebar passthrough toggles in subtitle hover/sidebar handlers with calls to the shared sync helper so state is recomputed on every transition.
3. Add regression tests for macOS embedded sidebar mode covering sidebar-open idle passthrough, subtitle-to-sidebar transitions, and sidebar-close restore behavior.
4. Run targeted renderer tests for mouse/sidebar passthrough coverage, then summarize any residual risk.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added shared renderer overlay mouse-ignore recompute so subtitle hover, embedded sidebar hover/open/close, and popup idle transitions all derive passthrough from current state instead of last hover target.
Added regression coverage for embedded sidebar idle passthrough on subtitle leave and for sidebar-close recompute behavior.
Verification: `bun run typecheck` passed; `bun test src/renderer/handlers/mouse.test.ts` passed; `bun test src/renderer/modals/subtitle-sidebar.test.ts` passed; core verification wrapper artifact at `.tmp/skill-verification/subminer-verify-20260321-162743-XhSBxw` hit an unrelated `bun run test:fast` failure in `scripts/update-aur-package.test.ts` because macOS system bash lacks `mapfile`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed stale embedded-sidebar passthrough sync on macOS by introducing a shared renderer mouse-ignore recompute path and tracking sidebar-hover state separately from subtitle hover. Subtitle hover leave, sidebar hover enter/leave, sidebar open, and sidebar close now all recompute passthrough from the current overlay state instead of waiting for a later hover event to repair it. Added regression tests covering subtitle-leave passthrough while the embedded sidebar is open but idle, plus sidebar-close restore behavior based on remaining subtitle hover state.
Tests run:
- `bun run typecheck`
- `bun test src/renderer/handlers/mouse.test.ts`
- `bun test src/renderer/modals/subtitle-sidebar.test.ts`
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/renderer/state.ts src/renderer/overlay-mouse-ignore.ts src/renderer/handlers/mouse.ts src/renderer/handlers/mouse.test.ts src/renderer/modals/subtitle-sidebar.ts src/renderer/modals/subtitle-sidebar.test.ts`
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/renderer/state.ts src/renderer/overlay-mouse-ignore.ts src/renderer/handlers/mouse.ts src/renderer/handlers/mouse.test.ts src/renderer/modals/subtitle-sidebar.ts src/renderer/modals/subtitle-sidebar.test.ts` (typecheck passed; `test:fast` blocked by unrelated `scripts/update-aur-package.test.ts` failure on macOS Bash 3.2 lacking `mapfile`)
Risk: the classifier flagged this as a real-runtime candidate, so actual Electron/mpv macOS pointer behavior was not exercised in a live runtime during this turn.
<!-- SECTION:FINAL_SUMMARY:END -->

508
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
type: fixed
area: anki
- Known-word cache refreshes now reconcile Anki changes incrementally instead of wiping and rebuilding on startup, mined cards can append their word into the cache immediately through a new default-enabled config flag, and explicit refreshes now run through `subminer doctor --refresh-known-words`.

View File

@@ -0,0 +1,4 @@
type: fixed
area: subtitle
- Restored known-word coloring and JLPT underlines for subtitle tokens like `大体` when the subtitle token is kanji but the known-word cache only matches the kana reading.

View File

@@ -0,0 +1,4 @@
type: fixed
area: stats
- Episode progress in the anime page now uses the last ended playback position instead of cumulative active watch time, avoiding distorted percentages after rewatches or repeated sessions.

View File

@@ -0,0 +1,4 @@
type: fixed
area: stats
- Anime episode progress now keeps the latest known playback position through active-session checkpoints and stale-session recovery, so recently watched episodes no longer lose their progress percentage.

View File

@@ -0,0 +1,4 @@
type: changed
area: docs
- Refreshed the vendored Texthooker docs/index.html bundle to match the latest local build artifacts.

View File

@@ -0,0 +1,4 @@
type: fixed
area: stats
- Anime episode progress now falls back to the latest retained subtitle/event timing when a session is missing a persisted playback-position checkpoint, so older watch sessions no longer get stuck at `0%` progress.

View File

@@ -1,5 +0,0 @@
type: changed
area: subtitle sidebar
- Added subtitle sidebar state and behavior updates, including startup-auto-open controls and resume positioning improvements.
- Fixed subtitle prefetch and embedded overlay passthrough sync between sidebar and overlay subtitle rendering.

View File

@@ -284,30 +284,6 @@
} // Secondary setting. } // Secondary setting.
}, // Primary and secondary subtitle styling. }, // Primary and secondary subtitle styling.
// ==========================================
// Subtitle Sidebar
// Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.
// ==========================================
"subtitleSidebar": {
"enabled": false, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
"opacity": 0.95, // Base opacity applied to the sidebar shell.
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text.
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ========================================== // ==========================================
// Shared AI Provider // Shared AI Provider
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.

View File

@@ -1,14 +1,5 @@
# Changelog # Changelog
## v0.8.0 (2026-03-22)
- Refreshed the vendored Texthooker docs/index.html bundle to match the latest local build artifacts.
- Added incremental known-word cache refresh behavior so mined cards can append cache entries immediately and `subminer doctor --refresh-known-words` is now the explicit full refresh path.
- Fixed known-word/JLPT subtitle styling so tokens like `大体` keep expected coloring even when only the kana reading is in cache.
- Fixed anime progress to use last ended playback position and keep latest known checkpoint across sessions, preventing stale or zero percent regressions.
- Kept subtitle sidebar cue tracking stable across transitions and improved sidebar configuration documentation for `layout`, `fontFamily`, and `fontSize`.
- Added `subtitleSidebar.autoOpen` to open the subtitle sidebar at startup when enabled.
- Improved sidebar resume/start behavior to jump directly to the active cue on resume while preserving auto-follow smooth motion.
## v0.7.0 (2026-03-19) ## v0.7.0 (2026-03-19)
- Added a full local immersion dashboard release line with Overview, Library, Trends, Vocabulary, and Sessions drill-down views backed by SQLite tracking data. - Added a full local immersion dashboard release line with Overview, Library, Trends, Vocabulary, and Sessions drill-down views backed by SQLite tracking data.
- Added browser-first stats workflows: `subminer stats`, background stats daemon controls (`-b` / `-s`), stats cleanup, and dashboard-side mining actions with media enrichment. - Added browser-first stats workflows: `subminer stats`, background stats daemon controls (`-b` / `-s`), stats cleanup, and dashboard-side mining actions with media enrichment.

View File

@@ -59,7 +59,6 @@ SubMiner watches the active config file (`config.jsonc` or `config.json`) while
Hot-reloadable fields: Hot-reloadable fields:
- `subtitleStyle` - `subtitleStyle`
- `subtitleSidebar`
- `keybindings` - `keybindings`
- `shortcuts` - `shortcuts`
- `secondarySub.defaultMode` - `secondarySub.defaultMode`
@@ -89,7 +88,6 @@ The configuration file includes several main sections:
**Subtitle Display** **Subtitle Display**
- [**Subtitle Style**](#subtitle-style) - Appearance customization - [**Subtitle Style**](#subtitle-style) - Appearance customization
- [**Subtitle Sidebar**](#subtitle-sidebar) - Parsed cue list sidebar modal
- [**Subtitle Position**](#subtitle-position) - Overlay vertical positioning - [**Subtitle Position**](#subtitle-position) - Overlay vertical positioning
- [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support - [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support
@@ -339,46 +337,6 @@ Secondary subtitle defaults: `fontFamily: "Inter, Noto Sans, Helvetica Neue, san
**See `config.example.jsonc`** for the complete list of subtitle style configuration options. **See `config.example.jsonc`** for the complete list of subtitle style configuration options.
### Subtitle Sidebar
Configure the parsed-subtitle sidebar modal.
```json
{
"subtitleSidebar": {
"enabled": true,
"layout": "overlay",
"toggleKey": "Backslash",
"pauseVideoOnHover": false,
"autoScroll": true,
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
"fontSize": 16
}
}
```
| Option | Values | Description |
| --------------------------- | ---------------- | -------------------------------------------------------------------------------- |
| `enabled` | boolean | Enable subtitle sidebar support (`false` by default) |
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list |
| `autoScroll` | boolean | Keep the active cue in view while playback advances |
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.78`) |
| `backgroundColor` | string | Sidebar shell background color |
| `textColor` | hex color | Default cue text color |
| `fontFamily` | string | CSS `font-family` value applied to sidebar cue text |
| `fontSize` | number | Base sidebar cue font size in CSS pixels (default: `16`) |
| `timestampColor` | hex color | Cue timestamp color |
| `activeLineColor` | hex color | Active cue text color |
| `activeLineBackgroundColor` | string | Active cue background color |
| `hoverLineBackgroundColor` | string | Hovered cue background color |
The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog.
`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.
`jlptColors` keys are: `jlptColors` keys are:
| Key | Default | Description | | Key | Default | Description |

View File

@@ -284,30 +284,6 @@
} // Secondary setting. } // Secondary setting.
}, // Primary and secondary subtitle styling. }, // Primary and secondary subtitle styling.
// ==========================================
// Subtitle Sidebar
// Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.
// ==========================================
"subtitleSidebar": {
"enabled": false, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
"opacity": 0.95, // Base opacity applied to the sidebar shell.
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text.
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ========================================== // ==========================================
// Shared AI Provider // Shared AI Provider
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.

View File

@@ -68,13 +68,10 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | | `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
| `` ` `` | Toggle stats overlay | `stats.toggleKey` | | `` ` `` | Toggle stats overlay | `stats.toggleKey` |
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`. The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source.
## Controller Shortcuts ## Controller Shortcuts
These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration. These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration.
@@ -136,4 +133,4 @@ The `keybindings` array overrides or extends the overlay's built-in key handling
} }
``` ```
Both `shortcuts`, `keybindings`, and `subtitleSidebar` are [hot-reloadable](/configuration#hot-reload-behavior) — changes take effect without restarting SubMiner. Both `shortcuts` and `keybindings` are [hot-reloadable](/configuration#hot-reload-behavior) — changes take effect without restarting SubMiner.

View File

@@ -8,7 +8,6 @@
4. Bump `package.json` to the release version. 4. Bump `package.json` to the release version.
5. Build release metadata before tagging: 5. Build release metadata before tagging:
`bun run changelog:build --version <version> --date <yyyy-mm-dd>` `bun run changelog:build --version <version> --date <yyyy-mm-dd>`
- Release CI now also auto-runs this step when releasing directly from a tag and `changes/*.md` fragments remain.
6. Review `CHANGELOG.md` and `release/release-notes.md`. 6. Review `CHANGELOG.md` and `release/release-notes.md`.
7. Run release gate locally: 7. Run release gate locally:
`bun run changelog:check --version <version>` `bun run changelog:check --version <version>`
@@ -30,8 +29,6 @@ Notes:
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night. - Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
- `changelog:check` now rejects tag/package version mismatches. - `changelog:check` now rejects tag/package version mismatches.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments. - `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
- In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes.
- Do not tag while `changes/*.md` fragments still exist. - Do not tag while `changes/*.md` fragments still exist.
- If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication. - Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation. - Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.

View File

@@ -1,6 +1,6 @@
{ {
"name": "subminer", "name": "subminer",
"version": "0.8.0", "version": "0.7.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",
@@ -77,12 +77,6 @@
"build:win": "bun run build && electron-builder --win nsis zip --publish never", "build:win": "bun run build && electron-builder --win nsis zip --publish never",
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs" "build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs"
}, },
"overrides": {
"app-builder-lib": "26.8.2",
"electron-builder-squirrel-windows": "26.8.2",
"minimatch": "10.2.3",
"tar": "7.5.11"
},
"keywords": [ "keywords": [
"anki", "anki",
"ankiconnect", "ankiconnect",
@@ -111,7 +105,7 @@
"@types/node": "^25.3.0", "@types/node": "^25.3.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "^37.10.3", "electron": "^37.10.3",
"electron-builder": "26.8.2", "electron-builder": "^26.8.1",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
@@ -165,21 +159,12 @@
"include": "build/installer.nsh" "include": "build/installer.nsh"
}, },
"files": [ "files": [
"**/*", "dist/**/*",
"!src{,/**/*}", "stats/dist/**/*",
"!launcher{,/**/*}", "vendor/texthooker-ui/docs/**/*",
"!stats/src{,/**/*}", "vendor/texthooker-ui/package.json",
"!stats/index.html", "package.json",
"!docs-site{,/**/*}", "scripts/get-mpv-window-macos.swift"
"!changes{,/**/*}",
"!backlog{,/**/*}",
"!.tmp{,/**/*}",
"!release-*{,/**/*}",
"!vendor/subminer-yomitan{,/**/*}",
"!vendor/texthooker-ui/src{,/**/*}",
"!vendor/texthooker-ui/node_modules{,/**/*}",
"!vendor/texthooker-ui/.svelte-kit{,/**/*}",
"!vendor/texthooker-ui/package-lock.json"
], ],
"extraResources": [ "extraResources": [
{ {

View File

@@ -95,43 +95,6 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
} }
}); });
test('writeChangelogArtifacts skips changelog prepend when release section already exists', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('write-artifacts-existing-version');
const projectRoot = path.join(workspace, 'SubMiner');
const existingChangelog = [
'# Changelog',
'',
'## v0.4.1 (2026-03-07)',
'### Added',
'- Existing release bullet.',
'',
].join('\n');
fs.mkdirSync(projectRoot, { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
fs.writeFileSync(path.join(projectRoot, 'changes', '001.md'), ['type: added', 'area: overlay', '', '- Stale release fragment.'].join('\n'), 'utf8');
try {
const result = writeChangelogArtifacts({
cwd: projectRoot,
version: '0.4.1',
date: '2026-03-08',
});
assert.deepEqual(result.deletedFragmentPaths, [path.join(projectRoot, 'changes', '001.md')]);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), false);
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.equal(changelog, existingChangelog);
const releaseNotes = fs.readFileSync(path.join(projectRoot, 'release', 'release-notes.md'), 'utf8');
assert.match(releaseNotes, /## Highlights\n### Added\n- Existing release bullet\./);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('verifyChangelogReadyForRelease ignores README but rejects pending fragments and missing version sections', async () => { test('verifyChangelogReadyForRelease ignores README but rejects pending fragments and missing version sections', async () => {
const { verifyChangelogReadyForRelease } = await loadModule(); const { verifyChangelogReadyForRelease } = await loadModule();
const workspace = createWorkspace('verify-release'); const workspace = createWorkspace('verify-release');

View File

@@ -341,34 +341,12 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
const version = resolveVersion(options ?? {}); const version = resolveVersion(options ?? {});
const date = resolveDate(options?.date); const date = resolveDate(options?.date);
const fragments = readChangeFragments(cwd, options?.deps); const fragments = readChangeFragments(cwd, options?.deps);
const releaseSection = buildReleaseSection(version, date, fragments);
const existingChangelogPath = path.join(cwd, 'CHANGELOG.md'); const existingChangelogPath = path.join(cwd, 'CHANGELOG.md');
const existingChangelog = existsSync(existingChangelogPath) const existingChangelog = existsSync(existingChangelogPath)
? readFileSync(existingChangelogPath, 'utf8') ? readFileSync(existingChangelogPath, 'utf8')
: ''; : '';
const outputPaths = resolveChangelogOutputPaths({ cwd }); const outputPaths = resolveChangelogOutputPaths({ cwd });
const existingReleaseSection = extractReleaseSectionBody(existingChangelog, version);
if (existingReleaseSection !== null) {
log(`Existing section found for v${version}; skipping changelog prepend.`);
for (const fragment of fragments) {
rmSync(fragment.path);
log(`Removed ${fragment.path}`);
}
const releaseNotesPath = writeReleaseNotesFile(
cwd,
existingReleaseSection,
options?.deps,
);
log(`Generated ${releaseNotesPath}`);
return {
deletedFragmentPaths: fragments.map((fragment) => fragment.path),
outputPaths,
releaseNotesPath,
};
}
const releaseSection = buildReleaseSection(version, date, fragments);
const nextChangelog = prependReleaseSection(existingChangelog, releaseSection, version); const nextChangelog = prependReleaseSection(existingChangelog, releaseSection, version);
for (const outputPath of outputPaths) { for (const outputPath of outputPaths) {

View File

@@ -1,157 +0,0 @@
#!/bin/bash
set -euo pipefail
TARGET="${HOME}/.config/mpv/scripts/modernz.lua"
usage() {
cat <<'EOF'
Usage: patch-modernz.sh [--target /path/to/modernz.lua]
Applies the local ModernZ OSC sidebar-resize patch to an existing modernz.lua.
If the target file does not exist, the script exits without changing anything.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--target)
if [[ $# -lt 2 || -z "${2:-}" || "$2" == -* ]]; then
echo "patch-modernz: --target requires a non-empty file path" >&2
usage >&2
exit 1
fi
TARGET="$2"
shift 2
;;
--help|-h)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
if [[ ! -f "$TARGET" ]]; then
echo "patch-modernz: target missing, skipped: $TARGET"
exit 0
fi
if grep -q 'get_external_video_margin_ratio' "$TARGET" \
&& grep -q 'observe_cached("video-margin-ratio-right"' "$TARGET"; then
echo "patch-modernz: already patched: $TARGET"
exit 0
fi
if ! patch --forward --quiet "$TARGET" <<'PATCH'
--- a/modernz.lua
+++ b/modernz.lua
@@ -931,6 +931,26 @@ local function reset_margins()
set_margin_offset("osd-margin-y", 0)
end
+local function get_external_video_margin_ratio(prop)
+ local value = mp.get_property_number(prop, 0) or 0
+ if value < 0 then return 0 end
+ if value > 0.95 then return 0.95 end
+ return value
+end
+
+local function get_layout_horizontal_bounds()
+ local margin_l = get_external_video_margin_ratio("video-margin-ratio-left")
+ local margin_r = get_external_video_margin_ratio("video-margin-ratio-right")
+ local width_ratio = math.max(0.05, 1 - margin_l - margin_r)
+ local pos_x = osc_param.playresx * margin_l
+ local width = osc_param.playresx * width_ratio
+
+ osc_param.video_margins.l = margin_l
+ osc_param.video_margins.r = margin_r
+
+ return pos_x, width
+end
+
local function update_margins()
local use_margins = get_hidetimeout() < 0 or user_opts.dynamic_margins
local top_vis = state.wc_visible
@@ -1965,8 +1985,9 @@ layouts["modern"] = function ()
local chapter_index = user_opts.show_chapter_title and mp.get_property_number("chapter", -1) >= 0
local osc_height_offset = (no_title and user_opts.notitle_osc_h_offset or 0) + ((no_chapter or not chapter_index) and user_opts.nochapter_osc_h_offset or 0)
+ local posX, layout_width = get_layout_horizontal_bounds()
local osc_geo = {
- w = osc_param.playresx,
+ w = layout_width,
h = user_opts.osc_height - osc_height_offset
}
@@ -1974,7 +1995,6 @@ layouts["modern"] = function ()
osc_param.video_margins.b = math.max(user_opts.osc_height, user_opts.fade_alpha) / osc_param.playresy
-- origin of the controllers, left/bottom corner
- local posX = 0
local posY = osc_param.playresy
osc_param.areas = {} -- delete areas
@@ -2191,8 +2211,9 @@ layouts["modern-compact"] = function ()
((user_opts.title_mbtn_left_command == "" and user_opts.title_mbtn_right_command == "") and 25 or 0) +
(((user_opts.chapter_title_mbtn_left_command == "" and user_opts.chapter_title_mbtn_right_command == "") or not chapter_index) and 10 or 0)
+ local posX, layout_width = get_layout_horizontal_bounds()
local osc_geo = {
- w = osc_param.playresx,
+ w = layout_width,
h = 145 - osc_height_offset
}
@@ -2200,7 +2221,6 @@ layouts["modern-compact"] = function ()
osc_param.video_margins.b = math.max(osc_geo.h, user_opts.fade_alpha) / osc_param.playresy
-- origin of the controllers, left/bottom corner
- local posX = 0
local posY = osc_param.playresy
osc_param.areas = {} -- delete areas
@@ -2370,8 +2390,9 @@ layouts["modern-compact"] = function ()
end
layouts["modern-image"] = function ()
+ local posX, layout_width = get_layout_horizontal_bounds()
local osc_geo = {
- w = osc_param.playresx,
+ w = layout_width,
h = 50
}
@@ -2379,7 +2400,6 @@ layouts["modern-image"] = function ()
osc_param.video_margins.b = math.max(50, user_opts.fade_alpha) / osc_param.playresy
-- origin of the controllers, left/bottom corner
- local posX = 0
local posY = osc_param.playresy
osc_param.areas = {} -- delete areas
@@ -3718,6 +3738,14 @@ observe_cached("border", request_init_resize)
observe_cached("title-bar", request_init_resize)
observe_cached("window-maximized", request_init_resize)
observe_cached("idle-active", request_tick)
+observe_cached("video-margin-ratio-left", function ()
+ state.marginsREQ = true
+ request_init_resize()
+end)
+observe_cached("video-margin-ratio-right", function ()
+ state.marginsREQ = true
+ request_init_resize()
+end)
mp.observe_property("user-data/mpv/console/open", "bool", function(_, val)
if val and user_opts.visibility == "auto" and not user_opts.showonselect then
osc_visible(false)
PATCH
then
echo "patch-modernz: failed to apply patch to $TARGET" >&2
exit 1
fi
echo "patch-modernz: patched $TARGET"

View File

@@ -1,76 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-patch-modernz-test-'));
try {
return fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function writeExecutable(filePath: string, contents: string): void {
fs.writeFileSync(filePath, contents, 'utf8');
fs.chmodSync(filePath, 0o755);
}
test('patch-modernz rejects a missing --target value', () => {
withTempDir((root) => {
const result = spawnSync('bash', ['scripts/patch-modernz.sh', '--target'], {
cwd: process.cwd(),
encoding: 'utf8',
env: {
...process.env,
HOME: path.join(root, 'home'),
},
});
assert.equal(result.status, 1, result.stderr || result.stdout);
assert.match(result.stderr, /--target requires a non-empty file path/);
assert.match(result.stderr, /Usage: patch-modernz\.sh/);
});
});
test('patch-modernz reports patch failures explicitly', () => {
withTempDir((root) => {
const binDir = path.join(root, 'bin');
const target = path.join(root, 'modernz.lua');
const patchLog = path.join(root, 'patch.log');
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, 'original', 'utf8');
writeExecutable(
path.join(binDir, 'patch'),
`#!/usr/bin/env bash
set -euo pipefail
cat > "${patchLog}"
exit 1
`,
);
const result = spawnSync(
'bash',
['scripts/patch-modernz.sh', '--target', target],
{
cwd: process.cwd(),
encoding: 'utf8',
env: {
...process.env,
HOME: path.join(root, 'home'),
PATH: `${binDir}:${process.env.PATH || ''}`,
},
},
);
assert.equal(result.status, 1, result.stderr || result.stdout);
assert.match(result.stderr, /failed to apply patch to/);
assert.equal(fs.readFileSync(patchLog, 'utf8').includes('modernz.lua'), true);
});
});

View File

@@ -36,7 +36,7 @@ const {
} = CORE_DEFAULT_CONFIG; } = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } = const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG; INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG; const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG; const { stats } = STATS_DEFAULT_CONFIG;
@@ -54,7 +54,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
subsync, subsync,
startupWarmups, startupWarmups,
subtitleStyle, subtitleStyle,
subtitleSidebar,
auto_start_overlay, auto_start_overlay,
jimaku, jimaku,
anilist, anilist,

View File

@@ -1,6 +1,6 @@
import { ResolvedConfig } from '../../types'; import { ResolvedConfig } from '../../types';
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = { export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
subtitleStyle: { subtitleStyle: {
enableJlpt: false, enableJlpt: false,
preserveLineBreaks: false, preserveLineBreaks: false,
@@ -57,22 +57,4 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
fontStyle: 'normal', fontStyle: 'normal',
}, },
}, },
subtitleSidebar: {
enabled: false,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 420,
opacity: 0.95,
backgroundColor: 'rgba(73, 77, 100, 0.9)',
textColor: '#cad3f5',
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
fontSize: 16,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
}; };

View File

@@ -110,102 +110,5 @@ export function buildSubtitleConfigOptionRegistry(
description: description:
'Five colors used for rank bands when mode is `banded` (from most common to least within topX).', 'Five colors used for rank bands when mode is `banded` (from most common to least within topX).',
}, },
{
path: 'subtitleSidebar.enabled',
kind: 'boolean',
defaultValue: defaultConfig.subtitleSidebar.enabled,
description: 'Enable the subtitle sidebar feature for parsed subtitle sources.',
},
{
path: 'subtitleSidebar.autoOpen',
kind: 'boolean',
defaultValue: defaultConfig.subtitleSidebar.autoOpen,
description: 'Automatically open the subtitle sidebar once during overlay startup.',
},
{
path: 'subtitleSidebar.layout',
kind: 'enum',
enumValues: ['overlay', 'embedded'],
defaultValue: defaultConfig.subtitleSidebar.layout,
description: 'Render the subtitle sidebar as a floating overlay or reserve space inside mpv.',
},
{
path: 'subtitleSidebar.toggleKey',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.toggleKey,
description: 'KeyboardEvent.code used to toggle the subtitle sidebar open and closed.',
},
{
path: 'subtitleSidebar.pauseVideoOnHover',
kind: 'boolean',
defaultValue: defaultConfig.subtitleSidebar.pauseVideoOnHover,
description: 'Pause mpv while hovering the subtitle sidebar, then resume on leave.',
},
{
path: 'subtitleSidebar.autoScroll',
kind: 'boolean',
defaultValue: defaultConfig.subtitleSidebar.autoScroll,
description: 'Auto-scroll the active subtitle cue into view while playback advances.',
},
{
path: 'subtitleSidebar.maxWidth',
kind: 'number',
defaultValue: defaultConfig.subtitleSidebar.maxWidth,
description: 'Maximum sidebar width in CSS pixels.',
},
{
path: 'subtitleSidebar.opacity',
kind: 'number',
defaultValue: defaultConfig.subtitleSidebar.opacity,
description: 'Base opacity applied to the sidebar shell.',
},
{
path: 'subtitleSidebar.backgroundColor',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.backgroundColor,
description: 'Background color for the subtitle sidebar shell.',
},
{
path: 'subtitleSidebar.textColor',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.textColor,
description: 'Default cue text color in the subtitle sidebar.',
},
{
path: 'subtitleSidebar.fontFamily',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.fontFamily,
description: 'Font family used for subtitle sidebar cue text.',
},
{
path: 'subtitleSidebar.fontSize',
kind: 'number',
defaultValue: defaultConfig.subtitleSidebar.fontSize,
description: 'Base font size for subtitle sidebar cue text in CSS pixels.',
},
{
path: 'subtitleSidebar.timestampColor',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.timestampColor,
description: 'Timestamp color in the subtitle sidebar.',
},
{
path: 'subtitleSidebar.activeLineColor',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.activeLineColor,
description: 'Text color for the active subtitle cue.',
},
{
path: 'subtitleSidebar.activeLineBackgroundColor',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.activeLineBackgroundColor,
description: 'Background color for the active subtitle cue.',
},
{
path: 'subtitleSidebar.hoverLineBackgroundColor',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.hoverLineBackgroundColor,
description: 'Background color for hovered subtitle cues.',
},
]; ];
} }

View File

@@ -98,12 +98,6 @@ const SUBTITLE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'], notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'],
key: 'subtitleStyle', key: 'subtitleStyle',
}, },
{
title: 'Subtitle Sidebar',
description: ['Parsed-subtitle sidebar cue list styling, behavior, and toggle key.'],
notes: ['Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.'],
key: 'subtitleSidebar',
},
]; ];
const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [

View File

@@ -15,22 +15,6 @@ export function asBoolean(value: unknown): boolean | undefined {
} }
const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const cssColorKeywords = new Set([
'transparent',
'currentcolor',
'inherit',
'initial',
'unset',
'revert',
'revert-layer',
]);
const cssColorFunctionPattern = /^(?:rgba?|hsla?)\(\s*[^()]+?\s*\)$/i;
function supportsCssColor(text: string): boolean {
const css = (globalThis as { CSS?: { supports?: (property: string, value: string) => boolean } })
.CSS;
return css?.supports?.('color', text) ?? false;
}
export function asColor(value: unknown): string | undefined { export function asColor(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined; if (typeof value !== 'string') return undefined;
@@ -38,30 +22,6 @@ export function asColor(value: unknown): string | undefined {
return hexColorPattern.test(text) ? text : undefined; return hexColorPattern.test(text) ? text : undefined;
} }
export function asCssColor(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const text = value.trim();
if (text.length === 0) {
return undefined;
}
if (supportsCssColor(text)) {
return text;
}
const normalized = text.toLowerCase();
if (
hexColorPattern.test(text) ||
cssColorKeywords.has(normalized) ||
cssColorFunctionPattern.test(text)
) {
return text;
}
return undefined;
}
export function asFrequencyBandedColors( export function asFrequencyBandedColors(
value: unknown, value: unknown,
): [string, string, string, string, string] | undefined { ): [string, string, string, string, string] | undefined {

View File

@@ -3,7 +3,6 @@ import { ResolveContext } from './context';
import { import {
asBoolean, asBoolean,
asColor, asColor,
asCssColor,
asFrequencyBandedColors, asFrequencyBandedColors,
asNumber, asNumber,
asString, asString,
@@ -419,180 +418,4 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
); );
} }
} }
if (isObject(src.subtitleSidebar)) {
const fallback = { ...resolved.subtitleSidebar };
resolved.subtitleSidebar = {
...resolved.subtitleSidebar,
...(src.subtitleSidebar as ResolvedConfig['subtitleSidebar']),
};
const enabled = asBoolean((src.subtitleSidebar as { enabled?: unknown }).enabled);
if (enabled !== undefined) {
resolved.subtitleSidebar.enabled = enabled;
} else if ((src.subtitleSidebar as { enabled?: unknown }).enabled !== undefined) {
resolved.subtitleSidebar.enabled = fallback.enabled;
warn(
'subtitleSidebar.enabled',
(src.subtitleSidebar as { enabled?: unknown }).enabled,
resolved.subtitleSidebar.enabled,
'Expected boolean.',
);
}
const autoOpen = asBoolean((src.subtitleSidebar as { autoOpen?: unknown }).autoOpen);
if (autoOpen !== undefined) {
resolved.subtitleSidebar.autoOpen = autoOpen;
} else if ((src.subtitleSidebar as { autoOpen?: unknown }).autoOpen !== undefined) {
resolved.subtitleSidebar.autoOpen = fallback.autoOpen;
warn(
'subtitleSidebar.autoOpen',
(src.subtitleSidebar as { autoOpen?: unknown }).autoOpen,
resolved.subtitleSidebar.autoOpen,
'Expected boolean.',
);
}
const layout = asString((src.subtitleSidebar as { layout?: unknown }).layout);
if (layout === 'overlay' || layout === 'embedded') {
resolved.subtitleSidebar.layout = layout;
} else if ((src.subtitleSidebar as { layout?: unknown }).layout !== undefined) {
resolved.subtitleSidebar.layout = fallback.layout;
warn(
'subtitleSidebar.layout',
(src.subtitleSidebar as { layout?: unknown }).layout,
resolved.subtitleSidebar.layout,
'Expected "overlay" or "embedded".',
);
}
const pauseVideoOnHover = asBoolean(
(src.subtitleSidebar as { pauseVideoOnHover?: unknown }).pauseVideoOnHover,
);
if (pauseVideoOnHover !== undefined) {
resolved.subtitleSidebar.pauseVideoOnHover = pauseVideoOnHover;
} else if ((src.subtitleSidebar as { pauseVideoOnHover?: unknown }).pauseVideoOnHover !== undefined) {
resolved.subtitleSidebar.pauseVideoOnHover = fallback.pauseVideoOnHover;
warn(
'subtitleSidebar.pauseVideoOnHover',
(src.subtitleSidebar as { pauseVideoOnHover?: unknown }).pauseVideoOnHover,
resolved.subtitleSidebar.pauseVideoOnHover,
'Expected boolean.',
);
}
const autoScroll = asBoolean((src.subtitleSidebar as { autoScroll?: unknown }).autoScroll);
if (autoScroll !== undefined) {
resolved.subtitleSidebar.autoScroll = autoScroll;
} else if ((src.subtitleSidebar as { autoScroll?: unknown }).autoScroll !== undefined) {
resolved.subtitleSidebar.autoScroll = fallback.autoScroll;
warn(
'subtitleSidebar.autoScroll',
(src.subtitleSidebar as { autoScroll?: unknown }).autoScroll,
resolved.subtitleSidebar.autoScroll,
'Expected boolean.',
);
}
const toggleKey = asString((src.subtitleSidebar as { toggleKey?: unknown }).toggleKey);
if (toggleKey !== undefined) {
resolved.subtitleSidebar.toggleKey = toggleKey;
} else if ((src.subtitleSidebar as { toggleKey?: unknown }).toggleKey !== undefined) {
resolved.subtitleSidebar.toggleKey = fallback.toggleKey;
warn(
'subtitleSidebar.toggleKey',
(src.subtitleSidebar as { toggleKey?: unknown }).toggleKey,
resolved.subtitleSidebar.toggleKey,
'Expected string.',
);
}
const maxWidth = asNumber((src.subtitleSidebar as { maxWidth?: unknown }).maxWidth);
if (maxWidth !== undefined && maxWidth > 0) {
resolved.subtitleSidebar.maxWidth = Math.floor(maxWidth);
} else if ((src.subtitleSidebar as { maxWidth?: unknown }).maxWidth !== undefined) {
resolved.subtitleSidebar.maxWidth = fallback.maxWidth;
warn(
'subtitleSidebar.maxWidth',
(src.subtitleSidebar as { maxWidth?: unknown }).maxWidth,
resolved.subtitleSidebar.maxWidth,
'Expected positive number.',
);
}
const opacity = asNumber((src.subtitleSidebar as { opacity?: unknown }).opacity);
if (opacity !== undefined && opacity >= 0 && opacity <= 1) {
resolved.subtitleSidebar.opacity = opacity;
} else if ((src.subtitleSidebar as { opacity?: unknown }).opacity !== undefined) {
resolved.subtitleSidebar.opacity = fallback.opacity;
warn(
'subtitleSidebar.opacity',
(src.subtitleSidebar as { opacity?: unknown }).opacity,
resolved.subtitleSidebar.opacity,
'Expected number between 0 and 1.',
);
}
const hexColorFields = ['textColor', 'timestampColor', 'activeLineColor'] as const;
for (const field of hexColorFields) {
const value = asColor((src.subtitleSidebar as Record<string, unknown>)[field]);
if (value !== undefined) {
resolved.subtitleSidebar[field] = value;
} else if ((src.subtitleSidebar as Record<string, unknown>)[field] !== undefined) {
resolved.subtitleSidebar[field] = fallback[field];
warn(
`subtitleSidebar.${field}`,
(src.subtitleSidebar as Record<string, unknown>)[field],
resolved.subtitleSidebar[field],
'Expected hex color.',
);
}
}
const cssColorFields = [
'backgroundColor',
'activeLineBackgroundColor',
'hoverLineBackgroundColor',
] as const;
for (const field of cssColorFields) {
const value = asCssColor((src.subtitleSidebar as Record<string, unknown>)[field]);
if (value !== undefined) {
resolved.subtitleSidebar[field] = value;
} else if ((src.subtitleSidebar as Record<string, unknown>)[field] !== undefined) {
resolved.subtitleSidebar[field] = fallback[field];
warn(
`subtitleSidebar.${field}`,
(src.subtitleSidebar as Record<string, unknown>)[field],
resolved.subtitleSidebar[field],
'Expected valid CSS color.',
);
}
}
const fontFamily = asString((src.subtitleSidebar as { fontFamily?: unknown }).fontFamily);
if (fontFamily !== undefined && fontFamily.trim().length > 0) {
resolved.subtitleSidebar.fontFamily = fontFamily.trim();
} else if ((src.subtitleSidebar as { fontFamily?: unknown }).fontFamily !== undefined) {
resolved.subtitleSidebar.fontFamily = fallback.fontFamily;
warn(
'subtitleSidebar.fontFamily',
(src.subtitleSidebar as { fontFamily?: unknown }).fontFamily,
resolved.subtitleSidebar.fontFamily,
'Expected non-empty string.',
);
}
const fontSize = asNumber((src.subtitleSidebar as { fontSize?: unknown }).fontSize);
if (fontSize !== undefined && fontSize > 0) {
resolved.subtitleSidebar.fontSize = fontSize;
} else if ((src.subtitleSidebar as { fontSize?: unknown }).fontSize !== undefined) {
resolved.subtitleSidebar.fontSize = fallback.fontSize;
warn(
'subtitleSidebar.fontSize',
(src.subtitleSidebar as { fontSize?: unknown }).fontSize,
resolved.subtitleSidebar.fontSize,
'Expected positive number.',
);
}
}
} }

View File

@@ -1,93 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createResolveContext } from './context';
import { applySubtitleDomainConfig } from './subtitle-domains';
test('subtitleSidebar resolves valid values and preserves dedicated defaults', () => {
const { context } = createResolveContext({
subtitleSidebar: {
enabled: true,
autoOpen: true,
layout: 'embedded',
toggleKey: 'KeyB',
pauseVideoOnHover: true,
autoScroll: false,
maxWidth: 540,
opacity: 0.72,
backgroundColor: 'rgba(36, 39, 58, 0.72)',
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)',
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleSidebar.enabled, true);
assert.equal(context.resolved.subtitleSidebar.autoOpen, true);
assert.equal(context.resolved.subtitleSidebar.layout, 'embedded');
assert.equal(context.resolved.subtitleSidebar.toggleKey, 'KeyB');
assert.equal(context.resolved.subtitleSidebar.pauseVideoOnHover, true);
assert.equal(context.resolved.subtitleSidebar.autoScroll, false);
assert.equal(context.resolved.subtitleSidebar.maxWidth, 540);
assert.equal(context.resolved.subtitleSidebar.opacity, 0.72);
assert.equal(context.resolved.subtitleSidebar.fontFamily, '"Iosevka Aile", sans-serif');
assert.equal(context.resolved.subtitleSidebar.fontSize, 17);
});
test('subtitleSidebar accepts zero opacity', () => {
const { context, warnings } = createResolveContext({
subtitleSidebar: {
opacity: 0,
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleSidebar.opacity, 0);
assert.equal(warnings.some((warning) => warning.path === 'subtitleSidebar.opacity'), false);
});
test('subtitleSidebar falls back and warns on invalid values', () => {
const { context, warnings } = createResolveContext({
subtitleSidebar: {
enabled: 'yes' as never,
autoOpen: 'yes' as never,
layout: 'floating' as never,
maxWidth: -1,
opacity: 5,
fontSize: 0,
textColor: 'blue',
backgroundColor: 'not-a-color',
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleSidebar.enabled, false);
assert.equal(context.resolved.subtitleSidebar.autoOpen, false);
assert.equal(context.resolved.subtitleSidebar.layout, 'overlay');
assert.equal(context.resolved.subtitleSidebar.maxWidth, 420);
assert.equal(context.resolved.subtitleSidebar.opacity, 0.95);
assert.equal(context.resolved.subtitleSidebar.fontSize, 16);
assert.equal(context.resolved.subtitleSidebar.textColor, '#cad3f5');
assert.equal(context.resolved.subtitleSidebar.backgroundColor, 'rgba(73, 77, 100, 0.9)');
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.enabled'));
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.autoOpen'));
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.layout'));
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.maxWidth'));
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.opacity'));
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.fontSize'));
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.textColor'));
assert.ok(
warnings.some(
(warning) =>
warning.path === 'subtitleSidebar.backgroundColor' &&
warning.message === 'Expected valid CSS color.',
),
);
});

View File

@@ -42,9 +42,6 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo
if (!isEqual(prev.shortcuts, next.shortcuts)) { if (!isEqual(prev.shortcuts, next.shortcuts)) {
hotReloadFields.push('shortcuts'); hotReloadFields.push('shortcuts');
} }
if (!isEqual(prev.subtitleSidebar, next.subtitleSidebar)) {
hotReloadFields.push('subtitleSidebar');
}
if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) { if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) {
hotReloadFields.push('secondarySub.defaultMode'); hotReloadFields.push('secondarySub.defaultMode');
} }
@@ -58,7 +55,7 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo
]); ]);
for (const key of keys) { for (const key of keys) {
if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts' || key === 'subtitleSidebar') { if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts') {
continue; continue;
} }

View File

@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc'; import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { SubtitleSidebarSnapshot } from '../../types';
interface FakeIpcRegistrar { interface FakeIpcRegistrar {
on: Map<string, (event: unknown, ...args: unknown[]) => void>; on: Map<string, (event: unknown, ...args: unknown[]) => void>;
@@ -78,31 +77,6 @@ function createControllerConfigFixture() {
}; };
} }
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
return {
cues: [],
currentSubtitle: { text: '', startTime: null, endTime: null },
config: {
enabled: false,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 420,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
textColor: '#cad3f5',
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
fontSize: 16,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
};
}
function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServiceDeps { function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServiceDeps {
return { return {
onOverlayModalClosed: () => {}, onOverlayModalClosed: () => {},
@@ -114,7 +88,6 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '', getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => false, getPlaybackPaused: () => false,
getSubtitlePosition: () => null, getSubtitlePosition: () => null,
getSubtitleStyle: () => null, getSubtitleStyle: () => null,
@@ -200,7 +173,6 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '', getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => true, getPlaybackPaused: () => true,
getSubtitlePosition: () => null, getSubtitlePosition: () => null,
getSubtitleStyle: () => null, getSubtitleStyle: () => null,
@@ -297,7 +269,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
cycles.push({ id, direction }); cycles.push({ id, direction });
return { ok: true }; return { ok: true };
}, },
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
reportOverlayContentBounds: () => {}, reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({}), getAnilistStatus: () => ({}),
clearAnilistToken: () => {}, clearAnilistToken: () => {},
@@ -349,24 +320,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
); );
}); });
test('registerIpcHandlers exposes subtitle sidebar snapshot request', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const snapshot = createSubtitleSidebarSnapshotFixture();
snapshot.cues = [{ startTime: 1, endTime: 2, text: 'line-1' }];
snapshot.config.enabled = true;
registerIpcHandlers(
createRegisterIpcDeps({
getSubtitleSidebarSnapshot: async () => snapshot,
}),
registrar,
);
const handler = handlers.handle.get(IPC_CHANNELS.request.getSubtitleSidebarSnapshot);
assert.ok(handler);
assert.deepEqual(await handler!({}), snapshot);
});
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => { test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
const { registrar, handlers } = createFakeIpcRegistrar(); const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = []; const calls: string[] = [];
@@ -577,7 +530,6 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '', getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => false, getPlaybackPaused: () => false,
getSubtitlePosition: () => null, getSubtitlePosition: () => null,
getSubtitleStyle: () => null, getSubtitleStyle: () => null,
@@ -644,7 +596,6 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '', getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => false, getPlaybackPaused: () => false,
getSubtitlePosition: () => null, getSubtitlePosition: () => null,
getSubtitleStyle: () => null, getSubtitleStyle: () => null,
@@ -716,7 +667,6 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '', getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => false, getPlaybackPaused: () => false,
getSubtitlePosition: () => null, getSubtitlePosition: () => null,
getSubtitleStyle: () => null, getSubtitleStyle: () => null,

View File

@@ -6,7 +6,6 @@ import type {
ResolvedControllerConfig, ResolvedControllerConfig,
RuntimeOptionId, RuntimeOptionId,
RuntimeOptionValue, RuntimeOptionValue,
SubtitleSidebarSnapshot,
SubtitlePosition, SubtitlePosition,
SubsyncManualRunRequest, SubsyncManualRunRequest,
SubsyncResult, SubsyncResult,
@@ -38,7 +37,6 @@ export interface IpcServiceDeps {
tokenizeCurrentSubtitle: () => Promise<unknown>; tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string; getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string; getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getPlaybackPaused: () => boolean | null; getPlaybackPaused: () => boolean | null;
getSubtitlePosition: () => unknown; getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown; getSubtitleStyle: () => unknown;
@@ -145,7 +143,6 @@ export interface IpcDepsRuntimeOptions {
tokenizeCurrentSubtitle: () => Promise<unknown>; tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string; getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string; getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getPlaybackPaused: () => boolean | null; getPlaybackPaused: () => boolean | null;
getSubtitlePosition: () => unknown; getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown; getSubtitleStyle: () => unknown;
@@ -193,7 +190,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle, tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw, getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss, getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot,
getPlaybackPaused: options.getPlaybackPaused, getPlaybackPaused: options.getPlaybackPaused,
getSubtitlePosition: options.getSubtitlePosition, getSubtitlePosition: options.getSubtitlePosition,
getSubtitleStyle: options.getSubtitleStyle, getSubtitleStyle: options.getSubtitleStyle,
@@ -325,13 +321,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getCurrentSubtitleAss(); return deps.getCurrentSubtitleAss();
}); });
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarSnapshot, async () => {
if (!deps.getSubtitleSidebarSnapshot) {
throw new Error('Subtitle sidebar snapshot is unavailable.');
}
return await deps.getSubtitleSidebarSnapshot();
});
ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => { ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => {
return deps.getPlaybackPaused(); return deps.getPlaybackPaused();
}); });

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { parseSrtCues, parseAssCues, parseSubtitleCues } from './subtitle-cue-parser'; import { parseSrtCues, parseAssCues, parseSubtitleCues } from './subtitle-cue-parser';
import type { SubtitleCue } from '../../types'; import type { SubtitleCue } from './subtitle-cue-parser';
test('parseSrtCues parses basic SRT content', () => { test('parseSrtCues parses basic SRT content', () => {
const content = [ const content = [

View File

@@ -183,13 +183,7 @@ export function parseSubtitleCues(content: string, filename: string): SubtitleCu
cues = parseAssCues(content); cues = parseAssCues(content);
break; break;
default: default:
cues = []; return [];
}
if (cues.length === 0) {
const assCues = parseAssCues(content);
const srtCues = parseSrtCues(content);
cues = assCues.length >= srtCues.length ? assCues : srtCues;
} }
cues.sort((a, b) => a.startTime - b.startTime); cues.sort((a, b) => a.startTime - b.startTime);

View File

@@ -1,8 +1,8 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { computePriorityWindow, createSubtitlePrefetchService } from './subtitle-prefetch'; import { computePriorityWindow, createSubtitlePrefetchService } from './subtitle-prefetch';
import type { SubtitleCue } from './subtitle-cue-parser';
import type { SubtitleData } from '../../types'; import type { SubtitleData } from '../../types';
import type { SubtitleCue } from '../../types';
function makeCues(count: number, startOffset = 0): SubtitleCue[] { function makeCues(count: number, startOffset = 0): SubtitleCue[] {
return Array.from({ length: count }, (_, i) => ({ return Array.from({ length: count }, (_, i) => ({

View File

@@ -1,5 +1,5 @@
import type { SubtitleCue } from './subtitle-cue-parser';
import type { SubtitleData } from '../../types'; import type { SubtitleData } from '../../types';
import type { SubtitleCue } from '../../types';
export interface SubtitlePrefetchServiceDeps { export interface SubtitlePrefetchServiceDeps {
cues: SubtitleCue[]; cues: SubtitleCue[];

View File

@@ -438,11 +438,10 @@ import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch'; import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch';
import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch'; import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch';
import { import {
buildSubtitleSidebarSourceKey, getActiveExternalSubtitleSource,
resolveSubtitleSourcePath, resolveSubtitleSourcePath,
} from './main/runtime/subtitle-prefetch-source'; } from './main/runtime/subtitle-prefetch-source';
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init'; import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
import { codecToExtension } from './subsync/utils';
if (process.platform === 'linux') { if (process.platform === 'linux') {
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
@@ -1143,23 +1142,15 @@ function maybeSignalPluginAutoplayReady(
let appTray: Tray | null = null; let appTray: Tray | null = null;
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null; let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
return {
...payload,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
};
}
function emitSubtitlePayload(payload: SubtitleData): void { function emitSubtitlePayload(payload: SubtitleData): void {
const timedPayload = withCurrentSubtitleTiming(payload); appState.currentSubtitleData = payload;
appState.currentSubtitleData = timedPayload; broadcastToOverlayWindows('subtitle:set', payload);
broadcastToOverlayWindows('subtitle:set', timedPayload); subtitleWsService.broadcast(payload, {
subtitleWsService.broadcast(timedPayload, {
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
}); });
annotationSubtitleWsService.broadcast(timedPayload, { annotationSubtitleWsService.broadcast(payload, {
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
@@ -1209,10 +1200,6 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
isCacheFull: () => subtitleProcessingController.isCacheFull(), isCacheFull: () => subtitleProcessingController.isCacheFull(),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
appState.activeParsedSubtitleCues = cues ?? [];
appState.activeParsedSubtitleSource = sourceKey;
},
}); });
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> { async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
@@ -1222,40 +1209,19 @@ async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
} }
try { try {
const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] = const [trackListRaw, sidRaw] = await Promise.all([
await Promise.all([
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
client.requestProperty('current-tracks/sub').catch(() => null),
client.requestProperty('track-list'), client.requestProperty('track-list'),
client.requestProperty('sid'), client.requestProperty('sid'),
client.requestProperty('path'),
]); ]);
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : ''; const externalFilename = getActiveExternalSubtitleSource(trackListRaw, sidRaw);
if (!videoPath) { if (!externalFilename) {
subtitlePrefetchInitController.cancelPendingInit(); subtitlePrefetchInitController.cancelPendingInit();
return; return;
} }
const resolvedSource = await resolveActiveSubtitleSidebarSource(
currentExternalFilenameRaw,
currentTrackRaw,
trackListRaw,
sidRaw,
videoPath,
);
if (!resolvedSource) {
subtitlePrefetchInitController.cancelPendingInit();
return;
}
try {
await subtitlePrefetchInitController.initSubtitlePrefetch( await subtitlePrefetchInitController.initSubtitlePrefetch(
resolvedSource.path, externalFilename,
lastObservedTimePos, lastObservedTimePos,
resolvedSource.sourceKey,
); );
} finally {
await resolvedSource.cleanup?.();
}
} catch { } catch {
// Track list query failed; skip subtitle prefetch refresh. // Track list query failed; skip subtitle prefetch refresh.
} }
@@ -2999,8 +2965,6 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
? { ? {
text: appState.currentSubText, text: appState.currentSubText,
tokens: null, tokens: null,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
} }
: null), : null),
() => ({ () => ({
@@ -3019,8 +2983,6 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
? { ? {
text: appState.currentSubText, text: appState.currentSubText,
tokens: null, tokens: null,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
} }
: null), : null),
() => ({ () => ({
@@ -3295,9 +3257,6 @@ const {
restoreMpvSubVisibility: () => { restoreMpvSubVisibility: () => {
restoreOverlayMpvSubtitles(); restoreOverlayMpvSubtitles();
}, },
resetSubtitleSidebarEmbeddedLayout: () => {
resetSubtitleSidebarEmbeddedLayoutRuntime();
},
getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(), getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => { resetAnilistMediaTracking: (mediaKey) => {
resetAnilistMediaTracking(mediaKey); resetAnilistMediaTracking(mediaKey);
@@ -3516,11 +3475,6 @@ function createMpvClientRuntimeService(): MpvIpcClient {
return createMpvClientRuntimeServiceHandler() as MpvIpcClient; return createMpvClientRuntimeServiceHandler() as MpvIpcClient;
} }
function resetSubtitleSidebarEmbeddedLayoutRuntime(): void {
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-margin-ratio-right', 0]);
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-pan-x', 0]);
}
function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>): void { function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>): void {
updateMpvSubtitleRenderMetricsHandler(patch); updateMpvSubtitleRenderMetricsHandler(patch);
} }
@@ -3965,176 +3919,6 @@ async function loadSubtitleSourceText(source: string): Promise<string> {
return fs.promises.readFile(filePath, 'utf8'); return fs.promises.readFile(filePath, 'utf8');
} }
type MpvSubtitleTrackLike = {
type?: unknown;
id?: unknown;
selected?: unknown;
external?: unknown;
codec?: unknown;
'ff-index'?: unknown;
'external-filename'?: unknown;
};
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isInteger(parsed) ? parsed : null;
}
return null;
}
function getActiveSubtitleTrack(
currentTrackRaw: unknown,
trackListRaw: unknown,
sidRaw: unknown,
): MpvSubtitleTrackLike | null {
if (currentTrackRaw && typeof currentTrackRaw === 'object') {
const track = currentTrackRaw as MpvSubtitleTrackLike;
if (track.type === undefined || track.type === 'sub') {
return track;
}
}
const sid = parseTrackId(sidRaw);
if (!Array.isArray(trackListRaw)) {
return null;
}
const bySid =
sid === null
? null
: ((trackListRaw.find((entry: unknown) => {
if (!entry || typeof entry !== 'object') {
return false;
}
const track = entry as MpvSubtitleTrackLike;
return track.type === 'sub' && parseTrackId(track.id) === sid;
}) as MpvSubtitleTrackLike | undefined) ?? null);
if (bySid) {
return bySid;
}
return (
(trackListRaw.find((entry: unknown) => {
if (!entry || typeof entry !== 'object') {
return false;
}
const track = entry as MpvSubtitleTrackLike;
return track.type === 'sub' && track.selected === true;
}) as MpvSubtitleTrackLike | undefined) ?? null
);
}
function buildFfmpegSubtitleExtractionArgs(
videoPath: string,
ffIndex: number,
outputPath: string,
): string[] {
return [
'-hide_banner',
'-nostdin',
'-y',
'-loglevel',
'error',
'-an',
'-vn',
'-i',
videoPath,
'-map',
`0:${ffIndex}`,
'-f',
path.extname(outputPath).slice(1),
outputPath,
];
}
async function extractInternalSubtitleTrackToTempFile(
ffmpegPath: string,
videoPath: string,
track: MpvSubtitleTrackLike,
): Promise<{ path: string; cleanup: () => Promise<void> } | null> {
const ffIndex = parseTrackId(track['ff-index']);
const codec = typeof track.codec === 'string' ? track.codec : null;
const extension = codecToExtension(codec ?? undefined);
if (ffIndex === null || extension === null) {
return null;
}
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-'));
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
try {
await new Promise<void>((resolve, reject) => {
const child = spawn(
ffmpegPath,
buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath),
);
let stderr = '';
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
reject(error);
});
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
});
});
} catch (error) {
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
return {
path: outputPath,
cleanup: async () => {
await fs.promises.rm(tempDir, { recursive: true, force: true });
},
};
}
async function resolveActiveSubtitleSidebarSource(
currentExternalFilenameRaw: unknown,
currentTrackRaw: unknown,
trackListRaw: unknown,
sidRaw: unknown,
videoPath: string,
): Promise<{ path: string; sourceKey: string; cleanup?: () => Promise<void> } | null> {
const currentExternalFilename =
typeof currentExternalFilenameRaw === 'string' ? currentExternalFilenameRaw.trim() : '';
if (currentExternalFilename) {
return { path: currentExternalFilename, sourceKey: currentExternalFilename };
}
const track = getActiveSubtitleTrack(currentTrackRaw, trackListRaw, sidRaw);
if (!track) {
return null;
}
const externalFilename =
typeof track['external-filename'] === 'string' ? track['external-filename'].trim() : '';
if (externalFilename) {
return { path: externalFilename, sourceKey: externalFilename };
}
const ffmpegPath = getResolvedConfig().subsync.ffmpeg_path.trim() || 'ffmpeg';
const extracted = await extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track);
if (!extracted) {
return null;
}
return {
...extracted,
sourceKey: buildSubtitleSidebarSourceKey(videoPath, track, extracted.path),
};
}
const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
loadSubtitleSourceText, loadSubtitleSourceText,
@@ -4192,99 +3976,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
quitApp: () => requestAppQuit(), quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: async () => withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)), tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
getCurrentSubtitleRaw: () => appState.currentSubText, getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText, getCurrentSubtitleAss: () => appState.currentSubAssText,
getSubtitleSidebarSnapshot: async () => {
const currentSubtitle = {
text: appState.currentSubText,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
};
const currentTimeSec = appState.mpvClient?.currentTimePos ?? null;
const config = getResolvedConfig().subtitleSidebar;
const client = appState.mpvClient;
if (!client?.connected) {
return {
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
try {
const [
currentExternalFilenameRaw,
currentTrackRaw,
trackListRaw,
sidRaw,
videoPathRaw,
] = await Promise.all([
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
client.requestProperty('current-tracks/sub').catch(() => null),
client.requestProperty('track-list'),
client.requestProperty('sid'),
client.requestProperty('path'),
]);
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) {
return {
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
const resolvedSource = await resolveActiveSubtitleSidebarSource(
currentExternalFilenameRaw,
currentTrackRaw,
trackListRaw,
sidRaw,
videoPath,
);
if (!resolvedSource) {
return {
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
return {
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
try {
const content = await loadSubtitleSourceText(resolvedSource.path);
const cues = parseSubtitleCues(content, resolvedSource.path);
appState.activeParsedSubtitleCues = cues;
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
return {
cues,
currentTimeSec,
currentSubtitle,
config,
};
} finally {
await resolvedSource.cleanup?.();
}
} catch {
return {
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
},
getPlaybackPaused: () => appState.playbackPaused, getPlaybackPaused: () => appState.playbackPaused,
getSubtitlePosition: () => loadSubtitlePosition(), getSubtitlePosition: () => loadSubtitlePosition(),
getSubtitleStyle: () => { getSubtitleStyle: () => {

View File

@@ -63,7 +63,6 @@ export interface MainIpcRuntimeServiceDepsParams {
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle']; tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw']; getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss']; getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused']; getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow']; focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition']; getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
@@ -213,7 +212,6 @@ export function createMainIpcRuntimeServiceDeps(
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle, tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw, getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
getCurrentSubtitleAss: params.getCurrentSubtitleAss, getCurrentSubtitleAss: params.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
getPlaybackPaused: params.getPlaybackPaused, getPlaybackPaused: params.getPlaybackPaused,
getSubtitlePosition: params.getSubtitlePosition, getSubtitlePosition: params.getSubtitlePosition,
getSubtitleStyle: params.getSubtitleStyle, getSubtitleStyle: params.getSubtitleStyle,

View File

@@ -36,7 +36,6 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
return { return {
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS), keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
subtitleStyle: resolveSubtitleStyleForRenderer(config), subtitleStyle: resolveSubtitleStyleForRenderer(config),
subtitleSidebar: config.subtitleSidebar,
secondarySubMode: config.secondarySub.defaultMode, secondarySubMode: config.secondarySub.defaultMode,
}; };
} }

View File

@@ -82,7 +82,6 @@ test('media path change handler reports stop for empty path and probes media key
updateCurrentMediaPath: (path) => calls.push(`path:${path}`), updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'), reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => 'show:1', getCurrentAnilistMediaKey: () => 'show:1',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`), resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -98,7 +97,6 @@ test('media path change handler reports stop for empty path and probes media key
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'flush-playback', 'flush-playback',
'path:', 'path:',
'reset-sidebar-layout',
'stopped', 'stopped',
'restore-mpv-sub', 'restore-mpv-sub',
'reset:show:1', 'reset:show:1',
@@ -115,7 +113,6 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
updateCurrentMediaPath: (path) => calls.push(`path:${path}`), updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'), reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => null, getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`), resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -131,7 +128,35 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'path:/tmp/video.mkv', 'path:/tmp/video.mkv',
'reset-sidebar-layout', 'reset:null',
'sync',
'dict-sync',
'autoplay:/tmp/video.mkv',
'presence',
]);
});
test('media path change handler ignores playback flush for non-empty path', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
refreshDiscordPresence: () => calls.push('presence'),
});
handler({ path: '/tmp/video.mkv' });
assert.ok(!calls.includes('flush-playback'));
assert.deepEqual(calls, [
'path:/tmp/video.mkv',
'reset:null', 'reset:null',
'sync', 'sync',
'dict-sync', 'dict-sync',

View File

@@ -46,7 +46,6 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
updateCurrentMediaPath: (path: string) => void; updateCurrentMediaPath: (path: string) => void;
reportJellyfinRemoteStopped: () => void; reportJellyfinRemoteStopped: () => void;
restoreMpvSubVisibility: () => void; restoreMpvSubVisibility: () => void;
resetSubtitleSidebarEmbeddedLayout: () => void;
getCurrentAnilistMediaKey: () => string | null; getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void; resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void; maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -63,7 +62,6 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath); deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath);
} }
deps.updateCurrentMediaPath(normalizedPath); deps.updateCurrentMediaPath(normalizedPath);
deps.resetSubtitleSidebarEmbeddedLayout();
if (!normalizedPath) { if (!normalizedPath) {
deps.reportJellyfinRemoteStopped(); deps.reportJellyfinRemoteStopped();
deps.restoreMpvSubVisibility(); deps.restoreMpvSubVisibility();

View File

@@ -9,7 +9,6 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
const bind = createBindMpvMainEventHandlersHandler({ const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'), reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'), syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
hasInitialJellyfinPlayArg: () => false, hasInitialJellyfinPlayArg: () => false,
isOverlayRuntimeInitialized: () => false, isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false, isQuitOnDisconnectArmed: () => false,
@@ -68,7 +67,6 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
}, },
}); });
handlers.get('connection-change')?.({ connected: true });
handlers.get('subtitle-change')?.({ text: 'line' }); handlers.get('subtitle-change')?.({ text: 'line' });
handlers.get('subtitle-track-change')?.({ sid: 3 }); handlers.get('subtitle-track-change')?.({ sid: 3 });
handlers.get('subtitle-track-list-change')?.({ trackList: [] }); handlers.get('subtitle-track-list-change')?.({ trackList: [] });
@@ -78,7 +76,6 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
handlers.get('pause-change')?.({ paused: true }); handlers.get('pause-change')?.({ paused: true });
assert.ok(calls.includes('set-sub:line')); assert.ok(calls.includes('set-sub:line'));
assert.ok(calls.includes('reset-sidebar-layout'));
assert.ok(calls.includes('broadcast-sub:line')); assert.ok(calls.includes('broadcast-sub:line'));
assert.ok(calls.includes('subtitle-change:line')); assert.ok(calls.includes('subtitle-change:line'));
assert.ok(calls.includes('subtitle-track-change')); assert.ok(calls.includes('subtitle-track-change'));

View File

@@ -21,7 +21,6 @@ type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandl
export function createBindMpvMainEventHandlersHandler(deps: { export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void; reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void; syncOverlayMpvSubtitleSuppression: () => void;
resetSubtitleSidebarEmbeddedLayout: () => void;
scheduleCharacterDictionarySync?: () => void; scheduleCharacterDictionarySync?: () => void;
hasInitialJellyfinPlayArg: () => boolean; hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean; isOverlayRuntimeInitialized: () => boolean;
@@ -84,12 +83,6 @@ export function createBindMpvMainEventHandlersHandler(deps: {
isMpvConnected: () => deps.isMpvConnected(), isMpvConnected: () => deps.isMpvConnected(),
quitApp: () => deps.quitApp(), quitApp: () => deps.quitApp(),
}); });
const handleMpvConnectionChangeWithSidebarReset = ({ connected }: { connected: boolean }): void => {
if (connected) {
deps.resetSubtitleSidebarEmbeddedLayout();
}
handleMpvConnectionChange({ connected });
};
const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({ const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({
recordImmersionSubtitleLine: (text, start, end) => recordImmersionSubtitleLine: (text, start, end) =>
deps.recordImmersionSubtitleLine(text, start, end), deps.recordImmersionSubtitleLine(text, start, end),
@@ -117,7 +110,6 @@ export function createBindMpvMainEventHandlersHandler(deps: {
updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path), updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path),
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(), restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey), resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey),
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey), maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
@@ -158,7 +150,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
}); });
createBindMpvClientEventHandlers({ createBindMpvClientEventHandlers({
onConnectionChange: handleMpvConnectionChangeWithSidebarReset, onConnectionChange: handleMpvConnectionChange,
onSubtitleChange: handleMpvSubtitleChange, onSubtitleChange: handleMpvSubtitleChange,
onSubtitleAssChange: handleMpvSubtitleAssChange, onSubtitleAssChange: handleMpvSubtitleAssChange,
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange, onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,

View File

@@ -47,7 +47,6 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'), ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`), updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => 'media-key', getCurrentAnilistMediaKey: () => 'media-key',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`), resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -83,7 +82,6 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.broadcastSecondarySubtitle('sec'); deps.broadcastSecondarySubtitle('sec');
deps.updateCurrentMediaPath('/tmp/video'); deps.updateCurrentMediaPath('/tmp/video');
deps.restoreMpvSubVisibility(); deps.restoreMpvSubVisibility();
deps.resetSubtitleSidebarEmbeddedLayout();
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key'); assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
deps.resetAnilistMediaTracking('media-key'); deps.resetAnilistMediaTracking('media-key');
deps.maybeProbeAnilistDuration('media-key'); deps.maybeProbeAnilistDuration('media-key');
@@ -114,5 +112,6 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('metrics')); assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh')); assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub')); assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('reset-sidebar-layout')); assert.ok(calls.includes('immersion-time:12.25'));
assert.ok(calls.includes('immersion-time:18.75'));
}); });

View File

@@ -50,7 +50,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void; onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
updateCurrentMediaPath: (path: string) => void; updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibility: () => void; restoreMpvSubVisibility: () => void;
resetSubtitleSidebarEmbeddedLayout?: () => void;
getCurrentAnilistMediaKey: () => string | null; getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void; resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void; maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -147,7 +146,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
deps.broadcastToOverlayWindows('secondary-subtitle:set', text), deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path), updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(), restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout?.(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey: string | null) => resetAnilistMediaTracking: (mediaKey: string | null) =>
deps.resetAnilistMediaTracking(mediaKey), deps.resetAnilistMediaTracking(mediaKey),

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import type { SubtitleCue } from '../../core/services/subtitle-cue-parser';
import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch'; import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch';
import type { SubtitleCue } from '../../types';
import { createSubtitlePrefetchInitController } from './subtitle-prefetch-init'; import { createSubtitlePrefetchInitController } from './subtitle-prefetch-init';
function createDeferred<T>(): { function createDeferred<T>(): {
@@ -112,125 +112,3 @@ test('cancelPendingInit prevents an in-flight load from attaching a stale servic
assert.equal(currentService, null); assert.equal(currentService, null);
assert.deepEqual(started, []); assert.deepEqual(started, []);
}); });
test('subtitle prefetch init publishes parsed cues and clears them on cancel', async () => {
const deferred = createDeferred<string>();
let currentService: SubtitlePrefetchService | null = null;
const cueUpdates: Array<SubtitleCue[] | null> = [];
const controller = createSubtitlePrefetchInitController({
getCurrentService: () => currentService,
setCurrentService: (service) => {
currentService = service;
},
loadSubtitleSourceText: async () => await deferred.promise,
parseSubtitleCues: () => [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
],
createSubtitlePrefetchService: () => ({
start: () => {},
stop: () => {},
onSeek: () => {},
pause: () => {},
resume: () => {},
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (cues) => {
cueUpdates.push(cues);
},
});
const initPromise = controller.initSubtitlePrefetch('episode.ass', 12);
deferred.resolve('content');
await initPromise;
controller.cancelPendingInit();
assert.deepEqual(cueUpdates, [
[
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
],
null,
]);
});
test('subtitle prefetch init publishes the provided stable source key instead of the load path', async () => {
const deferred = createDeferred<string>();
let currentService: SubtitlePrefetchService | null = null;
const sourceUpdates: Array<string | null> = [];
const controller = createSubtitlePrefetchInitController({
getCurrentService: () => currentService,
setCurrentService: (service) => {
currentService = service;
},
loadSubtitleSourceText: async () => await deferred.promise,
parseSubtitleCues: () => [{ startTime: 1, endTime: 2, text: 'first' }],
createSubtitlePrefetchService: () => ({
start: () => {},
stop: () => {},
onSeek: () => {},
pause: () => {},
resume: () => {},
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (_cues, source) => {
sourceUpdates.push(source);
},
});
const initPromise = controller.initSubtitlePrefetch(
'/tmp/subminer-sidebar-123/track_7.ass',
12,
'internal:/media/episode01.mkv:track:3:ff:7',
);
deferred.resolve('content');
await initPromise;
assert.deepEqual(sourceUpdates, ['internal:/media/episode01.mkv:track:3:ff:7']);
});
test('subtitle prefetch init clears parsed cues when initialization fails', async () => {
const cueUpdates: Array<SubtitleCue[] | null> = [];
let currentService: SubtitlePrefetchService | null = null;
const controller = createSubtitlePrefetchInitController({
getCurrentService: () => currentService,
setCurrentService: (service) => {
currentService = service;
},
loadSubtitleSourceText: async () => {
throw new Error('boom');
},
parseSubtitleCues: () => [{ startTime: 1, endTime: 2, text: 'first' }],
createSubtitlePrefetchService: () => ({
start: () => {},
stop: () => {},
onSeek: () => {},
pause: () => {},
resume: () => {},
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (cues) => {
cueUpdates.push(cues);
},
});
await controller.initSubtitlePrefetch('episode.ass', 12);
assert.deepEqual(cueUpdates, [null]);
});

View File

@@ -1,9 +1,9 @@
import type { SubtitleCue } from '../../core/services/subtitle-cue-parser';
import type { import type {
SubtitlePrefetchService, SubtitlePrefetchService,
SubtitlePrefetchServiceDeps, SubtitlePrefetchServiceDeps,
} from '../../core/services/subtitle-prefetch'; } from '../../core/services/subtitle-prefetch';
import type { SubtitleData } from '../../types'; import type { SubtitleData } from '../../types';
import type { SubtitleCue } from '../../types';
export interface SubtitlePrefetchInitControllerDeps { export interface SubtitlePrefetchInitControllerDeps {
getCurrentService: () => SubtitlePrefetchService | null; getCurrentService: () => SubtitlePrefetchService | null;
@@ -16,16 +16,11 @@ export interface SubtitlePrefetchInitControllerDeps {
isCacheFull: () => boolean; isCacheFull: () => boolean;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logWarn: (message: string) => void; logWarn: (message: string) => void;
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
} }
export interface SubtitlePrefetchInitController { export interface SubtitlePrefetchInitController {
cancelPendingInit: () => void; cancelPendingInit: () => void;
initSubtitlePrefetch: ( initSubtitlePrefetch: (externalFilename: string, currentTimePos: number) => Promise<void>;
sourcePath: string,
currentTimePos: number,
sourceKey?: string,
) => Promise<void>;
} }
export function createSubtitlePrefetchInitController( export function createSubtitlePrefetchInitController(
@@ -37,29 +32,24 @@ export function createSubtitlePrefetchInitController(
initRevision += 1; initRevision += 1;
deps.getCurrentService()?.stop(); deps.getCurrentService()?.stop();
deps.setCurrentService(null); deps.setCurrentService(null);
deps.onParsedSubtitleCuesChanged?.(null, null);
}; };
const initSubtitlePrefetch = async ( const initSubtitlePrefetch = async (
sourcePath: string, externalFilename: string,
currentTimePos: number, currentTimePos: number,
sourceKey = sourcePath,
): Promise<void> => { ): Promise<void> => {
const revision = ++initRevision; const revision = ++initRevision;
deps.getCurrentService()?.stop(); deps.getCurrentService()?.stop();
deps.setCurrentService(null); deps.setCurrentService(null);
try { try {
const content = await deps.loadSubtitleSourceText(sourcePath); const content = await deps.loadSubtitleSourceText(externalFilename);
if (revision !== initRevision) { if (revision !== initRevision) {
return; return;
} }
const cues = deps.parseSubtitleCues(content, sourcePath); const cues = deps.parseSubtitleCues(content, externalFilename);
if (revision !== initRevision || cues.length === 0) { if (revision !== initRevision || cues.length === 0) {
if (revision === initRevision) {
deps.onParsedSubtitleCuesChanged?.(null, null);
}
return; return;
} }
@@ -75,14 +65,12 @@ export function createSubtitlePrefetchInitController(
} }
deps.setCurrentService(nextService); deps.setCurrentService(nextService);
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
nextService.start(currentTimePos); nextService.start(currentTimePos);
deps.logInfo( deps.logInfo(
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${sourcePath}`, `[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`,
); );
} catch (error) { } catch (error) {
if (revision === initRevision) { if (revision === initRevision) {
deps.onParsedSubtitleCuesChanged?.(null, null);
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`); deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
} }
} }

View File

@@ -1,7 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
buildSubtitleSidebarSourceKey,
getActiveExternalSubtitleSource, getActiveExternalSubtitleSource,
resolveSubtitleSourcePath, resolveSubtitleSourcePath,
} from './subtitle-prefetch-source'; } from './subtitle-prefetch-source';
@@ -18,15 +17,6 @@ test('getActiveExternalSubtitleSource returns the active external subtitle path'
assert.equal(source, 'https://host/subs.ass'); assert.equal(source, 'https://host/subs.ass');
}); });
test('getActiveExternalSubtitleSource normalizes integer-like string track ids', () => {
const source = getActiveExternalSubtitleSource(
[{ type: 'sub', id: '2', external: true, 'external-filename': ' /tmp/subs.ass ' }],
'2',
);
assert.equal(source, '/tmp/subs.ass');
});
test('getActiveExternalSubtitleSource returns null when the selected track is not external', () => { test('getActiveExternalSubtitleSource returns null when the selected track is not external', () => {
const source = getActiveExternalSubtitleSource( const source = getActiveExternalSubtitleSource(
[{ type: 'sub', id: 2, external: false, 'external-filename': '/tmp/subs.ass' }], [{ type: 'sub', id: 2, external: false, 'external-filename': '/tmp/subs.ass' }],
@@ -58,38 +48,3 @@ test('resolveSubtitleSourcePath returns the original source for malformed file U
assert.equal(resolveSubtitleSourcePath(source), source); assert.equal(resolveSubtitleSourcePath(source), source);
}); });
test('buildSubtitleSidebarSourceKey uses a stable identifier for internal subtitle tracks', () => {
const firstKey = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
id: 3,
'ff-index': 7,
title: 'English',
lang: 'eng',
codec: 'ass',
});
const secondKey = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
id: 3,
'ff-index': 7,
title: 'English',
lang: 'eng',
codec: 'ass',
});
assert.equal(firstKey, secondKey);
assert.equal(firstKey, 'internal:/media/episode01.mkv:track:3:ff:7');
});
test('buildSubtitleSidebarSourceKey normalizes integer-like string track metadata', () => {
const key = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
id: '3',
'ff-index': '7',
});
assert.equal(key, 'internal:/media/episode01.mkv:track:3:ff:7');
});
test('buildSubtitleSidebarSourceKey falls back to source path when no track metadata is available', () => {
const key = buildSubtitleSidebarSourceKey('/media/episode01.mkv', null, '/tmp/subtitle.ass');
assert.equal(key, '/tmp/subtitle.ass');
});

View File

@@ -1,16 +1,5 @@
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isInteger(parsed) ? parsed : null;
}
return null;
}
export function getActiveExternalSubtitleSource( export function getActiveExternalSubtitleSource(
trackListRaw: unknown, trackListRaw: unknown,
sidRaw: unknown, sidRaw: unknown,
@@ -19,8 +8,9 @@ export function getActiveExternalSubtitleSource(
return null; return null;
} }
const sid = parseTrackId(sidRaw); const sid =
if (sid === null) { typeof sidRaw === 'number' ? sidRaw : typeof sidRaw === 'string' ? Number(sidRaw) : null;
if (sid == null || !Number.isFinite(sid)) {
return null; return null;
} }
@@ -29,7 +19,7 @@ export function getActiveExternalSubtitleSource(
return false; return false;
} }
const track = entry as Record<string, unknown>; const track = entry as Record<string, unknown>;
return track.type === 'sub' && parseTrackId(track.id) === sid && track.external === true; return track.type === 'sub' && track.id === sid && track.external === true;
}) as Record<string, unknown> | undefined; }) as Record<string, unknown> | undefined;
const externalFilename = const externalFilename =
@@ -50,21 +40,3 @@ export function resolveSubtitleSourcePath(source: string): string {
return source; return source;
} }
} }
export function buildSubtitleSidebarSourceKey(
videoPath: string,
track: unknown,
fallbackSourcePath?: string,
): string {
const normalizedVideoPath = videoPath.trim();
if (track && typeof track === 'object' && normalizedVideoPath) {
const subtitleTrack = track as Record<string, unknown>;
const trackId = parseTrackId(subtitleTrack.id);
const ffIndex = parseTrackId(subtitleTrack['ff-index']);
if (trackId !== null || ffIndex !== null) {
return `internal:${normalizedVideoPath}:track:${trackId ?? 'unknown'}:ff:${ffIndex ?? 'unknown'}`;
}
}
return fallbackSourcePath ?? normalizedVideoPath;
}

View File

@@ -9,7 +9,6 @@ import type {
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
JlptLevel, JlptLevel,
FrequencyDictionaryLookup, FrequencyDictionaryLookup,
SubtitleCue,
} from '../types'; } from '../types';
import type { CliArgs } from '../cli/args'; import type { CliArgs } from '../cli/args';
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker'; import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
@@ -159,8 +158,6 @@ export interface AppState {
currentSubText: string; currentSubText: string;
currentSubAssText: string; currentSubAssText: string;
currentSubtitleData: SubtitleData | null; currentSubtitleData: SubtitleData | null;
activeParsedSubtitleCues: SubtitleCue[];
activeParsedSubtitleSource: string | null;
windowTracker: BaseWindowTracker | null; windowTracker: BaseWindowTracker | null;
subtitlePosition: SubtitlePosition | null; subtitlePosition: SubtitlePosition | null;
currentMediaPath: string | null; currentMediaPath: string | null;
@@ -241,8 +238,6 @@ export function createAppState(values: AppStateInitialValues): AppState {
currentSubText: '', currentSubText: '',
currentSubAssText: '', currentSubAssText: '',
currentSubtitleData: null, currentSubtitleData: null,
activeParsedSubtitleCues: [],
activeParsedSubtitleSource: null,
windowTracker: null, windowTracker: null,
subtitlePosition: null, subtitlePosition: null,
currentMediaPath: null, currentMediaPath: null,

View File

@@ -169,8 +169,6 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw), ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw),
getCurrentSubtitleAss: (): Promise<string> => getCurrentSubtitleAss: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss), ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss),
getSubtitleSidebarSnapshot: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
getPlaybackPaused: (): Promise<boolean | null> => getPlaybackPaused: (): Promise<boolean | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused), ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused),
onSubtitleAss: (callback: (assText: string) => void) => { onSubtitleAss: (callback: (assText: string) => void) => {

View File

@@ -10,9 +10,6 @@ const makefile = readFileSync(makefilePath, 'utf8');
const packageJsonPath = resolve(__dirname, '../package.json'); const packageJsonPath = resolve(__dirname, '../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
scripts: Record<string, string>; scripts: Record<string, string>;
build?: {
files?: string[];
};
}; };
test('publish release leaves prerelease unset so gh creates a normal release', () => { test('publish release leaves prerelease unset so gh creates a normal release', () => {
@@ -27,11 +24,6 @@ test('release workflow verifies a committed changelog section before publish', (
assert.match(releaseWorkflow, /bun run changelog:check/); assert.match(releaseWorkflow, /bun run changelog:check/);
}); });
test('release workflow builds changelog artifacts when fragments are pending', () => {
assert.match(releaseWorkflow, /Build changelog artifacts for release/);
assert.match(releaseWorkflow, /changelog:build --version/);
});
test('release workflow verifies generated config examples before packaging artifacts', () => { test('release workflow verifies generated config examples before packaging artifacts', () => {
assert.match(releaseWorkflow, /bun run verify:config-example/); assert.match(releaseWorkflow, /bun run verify:config-example/);
}); });
@@ -68,18 +60,6 @@ test('release package scripts disable implicit electron-builder publishing', ()
assert.match(packageJson.scripts['build:win:unsigned'] ?? '', /build-win-unsigned\.mjs/); assert.match(packageJson.scripts['build:win:unsigned'] ?? '', /build-win-unsigned\.mjs/);
}); });
test('release packaging keeps default file inclusion and excludes large source-only trees explicitly', () => {
const files = packageJson.build?.files ?? [];
assert.ok(files.includes('**/*'));
assert.ok(files.includes('!src{,/**/*}'));
assert.ok(files.includes('!launcher{,/**/*}'));
assert.ok(files.includes('!stats/src{,/**/*}'));
assert.ok(files.includes('!.tmp{,/**/*}'));
assert.ok(files.includes('!release-*{,/**/*}'));
assert.ok(files.includes('!vendor/subminer-yomitan{,/**/*}'));
assert.ok(files.includes('!vendor/texthooker-ui/src{,/**/*}'));
});
test('config example generation runs directly from source without unrelated bundle prerequisites', () => { test('config example generation runs directly from source without unrelated bundle prerequisites', () => {
assert.equal( assert.equal(
packageJson.scripts['generate:config-example'], packageJson.scripts['generate:config-example'],

View File

@@ -27,7 +27,6 @@ export function createKeyboardHandlers(
getPlaybackPaused: () => Promise<boolean | null>; getPlaybackPaused: () => Promise<boolean | null>;
openControllerSelectModal: () => void; openControllerSelectModal: () => void;
openControllerDebugModal: () => void; openControllerDebugModal: () => void;
toggleSubtitleSidebarModal?: () => void;
}, },
) { ) {
// Timeout for the modal chord capture window (e.g. Y followed by H/K). // Timeout for the modal chord capture window (e.g. Y followed by H/K).
@@ -182,26 +181,6 @@ export function createKeyboardHandlers(
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC'; return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
} }
function isSubtitleSidebarToggle(e: KeyboardEvent): boolean {
const toggleKey = ctx.state.subtitleSidebarToggleKey;
if (!toggleKey) return false;
const isBackslashConfigured = toggleKey === 'Backslash' || toggleKey === '\\';
const isBackslashLikeCode = ['Backslash', 'IntlBackslash', 'IntlYen'].includes(e.code);
const keyMatches =
toggleKey === e.code ||
(isBackslashConfigured && isBackslashLikeCode) ||
(isBackslashConfigured && e.key === '\\') ||
(toggleKey.length === 1 && e.key === toggleKey);
return (
keyMatches &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
!e.repeat
);
}
function isStatsOverlayToggle(e: KeyboardEvent): boolean { function isStatsOverlayToggle(e: KeyboardEvent): boolean {
return ( return (
e.code === ctx.state.statsToggleKey && e.code === ctx.state.statsToggleKey &&
@@ -859,12 +838,6 @@ export function createKeyboardHandlers(
return; return;
} }
if (isSubtitleSidebarToggle(e)) {
e.preventDefault();
options.toggleSubtitleSidebarModal?.();
return;
}
if ( if (
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) && (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
!isControllerModalShortcut(e) !isControllerModalShortcut(e)

View File

@@ -1,7 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import type { SubtitleSidebarConfig } from '../../types';
import { createMouseHandlers } from './mouse.js'; import { createMouseHandlers } from './mouse.js';
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js'; import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
@@ -40,7 +39,6 @@ function createMouseTestContext() {
const overlayClassList = createClassList(); const overlayClassList = createClassList();
const subtitleRootClassList = createClassList(); const subtitleRootClassList = createClassList();
const subtitleContainerClassList = createClassList(); const subtitleContainerClassList = createClassList();
const secondarySubContainerClassList = createClassList();
const ctx = { const ctx = {
dom: { dom: {
@@ -56,7 +54,6 @@ function createMouseTestContext() {
addEventListener: () => {}, addEventListener: () => {},
}, },
secondarySubContainer: { secondarySubContainer: {
classList: secondarySubContainerClassList,
addEventListener: () => {}, addEventListener: () => {},
}, },
}, },
@@ -66,9 +63,6 @@ function createMouseTestContext() {
}, },
state: { state: {
isOverSubtitle: false, isOverSubtitle: false,
isOverSubtitleSidebar: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
isDragging: false, isDragging: false,
dragStartY: 0, dragStartY: 0,
startYPercent: 0, startYPercent: 0,
@@ -78,7 +72,7 @@ function createMouseTestContext() {
return ctx; return ctx;
} }
test('secondary hover pauses on enter, reveals secondary subtitle, and resumes on leave when enabled', async () => { test('auto-pause on subtitle hover pauses on enter and resumes on leave when enabled', async () => {
const ctx = createMouseTestContext(); const ctx = createMouseTestContext();
const mpvCommands: Array<(string | number)[]> = []; const mpvCommands: Array<(string | number)[]> = [];
@@ -98,10 +92,8 @@ test('secondary hover pauses on enter, reveals secondary subtitle, and resumes o
}, },
}); });
await handlers.handleSecondaryMouseEnter(); await handlers.handleMouseEnter();
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), true); await handlers.handleMouseLeave();
await handlers.handleSecondaryMouseLeave();
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
assert.deepEqual(mpvCommands, [ assert.deepEqual(mpvCommands, [
['set_property', 'pause', 'yes'], ['set_property', 'pause', 'yes'],
@@ -109,68 +101,6 @@ test('secondary hover pauses on enter, reveals secondary subtitle, and resumes o
]); ]);
}); });
test('moving between primary and secondary subtitle containers keeps the hover pause active', async () => {
const ctx = createMouseTestContext();
const mpvCommands: Array<(string | number)[]> = [];
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => true,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: (command) => {
mpvCommands.push(command);
},
});
await handlers.handleSecondaryMouseEnter();
await handlers.handleSecondaryMouseLeave({
relatedTarget: ctx.dom.subtitleContainer,
} as unknown as MouseEvent);
await handlers.handlePrimaryMouseEnter({
relatedTarget: ctx.dom.secondarySubContainer,
} as unknown as MouseEvent);
assert.equal(ctx.state.isOverSubtitle, true);
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
});
test('secondary leave toward primary subtitle container clears the secondary hover class', async () => {
const ctx = createMouseTestContext();
const mpvCommands: Array<(string | number)[]> = [];
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => true,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: (command) => {
mpvCommands.push(command);
},
});
await handlers.handleSecondaryMouseEnter();
await handlers.handleSecondaryMouseLeave({
relatedTarget: ctx.dom.subtitleContainer,
} as unknown as MouseEvent);
assert.equal(ctx.state.isOverSubtitle, false);
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
});
test('auto-pause on subtitle hover skips when playback is already paused', async () => { test('auto-pause on subtitle hover skips when playback is already paused', async () => {
const ctx = createMouseTestContext(); const ctx = createMouseTestContext();
const mpvCommands: Array<(string | number)[]> = []; const mpvCommands: Array<(string | number)[]> = [];
@@ -197,36 +127,6 @@ test('auto-pause on subtitle hover skips when playback is already paused', async
assert.deepEqual(mpvCommands, []); assert.deepEqual(mpvCommands, []);
}); });
test('primary hover pauses on enter without revealing secondary subtitle', async () => {
const ctx = createMouseTestContext();
const mpvCommands: Array<(string | number)[]> = [];
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => true,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: (command) => {
mpvCommands.push(command);
},
});
await handlers.handlePrimaryMouseEnter();
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
await handlers.handlePrimaryMouseLeave();
assert.deepEqual(mpvCommands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
});
test('auto-pause on subtitle hover is skipped when disabled in config', async () => { test('auto-pause on subtitle hover is skipped when disabled in config', async () => {
const ctx = createMouseTestContext(); const ctx = createMouseTestContext();
const mpvCommands: Array<(string | number)[]> = []; const mpvCommands: Array<(string | number)[]> = [];
@@ -253,67 +153,6 @@ test('auto-pause on subtitle hover is skipped when disabled in config', async ()
assert.deepEqual(mpvCommands, []); assert.deepEqual(mpvCommands, []);
}); });
test('subtitle leave restores passthrough while embedded sidebar is open but not hovered', async () => {
const ctx = createMouseTestContext();
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
const previousWindow = (globalThis as { window?: unknown }).window;
ctx.platform.shouldToggleMouseIgnore = true;
ctx.state.isOverSubtitle = true;
ctx.state.subtitleSidebarModalOpen = true;
ctx.state.subtitleSidebarConfig = {
enabled: true,
autoOpen: false,
layout: 'embedded',
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: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreMouseCalls.push([ignore, options]);
},
},
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => true,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
await handlers.handlePrimaryMouseLeave();
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
test('pending hover pause check is ignored when mouse leaves before pause state resolves', async () => { test('pending hover pause check is ignored when mouse leaves before pause state resolves', async () => {
const ctx = createMouseTestContext(); const ctx = createMouseTestContext();
const mpvCommands: Array<(string | number)[]> = []; const mpvCommands: Array<(string | number)[]> = [];

View File

@@ -1,5 +1,4 @@
import type { ModalStateReader, RendererContext } from '../context'; import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
import { import {
YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT, YOMITAN_POPUP_SHOWN_EVENT,
@@ -26,19 +25,6 @@ export function createMouseHandlers(
let pausedBySubtitleHover = false; let pausedBySubtitleHover = false;
let pausedByYomitanPopup = false; let pausedByYomitanPopup = false;
function isWithinOtherSubtitleContainer(
relatedTarget: EventTarget | null,
otherContainer: HTMLElement,
): boolean {
if (relatedTarget === otherContainer) {
return true;
}
if (typeof Node !== 'undefined' && relatedTarget instanceof Node) {
return otherContainer.contains(relatedTarget);
}
return false;
}
function maybeResumeHoverPause(): void { function maybeResumeHoverPause(): void {
if (!pausedBySubtitleHover) return; if (!pausedBySubtitleHover) return;
if (pausedByYomitanPopup) return; if (pausedByYomitanPopup) return;
@@ -94,7 +80,10 @@ export function createMouseHandlers(
function enablePopupInteraction(): void { function enablePopupInteraction(): void {
yomitanPopupVisible = true; yomitanPopupVisible = true;
ctx.state.yomitanPopupVisible = true; ctx.state.yomitanPopupVisible = true;
syncOverlayMouseIgnoreState(ctx); ctx.dom.overlay.classList.add('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
if (ctx.platform.isMacOSPlatform) { if (ctx.platform.isMacOSPlatform) {
window.focus(); window.focus();
} }
@@ -112,18 +101,20 @@ export function createMouseHandlers(
popupPauseRequestId += 1; popupPauseRequestId += 1;
maybeResumeYomitanPopupPause(); maybeResumeYomitanPopupPause();
maybeResumeHoverPause(); maybeResumeHoverPause();
syncOverlayMouseIgnoreState(ctx); if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
}
}
} }
async function handleMouseEnter( async function handleMouseEnter(): Promise<void> {
_event?: MouseEvent,
showSecondaryHover = false,
): Promise<void> {
ctx.state.isOverSubtitle = true; ctx.state.isOverSubtitle = true;
if (showSecondaryHover) { ctx.dom.overlay.classList.add('interactive');
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active'); if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
} }
syncOverlayMouseIgnoreState(ctx);
if (yomitanPopupVisible && options.getYomitanPopupAutoPauseEnabled()) { if (yomitanPopupVisible && options.getYomitanPopupAutoPauseEnabled()) {
return; return;
@@ -133,10 +124,6 @@ export function createMouseHandlers(
return; return;
} }
if (pausedBySubtitleHover) {
return;
}
const requestId = ++hoverPauseRequestId; const requestId = ++hoverPauseRequestId;
let paused: boolean | null = null; let paused: boolean | null = null;
try { try {
@@ -154,26 +141,8 @@ export function createMouseHandlers(
pausedBySubtitleHover = true; pausedBySubtitleHover = true;
} }
async function handleMouseLeave( async function handleMouseLeave(): Promise<void> {
_event?: MouseEvent,
hideSecondaryHover = false,
): Promise<void> {
const relatedTarget = _event?.relatedTarget ?? null;
const otherContainer = hideSecondaryHover
? ctx.dom.subtitleContainer
: ctx.dom.secondarySubContainer;
if (relatedTarget && isWithinOtherSubtitleContainer(relatedTarget, otherContainer)) {
ctx.state.isOverSubtitle = false; ctx.state.isOverSubtitle = false;
if (hideSecondaryHover) {
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
}
return;
}
ctx.state.isOverSubtitle = false;
if (hideSecondaryHover) {
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
}
hoverPauseRequestId += 1; hoverPauseRequestId += 1;
maybeResumeHoverPause(); maybeResumeHoverPause();
if (yomitanPopupVisible) return; if (yomitanPopupVisible) return;
@@ -277,10 +246,6 @@ export function createMouseHandlers(
} }
return { return {
handlePrimaryMouseEnter: (event?: MouseEvent) => handleMouseEnter(event, false),
handlePrimaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, false),
handleSecondaryMouseEnter: (event?: MouseEvent) => handleMouseEnter(event, true),
handleSecondaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, true),
handleMouseEnter, handleMouseEnter,
handleMouseLeave, handleMouseLeave,
setupDragging, setupDragging,

View File

@@ -256,18 +256,6 @@
</div> </div>
</div> </div>
</div> </div>
<div id="subtitleSidebarModal" class="modal hidden subtitle-sidebar-modal" aria-hidden="true">
<div id="subtitleSidebarContent" class="modal-content subtitle-sidebar-content">
<div class="modal-header">
<div class="modal-title">Subtitle Sidebar</div>
<button id="subtitleSidebarClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body subtitle-sidebar-body">
<div id="subtitleSidebarStatus" class="runtime-options-status"></div>
<ul id="subtitleSidebarList" class="subtitle-sidebar-list"></ul>
</div>
</div>
</div>
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true"> <div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
<div class="modal-content session-help-content"> <div class="modal-content session-help-content">
<div class="modal-header"> <div class="modal-header">

File diff suppressed because it is too large Load Diff

View File

@@ -1,599 +0,0 @@
import type {
SubtitleCue,
SubtitleData,
SubtitleSidebarSnapshot,
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
const MANUAL_SCROLL_HOLD_MS = 1500;
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
const CLICK_SEEK_OFFSET_SEC = 0.08;
const SNAPSHOT_POLL_INTERVAL_MS = 80;
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
function subtitleCueListsEqual(a: SubtitleCue[], b: SubtitleCue[]): boolean {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i += 1) {
const left = a[i]!;
const right = b[i]!;
if (
left.startTime !== right.startTime ||
left.endTime !== right.endTime ||
left.text !== right.text
) {
return false;
}
}
return true;
}
function normalizeCueText(text: string): string {
return text.replace(/\r\n/g, '\n').trim();
}
function formatCueTimestamp(seconds: number): string {
const totalSeconds = Math.max(0, Math.floor(seconds));
const hours = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
if (hours > 0) {
return [
String(hours).padStart(2, '0'),
String(mins).padStart(2, '0'),
String(secs).padStart(2, '0'),
].join(':');
}
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
export function findActiveSubtitleCueIndex(
cues: SubtitleCue[],
current: { text: string; startTime?: number | null } | null,
currentTimeSec: number | null = null,
preferredCueIndex: number = -1,
): number {
if (cues.length === 0) {
return -1;
}
if (typeof currentTimeSec === 'number' && Number.isFinite(currentTimeSec)) {
const activeOrUpcomingCue = cues.findIndex(
(cue) =>
cue.endTime > currentTimeSec &&
cue.startTime <= currentTimeSec + ACTIVE_CUE_LOOKAHEAD_SEC,
);
if (activeOrUpcomingCue >= 0) {
return activeOrUpcomingCue;
}
const nextCue = cues.findIndex((cue) => cue.endTime > currentTimeSec);
if (nextCue >= 0) {
return nextCue;
}
}
if (!current) {
return -1;
}
if (typeof current.startTime === 'number' && Number.isFinite(current.startTime)) {
const timingMatch = cues.findIndex(
(cue) => current.startTime! >= cue.startTime && current.startTime! < cue.endTime,
);
if (timingMatch >= 0) {
return timingMatch;
}
}
const normalizedText = normalizeCueText(current.text);
if (!normalizedText) {
return -1;
}
const matchingIndices: number[] = [];
for (const [index, cue] of cues.entries()) {
if (normalizeCueText(cue.text) === normalizedText) {
matchingIndices.push(index);
}
}
if (matchingIndices.length === 0) {
return -1;
}
const hasTiming =
typeof current.startTime === 'number' && Number.isFinite(current.startTime);
if (preferredCueIndex >= 0) {
if (!hasTiming && currentTimeSec === null) {
const forwardMatches = matchingIndices.filter((index) => index >= preferredCueIndex);
if (forwardMatches.length > 0) {
return forwardMatches[0]!;
}
if (matchingIndices.includes(preferredCueIndex)) {
return preferredCueIndex;
}
return matchingIndices[matchingIndices.length - 1] ?? -1;
}
let nearestIndex = matchingIndices[0]!;
let nearestDistance = Math.abs(nearestIndex - preferredCueIndex);
for (const matchIndex of matchingIndices) {
const distance = Math.abs(matchIndex - preferredCueIndex);
if (distance < nearestDistance) {
nearestIndex = matchIndex;
nearestDistance = distance;
}
}
return nearestIndex;
}
return matchingIndices[0]!;
}
export function createSubtitleSidebarModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
},
) {
let snapshotPollInterval: ReturnType<typeof setTimeout> | null = null;
let lastAppliedVideoMarginRatio: number | null = null;
let subtitleSidebarHoverRequestId = 0;
let disposeDomEvents: (() => void) | null = null;
function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx);
}
function setStatus(message: string): void {
ctx.dom.subtitleSidebarStatus.textContent = message;
}
function getReservedSidebarWidthPx(): number {
const config = ctx.state.subtitleSidebarConfig;
if (!config || config.layout !== 'embedded' || !ctx.state.subtitleSidebarModalOpen) {
return 0;
}
const measuredWidth = ctx.dom.subtitleSidebarContent.getBoundingClientRect().width;
if (Number.isFinite(measuredWidth) && measuredWidth > 0) {
return measuredWidth;
}
return Math.max(EMBEDDED_SIDEBAR_MIN_WIDTH_PX, config.maxWidth);
}
function syncEmbeddedSidebarLayout(): void {
const config = ctx.state.subtitleSidebarConfig;
const wantsEmbedded = Boolean(
config && config.layout === 'embedded' && ctx.state.subtitleSidebarModalOpen,
);
if (wantsEmbedded) {
ctx.dom.subtitleSidebarContent.classList.add('subtitle-sidebar-content-embedded');
ctx.dom.subtitleSidebarModal.classList.add('subtitle-sidebar-modal-embedded');
document.body.classList.add('subtitle-sidebar-embedded-open');
} else {
ctx.dom.subtitleSidebarContent.classList.remove('subtitle-sidebar-content-embedded');
ctx.dom.subtitleSidebarModal.classList.remove('subtitle-sidebar-modal-embedded');
document.body.classList.remove('subtitle-sidebar-embedded-open');
}
const reservedWidthPx = wantsEmbedded ? getReservedSidebarWidthPx() : 0;
const embedded = wantsEmbedded && reservedWidthPx > 0;
document.documentElement.style.setProperty(
'--subtitle-sidebar-reserved-width',
`${Math.max(0, Math.round(reservedWidthPx))}px`,
);
const viewportWidth = window.innerWidth;
const ratio =
embedded && Number.isFinite(viewportWidth) && viewportWidth > 0
? Math.min(EMBEDDED_SIDEBAR_MAX_RATIO, reservedWidthPx / viewportWidth)
: 0;
if (
lastAppliedVideoMarginRatio !== null &&
Math.abs(ratio - lastAppliedVideoMarginRatio) < 0.0001
) {
return;
}
lastAppliedVideoMarginRatio = ratio;
window.electronAPI.sendMpvCommand([
'set_property',
'video-margin-ratio-right',
Number(ratio.toFixed(4)),
]);
window.electronAPI.sendMpvCommand([
'set_property',
'osd-align-x',
'left',
]);
window.electronAPI.sendMpvCommand([
'set_property',
'osd-align-y',
'top',
]);
window.electronAPI.sendMpvCommand([
'set_property',
'user-data/osc/margins',
JSON.stringify({
l: 0,
r: Number(ratio.toFixed(4)),
t: 0,
b: 0,
}),
]);
if (ratio === 0) {
window.electronAPI.sendMpvCommand(['set_property', 'video-pan-x', 0]);
}
}
function applyConfig(snapshot: SubtitleSidebarSnapshot): void {
ctx.state.subtitleSidebarConfig = snapshot.config;
ctx.state.subtitleSidebarToggleKey = snapshot.config.toggleKey;
ctx.state.subtitleSidebarPauseVideoOnHover = snapshot.config.pauseVideoOnHover;
ctx.state.subtitleSidebarAutoScroll = snapshot.config.autoScroll;
const style = ctx.dom.subtitleSidebarModal.style;
style.setProperty('--subtitle-sidebar-max-width', `${snapshot.config.maxWidth}px`);
style.setProperty('--subtitle-sidebar-opacity', String(snapshot.config.opacity));
style.setProperty('--subtitle-sidebar-background-color', snapshot.config.backgroundColor);
style.setProperty('--subtitle-sidebar-text-color', snapshot.config.textColor);
style.setProperty('--subtitle-sidebar-font-family', snapshot.config.fontFamily);
style.setProperty('--subtitle-sidebar-font-size', `${snapshot.config.fontSize}px`);
style.setProperty('--subtitle-sidebar-timestamp-color', snapshot.config.timestampColor);
style.setProperty('--subtitle-sidebar-active-line-color', snapshot.config.activeLineColor);
style.setProperty(
'--subtitle-sidebar-active-background-color',
snapshot.config.activeLineBackgroundColor,
);
style.setProperty(
'--subtitle-sidebar-hover-background-color',
snapshot.config.hoverLineBackgroundColor,
);
}
function seekToCue(cue: SubtitleCue): void {
const targetTime = Math.min(cue.endTime - 0.01, cue.startTime + CLICK_SEEK_OFFSET_SEC);
window.electronAPI.sendMpvCommand([
'seek',
Math.max(cue.startTime, targetTime),
'absolute+exact',
]);
}
function getCueRowLabel(cue: SubtitleCue): string {
return `Jump to subtitle at ${formatCueTimestamp(cue.startTime)}`;
}
function resumeSubtitleSidebarHoverPause(): void {
subtitleSidebarHoverRequestId += 1;
if (!ctx.state.subtitleSidebarPausedByHover) {
restoreEmbeddedSidebarPassthrough();
return;
}
ctx.state.subtitleSidebarPausedByHover = false;
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'no']);
restoreEmbeddedSidebarPassthrough();
}
function maybeAutoScrollActiveCue(
previousActiveCueIndex: number,
behavior: ScrollBehavior = 'smooth',
force = false,
): void {
if (
!ctx.state.subtitleSidebarAutoScroll ||
ctx.state.subtitleSidebarActiveCueIndex < 0 ||
(!force && ctx.state.subtitleSidebarActiveCueIndex === previousActiveCueIndex) ||
Date.now() < ctx.state.subtitleSidebarManualScrollUntilMs
) {
return;
}
const list = ctx.dom.subtitleSidebarList;
const active = list.children[ctx.state.subtitleSidebarActiveCueIndex] as HTMLElement | undefined;
if (!active) {
return;
}
const targetScrollTop =
active.offsetTop - (list.clientHeight - active.clientHeight) / 2;
const nextScrollTop = Math.max(0, targetScrollTop);
if (previousActiveCueIndex < 0) {
list.scrollTop = nextScrollTop;
return;
}
list.scrollTo({
top: nextScrollTop,
behavior,
});
}
function renderCueList(): void {
ctx.dom.subtitleSidebarList.innerHTML = '';
for (const [index, cue] of ctx.state.subtitleSidebarCues.entries()) {
const row = document.createElement('li');
row.className = 'subtitle-sidebar-item';
row.classList.toggle('active', index === ctx.state.subtitleSidebarActiveCueIndex);
row.dataset.index = String(index);
row.tabIndex = 0;
row.setAttribute('role', 'button');
row.setAttribute('aria-label', getCueRowLabel(cue));
row.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
seekToCue(cue);
});
const timestamp = document.createElement('div');
timestamp.className = 'subtitle-sidebar-timestamp';
timestamp.textContent = formatCueTimestamp(cue.startTime);
const text = document.createElement('div');
text.className = 'subtitle-sidebar-text';
text.textContent = cue.text;
row.appendChild(timestamp);
row.appendChild(text);
ctx.dom.subtitleSidebarList.appendChild(row);
}
}
function syncActiveCueClasses(previousActiveCueIndex: number): void {
if (previousActiveCueIndex >= 0) {
const previous = ctx.dom.subtitleSidebarList.children[previousActiveCueIndex] as
| HTMLElement
| undefined;
previous?.classList.remove('active');
}
if (ctx.state.subtitleSidebarActiveCueIndex >= 0) {
const current = ctx.dom.subtitleSidebarList.children[ctx.state.subtitleSidebarActiveCueIndex] as
| HTMLElement
| undefined;
current?.classList.add('active');
}
}
function updateActiveCue(
current: { text: string; startTime?: number | null } | null,
currentTimeSec: number | null = null,
): void {
const previousActiveCueIndex = ctx.state.subtitleSidebarActiveCueIndex;
ctx.state.subtitleSidebarActiveCueIndex = findActiveSubtitleCueIndex(
ctx.state.subtitleSidebarCues,
current,
currentTimeSec,
previousActiveCueIndex,
);
if (ctx.state.subtitleSidebarModalOpen) {
syncActiveCueClasses(previousActiveCueIndex);
maybeAutoScrollActiveCue(previousActiveCueIndex);
}
}
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
applyConfig(snapshot);
if (!snapshot.config.enabled) {
resumeSubtitleSidebarHoverPause();
ctx.state.subtitleSidebarCues = [];
ctx.state.subtitleSidebarModalOpen = false;
ctx.dom.subtitleSidebarModal.classList.add('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
stopSnapshotPolling();
updateActiveCue(null, snapshot.currentTimeSec ?? null);
setStatus('Subtitle sidebar disabled in config.');
syncEmbeddedSidebarLayout();
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
restoreEmbeddedSidebarPassthrough();
return snapshot;
}
const cuesChanged = !subtitleCueListsEqual(ctx.state.subtitleSidebarCues, snapshot.cues);
if (cuesChanged) {
ctx.state.subtitleSidebarCues = snapshot.cues;
if (ctx.state.subtitleSidebarModalOpen) {
renderCueList();
}
}
updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec ?? null);
syncEmbeddedSidebarLayout();
return snapshot;
}
function startSnapshotPolling(): void {
stopSnapshotPolling();
const pollOnce = async (): Promise<void> => {
try {
await refreshSnapshot();
} catch {
// Keep polling; a transient IPC failure should not stop updates.
}
if (!ctx.state.subtitleSidebarModalOpen) {
snapshotPollInterval = null;
return;
}
snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS);
};
snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS);
}
function stopSnapshotPolling(): void {
if (!snapshotPollInterval) {
return;
}
clearTimeout(snapshotPollInterval);
snapshotPollInterval = null;
}
async function openSubtitleSidebarModal(): Promise<void> {
const snapshot = await refreshSnapshot();
if (!snapshot.config.enabled) {
setStatus('Subtitle sidebar disabled in config.');
return;
}
ctx.dom.subtitleSidebarList.innerHTML = '';
if (snapshot.cues.length === 0) {
setStatus('No parsed subtitle cues available.');
} else {
setStatus(`${snapshot.cues.length} parsed subtitle lines`);
}
ctx.state.subtitleSidebarModalOpen = true;
ctx.state.isOverSubtitleSidebar = false;
ctx.dom.subtitleSidebarModal.classList.remove('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'false');
renderCueList();
syncActiveCueClasses(-1);
maybeAutoScrollActiveCue(-1, 'auto', true);
startSnapshotPolling();
syncEmbeddedSidebarLayout();
restoreEmbeddedSidebarPassthrough();
}
async function autoOpenSubtitleSidebarOnStartup(): Promise<void> {
const snapshot = await refreshSnapshot();
if (!snapshot.config.enabled || !snapshot.config.autoOpen || ctx.state.subtitleSidebarModalOpen) {
return;
}
await openSubtitleSidebarModal();
}
function closeSubtitleSidebarModal(): void {
if (!ctx.state.subtitleSidebarModalOpen) {
return;
}
resumeSubtitleSidebarHoverPause();
ctx.state.isOverSubtitleSidebar = false;
ctx.state.subtitleSidebarModalOpen = false;
ctx.dom.subtitleSidebarModal.classList.add('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
stopSnapshotPolling();
syncEmbeddedSidebarLayout();
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
restoreEmbeddedSidebarPassthrough();
}
async function toggleSubtitleSidebarModal(): Promise<void> {
if (ctx.state.subtitleSidebarModalOpen) {
closeSubtitleSidebarModal();
return;
}
await openSubtitleSidebarModal();
}
function handleSubtitleUpdated(data: SubtitleData): void {
if (ctx.state.subtitleSidebarModalOpen) {
return;
}
updateActiveCue(
{ text: data.text, startTime: data.startTime },
data.startTime ?? null,
);
}
function wireDomEvents(): void {
if (disposeDomEvents) {
return;
}
ctx.dom.subtitleSidebarClose.addEventListener('click', () => {
closeSubtitleSidebarModal();
});
ctx.dom.subtitleSidebarList.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const row = target.closest<HTMLElement>('.subtitle-sidebar-item');
if (!row) {
return;
}
const index = Number.parseInt(row.dataset.index ?? '', 10);
if (!Number.isInteger(index) || index < 0 || index >= ctx.state.subtitleSidebarCues.length) {
return;
}
const cue = ctx.state.subtitleSidebarCues[index];
if (!cue) {
return;
}
seekToCue(cue);
});
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
ctx.state.subtitleSidebarManualScrollUntilMs = Date.now() + MANUAL_SCROLL_HOLD_MS;
});
ctx.dom.subtitleSidebarModal.addEventListener('mouseenter', async () => {
ctx.state.isOverSubtitleSidebar = true;
restoreEmbeddedSidebarPassthrough();
if (!ctx.state.subtitleSidebarPauseVideoOnHover || ctx.state.subtitleSidebarPausedByHover) {
return;
}
const requestId = ++subtitleSidebarHoverRequestId;
let paused: boolean | null | undefined;
try {
paused = await window.electronAPI.getPlaybackPaused();
} catch {
paused = undefined;
}
if (requestId !== subtitleSidebarHoverRequestId) {
return;
}
if (paused === false) {
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
ctx.state.subtitleSidebarPausedByHover = true;
}
});
ctx.dom.subtitleSidebarModal.addEventListener('mouseleave', () => {
ctx.state.isOverSubtitleSidebar = false;
resumeSubtitleSidebarHoverPause();
});
const resizeHandler = () => {
if (!ctx.state.subtitleSidebarModalOpen) {
return;
}
syncEmbeddedSidebarLayout();
};
window.addEventListener('resize', resizeHandler);
disposeDomEvents = () => {
window.removeEventListener('resize', resizeHandler);
disposeDomEvents = null;
};
}
return {
autoOpenSubtitleSidebarOnStartup,
openSubtitleSidebarModal,
closeSubtitleSidebarModal,
toggleSubtitleSidebarModal,
refreshSubtitleSidebarSnapshot: refreshSnapshot,
wireDomEvents,
disposeDomEvents: () => {
disposeDomEvents?.();
},
handleSubtitleUpdated,
seekToCue,
};
}

View File

@@ -1,42 +0,0 @@
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 ||
state.jimakuModalOpen ||
state.kikuModalOpen ||
state.runtimeOptionsModalOpen ||
state.subsyncModalOpen ||
state.sessionHelpModalOpen ||
(state.subtitleSidebarModalOpen && !embeddedSidebarOpen),
);
}
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
const shouldStayInteractive =
ctx.state.isOverSubtitle ||
ctx.state.isOverSubtitleSidebar ||
ctx.state.yomitanPopupVisible ||
isBlockingOverlayModalOpen(ctx.state);
if (shouldStayInteractive) {
ctx.dom.overlay.classList.add('interactive');
} else {
ctx.dom.overlay.classList.remove('interactive');
}
if (!ctx.platform?.shouldToggleMouseIgnore) {
return;
}
if (shouldStayInteractive) {
window.electronAPI.setIgnoreMouseEvents(false);
return;
}
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
}

View File

@@ -34,12 +34,10 @@ import { createControllerSelectModal } from './modals/controller-select.js';
import { createJimakuModal } from './modals/jimaku.js'; import { createJimakuModal } from './modals/jimaku.js';
import { createKikuModal } from './modals/kiku.js'; import { createKikuModal } from './modals/kiku.js';
import { createSessionHelpModal } from './modals/session-help.js'; import { createSessionHelpModal } from './modals/session-help.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
import { createRuntimeOptionsModal } from './modals/runtime-options.js'; import { createRuntimeOptionsModal } from './modals/runtime-options.js';
import { createSubsyncModal } from './modals/subsync.js'; import { createSubsyncModal } from './modals/subsync.js';
import { createPositioningController } from './positioning.js'; import { createPositioningController } from './positioning.js';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js'; import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
import { createRendererState } from './state.js'; import { createRendererState } from './state.js';
import { createSubtitleRenderer } from './subtitle-render.js'; import { createSubtitleRenderer } from './subtitle-render.js';
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js'; import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
@@ -80,8 +78,7 @@ function isAnyModalOpen(): boolean {
ctx.state.kikuModalOpen || ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen || ctx.state.runtimeOptionsModalOpen ||
ctx.state.subsyncModalOpen || ctx.state.subsyncModalOpen ||
ctx.state.sessionHelpModalOpen || ctx.state.sessionHelpModalOpen
ctx.state.subtitleSidebarModalOpen
); );
} }
@@ -117,9 +114,6 @@ const sessionHelpModal = createSessionHelpModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
}); });
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
modalStateReader: { isAnyModalOpen },
});
const kikuModal = createKikuModal(ctx, { const kikuModal = createKikuModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
@@ -149,9 +143,6 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
controllerDebugModal.openControllerDebugModal(); controllerDebugModal.openControllerDebugModal();
window.electronAPI.notifyOverlayModalOpened('controller-debug'); window.electronAPI.notifyOverlayModalOpened('controller-debug');
}, },
toggleSubtitleSidebarModal: () => {
void subtitleSidebarModal.toggleSubtitleSidebarModal();
},
}); });
const mouseHandlers = createMouseHandlers(ctx, { const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen }, modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
@@ -192,7 +183,6 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
function getActiveModal(): string | null { function getActiveModal(): string | null {
if (ctx.state.controllerSelectModalOpen) return 'controller-select'; if (ctx.state.controllerSelectModalOpen) return 'controller-select';
if (ctx.state.controllerDebugModalOpen) return 'controller-debug'; if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
if (ctx.state.jimakuModalOpen) return 'jimaku'; if (ctx.state.jimakuModalOpen) return 'jimaku';
if (ctx.state.kikuModalOpen) return 'kiku'; if (ctx.state.kikuModalOpen) return 'kiku';
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options'; if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
@@ -208,9 +198,6 @@ function dismissActiveUiAfterError(): void {
if (ctx.state.controllerDebugModalOpen) { if (ctx.state.controllerDebugModalOpen) {
controllerDebugModal.closeControllerDebugModal(); controllerDebugModal.closeControllerDebugModal();
} }
if (ctx.state.subtitleSidebarModalOpen) {
subtitleSidebarModal.closeSubtitleSidebarModal();
}
if (ctx.state.jimakuModalOpen) { if (ctx.state.jimakuModalOpen) {
jimakuModal.closeJimakuModal(); jimakuModal.closeJimakuModal();
} }
@@ -481,7 +468,6 @@ async function init(): Promise<void> {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data)); lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
keyboardHandlers.handleSubtitleContentUpdated(); keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(data); subtitleRenderer.renderSubtitle(data);
subtitleSidebarModal.handleSubtitleUpdated(data);
measurementReporter.schedule(); measurementReporter.schedule();
}); });
}); });
@@ -522,10 +508,10 @@ async function init(): Promise<void> {
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub()); subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub());
measurementReporter.schedule(); measurementReporter.schedule();
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter); ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave); ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleSecondaryMouseEnter); ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleSecondaryMouseLeave); ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
mouseHandlers.setupResizeHandler(); mouseHandlers.setupResizeHandler();
mouseHandlers.setupSelectionObserver(); mouseHandlers.setupSelectionObserver();
@@ -542,10 +528,6 @@ async function init(): Promise<void> {
controllerSelectModal.wireDomEvents(); controllerSelectModal.wireDomEvents();
controllerDebugModal.wireDomEvents(); controllerDebugModal.wireDomEvents();
sessionHelpModal.wireDomEvents(); sessionHelpModal.wireDomEvents();
subtitleSidebarModal.wireDomEvents();
window.addEventListener('beforeunload', () => {
subtitleSidebarModal.disposeDomEvents();
});
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => { window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
runGuarded('runtime-options:changed', () => { runGuarded('runtime-options:changed', () => {
@@ -557,11 +539,6 @@ async function init(): Promise<void> {
keyboardHandlers.updateKeybindings(payload.keybindings); keyboardHandlers.updateKeybindings(payload.keybindings);
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle); subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode); subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
ctx.state.subtitleSidebarConfig = payload.subtitleSidebar;
ctx.state.subtitleSidebarToggleKey = payload.subtitleSidebar.toggleKey;
ctx.state.subtitleSidebarPauseVideoOnHover = payload.subtitleSidebar.pauseVideoOnHover;
ctx.state.subtitleSidebarAutoScroll = payload.subtitleSidebar.autoScroll;
void subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
measurementReporter.schedule(); measurementReporter.schedule();
}); });
}); });
@@ -578,8 +555,6 @@ async function init(): Promise<void> {
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle(); const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle); subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
await subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
await subtitleSidebarModal.autoOpenSubtitleSidebarOnStartup();
positioning.applyStoredSubtitlePosition( positioning.applyStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(), await window.electronAPI.getSubtitlePosition(),
@@ -588,7 +563,7 @@ async function init(): Promise<void> {
measurementReporter.schedule(); measurementReporter.schedule();
if (ctx.platform.shouldToggleMouseIgnore) { if (ctx.platform.shouldToggleMouseIgnore) {
syncOverlayMouseIgnoreState(ctx); window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
} }
measurementReporter.emitNow(); measurementReporter.emitNow();

View File

@@ -10,8 +10,6 @@ import type {
RuntimeOptionState, RuntimeOptionState,
RuntimeOptionValue, RuntimeOptionValue,
SubtitlePosition, SubtitlePosition,
SubtitleSidebarConfig,
SubtitleCue,
SubsyncSourceTrack, SubsyncSourceTrack,
} from '../types'; } from '../types';
@@ -25,7 +23,6 @@ export type ChordAction =
export type RendererState = { export type RendererState = {
isOverSubtitle: boolean; isOverSubtitle: boolean;
isOverSubtitleSidebar: boolean;
isDragging: boolean; isDragging: boolean;
dragStartY: number; dragStartY: number;
startYPercent: number; startYPercent: number;
@@ -61,7 +58,6 @@ export type RendererState = {
controllerSelectModalOpen: boolean; controllerSelectModalOpen: boolean;
controllerDebugModalOpen: boolean; controllerDebugModalOpen: boolean;
subtitleSidebarModalOpen: boolean;
controllerDeviceSelectedIndex: number; controllerDeviceSelectedIndex: number;
controllerConfig: ResolvedControllerConfig | null; controllerConfig: ResolvedControllerConfig | null;
connectedGamepads: ControllerDeviceInfo[]; connectedGamepads: ControllerDeviceInfo[];
@@ -71,14 +67,6 @@ export type RendererState = {
sessionHelpModalOpen: boolean; sessionHelpModalOpen: boolean;
sessionHelpSelectedIndex: number; sessionHelpSelectedIndex: number;
subtitleSidebarCues: SubtitleCue[];
subtitleSidebarActiveCueIndex: number;
subtitleSidebarToggleKey: string;
subtitleSidebarPauseVideoOnHover: boolean;
subtitleSidebarAutoScroll: boolean;
subtitleSidebarConfig: Required<SubtitleSidebarConfig> | null;
subtitleSidebarManualScrollUntilMs: number;
subtitleSidebarPausedByHover: boolean;
knownWordColor: string; knownWordColor: string;
nPlusOneColor: string; nPlusOneColor: string;
@@ -116,7 +104,6 @@ export type RendererState = {
export function createRendererState(): RendererState { export function createRendererState(): RendererState {
return { return {
isOverSubtitle: false, isOverSubtitle: false,
isOverSubtitleSidebar: false,
isDragging: false, isDragging: false,
dragStartY: 0, dragStartY: 0,
startYPercent: 0, startYPercent: 0,
@@ -152,7 +139,6 @@ export function createRendererState(): RendererState {
controllerSelectModalOpen: false, controllerSelectModalOpen: false,
controllerDebugModalOpen: false, controllerDebugModalOpen: false,
subtitleSidebarModalOpen: false,
controllerDeviceSelectedIndex: 0, controllerDeviceSelectedIndex: 0,
controllerConfig: null, controllerConfig: null,
connectedGamepads: [], connectedGamepads: [],
@@ -162,14 +148,6 @@ export function createRendererState(): RendererState {
sessionHelpModalOpen: false, sessionHelpModalOpen: false,
sessionHelpSelectedIndex: 0, sessionHelpSelectedIndex: 0,
subtitleSidebarCues: [],
subtitleSidebarActiveCueIndex: -1,
subtitleSidebarToggleKey: 'Backslash',
subtitleSidebarPauseVideoOnHover: false,
subtitleSidebarAutoScroll: true,
subtitleSidebarConfig: null,
subtitleSidebarManualScrollUntilMs: 0,
subtitleSidebarPausedByHover: false,
knownWordColor: '#a6da95', knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6', nPlusOneColor: '#c6a0f6',

View File

@@ -40,10 +40,6 @@ body {
'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Arial Unicode MS', Arial, sans-serif; 'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Arial Unicode MS', Arial, sans-serif;
} }
:root {
--subtitle-sidebar-reserved-width: 0px;
}
#overlay { #overlay {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -298,19 +294,13 @@ body {
} }
} }
body.subtitle-sidebar-embedded-open #subtitleContainer {
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
}
#subtitleContainer { #subtitleContainer {
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px)); max-width: 80%;
margin-bottom: 60px; margin-bottom: 60px;
padding: 12px 20px; padding: 12px 20px;
background: rgb(30, 32, 48, 0.88); background: rgb(30, 32, 48, 0.88);
border-radius: 8px; border-radius: 8px;
pointer-events: auto; pointer-events: auto;
transform: translateX(0);
} }
#subtitleRoot { #subtitleRoot {
@@ -715,26 +705,20 @@ body.platform-macos.layer-visible #subtitleRoot {
background: transparent; background: transparent;
} }
body.subtitle-sidebar-embedded-open #secondarySubContainer {
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
transform: translateX(calc(-50% - (var(--subtitle-sidebar-reserved-width) * 0.5)));
}
#secondarySubContainer { #secondarySubContainer {
--secondary-sub-background-color: transparent; --secondary-sub-background-color: transparent;
--secondary-sub-backdrop-filter: none; --secondary-sub-backdrop-filter: none;
position: absolute; position: absolute;
top: 40px; top: 40px;
left: 50%; left: 50%;
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px)); transform: translateX(-50%);
max-width: 80%;
padding: 10px 18px; padding: 10px 18px;
background: var(--secondary-sub-background-color, transparent); background: var(--secondary-sub-background-color, transparent);
backdrop-filter: var(--secondary-sub-backdrop-filter, none); backdrop-filter: var(--secondary-sub-backdrop-filter, none);
-webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none); -webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none);
border-radius: 8px; border-radius: 8px;
pointer-events: auto; pointer-events: auto;
transform: translateX(-50%);
} }
body.layer-modal #subtitleContainer, body.layer-modal #subtitleContainer,
@@ -779,14 +763,6 @@ body.settings-modal-open #secondarySubContainer {
display: none !important; display: none !important;
} }
body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
left: 0;
right: var(--subtitle-sidebar-reserved-width);
max-width: none;
padding-right: 0;
transform: none;
}
#secondarySubContainer.secondary-sub-hover { #secondarySubContainer.secondary-sub-hover {
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
@@ -813,13 +789,11 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
padding: 10px 18px; padding: 10px 18px;
} }
#secondarySubContainer.secondary-sub-hover:hover, #secondarySubContainer.secondary-sub-hover:hover {
#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active {
opacity: 1; opacity: 1;
} }
#secondarySubContainer.secondary-sub-hover:hover #secondarySubRoot, #secondarySubContainer.secondary-sub-hover:hover #secondarySubRoot {
#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active #secondarySubRoot {
background: var(--secondary-sub-background-color, transparent); background: var(--secondary-sub-background-color, transparent);
backdrop-filter: var(--secondary-sub-backdrop-filter, none); backdrop-filter: var(--secondary-sub-backdrop-filter, none);
-webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none); -webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none);
@@ -1388,206 +1362,6 @@ iframe[id^='yomitan-popup'] {
white-space: pre-wrap; white-space: pre-wrap;
} }
.subtitle-sidebar-modal {
inset: 0;
justify-content: flex-end;
align-items: flex-start;
padding: 14px;
background: transparent;
pointer-events: none;
}
body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal {
padding: 0;
align-items: stretch;
}
.subtitle-sidebar-content {
width: min(var(--subtitle-sidebar-max-width, 420px), 92vw);
max-height: calc(100vh - 28px);
height: auto;
margin-left: auto;
font-family:
var(
--subtitle-sidebar-font-family,
'M PLUS 1',
'Noto Sans CJK JP',
'Hiragino Sans',
sans-serif
);
font-size: var(--subtitle-sidebar-font-size, 16px);
background: var(--subtitle-sidebar-background-color, rgba(73, 77, 100, 0.9));
color: var(--subtitle-sidebar-text-color, #cad3f5);
border: 1px solid rgba(110, 115, 141, 0.18);
border-radius: 10px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 2px 8px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(183, 189, 248, 0.06);
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
opacity: var(--subtitle-sidebar-opacity, 0.95);
pointer-events: auto;
}
.subtitle-sidebar-content .modal-header {
padding: 10px 14px 8px;
border-bottom: 1px solid rgba(110, 115, 141, 0.14);
gap: 8px;
}
.subtitle-sidebar-content .modal-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
color: #b8c0e0;
text-transform: uppercase;
}
.subtitle-sidebar-content .modal-close {
font-size: 11px;
padding: 4px 10px;
border-radius: 6px;
background: rgba(73, 77, 100, 0.5);
border: 1px solid rgba(110, 115, 141, 0.2);
color: #a5adcb;
transition: all 140ms ease;
}
.subtitle-sidebar-content .modal-close:hover {
background: rgba(91, 96, 120, 0.6);
color: #cad3f5;
border-color: rgba(110, 115, 141, 0.35);
}
body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
width: min(var(--subtitle-sidebar-max-width, 420px), 44vw);
max-height: 100vh;
height: 100vh;
border-radius: 0;
border-top: none;
border-right: none;
border-bottom: none;
box-shadow:
-12px 0 32px rgba(0, 0, 0, 0.3),
-1px 0 0 rgba(110, 115, 141, 0.12);
}
.subtitle-sidebar-body {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0;
}
.subtitle-sidebar-content .runtime-options-status {
font-size: 11px;
padding: 4px 14px;
color: #6e738d;
letter-spacing: 0.02em;
}
.subtitle-sidebar-list {
position: relative;
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
min-height: 0;
border-radius: 0;
background: transparent;
}
.subtitle-sidebar-list::-webkit-scrollbar {
width: 6px;
}
.subtitle-sidebar-list::-webkit-scrollbar-track {
background: transparent;
}
.subtitle-sidebar-list::-webkit-scrollbar-thumb {
background: rgba(110, 115, 141, 0.25);
border-radius: 3px;
}
.subtitle-sidebar-list::-webkit-scrollbar-thumb:hover {
background: rgba(110, 115, 141, 0.4);
}
.subtitle-sidebar-item {
display: grid;
grid-template-columns: 52px 1fr;
gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid rgba(110, 115, 141, 0.08);
cursor: pointer;
transition:
background-color 120ms ease,
color 120ms ease;
position: relative;
}
.subtitle-sidebar-item:last-child {
border-bottom: none;
}
.subtitle-sidebar-item:hover {
background: var(--subtitle-sidebar-hover-background-color, rgba(54, 58, 79, 0.65));
}
.subtitle-sidebar-item:focus-visible {
outline: 2px solid var(--subtitle-sidebar-active-line-color, #f5bde6);
outline-offset: -2px;
background: var(--subtitle-sidebar-hover-background-color, rgba(54, 58, 79, 0.65));
}
.subtitle-sidebar-item.active {
background: var(--subtitle-sidebar-active-background-color, rgba(138, 173, 244, 0.12));
}
.subtitle-sidebar-item.active::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--subtitle-sidebar-active-line-color, #f5bde6);
opacity: 0.85;
}
.subtitle-sidebar-timestamp {
font-size: calc(var(--subtitle-sidebar-font-size, 16px) * 0.72);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.03em;
color: var(--subtitle-sidebar-timestamp-color, #6e738d);
padding-top: 2px;
}
.subtitle-sidebar-item:hover .subtitle-sidebar-timestamp {
color: var(--subtitle-sidebar-timestamp-color, #a5adcb);
}
.subtitle-sidebar-item.active .subtitle-sidebar-timestamp {
color: var(--subtitle-sidebar-active-line-color, #f5bde6);
opacity: 0.75;
}
.subtitle-sidebar-item.active .subtitle-sidebar-text {
color: var(--subtitle-sidebar-active-line-color, #f5bde6);
}
.subtitle-sidebar-text {
white-space: pre-wrap;
line-height: 1.5;
font-size: 1em;
color: var(--subtitle-sidebar-text-color, #cad3f5);
}
.session-help-content { .session-help-content {
width: min(760px, 92%); width: min(760px, 92%);
max-height: 84%; max-height: 84%;

View File

@@ -977,30 +977,6 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
); );
assert.match(secondaryHoverBaseBlock, /background:\s*transparent;/); assert.match(secondaryHoverBaseBlock, /background:\s*transparent;/);
const secondaryEmbeddedHoverBlock = extractClassBlock(
cssText,
'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover',
);
assert.match(
secondaryEmbeddedHoverBlock,
/right:\s*var\(--subtitle-sidebar-reserved-width\);/,
);
assert.match(
secondaryEmbeddedHoverBlock,
/max-width:\s*none;/,
);
assert.match(
secondaryEmbeddedHoverBlock,
/transform:\s*none;/,
);
assert.doesNotMatch(
secondaryEmbeddedHoverBlock,
/transform:\s*translateX\(calc\(var\(--subtitle-sidebar-reserved-width\)\s*\*\s*-0\.5\)\);/,
);
const subtitleSidebarListBlock = extractClassBlock(cssText, '.subtitle-sidebar-list');
assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/);
const secondaryHoverVisibleBlock = extractClassBlock( const secondaryHoverVisibleBlock = extractClassBlock(
cssText, cssText,
'#secondarySubContainer.secondary-sub-hover:hover #secondarySubRoot', '#secondarySubContainer.secondary-sub-hover:hover #secondarySubRoot',
@@ -1014,25 +990,6 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
/backdrop-filter:\s*var\(--secondary-sub-backdrop-filter,\s*none\);/, /backdrop-filter:\s*var\(--secondary-sub-backdrop-filter,\s*none\);/,
); );
const secondaryHoverActiveBlock = extractClassBlock(
cssText,
'#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active',
);
assert.match(secondaryHoverActiveBlock, /opacity:\s*1;/);
const secondaryHoverActiveRootBlock = extractClassBlock(
cssText,
'#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active #secondarySubRoot',
);
assert.match(
secondaryHoverActiveRootBlock,
/background:\s*var\(--secondary-sub-background-color,\s*transparent\);/,
);
assert.match(
secondaryHoverActiveRootBlock,
/backdrop-filter:\s*var\(--secondary-sub-backdrop-filter,\s*none\);/,
);
assert.doesNotMatch( assert.doesNotMatch(
cssText, cssText,
/body\.layer-visible\s+#secondarySubContainer\s*\{[^}]*display:\s*none/i, /body\.layer-visible\s+#secondarySubContainer\s*\{[^}]*display:\s*none/i,

View File

@@ -74,11 +74,6 @@ export type RendererDom = {
controllerDebugAxes: HTMLPreElement; controllerDebugAxes: HTMLPreElement;
controllerDebugButtons: HTMLPreElement; controllerDebugButtons: HTMLPreElement;
controllerDebugButtonIndices: HTMLPreElement; controllerDebugButtonIndices: HTMLPreElement;
subtitleSidebarModal: HTMLDivElement;
subtitleSidebarContent: HTMLDivElement;
subtitleSidebarClose: HTMLButtonElement;
subtitleSidebarStatus: HTMLDivElement;
subtitleSidebarList: HTMLUListElement;
sessionHelpModal: HTMLDivElement; sessionHelpModal: HTMLDivElement;
sessionHelpClose: HTMLButtonElement; sessionHelpClose: HTMLButtonElement;
@@ -176,11 +171,6 @@ export function resolveRendererDom(): RendererDom {
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>( controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>(
'controllerDebugButtonIndices', 'controllerDebugButtonIndices',
), ),
subtitleSidebarModal: getRequiredElement<HTMLDivElement>('subtitleSidebarModal'),
subtitleSidebarContent: getRequiredElement<HTMLDivElement>('subtitleSidebarContent'),
subtitleSidebarClose: getRequiredElement<HTMLButtonElement>('subtitleSidebarClose'),
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
subtitleSidebarList: getRequiredElement<HTMLUListElement>('subtitleSidebarList'),
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'), sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'), sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),

View File

@@ -7,7 +7,6 @@ export const OVERLAY_HOSTED_MODALS = [
'kiku', 'kiku',
'controller-select', 'controller-select',
'controller-debug', 'controller-debug',
'subtitle-sidebar',
] as const; ] as const;
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number]; export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
@@ -39,7 +38,6 @@ export const IPC_CHANNELS = {
getCurrentSubtitle: 'get-current-subtitle', getCurrentSubtitle: 'get-current-subtitle',
getCurrentSubtitleRaw: 'get-current-subtitle-raw', getCurrentSubtitleRaw: 'get-current-subtitle-raw',
getCurrentSubtitleAss: 'get-current-subtitle-ass', getCurrentSubtitleAss: 'get-current-subtitle-ass',
getSubtitleSidebarSnapshot: 'get-subtitle-sidebar-snapshot',
getPlaybackPaused: 'get-playback-paused', getPlaybackPaused: 'get-playback-paused',
getSubtitlePosition: 'get-subtitle-position', getSubtitlePosition: 'get-subtitle-position',
getSubtitleStyle: 'get-subtitle-style', getSubtitleStyle: 'get-subtitle-style',

View File

@@ -16,8 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type { SubtitleCue } from './core/services/subtitle-cue-parser';
export enum PartOfSpeech { export enum PartOfSpeech {
noun = 'noun', noun = 'noun',
verb = 'verb', verb = 'verb',
@@ -366,29 +364,6 @@ export interface ResolvedTokenPos2ExclusionConfig {
export type FrequencyDictionaryMode = 'single' | 'banded'; export type FrequencyDictionaryMode = 'single' | 'banded';
export type { SubtitleCue } from './core/services/subtitle-cue-parser';
export type SubtitleSidebarLayout = 'overlay' | 'embedded';
export interface SubtitleSidebarConfig {
enabled?: boolean;
autoOpen?: boolean;
layout?: SubtitleSidebarLayout;
toggleKey?: string;
pauseVideoOnHover?: boolean;
autoScroll?: boolean;
maxWidth?: number;
opacity?: number;
backgroundColor?: string;
textColor?: string;
fontFamily?: string;
fontSize?: number;
timestampColor?: string;
activeLineColor?: string;
activeLineBackgroundColor?: string;
hoverLineBackgroundColor?: string;
}
export interface ShortcutsConfig { export interface ShortcutsConfig {
toggleVisibleOverlayGlobal?: string | null; toggleVisibleOverlayGlobal?: string | null;
copySubtitle?: string | null; copySubtitle?: string | null;
@@ -700,7 +675,6 @@ export interface Config {
subsync?: SubsyncConfig; subsync?: SubsyncConfig;
startupWarmups?: StartupWarmupsConfig; startupWarmups?: StartupWarmupsConfig;
subtitleStyle?: SubtitleStyleConfig; subtitleStyle?: SubtitleStyleConfig;
subtitleSidebar?: SubtitleSidebarConfig;
auto_start_overlay?: boolean; auto_start_overlay?: boolean;
jimaku?: JimakuConfig; jimaku?: JimakuConfig;
anilist?: AnilistConfig; anilist?: AnilistConfig;
@@ -833,7 +807,6 @@ export interface ResolvedConfig {
bandedColors: [string, string, string, string, string]; bandedColors: [string, string, string, string, string];
}; };
}; };
subtitleSidebar: Required<SubtitleSidebarConfig>;
auto_start_overlay: boolean; auto_start_overlay: boolean;
jimaku: JimakuConfig & { jimaku: JimakuConfig & {
apiBaseUrl: string; apiBaseUrl: string;
@@ -966,19 +939,6 @@ export interface ClipboardAppendResult {
export interface SubtitleData { export interface SubtitleData {
text: string; text: string;
tokens: MergedToken[] | null; tokens: MergedToken[] | null;
startTime?: number | null;
endTime?: number | null;
}
export interface SubtitleSidebarSnapshot {
cues: SubtitleCue[];
currentTimeSec?: number | null;
currentSubtitle: {
text: string;
startTime: number | null;
endTime: number | null;
};
config: Required<SubtitleSidebarConfig>;
} }
export interface MpvSubtitleRenderMetrics { export interface MpvSubtitleRenderMetrics {
@@ -1097,7 +1057,6 @@ export type JimakuDownloadResult =
export interface ConfigHotReloadPayload { export interface ConfigHotReloadPayload {
keybindings: Keybinding[]; keybindings: Keybinding[];
subtitleStyle: SubtitleStyleConfig | null; subtitleStyle: SubtitleStyleConfig | null;
subtitleSidebar: Required<SubtitleSidebarConfig>;
secondarySubMode: SecondarySubMode; secondarySubMode: SecondarySubMode;
} }
@@ -1116,7 +1075,6 @@ export interface ElectronAPI {
getCurrentSubtitle: () => Promise<SubtitleData>; getCurrentSubtitle: () => Promise<SubtitleData>;
getCurrentSubtitleRaw: () => Promise<string>; getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>; getCurrentSubtitleAss: () => Promise<string>;
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
getPlaybackPaused: () => Promise<boolean | null>; getPlaybackPaused: () => Promise<boolean | null>;
onSubtitleAss: (callback: (assText: string) => void) => void; onSubtitleAss: (callback: (assText: string) => void) => void;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
@@ -1176,8 +1134,7 @@ export interface ElectronAPI {
| 'jimaku' | 'jimaku'
| 'kiku' | 'kiku'
| 'controller-select' | 'controller-select'
| 'controller-debug' | 'controller-debug',
| 'subtitle-sidebar',
) => void; ) => void;
notifyOverlayModalOpened: ( notifyOverlayModalOpened: (
modal: modal:
@@ -1186,8 +1143,7 @@ export interface ElectronAPI {
| 'jimaku' | 'jimaku'
| 'kiku' | 'kiku'
| 'controller-select' | 'controller-select'
| 'controller-debug' | 'controller-debug',
| 'subtitle-sidebar',
) => void; ) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;