feat(overlay): add optional subtitle selection modal (#265)

This commit is contained in:
2026-09-22 18:30:26 -07:00
committed by GitHub
parent a7a302bdd0
commit b0fb39a8f5
54 changed files with 1397 additions and 52 deletions
@@ -4,6 +4,7 @@ type ControllerInteractionModalState = {
jimakuModalOpen: boolean;
kikuModalOpen: boolean;
runtimeOptionsModalOpen: boolean;
subtitleSelectionModalOpen?: boolean;
subsyncModalOpen: boolean;
subtitleGenerationModalOpen?: boolean;
youtubePickerModalOpen: boolean;
@@ -18,6 +19,7 @@ export function isControllerInteractionBlocked(state: ControllerInteractionModal
state.jimakuModalOpen ||
state.kikuModalOpen ||
state.runtimeOptionsModalOpen ||
state.subtitleSelectionModalOpen ||
state.subsyncModalOpen ||
Boolean(state.subtitleGenerationModalOpen) ||
state.youtubePickerModalOpen ||
+84
View File
@@ -92,6 +92,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
@@ -2404,3 +2405,86 @@ test('stalled mpv discovery does not delay configured overlay controls', async (
testGlobals.restore();
}
});
test('session binding: g-s opens subtitle selection only after the complete sequence', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openSubtitleSelection',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
},
]);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
assert.deepEqual(testGlobals.sessionActions, []);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.deepEqual(testGlobals.sessionActions, [
{ actionId: 'openSubtitleSelection', payload: undefined },
]);
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
handlers.updateSessionBindings([]);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.equal(testGlobals.sessionActions.length, 1);
} finally {
testGlobals.restore();
}
});
test('single-key actions run immediately even if a conflicting sequence reaches the renderer', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'sequence',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
},
{
sourcePath: 'single',
originalKey: 'g',
key: { code: 'KeyG', modifiers: [] },
actionType: 'mpv-command',
command: ['show-text', 'single'],
},
]);
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
assert.deepEqual(testGlobals.mpvCommands, [['show-text', 'single']]);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.deepEqual(testGlobals.sessionActions, []);
} finally {
testGlobals.restore();
}
});
test('an unfinished built-in y chord cannot start a configured sequence', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'sequence',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
},
]);
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' });
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.deepEqual(testGlobals.sessionActions, []);
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.equal(testGlobals.sessionActions.length, 1);
} finally {
testGlobals.restore();
}
});
+36 -1
View File
@@ -15,6 +15,7 @@ export function createKeyboardHandlers(
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean;
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
handleSubtitleSelectionKeydown?: (e: KeyboardEvent) => boolean;
handleSubtitleGenerationKeydown?: (e: KeyboardEvent) => boolean;
handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
@@ -133,7 +134,10 @@ export function createKeyboardHandlers(
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
}
let pendingSequence: { prefix: string; expires: number } | null = null;
function updateSessionBindings(bindings: CompiledSessionBinding[]): void {
pendingSequence = null;
ctx.state.sessionBindings = bindings;
ctx.state.sessionBindingMap = new Map(
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
@@ -1049,7 +1053,10 @@ export function createKeyboardHandlers(
window.addEventListener('focus', () => {
void importedMpvBindings.refresh();
});
window.addEventListener('blur', importedMpvBindings.releaseAll);
window.addEventListener('blur', () => {
pendingSequence = null;
importedMpvBindings.releaseAll();
});
window.addEventListener('beforeunload', () => {
clearTimeout(lateScriptRefresh);
importedMpvBindings.dispose();
@@ -1103,6 +1110,13 @@ export function createKeyboardHandlers(
);
document.addEventListener('keydown', (e: KeyboardEvent) => {
const sequence = pendingSequence;
pendingSequence = null;
if (ctx.state.subtitleSelectionModalOpen) {
pendingSequence = null;
options.handleSubtitleSelectionKeydown?.(e);
return;
}
if (ctx.state.subtitleGenerationModalOpen) {
options.handleSubtitleGenerationKeydown?.(e);
return;
@@ -1187,6 +1201,7 @@ export function createKeyboardHandlers(
}
if (isTextEntryTarget(e.target)) {
pendingSequence = null;
return;
}
@@ -1194,6 +1209,16 @@ export function createKeyboardHandlers(
return;
}
const sequenceKey = keyEventToString(e);
if (sequence && !ctx.state.chordPending && Date.now() <= sequence.expires && !e.repeat) {
const binding = ctx.state.sessionBindingMap.get(`${sequence.prefix}-${sequenceKey}`);
if (binding) {
e.preventDefault();
dispatchSessionBinding(binding);
return;
}
}
if (isStatsOverlayToggle(e)) {
e.preventDefault();
window.electronAPI.toggleStatsOverlay();
@@ -1276,6 +1301,16 @@ export function createKeyboardHandlers(
dispatchSessionBinding(binding);
return;
}
if (
!e.repeat &&
ctx.state.sessionBindings.some((binding) =>
keyEventToStringFromBinding(binding).startsWith(`${sequenceKey}-`),
)
) {
pendingSequence = { prefix: sequenceKey, expires: Date.now() + 1000 };
e.preventDefault();
return;
}
if (
keyboardConfigLoaded &&
!ctx.state.playlistBrowserModalOpen &&
+37
View File
@@ -833,6 +833,43 @@
</div>
</div>
</div>
<div id="subtitleSelectionModal" class="modal hidden" aria-hidden="true">
<div
class="modal-content subsync-modal-content"
role="dialog"
aria-modal="true"
aria-labelledby="subtitleSelectionTitle"
>
<div class="modal-header">
<h2 id="subtitleSelectionTitle">Select subtitles</h2>
<button id="subtitleSelectionClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div class="subsync-form">
<label class="subsync-field">
<span>Primary subtitle</span>
<select id="subtitleSelectionPrimary"></select>
</label>
<label class="subsync-field">
<span>Secondary subtitle</span>
<select id="subtitleSelectionSecondary"></select>
</label>
</div>
<div
id="subtitleSelectionStatus"
class="runtime-options-status"
role="status"
aria-live="polite"
></div>
<div class="subsync-footer">
<button id="subtitleSelectionApply" class="kiku-confirm-button" type="button">
Apply
</button>
</div>
</div>
</div>
</div>
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
<div class="modal-content subsync-modal-content">
<div class="modal-header">
@@ -225,6 +225,8 @@ function describeSessionAction(
return 'Open jimaku';
case 'openTsukihime':
return 'Open TsukiHime';
case 'openSubtitleSelection':
return 'Select subtitle tracks';
case 'openSubtitleGeneration':
return 'Generate Japanese subtitles';
case 'openYoutubePicker':
@@ -268,6 +270,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
case 'openJimaku':
case 'openTsukihime':
case 'openCharacterDictionaryManager':
case 'openSubtitleSelection':
case 'openSubtitleGeneration':
case 'openControllerSelect':
case 'openControllerDebug':
+172
View File
@@ -0,0 +1,172 @@
import type { SubtitleSelectionState } from '../../shared/subtitle-selection';
import type { RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore';
import { createModalFocusGuard } from './modal-focus-guard';
function element<T extends HTMLElement>(id: string, constructor: new () => T): T {
const node = document.getElementById(id);
if (!(node instanceof constructor)) throw new Error(`Missing subtitle selection element: ${id}`);
return node;
}
export function createSubtitleSelectionModal(
ctx: RendererContext,
options: { syncSettingsModalSubtitleSuppression: () => void },
) {
const dom = {
modal: element('subtitleSelectionModal', HTMLDivElement),
primary: element('subtitleSelectionPrimary', HTMLSelectElement),
secondary: element('subtitleSelectionSecondary', HTMLSelectElement),
status: element('subtitleSelectionStatus', HTMLDivElement),
apply: element('subtitleSelectionApply', HTMLButtonElement),
close: element('subtitleSelectionClose', HTMLButtonElement),
};
let snapshot: SubtitleSelectionState | null = null;
let generation = 0;
let pending = false;
let priorFocus: Element | null = null;
const focus = createModalFocusGuard({
isOpen: () => ctx.state.subtitleSelectionModalOpen,
getModalRoot: () => dom.modal,
getPreferredFocusTargets: () => [dom.primary, dom.secondary, dom.apply],
getFallbackFocusTarget: () => dom.close,
isModalLayer: ctx.platform.isModalLayer,
});
function status(message: string, error = false): void {
dom.status.textContent = message;
dom.status.classList.toggle('error', error);
}
function updateControls(): void {
const disabled = pending || !snapshot;
dom.primary.disabled = disabled;
dom.secondary.disabled = disabled;
const duplicate = dom.primary.value !== 'no' && dom.primary.value === dom.secondary.value;
dom.apply.disabled = disabled || duplicate;
for (const option of dom.secondary.options)
option.disabled = option.value !== 'no' && option.value === dom.primary.value;
}
function populate(select: HTMLSelectElement, selected: number | null): void {
select.replaceChildren();
for (const track of [{ id: null, label: 'None' }, ...(snapshot?.tracks ?? [])]) {
const option = document.createElement('option');
option.value = track.id === null ? 'no' : String(track.id);
option.textContent = track.label;
select.append(option);
}
select.value = selected === null ? 'no' : String(selected);
}
async function refresh(openGeneration: number): Promise<void> {
try {
const next = await window.electronAPI.getSubtitleSelection();
if (generation !== openGeneration || !ctx.state.subtitleSelectionModalOpen) return;
snapshot = next;
populate(dom.primary, next.primary);
populate(dom.secondary, next.secondary);
status(
next.tracks.length
? 'Choose subtitle tracks, then apply.'
: 'No subtitle tracks loaded in this video.',
);
} catch (cause) {
if (generation !== openGeneration || !ctx.state.subtitleSelectionModalOpen) return;
status(cause instanceof Error ? cause.message : 'Could not read subtitle tracks.', true);
} finally {
if (generation === openGeneration && ctx.state.subtitleSelectionModalOpen) {
pending = false;
updateControls();
focus.focusFallbackTarget();
}
}
}
function open(): void {
if (ctx.state.subtitleSelectionModalOpen) return;
priorFocus = document.activeElement;
snapshot = null;
pending = true;
generation += 1;
populate(dom.primary, null);
populate(dom.secondary, null);
status('Loading subtitle tracks...');
updateControls();
ctx.state.subtitleSelectionModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
dom.modal.classList.remove('hidden');
dom.modal.setAttribute('aria-hidden', 'false');
syncOverlayMouseIgnoreState(ctx);
focus.attach();
focus.focusFallbackTarget();
window.electronAPI.notifyOverlayModalOpened('subtitle-selection');
void refresh(generation);
}
function close(): void {
if (!ctx.state.subtitleSelectionModalOpen) return;
generation += 1;
ctx.state.subtitleSelectionModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
dom.modal.classList.add('hidden');
dom.modal.setAttribute('aria-hidden', 'true');
focus.detach();
window.electronAPI.notifyOverlayModalClosed('subtitle-selection');
syncOverlayMouseIgnoreState(ctx);
if (priorFocus instanceof HTMLElement) priorFocus.focus({ preventScroll: true });
priorFocus = null;
}
async function apply(): Promise<void> {
if (dom.apply.disabled || pending || !snapshot) return;
const openGeneration = generation;
pending = true;
updateControls();
status('Applying subtitle tracks...');
try {
await window.electronAPI.applySubtitleSelection({
mediaPath: snapshot.mediaPath,
primary: dom.primary.value === 'no' ? null : Number(dom.primary.value),
secondary: dom.secondary.value === 'no' ? null : Number(dom.secondary.value),
});
if (generation === openGeneration) close();
} catch (cause) {
if (generation === openGeneration)
status(cause instanceof Error ? cause.message : 'Could not select subtitle tracks.', true);
} finally {
if (generation === openGeneration) {
pending = false;
updateControls();
}
}
}
function handleKeydown(event: KeyboardEvent): boolean {
if (event.key === 'Escape') {
event.preventDefault();
close();
} else if (
event.key === 'Enter' &&
!(event.target instanceof HTMLSelectElement) &&
event.target !== dom.close
) {
event.preventDefault();
void apply();
}
return true;
}
function wireDomEvents(): void {
dom.close.addEventListener('click', close);
dom.apply.addEventListener('click', () => void apply());
dom.primary.addEventListener('change', () => {
if (dom.primary.value !== 'no' && dom.primary.value === dom.secondary.value)
dom.secondary.value = 'no';
updateControls();
});
dom.secondary.addEventListener('change', updateControls);
}
return { open, close, handleKeydown, wireDomEvents, dispose: () => focus.detach() };
}
+1
View File
@@ -10,6 +10,7 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
state.youtubePickerModalOpen ||
state.kikuModalOpen ||
state.runtimeOptionsModalOpen ||
state.subtitleSelectionModalOpen ||
state.subsyncModalOpen ||
state.subtitleGenerationModalOpen ||
state.sessionHelpModalOpen,
+21 -1
View File
@@ -44,6 +44,7 @@ import { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selectio
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
import { createSubtitleSelectionModal } from './modals/subtitle-selection';
import { createSubsyncModal } from './modals/subsync.js';
import { createSubtitleGenerationModal } from './modals/subtitle-generation.js';
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
@@ -153,6 +154,12 @@ const modalDescriptors = [
close: () => characterDictionaryModal.closeCharacterDictionaryModal(),
suppressesSubtitles: true,
},
{
id: 'subtitle-selection',
isOpen: () => ctx.state.subtitleSelectionModalOpen,
close: () => subtitleSelectionModal.close(),
suppressesSubtitles: true,
},
{
id: 'subsync',
isOpen: () => ctx.state.subsyncModalOpen,
@@ -216,6 +223,9 @@ const characterDictionaryModal = createCharacterDictionaryModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const subtitleSelectionModal = createSubtitleSelectionModal(ctx, {
syncSettingsModalSubtitleSuppression,
});
const subsyncModal = createSubsyncModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
@@ -293,6 +303,7 @@ const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
const keyboardHandlers = createKeyboardHandlers(ctx, {
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
handleSubtitleSelectionKeydown: subtitleSelectionModal.handleKeydown,
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleSubtitleGenerationKeydown: subtitleGenerationModal.handleKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown,
@@ -624,6 +635,9 @@ function registerModalOpenHandlers(): void {
youtubePickerModal.closeYoutubePickerModal();
});
});
window.electronAPI.onSubtitleSelectionOpen(() => {
runGuarded('subtitle-selection:open', () => subtitleSelectionModal.open());
});
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
runGuarded('subsync:manual-open', () => {
subsyncModal.openSubsyncModal(payload);
@@ -849,6 +863,7 @@ async function init(): Promise<void> {
playlistBrowserModal.wireDomEvents();
kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents();
subtitleSelectionModal.wireDomEvents();
subsyncModal.wireDomEvents();
subtitleGenerationModal.wireDomEvents();
controllerSelectModal.wireDomEvents();
@@ -858,6 +873,7 @@ async function init(): Promise<void> {
subtitleSidebarModal.wireDomEvents();
characterDictionaryModal.wireDomEvents();
window.addEventListener('beforeunload', () => {
subtitleSelectionModal.dispose();
subtitleGenerationModal.dispose();
subtitleSidebarModal.disposeDomEvents();
});
@@ -867,9 +883,13 @@ async function init(): Promise<void> {
runtimeOptionsModal.updateRuntimeOptions(options);
});
});
window.electronAPI.onSessionBindingsChanged(keyboardHandlers.updateSessionBindings);
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
runGuarded('config:hot-reload', () => {
keyboardHandlers.updateSessionBindings(payload.sessionBindings);
void window.electronAPI
.getSessionBindings()
.then(keyboardHandlers.updateSessionBindings)
.catch((error: unknown) => console.error('Could not refresh session bindings', error));
void keyboardHandlers.refreshConfiguredShortcuts();
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
subtitleRenderer.updatePrimarySubMode(payload.primarySubMode);
+2
View File
@@ -89,6 +89,7 @@ export type RendererState = {
characterDictionaryStatus: string;
subsyncModalOpen: boolean;
subtitleSelectionModalOpen: boolean;
subtitleGenerationModalOpen: boolean;
subsyncSubtitleTracks: SubsyncSubtitleTrack[];
subsyncSubmitting: boolean;
@@ -223,6 +224,7 @@ export function createRendererState(): RendererState {
characterDictionaryStatus: '',
subsyncModalOpen: false,
subtitleSelectionModalOpen: false,
subtitleGenerationModalOpen: false,
subsyncSubtitleTracks: [],
subsyncSubmitting: false,
+10
View File
@@ -3228,6 +3228,16 @@ iframe[id^='yomitan-popup'],
padding: 8px 10px;
}
.subsync-field select:focus-visible {
outline: 2px solid var(--ctp-blue);
outline-offset: 2px;
}
#subtitleSelectionModal .kiku-confirm-button:disabled {
opacity: 0.5;
cursor: default;
}
.subsync-footer {
display: flex;
justify-content: flex-end;