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
@@ -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() };
}