mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-12 05:16:19 -07:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a610c90c25
|
||
|
|
144db675e9
|
||
|
|
8e47e0d761
|
||
|
|
dcd05ee4ea
|
||
|
|
56a2a25312
|
||
|
|
c85db5e07e
|
||
|
|
f90ee78204
|
||
|
|
760b3e1d3d
|
||
|
|
4d5ea8801f
|
||
|
|
b58f391b42
|
||
|
|
90a5dcd2af
|
||
|
|
989122c006
|
||
|
|
ad1c1aea1b
|
||
|
|
62d645e577
|
||
|
|
f36e7f6182
|
||
|
|
475f81d96d
|
||
|
|
046ab59da9
|
||
|
|
e91f031faa
|
||
|
|
ec6a106748
|
||
|
|
ca27a4275f
|
||
|
|
256eb5da1b
|
@@ -1,36 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.6 (2026-09-04)
|
||||
|
||||
### Added
|
||||
|
||||
- **Card Timing Review**:
|
||||
- Optional pre-generation timing review for word, sentence, and audio cards, with a speech-weighted waveform that flattens background noise so dialogue edges stand out clearly.
|
||||
- The clip end automatically snaps back to where the line's dialogue actually ends once the waveform loads, with drag and keyboard adjustments available.
|
||||
- Audio preview includes a sweeping playhead that plays the clip to its true end, even on high-latency outputs like Bluetooth headphones.
|
||||
- Previous and next subtitle lines can be pulled onto the card with `P`/`N` (or the Prev/Next steppers) and removed with Shift; the sentence preview and waveform markers update automatically.
|
||||
- Cancelling lets you keep a card without media, and the review can be toggled on or off for the session.
|
||||
- **Senren Field Grouping**:
|
||||
- Enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, grouping sentence, furigana, audio, picture, and misc-info fields.
|
||||
- Supports the same auto/manual/disabled modes as Kiku, including the manual merge modal; only one of Senren or Kiku can be enabled at a time.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Remote Stream Mining Performance**: Mining a card from a remote stream (Jellyfin and other HTTP sources) now downloads the clip window once and reuses it for the timing review waveform, audio preview, audio extraction, and screenshot, instead of re-fetching the stream at each step; the temporary file is cleaned up after ten minutes of inactivity or on exit.
|
||||
- **TsukiHime Release Filtering**: The TsukiHime modal's Japanese and secondary-language tabs now filter the release list by the subtitle languages each release actually carries, and report when no release has subtitles for the active tab.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Subtitle & Mining Accuracy**:
|
||||
- Broadcast-style captions that split one sentence across two on-screen rows (e.g. Crunchyroll Japanese subs) now merge into a single line for the sidebar and mined cards, while separate speakers, sound effects, and labeled turns still stay on their own lines.
|
||||
- Mining from the overlay no longer pulls in a lingering row from the previous caption; the mined sentence and clip timing now match what's actually on screen.
|
||||
- Multi-line copy and mining now select lines backward in timeline order after seeking, instead of in playback encounter order.
|
||||
- Copying a subtitle, mining a sentence, or recording immersion stats no longer includes the separate furigana line that broadcast ASS captions place above a word.
|
||||
- **Card Update Notifications**: Dismissed lingering overlay card-update progress when notification settings switch to OSD before an update finishes.
|
||||
- **Overlay Stability on Hyprland**: Opening a modal window (timing review, Jimaku, session help, and others) while mpv is fullscreen no longer causes the overlay to flicker while the modal loads; the overlay now stays on screen untouched until the modal is ready.
|
||||
- **Jellyfin Subtitle Sync**: Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
|
||||
- **Secondary Subtitle Visibility**: Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
|
||||
|
||||
## v0.19.5 (2026-08-30)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Dismissed active overlay card-update progress when notification settings switch to OSD before an update finishes.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Broadcast-style Japanese caption tracks (Crunchyroll JA subs) that split one sentence across two positioned events now publish it as a single line, so `preserveLineBreaks: false` flattens it, the sidebar lists it once, and mined sentences are whole. Rows from two different speakers, sound effects, and labeled turns still stay on separate lines.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: added
|
||||
area: mining
|
||||
|
||||
- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform that flattens steady background noise so dialogue edges are easy to see, clearly labeled mined-line boundaries, a clip end that moves back to where the line's dialogue actually ends once the waveform loads (subtitles usually linger past speech), drag and keyboard adjustments, audio preview with a sweeping playhead that plays the clip to its true end even on high-latency outputs such as Bluetooth headphones, exact screenshot and AVIF timing, cancellation choices that include keeping a card without media, and a session-only runtime toggle.
|
||||
- The timing review can pull any number of previous and next subtitle lines onto the card: `P`/`N` (or the Prev/Next steppers) add lines one at a time, Shift removes them, the sentence preview highlights exactly what the card will contain, and the clip range and line boundary markers on the waveform follow the added lines automatically.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: mining
|
||||
|
||||
- Mining from the overlay no longer copies a lingering row of the previous broadcast caption onto the card sentence. The mined line and its clip timing now come from the parsed cue the overlay is showing, falling back to mpv's live text only when the cue list cannot explain it.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: mining
|
||||
|
||||
- Multi-line copy and mining now select backward from the current subtitle in timeline order after seeking, instead of copying lines in playback encounter order. Jumping back to a short previous line also counts as a seek with external subtitle files, so that line becomes the current one.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: anki
|
||||
|
||||
- Clarify that the configured Anki audio field receives sentence clips, including media timing review, and should be separate from Yomitan's word pronunciation field.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: mining
|
||||
|
||||
- Mining a card from a remote stream (Jellyfin and other HTTP sources) now downloads the clip window once into a temporary file and reuses it for the timing review waveform, audio preview, audio extraction, and screenshot, instead of re-fetching the stream for every step. The temporary window is removed after ten minutes without use or on exit.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: added
|
||||
area: anki
|
||||
|
||||
- Senren note type support for duplicate-card field grouping: enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, with grouped sentence, furigana, audio, picture, and miscInfo entries.
|
||||
- Senren field grouping supports the same auto/manual/disabled modes as Kiku, including the manual merge modal, and is mutually exclusive with Kiku (only one can be enabled at a time).
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Select dialogue across subtitle sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button. Selection keeps the excerpt in view during playback and does not seek or require mining a card.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Copying the current subtitle, Anki sentence mining from recent lines, and immersion stats no longer include the separate furigana lines that broadcast-caption ASS files place above a word; recorders now use the same furigana-free text the overlay displays.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- The TsukiHime modal's Japanese and secondary-language tabs now filter the release list by the subtitle languages each release carries, and report when no release has subtitles for the active tab.
|
||||
@@ -138,8 +138,6 @@ Field names are matched against your Anki note type case-insensitively (an exact
|
||||
|
||||
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
|
||||
|
||||
The audio field receives the sentence clip from the video, including when media timing review is enabled. In Settings, set the Anki audio field to your note type's sentence-audio field, such as `SentenceAudio`, and keep it separate from the field Yomitan uses for word pronunciation. If both write to the same field, SubMiner can overwrite the pronunciation audio. Changing the mapping affects future updates; existing cards need their word audio restored separately.
|
||||
|
||||
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
|
||||
|
||||
### Minimal Config
|
||||
|
||||
@@ -1,36 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.6 (2026-09-04)
|
||||
|
||||
**Added**
|
||||
|
||||
- **Card Timing Review**:
|
||||
- Optional pre-generation timing review for word, sentence, and audio cards, with a speech-weighted waveform that flattens background noise so dialogue edges stand out clearly.
|
||||
- The clip end automatically snaps back to where the line's dialogue actually ends once the waveform loads, with drag and keyboard adjustments available.
|
||||
- Audio preview includes a sweeping playhead that plays the clip to its true end, even on high-latency outputs like Bluetooth headphones.
|
||||
- Previous and next subtitle lines can be pulled onto the card with `P`/`N` (or the Prev/Next steppers) and removed with Shift; the sentence preview and waveform markers update automatically.
|
||||
- Cancelling lets you keep a card without media, and the review can be toggled on or off for the session.
|
||||
- **Senren Field Grouping**:
|
||||
- Enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, grouping sentence, furigana, audio, picture, and misc-info fields.
|
||||
- Supports the same auto/manual/disabled modes as Kiku, including the manual merge modal; only one of Senren or Kiku can be enabled at a time.
|
||||
|
||||
**Changed**
|
||||
|
||||
- **Remote Stream Mining Performance**: Mining a card from a remote stream (Jellyfin and other HTTP sources) now downloads the clip window once and reuses it for the timing review waveform, audio preview, audio extraction, and screenshot, instead of re-fetching the stream at each step; the temporary file is cleaned up after ten minutes of inactivity or on exit.
|
||||
- **TsukiHime Release Filtering**: The TsukiHime modal's Japanese and secondary-language tabs now filter the release list by the subtitle languages each release actually carries, and report when no release has subtitles for the active tab.
|
||||
|
||||
**Fixed**
|
||||
|
||||
- **Subtitle & Mining Accuracy**:
|
||||
- Broadcast-style captions that split one sentence across two on-screen rows (e.g. Crunchyroll Japanese subs) now merge into a single line for the sidebar and mined cards, while separate speakers, sound effects, and labeled turns still stay on their own lines.
|
||||
- Mining from the overlay no longer pulls in a lingering row from the previous caption; the mined sentence and clip timing now match what's actually on screen.
|
||||
- Multi-line copy and mining now select lines backward in timeline order after seeking, instead of in playback encounter order.
|
||||
- Copying a subtitle, mining a sentence, or recording immersion stats no longer includes the separate furigana line that broadcast ASS captions place above a word.
|
||||
- **Card Update Notifications**: Dismissed lingering overlay card-update progress when notification settings switch to OSD before an update finishes.
|
||||
- **Overlay Stability on Hyprland**: Opening a modal window (timing review, Jimaku, session help, and others) while mpv is fullscreen no longer causes the overlay to flicker while the modal loads; the overlay now stays on screen untouched until the modal is ready.
|
||||
- **Jellyfin Subtitle Sync**: Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
|
||||
- **Secondary Subtitle Visibility**: Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
|
||||
|
||||
## v0.19.5 (2026-08-30)
|
||||
|
||||
**Fixed**
|
||||
|
||||
@@ -25,8 +25,6 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
|
||||
|
||||
These work when the overlay window has focus.
|
||||
|
||||
When text is selected in the [subtitle sidebar](./subtitle-sidebar.md#selecting-and-copying-dialogue), `Ctrl/Cmd+C` copies that selection without timestamps, taking priority over the current-subtitle action. `Escape` clears the sidebar selection.
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------------ | ----------------------------------------------- | --------------------------------------- |
|
||||
| `Ctrl/Cmd+S` | Mine current subtitle as sentence card | `shortcuts.mineSentence` |
|
||||
|
||||
@@ -16,14 +16,6 @@ For typeset ASS karaoke and animated signs, SubMiner collapses generated animati
|
||||
|
||||
The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar.
|
||||
|
||||
## Selecting and copying dialogue
|
||||
|
||||
Drag across subtitle text to select an excerpt, including across multiple rows. Scroll to extend a selection through a longer conversation. `Ctrl/Cmd+C` or the **Copy** button copies the highlighted text in subtitle order, without timestamps. Partial first and last lines are preserved, with a blank line between subtitle cues.
|
||||
|
||||
Dragging to select does not seek playback. Playback-following auto-scroll stops while you drag or have a selection, so the excerpt stays in view. Press `Escape` to clear the selection. An ordinary click with no selection still seeks to that cue.
|
||||
|
||||
Selection survives playback updates and Yomitan popup dismissal. Changing media or subtitle sources, refreshing the cue list, or closing the sidebar clears it. Copying an excerpt does not require creating an Anki card.
|
||||
|
||||
## Layout Modes
|
||||
|
||||
Two layout modes are available via `subtitleSidebar.layout`:
|
||||
|
||||
@@ -37,14 +37,6 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
|
||||
## Shared Contract Entry Points
|
||||
|
||||
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
|
||||
identifies the media and subtitle source so renderer selections are invalidated on source changes,
|
||||
including changes whose cue text and timings are identical. Native selection and clean clipboard
|
||||
serialization live in `src/renderer/modals/subtitle-sidebar-selection.ts`. Electron lets standard
|
||||
Copy input reach the renderer, where sidebar selection takes priority over the live-subtitle binding.
|
||||
The preload bridge writes selections through Electron's clipboard API so copying does not depend
|
||||
on Chromium document focus or require activating the overlay window.
|
||||
|
||||
- Config + app-state contracts: `src/types/config.ts`
|
||||
- Subtitle/token/media annotation contracts: `src/types/subtitle.ts`
|
||||
- Runtime/window/controller/Electron bridge contracts: `src/types/runtime.ts`
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.6",
|
||||
"version": "0.19.5",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
+15
-33
@@ -1,41 +1,23 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- **Pre-Mining Timing Review**:
|
||||
- Optional review step before creating word, sentence, or audio cards, with a speech-focused waveform that filters out steady background noise so dialogue is easy to spot.
|
||||
- The clip end automatically snaps back to where dialogue actually ends, since subtitles often linger after speech stops.
|
||||
- Drag or use the keyboard to adjust clip boundaries, and preview audio with a sweeping playhead that plays to the true end even on high-latency outputs like Bluetooth headphones.
|
||||
- Pull extra previous or next subtitle lines onto the card with `P`/`N` (or the Prev/Next steppers); a live preview shows exactly what the card will contain.
|
||||
- You can cancel and still keep the card without media, and the review can be toggled on or off for the session.
|
||||
- **Senren Note Type Support**:
|
||||
- Enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, combining sentence, furigana, audio, picture, and misc-info fields.
|
||||
- Supports the same auto/manual/disabled grouping modes as Kiku, including the manual merge modal. Senren and Kiku are mutually exclusive, so only one can be enabled at a time.
|
||||
|
||||
### Changed
|
||||
- **Remote Streaming Mining**: Mining a card from a remote stream (Jellyfin and other HTTP sources) now downloads the clip window once and reuses it for the timing review waveform, audio preview, audio extraction, and screenshot, instead of re-fetching the stream for every step. No action needed; the temporary download is cleaned up automatically after ten minutes of inactivity.
|
||||
- **TsukiHime Release Picker**: The Japanese and secondary-language tabs now filter releases down to ones that actually carry subtitles for that language, and tell you when none do.
|
||||
|
||||
### Fixed
|
||||
- **Broadcast Caption Accuracy**:
|
||||
- Japanese caption tracks split across two positioned lines (e.g. Crunchyroll) now merge into one, so mined sentences, the sidebar, and line-break settings treat them as a single line; lines from different speakers or sound effects still stay separate.
|
||||
- Mining from the overlay no longer picks up a leftover line from the previous caption, so the mined sentence and clip timing match what's actually on screen.
|
||||
- Copying or mining subtitles no longer includes the separate furigana line that some broadcast subtitle files place above kanji.
|
||||
- **Multi-line Copy After Seeking**: Selecting multiple subtitle lines to copy or mine now selects backward in timeline order after a seek, rather than in playback encounter order.
|
||||
- **Overlay Stability**:
|
||||
- On Hyprland, opening a modal (timing review, Jimaku, session help, and others) over fullscreen mpv no longer makes the overlay flicker while the modal loads.
|
||||
- Switching secondary subtitle tracks no longer causes mpv's native secondary subtitles to flash on screen.
|
||||
- **Anki Update Notifications**: Switching notification settings to on-screen display while a card update is still in progress now correctly dismisses the old overlay progress indicator.
|
||||
- **Jellyfin Subtitles**: Subtitle files now load with zero delay in mpv instead of Jellyfin inferring and applying a sync offset.
|
||||
|
||||
- **Anki Card Update Progress**: The update spinner now stays visible until audio and image updates actually finish, so you won't mistake an in-progress update for a failure.
|
||||
- **Word-Card Field Enrichment**: Word-card enrichment now reliably writes sentence text and audio into whichever AnkiConnect fields you've configured, while the dedicated Lapis/Kiku sentence-card and audio-card actions still use their expected field names.
|
||||
- **Overlapping Subtitles**:
|
||||
- Lines that start while another is still on screen now show together instead of staying hidden until you switch tracks or seek.
|
||||
- Subtitles shown at the same time now stack by their authored screen position, with signs and song lyrics above dialogue.
|
||||
- Half-size ASS furigana no longer shows up as if it were its own subtitle line.
|
||||
- **YouTube Auto-Generated Captions**:
|
||||
- Captions now follow their intended timing instead of drifting off sync.
|
||||
- Long speech is paged across two rows instead of piling into a wall of text.
|
||||
- Timed sound cues like `[音楽]` no longer linger over later dialogue.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(anki): add media timing review before card creation by @ksyasuda in #203
|
||||
- fix(jellyfin): stop inferring subtitle delays by @ksyasuda in #227
|
||||
- feat(anki): support Senren scene-switching field grouping by @ksyasuda in #230
|
||||
- fix(mining): copy multi-line subtitles backward from current line by @ksyasuda in #231
|
||||
- fix(subtitles): keep native secondary subtitles hidden by @ksyasuda in #232
|
||||
- fix(subtitles): drop ASS furigana from recorded cues by @ksyasuda in #233
|
||||
- fix(subtitles): merge wrapped positioned caption rows by @ksyasuda in #234
|
||||
- fix(tsukihime): filter releases by subtitle language by @ksyasuda in #235
|
||||
- fix(anki): keep overlay progress visible through card updates by @ksyasuda in #218
|
||||
- fix(youtube): keep auto captions on screen for their full span by @ksyasuda in #219
|
||||
- fix(subtitles): keep overlapping lines that join an already active cue by @ksyasuda in #221
|
||||
- fix(anki): respect configured fields for word-card enrichment by @ksyasuda in #223
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -121,51 +121,6 @@ test('NoteUpdateWorkflow updates sentence field and emits notification', async (
|
||||
assert.equal(harness.notifications.length, 1);
|
||||
});
|
||||
|
||||
for (const audioField of ['SentenceAudio', 'ContextAudio']) {
|
||||
for (const action of ['confirm', 'use-original'] as const) {
|
||||
test(`NoteUpdateWorkflow respects the configured ${audioField} field with ${action} timing`, async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: { sentence: 'Sentence', audio: audioField },
|
||||
media: { generateAudio: true, generateImage: false },
|
||||
});
|
||||
harness.deps.client.notesInfo = async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
ExpressionAudio: { value: '[sound:word.mp3]' },
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
ContextAudio: { value: '' },
|
||||
},
|
||||
},
|
||||
];
|
||||
harness.deps.captureSubtitleMediaContext = () => ({
|
||||
source: 'overlay',
|
||||
text: 'subtitle-text',
|
||||
startTime: 4,
|
||||
endTime: 6,
|
||||
});
|
||||
harness.deps.reviewMediaTiming = async () => ({
|
||||
action,
|
||||
startTime: 4.2,
|
||||
endTime: 5.8,
|
||||
});
|
||||
harness.deps.generateAudio = async () => Buffer.from('sentence audio');
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.equal(harness.updates[0]?.fields.ExpressionAudio, undefined);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
[audioField]: '[sound:audio_1.mp3]',
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('NoteUpdateWorkflow uses configured fields for word-card enrichment with Lapis and Kiku enabled', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getConfig = () => ({
|
||||
|
||||
@@ -89,7 +89,6 @@ function createControllerConfigFixture() {
|
||||
|
||||
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
|
||||
return {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [],
|
||||
currentSubtitle: { text: '', startTime: null, endTime: null },
|
||||
config: {
|
||||
|
||||
@@ -37,15 +37,6 @@ export function handleOverlayWindowBeforeInputEvent(options: {
|
||||
if (options.kind === 'modal') return false;
|
||||
if (!options.windowVisible) return false;
|
||||
|
||||
// The renderer decides whether Copy targets selected sidebar text or the live cue.
|
||||
if (
|
||||
(options.input.control || options.input.meta) &&
|
||||
!options.input.alt &&
|
||||
!options.input.shift &&
|
||||
(options.input.code === 'KeyC' || options.input.key.toLowerCase() === 'c')
|
||||
)
|
||||
return false;
|
||||
|
||||
if (isKeyboardModeToggleInput(options.input)) {
|
||||
options.preventDefault();
|
||||
options.sendKeyboardModeToggleRequested();
|
||||
|
||||
@@ -85,35 +85,6 @@ test('handleOverlayWindowBeforeInputEvent leaves modal Tab handling alone', () =
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('native Copy reaches the renderer before the current-subtitle fallback', () => {
|
||||
for (const modifier of [{ control: true }, { meta: true }]) {
|
||||
const handled = handleOverlayWindowBeforeInputEvent({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
input: {
|
||||
type: 'keyDown',
|
||||
key: 'c',
|
||||
code: 'KeyC',
|
||||
isAutoRepeat: false,
|
||||
isComposing: false,
|
||||
shift: false,
|
||||
control: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
location: 0,
|
||||
modifiers: [],
|
||||
...modifier,
|
||||
},
|
||||
preventDefault: () => assert.fail('Copy must reach Chromium'),
|
||||
sendKeyboardModeToggleRequested: () => assert.fail('Unexpected mode toggle'),
|
||||
sendLookupWindowToggleRequested: () => assert.fail('Unexpected lookup toggle'),
|
||||
tryHandleOverlayShortcutLocalFallback: () => assert.fail('Renderer owns Copy'),
|
||||
forwardTabToMpv: () => assert.fail('Unexpected mpv input'),
|
||||
});
|
||||
assert.equal(handled, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred skips visible overlay restacking after manual hide', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
-28
@@ -5724,10 +5724,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
const client = appState.mpvClient;
|
||||
if (!client?.connected) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5747,10 +5743,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
||||
if (!videoPath) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5765,10 +5757,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
})
|
||||
) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5785,10 +5773,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
});
|
||||
if (!resolvedSource) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5799,10 +5783,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
try {
|
||||
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5816,10 +5796,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
|
||||
appState.activeParsedSubtitleMediaPath = videoPath || null;
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5830,10 +5806,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
|
||||
@@ -828,7 +828,6 @@ test('modal fallback reveal skips showing window when content is not ready', asy
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{
|
||||
platform: 'darwin',
|
||||
scheduleRevealFallback: (callback) => {
|
||||
scheduledReveal = callback;
|
||||
return { scheduled: true } as never;
|
||||
@@ -1364,62 +1363,3 @@ test('modal placement reconcile cancels stale retry ladder after a newer visible
|
||||
globalThis.clearTimeout = originalClearTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test('Linux keeps the dedicated modal window unmapped until the renderer opens the modal, then hides the overlay before revealing it', () => {
|
||||
const mainWindow = createMockWindow();
|
||||
mainWindow.visible = true;
|
||||
const modalWindow = createMockWindow();
|
||||
const order: string[] = [];
|
||||
const hideMain = mainWindow.hide;
|
||||
mainWindow.hide = () => {
|
||||
order.push('main:hide');
|
||||
hideMain();
|
||||
};
|
||||
const showModal = modalWindow.show;
|
||||
modalWindow.show = () => {
|
||||
order.push('modal:show');
|
||||
showModal();
|
||||
};
|
||||
let revealScheduled = false;
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => mainWindow as never,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
scheduleRevealFallback: () => {
|
||||
revealScheduled = true;
|
||||
return { scheduled: true } as never;
|
||||
},
|
||||
clearRevealFallback: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const open = () =>
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'media-timing-review:open',
|
||||
{ reviewId: 'review' },
|
||||
{ restoreOnModalClose: 'media-timing-review', preferModalWindow: true },
|
||||
);
|
||||
|
||||
assert.equal(open(), true);
|
||||
assert.deepEqual(modalWindow.sent, [['media-timing-review:open', { reviewId: 'review' }]]);
|
||||
assert.equal(revealScheduled, false);
|
||||
assert.equal(modalWindow.getShowCount(), 0);
|
||||
assert.equal(mainWindow.getHideCount(), 0);
|
||||
|
||||
// The open retry must not map the window before the renderer answers either.
|
||||
assert.equal(open(), true);
|
||||
assert.equal(modalWindow.getShowCount(), 0);
|
||||
|
||||
runtime.notifyOverlayModalOpened('media-timing-review');
|
||||
|
||||
assert.deepEqual(order, ['main:hide', 'modal:show']);
|
||||
assert.equal(mainWindow.isVisible(), false);
|
||||
assert.equal(modalWindow.isVisible(), true);
|
||||
assert.equal(modalWindow.ignoreMouseEvents, false);
|
||||
});
|
||||
|
||||
@@ -90,12 +90,6 @@ export function createOverlayModalRuntimeService(
|
||||
const platform = options.platform ?? process.platform;
|
||||
const shouldPrimeModalWindow = platform === 'darwin' || platform === 'win32';
|
||||
const reuseModalWindowAfterClose = platform === 'darwin';
|
||||
// On Linux (Hyprland) every placement dispatch on a mapped window (resize, move, set_prop)
|
||||
// blanks the still-visible overlay for a few frames while mpv is fullscreen. Revealing the
|
||||
// dedicated modal window before its renderer has the modal open runs the placement ladder,
|
||||
// and the open retry, against a visible overlay, which the user sees as flicker. Keep the
|
||||
// window unmapped until the renderer acknowledges the open, then hide the overlay first.
|
||||
const deferModalRevealUntilOpened = platform === 'linux';
|
||||
const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus;
|
||||
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
|
||||
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
||||
@@ -463,9 +457,7 @@ export function createOverlayModalRuntimeService(
|
||||
deps.setModalWindowBounds(deps.getModalGeometry());
|
||||
const wasVisible = modalWindow.isVisible();
|
||||
if (!wasVisible) {
|
||||
if (deferModalRevealUntilOpened) {
|
||||
// notifyOverlayModalOpened reveals the window once the renderer has the modal open.
|
||||
} else if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
|
||||
if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
|
||||
showModalWindow(modalWindow);
|
||||
} else {
|
||||
scheduleModalWindowReveal(modalWindow);
|
||||
@@ -568,23 +560,15 @@ export function createOverlayModalRuntimeService(
|
||||
}
|
||||
|
||||
const modalWindow = deps.getModalWindow();
|
||||
const targetIsModalWindow =
|
||||
modalWindow !== null && !modalWindow.isDestroyed() && targetWindow === modalWindow;
|
||||
const handOffMainWindowToModal = (): void => {
|
||||
setMainWindowMousePassthroughForModal(true);
|
||||
setMainWindowVisibilityForModal(true);
|
||||
};
|
||||
|
||||
if (targetIsModalWindow && deferModalRevealUntilOpened) {
|
||||
handOffMainWindowToModal();
|
||||
}
|
||||
if (targetWindow.isVisible()) {
|
||||
ensureModalWindowInteractive(targetWindow);
|
||||
} else {
|
||||
showModalWindow(targetWindow);
|
||||
}
|
||||
if (targetIsModalWindow && !deferModalRevealUntilOpened) {
|
||||
handOffMainWindowToModal();
|
||||
|
||||
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
|
||||
setMainWindowMousePassthroughForModal(true);
|
||||
setMainWindowVisibilityForModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -25,9 +25,7 @@ export async function openMediaTimingReviewModal(
|
||||
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
|
||||
{
|
||||
modal: MODAL,
|
||||
// The review renderer regularly needs more than the 1.5 s the other modals allow; a
|
||||
// premature retry re-sends the payload and reloads the waveform for nothing.
|
||||
timeoutMs: 4_000,
|
||||
timeoutMs: 1_500,
|
||||
retryWarning:
|
||||
'Media timing review did not acknowledge modal open; retrying the dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { build } from 'esbuild';
|
||||
|
||||
test('sidebar clipboard bridge writes exact text without renderer focus and rejects non-text input', async () => {
|
||||
const result = await build({
|
||||
entryPoints: ['src/preload.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
external: ['electron'],
|
||||
write: false,
|
||||
});
|
||||
const output = result.outputFiles[0];
|
||||
assert.ok(output);
|
||||
const writes: string[] = [];
|
||||
let exposed: unknown;
|
||||
runInNewContext(output.text, {
|
||||
process: { argv: [] },
|
||||
require: (name: string) => {
|
||||
assert.equal(name, 'electron');
|
||||
return {
|
||||
ipcRenderer: { on: () => {} },
|
||||
clipboard: { writeText: (text: string) => writes.push(text) },
|
||||
contextBridge: {
|
||||
exposeInMainWorld: (_name: string, api: unknown) => {
|
||||
exposed = api;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.ok(
|
||||
typeof exposed === 'object' && exposed !== null && 'copySubtitleSidebarSelection' in exposed,
|
||||
);
|
||||
const copy = exposed.copySubtitleSidebarSelection;
|
||||
if (typeof copy !== 'function') throw new Error('Missing clipboard bridge');
|
||||
const text = '最初の台詞\n二行目\n\n同じ台詞';
|
||||
await copy(text);
|
||||
assert.deepEqual(writes, [text]);
|
||||
for (const value of [null, undefined, 42, { text }, ['台詞']]) {
|
||||
await assert.rejects(async () => copy(value), /Subtitle selection must be text/);
|
||||
}
|
||||
assert.deepEqual(writes, [text]);
|
||||
});
|
||||
+1
-5
@@ -16,7 +16,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { clipboard, contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron';
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron';
|
||||
import { resolveOverlayLayerFromArgv } from './preload-args';
|
||||
import type {
|
||||
SubtitleData,
|
||||
@@ -301,10 +301,6 @@ const electronAPI: ElectronAPI = {
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen),
|
||||
getSubtitleSidebarSnapshot: () =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
|
||||
copySubtitleSidebarSelection: async (text: unknown) => {
|
||||
if (typeof text !== 'string') throw new TypeError('Subtitle selection must be text.');
|
||||
clipboard.writeText(text);
|
||||
},
|
||||
getPlaybackPaused: (): Promise<boolean | null> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused),
|
||||
onSubtitleAss: (callback: (assText: string) => void) => {
|
||||
|
||||
@@ -1863,29 +1863,6 @@ test('keyboard mode: popup hidden after mode off clears stale selected token hig
|
||||
}
|
||||
});
|
||||
|
||||
test('Yomitan popup dismissal and subtitle updates preserve selection outside the overlay subtitle', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
let cleared = false;
|
||||
try {
|
||||
Object.defineProperty(window, 'getSelection', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
anchorNode: {},
|
||||
removeAllRanges: () => {
|
||||
cleared = true;
|
||||
},
|
||||
}),
|
||||
});
|
||||
Object.assign(ctx.dom.subtitleRoot, { contains: () => false });
|
||||
await handlers.setupMpvInputForwarding();
|
||||
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
assert.equal(cleared, false);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: closing lookup keeps controller selection but clears native text selection', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -436,10 +436,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function clearNativeSubtitleSelection(): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.anchorNode || ctx.dom.subtitleRoot.contains(selection.anchorNode)) {
|
||||
selection?.removeAllRanges();
|
||||
}
|
||||
window.getSelection()?.removeAllRanges();
|
||||
ctx.dom.subtitleRoot.classList.remove('has-selection');
|
||||
}
|
||||
|
||||
|
||||
@@ -731,7 +731,6 @@
|
||||
<div id="subtitleSidebarContent" class="modal-content subtitle-sidebar-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Subtitle Sidebar</div>
|
||||
<button id="subtitleSidebarCopy" class="modal-close" type="button" hidden>Copy</button>
|
||||
<button id="subtitleSidebarClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body subtitle-sidebar-body">
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types';
|
||||
import { SUBTITLE_DEFAULT_CONFIG } from '../../config/definitions/defaults-subtitle';
|
||||
import { createRendererState } from '../state';
|
||||
import { resolveRendererDom } from '../utils/dom';
|
||||
import { resolvePlatformInfo } from '../utils/platform';
|
||||
import { createSubtitleSidebarModal } from './subtitle-sidebar';
|
||||
import {
|
||||
getSubtitleSidebarSelection,
|
||||
wireSubtitleSidebarSelection,
|
||||
} from './subtitle-sidebar-selection';
|
||||
|
||||
export async function setup() {
|
||||
const commands: unknown[] = [];
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'episode-1:track-1',
|
||||
cues: [
|
||||
{ text: '最初の台詞', startTime: 0, endTime: 1 },
|
||||
{ text: '同じ台詞\n二行目', startTime: 1, endTime: 2 },
|
||||
{ text: '同じ台詞', startTime: 2, endTime: 3 },
|
||||
...Array.from({ length: 30 }, (_, i) => ({
|
||||
text: `後の台詞${i}`,
|
||||
startTime: i + 3,
|
||||
endTime: i + 4,
|
||||
})),
|
||||
],
|
||||
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
|
||||
currentTimeSec: 0,
|
||||
config: {
|
||||
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
|
||||
enabled: true,
|
||||
layout: 'overlay',
|
||||
pauseVideoOnHover: false,
|
||||
autoScroll: true,
|
||||
css: {},
|
||||
},
|
||||
};
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
value: {
|
||||
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||
copySubtitleSidebarSelection: async (text) => {
|
||||
if (!('copyTestSelection' in window) || typeof window.copyTestSelection !== 'function')
|
||||
throw new Error('Missing test clipboard bridge');
|
||||
window.copyTestSelection(text);
|
||||
},
|
||||
getOverlayLayer: () => 'visible',
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
},
|
||||
setIgnoreMouseEvents: () => {},
|
||||
} satisfies Pick<
|
||||
ElectronAPI,
|
||||
| 'getSubtitleSidebarSnapshot'
|
||||
| 'copySubtitleSidebarSelection'
|
||||
| 'getOverlayLayer'
|
||||
| 'sendMpvCommand'
|
||||
| 'setIgnoreMouseEvents'
|
||||
>,
|
||||
});
|
||||
const ctx = {
|
||||
dom: resolveRendererDom(),
|
||||
state: createRendererState(),
|
||||
platform: resolvePlatformInfo(),
|
||||
};
|
||||
const modal = createSubtitleSidebarModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
});
|
||||
modal.wireDomEvents();
|
||||
wireSubtitleSidebarSelection(ctx);
|
||||
await modal.openSubtitleSidebarModal();
|
||||
const list = ctx.dom.subtitleSidebarList;
|
||||
list.style.height = '180px';
|
||||
list.style.overflowY = 'auto';
|
||||
const selection = window.getSelection();
|
||||
if (!selection) throw new Error('Native selection unavailable');
|
||||
const textNode = (index: number) => {
|
||||
const node = list.children[index]?.querySelector('.subtitle-sidebar-text')?.firstChild;
|
||||
if (!node) throw new Error(`Missing cue ${index}`);
|
||||
return node;
|
||||
};
|
||||
const select = (backward = false) => {
|
||||
const start = textNode(0);
|
||||
const end = textNode(2);
|
||||
selection.setBaseAndExtent(backward ? end : start, 2, backward ? start : end, 2);
|
||||
document.dispatchEvent(new Event('selectionchange'));
|
||||
return getSubtitleSidebarSelection(list);
|
||||
};
|
||||
// This is the same competing action as the renderer's current-subtitle shortcut.
|
||||
let fallbackCopies = 0;
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') fallbackCopies += 1;
|
||||
});
|
||||
return {
|
||||
select,
|
||||
selected: () => getSubtitleSidebarSelection(list),
|
||||
buttonVisible: () => !ctx.dom.subtitleSidebarCopy.hidden,
|
||||
fallbackCopies: () => fallbackCopies,
|
||||
dragPoints: () =>
|
||||
[0, 2].map((index) => {
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode(index), 2);
|
||||
range.collapse(true);
|
||||
const rect = range.getBoundingClientRect();
|
||||
return { x: Math.round(rect.x), y: Math.round(rect.y + rect.height / 2) };
|
||||
}),
|
||||
clickCopy: () => ctx.dom.subtitleSidebarCopy.click(),
|
||||
clickCue: () => {
|
||||
const before = commands.length;
|
||||
list.children[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
return commands.length - before;
|
||||
},
|
||||
updatePlayback: async () => {
|
||||
list.scrollTop = 0;
|
||||
snapshot.currentTimeSec = 25;
|
||||
snapshot.currentSubtitle = { text: '後の台詞22', startTime: 25, endTime: 26 };
|
||||
await modal.refreshSubtitleSidebarSnapshot();
|
||||
return list.scrollTop;
|
||||
},
|
||||
changeSource: async () => {
|
||||
snapshot.sourceKey = 'episode-2:track-1';
|
||||
await modal.refreshSubtitleSidebarSnapshot();
|
||||
return getSubtitleSidebarSelection(list);
|
||||
},
|
||||
clear: () => selection.removeAllRanges(),
|
||||
close: () => modal.closeSubtitleSidebarModal(),
|
||||
};
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import test from 'node:test';
|
||||
import { build } from 'esbuild';
|
||||
|
||||
// This check opens Electron and uses the clipboard. Keep it out of normal code-only lanes.
|
||||
const electronTest =
|
||||
process.env.SUBMINER_ELECTRON_TESTS === '1' &&
|
||||
(process.platform !== 'linux' || process.env.DISPLAY)
|
||||
? test
|
||||
: test.skip;
|
||||
|
||||
electronTest(
|
||||
'sidebar selection copies clean chronological text without seeking or losing context',
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'subminer-sidebar-selection-'));
|
||||
await build({
|
||||
entryPoints: [resolve('src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts')],
|
||||
bundle: true,
|
||||
platform: 'browser',
|
||||
format: 'iife',
|
||||
globalName: 'sidebarTest',
|
||||
outfile: join(dir, 'fixture.js'),
|
||||
});
|
||||
const html = (await readFile('src/renderer/index.html', 'utf8'))
|
||||
.replace(
|
||||
'<script type="module" src="renderer.js"></script>',
|
||||
'<script src="fixture.js"></script>',
|
||||
)
|
||||
.replace(
|
||||
'href="style.css"',
|
||||
`href="${new URL(`file://${resolve('src/renderer/style.css')}`).href}"`,
|
||||
);
|
||||
await writeFile(join(dir, 'index.html'), html);
|
||||
await writeFile(
|
||||
join(dir, 'clipboard.cjs'),
|
||||
'const { clipboard, contextBridge } = require("electron"); contextBridge.exposeInMainWorld("copyTestSelection", text => clipboard.writeText(text));',
|
||||
);
|
||||
await build({
|
||||
entryPoints: [resolve('src/core/services/overlay-window-input.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
outfile: join(dir, 'input.cjs'),
|
||||
});
|
||||
await writeFile(
|
||||
join(dir, 'run.cjs'),
|
||||
`
|
||||
const { app, BrowserWindow, clipboard } = require('electron');
|
||||
const assert = require('node:assert/strict');
|
||||
const { handleOverlayWindowBeforeInputEvent } = require('./input.cjs');
|
||||
app.setPath('userData', ${JSON.stringify(join(dir, 'user-data'))});
|
||||
app.whenReady().then(async () => {
|
||||
const window = new BrowserWindow({ width: 900, height: 700, show: false, webPreferences: { preload: ${JSON.stringify(join(dir, 'clipboard.cjs'))}, sandbox: false } });
|
||||
window.webContents.on('console-message', (_event, details) => {
|
||||
if (details.level === 'error') console.error(details.message);
|
||||
});
|
||||
let intercepted = 0;
|
||||
window.webContents.on('before-input-event', (event, input) => handleOverlayWindowBeforeInputEvent({
|
||||
kind: 'visible', windowVisible: true, input,
|
||||
preventDefault: () => event.preventDefault(),
|
||||
sendKeyboardModeToggleRequested() {}, sendLookupWindowToggleRequested() {}, forwardTabToMpv() {},
|
||||
tryHandleOverlayShortcutLocalFallback() { intercepted++; return true; },
|
||||
}));
|
||||
await window.loadFile(${JSON.stringify(join(dir, 'index.html'))});
|
||||
const run = (code) => window.webContents.executeJavaScript(code, true);
|
||||
await run('sidebarTest.setup().then(checks => { window.checks = checks; })');
|
||||
const expected = { text: 'の台詞\\n\\n同じ台詞\\n二行目\\n\\n同じ', cueCount: 3 };
|
||||
assert.deepEqual(await run('checks.select()'), expected);
|
||||
assert.deepEqual(await run('checks.select(true)'), expected);
|
||||
assert.equal(await run('checks.buttonVisible()'), true);
|
||||
assert.equal(await run('checks.clickCue()'), 0);
|
||||
assert.equal(await run('checks.updatePlayback()'), 0);
|
||||
assert.deepEqual(await run('checks.selected()'), expected);
|
||||
const previousClipboard = clipboard.readText();
|
||||
try {
|
||||
clipboard.writeText('sentinel');
|
||||
window.show(); app.focus({ steal: true }); window.focus(); window.webContents.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await run('checks.clear()');
|
||||
const [start, end] = await run('checks.dragPoints()');
|
||||
window.webContents.sendInputEvent({ type: 'mouseDown', ...start, button: 'left', clickCount: 1 });
|
||||
window.webContents.sendInputEvent({ type: 'mouseMove', ...end, button: 'left' });
|
||||
window.webContents.sendInputEvent({ type: 'mouseUp', ...end, button: 'left', clickCount: 1 });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
assert.deepEqual(await run('checks.selected()'), expected);
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'C', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'C', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
assert.equal(intercepted, 0);
|
||||
assert.equal(await run('checks.fallbackCopies()'), 0);
|
||||
assert.equal(clipboard.readText() === expected.text, true, 'Keyboard copies the selected excerpt');
|
||||
clipboard.writeText('sentinel');
|
||||
await run('checks.clickCopy()');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
assert.equal(clipboard.readText() === expected.text, true, 'Button copies the selected excerpt');
|
||||
} finally { clipboard.writeText(previousClipboard); }
|
||||
await run('document.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape", bubbles:true}))');
|
||||
assert.equal(await run('checks.selected()'), null);
|
||||
assert.equal(await run('checks.buttonVisible()'), false);
|
||||
assert.equal(await run('checks.clickCue()'), 1);
|
||||
await run('document.dispatchEvent(new KeyboardEvent("keydown", {key:"c", ctrlKey:true, bubbles:true}))');
|
||||
assert.equal(await run('checks.fallbackCopies()'), 1);
|
||||
await run('checks.select()');
|
||||
assert.equal(await run('checks.changeSource()'), null);
|
||||
await run('checks.select(); checks.close()');
|
||||
assert.equal(await run('checks.selected()'), null);
|
||||
window.destroy();
|
||||
console.log('SIDEBAR_SELECTION_OK');
|
||||
app.quit();
|
||||
}).catch(error => { console.error(error); app.exit(1); });
|
||||
`,
|
||||
);
|
||||
const env = { ...process.env };
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
const { stdout } = await promisify(execFile)(
|
||||
resolve('node_modules/.bin/electron'),
|
||||
[join(dir, 'run.cjs')],
|
||||
{ env, timeout: 25_000 },
|
||||
);
|
||||
assert.match(stdout, /SIDEBAR_SELECTION_OK/);
|
||||
},
|
||||
);
|
||||
@@ -1,150 +0,0 @@
|
||||
import type { RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore';
|
||||
|
||||
function isEditingText(target: EventTarget | null): boolean {
|
||||
return (
|
||||
target instanceof HTMLElement &&
|
||||
(target.isContentEditable ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement)
|
||||
);
|
||||
}
|
||||
|
||||
function getSelectionRange(list: HTMLElement): Range | null {
|
||||
const selection = list.ownerDocument?.defaultView?.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
|
||||
const range = selection.getRangeAt(0);
|
||||
return list.contains(range.startContainer) && list.contains(range.endContainer) ? range : null;
|
||||
}
|
||||
|
||||
export function hasSubtitleSidebarSelection(list: HTMLElement): boolean {
|
||||
return getSelectionRange(list) !== null;
|
||||
}
|
||||
|
||||
// Read only dialogue nodes, preserving partial first/last lines and DOM cue order.
|
||||
export function getSubtitleSidebarSelection(list: HTMLElement): {
|
||||
text: string;
|
||||
cueCount: number;
|
||||
} | null {
|
||||
const range = getSelectionRange(list);
|
||||
if (!range) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const text of list.querySelectorAll<HTMLElement>('.subtitle-sidebar-text')) {
|
||||
if (!range.intersectsNode(text)) continue;
|
||||
const part = list.ownerDocument.createRange();
|
||||
part.selectNodeContents(text);
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, part) > 0) {
|
||||
part.setStart(range.startContainer, range.startOffset);
|
||||
}
|
||||
if (range.compareBoundaryPoints(Range.END_TO_END, part) < 0) {
|
||||
part.setEnd(range.endContainer, range.endOffset);
|
||||
}
|
||||
const selectedText = part.toString();
|
||||
if (selectedText.trim()) parts.push(selectedText);
|
||||
}
|
||||
return parts.length > 0 ? { text: parts.join('\n\n'), cueCount: parts.length } : null;
|
||||
}
|
||||
|
||||
export function clearSubtitleSidebarSelection(list: HTMLElement): void {
|
||||
const selection = list.ownerDocument?.defaultView?.getSelection();
|
||||
if (selection?.anchorNode && list.contains(selection.anchorNode)) {
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
}
|
||||
|
||||
export function wireSubtitleSidebarSelection(ctx: RendererContext): () => void {
|
||||
const list = ctx.dom.subtitleSidebarList;
|
||||
const button = ctx.dom.subtitleSidebarCopy;
|
||||
const doc = list.ownerDocument;
|
||||
const abort = new AbortController();
|
||||
const { signal } = abort;
|
||||
|
||||
const updateButton = () => {
|
||||
const selected = getSubtitleSidebarSelection(list);
|
||||
button.hidden = !selected;
|
||||
button.textContent = selected
|
||||
? `Copy ${selected.cueCount} ${selected.cueCount === 1 ? 'line' : 'lines'}`
|
||||
: 'Copy';
|
||||
};
|
||||
const copied = () => {
|
||||
ctx.dom.subtitleSidebarStatus.textContent = 'Selection copied.';
|
||||
};
|
||||
const copySelection = async () => {
|
||||
const selected = getSubtitleSidebarSelection(list);
|
||||
if (!selected) return;
|
||||
try {
|
||||
await window.electronAPI.copySubtitleSidebarSelection(selected.text);
|
||||
copied();
|
||||
} catch {
|
||||
ctx.dom.subtitleSidebarStatus.textContent = 'Could not copy selection. Try again.';
|
||||
}
|
||||
};
|
||||
|
||||
doc.addEventListener('selectionchange', updateButton, { signal });
|
||||
doc.addEventListener(
|
||||
'copy',
|
||||
(event) => {
|
||||
if (!ctx.state.subtitleSidebarModalOpen || isEditingText(event.target)) return;
|
||||
const selected = getSubtitleSidebarSelection(list);
|
||||
if (!selected || !event.clipboardData) return;
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData('text/plain', selected.text);
|
||||
copied();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
// Capture before modal Escape handling and the current-subtitle copy shortcut.
|
||||
doc.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (
|
||||
!ctx.state.subtitleSidebarModalOpen ||
|
||||
isEditingText(event.target) ||
|
||||
!getSubtitleSidebarSelection(list)
|
||||
)
|
||||
return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
clearSubtitleSidebarSelection(list);
|
||||
updateButton();
|
||||
} else if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === 'c'
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
void copySelection();
|
||||
}
|
||||
},
|
||||
{ capture: true, signal },
|
||||
);
|
||||
button.addEventListener('mousedown', (event) => event.preventDefault(), { signal });
|
||||
button.addEventListener('click', copySelection, { signal });
|
||||
list.addEventListener(
|
||||
'pointerdown',
|
||||
(event) => {
|
||||
if (event.button === 0) {
|
||||
list.dataset.selecting = 'true';
|
||||
list.scrollTo({ top: list.scrollTop, behavior: 'instant' });
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
const stopDragging = () => {
|
||||
delete list.dataset.selecting;
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
};
|
||||
doc.addEventListener('pointerup', stopDragging, { signal });
|
||||
doc.addEventListener('pointercancel', stopDragging, { signal });
|
||||
doc.defaultView?.addEventListener('blur', stopDragging, { signal });
|
||||
updateButton();
|
||||
return () => {
|
||||
abort.abort();
|
||||
stopDragging();
|
||||
};
|
||||
}
|
||||
@@ -239,7 +239,6 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
|
||||
const modalNotifications: string[] = [];
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [
|
||||
{ startTime: 1, endTime: 3.4, text: 'first' },
|
||||
{ startTime: 3, endTime: 4, text: 'second' },
|
||||
@@ -382,7 +381,6 @@ test('subtitle sidebar rows support keyboard activation', async () => {
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [
|
||||
{ startTime: 1, endTime: 2, text: 'first' },
|
||||
{ startTime: 3, endTime: 4, text: 'second' },
|
||||
@@ -489,7 +487,6 @@ test('subtitle sidebar renders hour-long cue timestamps as HH:MM:SS', async () =
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 3665, endTime: 3670, text: 'long cue' }],
|
||||
currentSubtitle: {
|
||||
text: 'long cue',
|
||||
@@ -583,7 +580,6 @@ test('subtitle sidebar does not open when the feature is disabled', async () =>
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [],
|
||||
currentSubtitle: {
|
||||
text: '',
|
||||
@@ -680,7 +676,6 @@ test('subtitle sidebar auto-open on startup only opens when enabled and configur
|
||||
const previousDocument = globals.document;
|
||||
|
||||
let snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -791,7 +786,6 @@ test('subtitle sidebar auto-open restores previously open sidebar after renderer
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -890,7 +884,6 @@ test('subtitle sidebar refresh closes and clears state when config becomes disab
|
||||
const previousDocument = globals.document;
|
||||
const bodyClassList = createClassList();
|
||||
let snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -1015,7 +1008,6 @@ test('subtitle sidebar keeps nearby repeated cue when subtitle update lacks timi
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [
|
||||
{ startTime: 1, endTime: 2, text: 'same' },
|
||||
{ startTime: 3, endTime: 4, text: 'other' },
|
||||
@@ -1133,7 +1125,6 @@ test('subtitle sidebar does not regress to previous cue on text-only transition
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [
|
||||
{ startTime: 1, endTime: 2, text: 'first' },
|
||||
{ startTime: 3, endTime: 4, text: 'second' },
|
||||
@@ -1242,7 +1233,6 @@ test('subtitle sidebar jumps to first resolved active cue, then resumes smooth a
|
||||
const previousDocument = globals.document;
|
||||
|
||||
let snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: Array.from({ length: 12 }, (_, index) => ({
|
||||
startTime: index * 2,
|
||||
endTime: index * 2 + 1.5,
|
||||
@@ -1413,7 +1403,6 @@ test('subtitle sidebar polling schedules serialized timeouts instead of interval
|
||||
});
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -1526,7 +1515,6 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
|
||||
const contentListeners = new Map<string, Array<() => void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -1645,7 +1633,6 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
|
||||
const contentListeners = new Map<string, Array<() => Promise<void> | void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -1765,7 +1752,6 @@ test('subtitle sidebar keeps hover pause while a Yomitan lookup popup remains op
|
||||
const windowListeners = new Map<string, Array<() => Promise<void> | void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -1895,7 +1881,6 @@ test('subtitle sidebar embedded layout reserves and releases mpv right margin',
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -2055,7 +2040,6 @@ test('subtitle sidebar embedded layout measures reserved width after embedded cl
|
||||
const contentClassList = createClassList();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -2177,7 +2161,6 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
|
||||
const contentListeners = new Map<string, Array<() => void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -2307,7 +2290,6 @@ test('subtitle sidebar overlay layout restores macOS and Windows passthrough out
|
||||
const contentListeners = new Map<string, Array<() => void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -2435,7 +2417,6 @@ test('subtitle sidebar overlay layout only stays interactive while focus remains
|
||||
const contentListeners = new Map<string, Array<(event?: FocusEvent) => void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -2551,7 +2532,6 @@ test('closing embedded subtitle sidebar recomputes passthrough from remaining su
|
||||
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -2660,7 +2640,6 @@ test('subtitle sidebar resets embedded mpv margin on startup while closed', asyn
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
|
||||
@@ -7,10 +7,6 @@ import type {
|
||||
import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||
import {
|
||||
clearSubtitleSidebarSelection,
|
||||
hasSubtitleSidebarSelection,
|
||||
} from './subtitle-sidebar-selection.js';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
@@ -213,7 +209,6 @@ export function createSubtitleSidebarModal(
|
||||
let subtitleSidebarYomitanPopupVisible = false;
|
||||
let subtitleSidebarPauseHeldByYomitanPopup = false;
|
||||
let lastSubtitleSidebarLookupCueIndex = -1;
|
||||
let subtitleSourceKey: string | null = null;
|
||||
|
||||
function restoreEmbeddedSidebarPassthrough(): void {
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
@@ -474,8 +469,6 @@ export function createSubtitleSidebarModal(
|
||||
): void {
|
||||
if (
|
||||
!ctx.state.subtitleSidebarAutoScroll ||
|
||||
ctx.dom.subtitleSidebarList.dataset?.selecting === 'true' ||
|
||||
hasSubtitleSidebarSelection(ctx.dom.subtitleSidebarList) ||
|
||||
ctx.state.subtitleSidebarActiveCueIndex < 0 ||
|
||||
(!force && ctx.state.subtitleSidebarActiveCueIndex === previousActiveCueIndex) ||
|
||||
nowForUiTiming() < ctx.state.subtitleSidebarManualScrollUntilMs
|
||||
@@ -572,14 +565,8 @@ export function createSubtitleSidebarModal(
|
||||
|
||||
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
|
||||
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
|
||||
if (snapshot.sourceKey !== subtitleSourceKey) {
|
||||
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
||||
lastSubtitleSidebarLookupCueIndex = -1;
|
||||
subtitleSourceKey = snapshot.sourceKey;
|
||||
}
|
||||
applyConfig(snapshot);
|
||||
if (!snapshot.config.enabled) {
|
||||
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
||||
resumeSubtitleSidebarHoverPause();
|
||||
clearSidebarInteractionState();
|
||||
ctx.state.subtitleSidebarCues = [];
|
||||
@@ -599,7 +586,6 @@ export function createSubtitleSidebarModal(
|
||||
|
||||
const cuesChanged = !subtitleCueListsEqual(ctx.state.subtitleSidebarCues, snapshot.cues);
|
||||
if (cuesChanged) {
|
||||
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
||||
ctx.state.subtitleSidebarCues = snapshot.cues;
|
||||
if (ctx.state.subtitleSidebarModalOpen) {
|
||||
renderCueList();
|
||||
@@ -684,7 +670,6 @@ export function createSubtitleSidebarModal(
|
||||
if (!ctx.state.subtitleSidebarModalOpen) {
|
||||
return;
|
||||
}
|
||||
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
||||
resumeSubtitleSidebarHoverPause();
|
||||
clearSidebarInteractionState();
|
||||
ctx.state.subtitleSidebarModalOpen = false;
|
||||
@@ -725,7 +710,6 @@ export function createSubtitleSidebarModal(
|
||||
closeSubtitleSidebarModal();
|
||||
});
|
||||
ctx.dom.subtitleSidebarList.addEventListener('click', (event) => {
|
||||
if (hasSubtitleSidebarSelection(ctx.dom.subtitleSidebarList)) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
|
||||
@@ -27,9 +27,7 @@ function isYomitanPopupInteractionActive(state: RendererState): boolean {
|
||||
|
||||
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
||||
const shouldKeepWindowInteractive =
|
||||
ctx.dom.subtitleSidebarList?.dataset?.selecting === 'true' ||
|
||||
isYomitanPopupInteractionActive(ctx.state) ||
|
||||
isBlockingOverlayModalOpen(ctx.state);
|
||||
isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state);
|
||||
const shouldStayInteractive =
|
||||
ctx.state.isOverSubtitle ||
|
||||
ctx.state.isOverSubtitleSidebar ||
|
||||
|
||||
@@ -40,7 +40,6 @@ import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
||||
import { createSessionHelpModal } from './modals/session-help.js';
|
||||
import { createChangelogModal } from './modals/changelog.js';
|
||||
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||
import { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selection.js';
|
||||
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||
@@ -240,8 +239,6 @@ const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
|
||||
measurementReporter.emitNow();
|
||||
},
|
||||
});
|
||||
const disposeSubtitleSidebarSelection = wireSubtitleSidebarSelection(ctx);
|
||||
window.addEventListener('beforeunload', disposeSubtitleSidebarSelection, { once: true });
|
||||
const kikuModal = createKikuModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
|
||||
@@ -3690,7 +3690,6 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
||||
}
|
||||
|
||||
.subtitle-sidebar-timestamp {
|
||||
user-select: none;
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -3713,8 +3712,6 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
||||
}
|
||||
|
||||
.subtitle-sidebar-text {
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
font-size: 1em;
|
||||
|
||||
@@ -157,7 +157,6 @@ export type RendererDom = {
|
||||
subtitleSidebarModal: HTMLDivElement;
|
||||
subtitleSidebarContent: HTMLDivElement;
|
||||
subtitleSidebarClose: HTMLButtonElement;
|
||||
subtitleSidebarCopy: HTMLButtonElement;
|
||||
subtitleSidebarStatus: HTMLDivElement;
|
||||
subtitleSidebarList: HTMLUListElement;
|
||||
|
||||
@@ -406,7 +405,6 @@ export function resolveRendererDom(): RendererDom {
|
||||
subtitleSidebarModal: getRequiredElement<HTMLDivElement>('subtitleSidebarModal'),
|
||||
subtitleSidebarContent: getRequiredElement<HTMLDivElement>('subtitleSidebarContent'),
|
||||
subtitleSidebarClose: getRequiredElement<HTMLButtonElement>('subtitleSidebarClose'),
|
||||
subtitleSidebarCopy: getRequiredElement<HTMLButtonElement>('subtitleSidebarCopy'),
|
||||
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
|
||||
subtitleSidebarList: getRequiredElement<HTMLUListElement>('subtitleSidebarList'),
|
||||
|
||||
|
||||
@@ -448,7 +448,6 @@ export interface ElectronAPI {
|
||||
getCurrentSubtitleRaw: () => Promise<string>;
|
||||
getCurrentSubtitleAss: () => Promise<string>;
|
||||
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
|
||||
copySubtitleSidebarSelection: (text: string) => Promise<void>;
|
||||
getSubtitleSidebarOpen: () => Promise<boolean>;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
onSubtitleAss: (callback: (assText: string) => void) => void;
|
||||
|
||||
@@ -227,7 +227,6 @@ export interface SubtitleData {
|
||||
}
|
||||
|
||||
export interface SubtitleSidebarSnapshot {
|
||||
sourceKey: string | null;
|
||||
cues: SubtitleCue[];
|
||||
currentTimeSec?: number | null;
|
||||
currentSubtitle: {
|
||||
|
||||
Reference in New Issue
Block a user