mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 05:16:23 -07:00
feat(overlay): add optional subtitle selection modal (#265)
This commit is contained in:
@@ -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':
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
Reference in New Issue
Block a user