mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
384
src/renderer/modals/jimaku.ts
Normal file
384
src/renderer/modals/jimaku.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import type {
|
||||
JimakuApiResponse,
|
||||
JimakuDownloadResult,
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
JimakuMediaInfo,
|
||||
} from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
export function createJimakuModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
function setJimakuStatus(message: string, isError = false): void {
|
||||
ctx.dom.jimakuStatus.textContent = message;
|
||||
ctx.dom.jimakuStatus.style.color = isError
|
||||
? 'rgba(255, 120, 120, 0.95)'
|
||||
: 'rgba(255, 255, 255, 0.8)';
|
||||
}
|
||||
|
||||
function resetJimakuLists(): void {
|
||||
ctx.state.jimakuEntries = [];
|
||||
ctx.state.jimakuFiles = [];
|
||||
ctx.state.selectedEntryIndex = 0;
|
||||
ctx.state.selectedFileIndex = 0;
|
||||
ctx.state.currentEntryId = null;
|
||||
|
||||
ctx.dom.jimakuEntriesList.innerHTML = '';
|
||||
ctx.dom.jimakuFilesList.innerHTML = '';
|
||||
ctx.dom.jimakuEntriesSection.classList.add('hidden');
|
||||
ctx.dom.jimakuFilesSection.classList.add('hidden');
|
||||
ctx.dom.jimakuBroadenButton.classList.add('hidden');
|
||||
}
|
||||
|
||||
function formatEntryLabel(entry: JimakuEntry): string {
|
||||
if (entry.english_name && entry.english_name !== entry.name) {
|
||||
return `${entry.name} / ${entry.english_name}`;
|
||||
}
|
||||
return entry.name;
|
||||
}
|
||||
|
||||
function renderEntries(): void {
|
||||
ctx.dom.jimakuEntriesList.innerHTML = '';
|
||||
if (ctx.state.jimakuEntries.length === 0) {
|
||||
ctx.dom.jimakuEntriesSection.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.jimakuEntriesSection.classList.remove('hidden');
|
||||
ctx.state.jimakuEntries.forEach((entry, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = formatEntryLabel(entry);
|
||||
|
||||
if (entry.japanese_name) {
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'jimaku-subtext';
|
||||
sub.textContent = entry.japanese_name;
|
||||
li.appendChild(sub);
|
||||
}
|
||||
|
||||
if (index === ctx.state.selectedEntryIndex) {
|
||||
li.classList.add('active');
|
||||
}
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
selectEntry(index);
|
||||
});
|
||||
|
||||
ctx.dom.jimakuEntriesList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(size: number): string {
|
||||
if (!Number.isFinite(size)) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let value = size;
|
||||
let idx = 0;
|
||||
while (value >= 1024 && idx < units.length - 1) {
|
||||
value /= 1024;
|
||||
idx += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
|
||||
}
|
||||
|
||||
function renderFiles(): void {
|
||||
ctx.dom.jimakuFilesList.innerHTML = '';
|
||||
if (ctx.state.jimakuFiles.length === 0) {
|
||||
ctx.dom.jimakuFilesSection.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.jimakuFilesSection.classList.remove('hidden');
|
||||
ctx.state.jimakuFiles.forEach((file, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = file.name;
|
||||
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'jimaku-subtext';
|
||||
sub.textContent = `${formatBytes(file.size)} • ${file.last_modified}`;
|
||||
li.appendChild(sub);
|
||||
|
||||
if (index === ctx.state.selectedFileIndex) {
|
||||
li.classList.add('active');
|
||||
}
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
void selectFile(index);
|
||||
});
|
||||
|
||||
ctx.dom.jimakuFilesList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function getSearchQuery(): { query: string; episode: number | null } {
|
||||
const title = ctx.dom.jimakuTitleInput.value.trim();
|
||||
const episode = ctx.dom.jimakuEpisodeInput.value
|
||||
? Number.parseInt(ctx.dom.jimakuEpisodeInput.value, 10)
|
||||
: null;
|
||||
return { query: title, episode: Number.isFinite(episode) ? episode : null };
|
||||
}
|
||||
|
||||
async function performJimakuSearch(): Promise<void> {
|
||||
const { query, episode } = getSearchQuery();
|
||||
if (!query) {
|
||||
setJimakuStatus('Enter a title before searching.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
resetJimakuLists();
|
||||
setJimakuStatus('Searching Jimaku...');
|
||||
ctx.state.currentEpisodeFilter = episode;
|
||||
|
||||
const response: JimakuApiResponse<JimakuEntry[]> = await window.electronAPI.jimakuSearchEntries(
|
||||
{ query },
|
||||
);
|
||||
if (!response.ok) {
|
||||
const retry = response.error.retryAfter
|
||||
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
|
||||
: '';
|
||||
setJimakuStatus(`${response.error.error}${retry}`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.jimakuEntries = response.data;
|
||||
ctx.state.selectedEntryIndex = 0;
|
||||
|
||||
if (ctx.state.jimakuEntries.length === 0) {
|
||||
setJimakuStatus('No entries found.');
|
||||
return;
|
||||
}
|
||||
|
||||
setJimakuStatus('Select an entry.');
|
||||
renderEntries();
|
||||
if (ctx.state.jimakuEntries.length === 1) {
|
||||
void selectEntry(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles(entryId: number, episode: number | null): Promise<void> {
|
||||
setJimakuStatus('Loading files...');
|
||||
ctx.state.jimakuFiles = [];
|
||||
ctx.state.selectedFileIndex = 0;
|
||||
|
||||
ctx.dom.jimakuFilesList.innerHTML = '';
|
||||
ctx.dom.jimakuFilesSection.classList.add('hidden');
|
||||
|
||||
const response: JimakuApiResponse<JimakuFileEntry[]> = await window.electronAPI.jimakuListFiles(
|
||||
{
|
||||
entryId,
|
||||
episode,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const retry = response.error.retryAfter
|
||||
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
|
||||
: '';
|
||||
setJimakuStatus(`${response.error.error}${retry}`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.jimakuFiles = response.data;
|
||||
if (ctx.state.jimakuFiles.length === 0) {
|
||||
if (episode !== null) {
|
||||
setJimakuStatus('No files found for this episode.');
|
||||
ctx.dom.jimakuBroadenButton.classList.remove('hidden');
|
||||
} else {
|
||||
setJimakuStatus('No files found.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.jimakuBroadenButton.classList.add('hidden');
|
||||
setJimakuStatus('Select a subtitle file.');
|
||||
renderFiles();
|
||||
if (ctx.state.jimakuFiles.length === 1) {
|
||||
await selectFile(0);
|
||||
}
|
||||
}
|
||||
|
||||
function selectEntry(index: number): void {
|
||||
if (index < 0 || index >= ctx.state.jimakuEntries.length) return;
|
||||
|
||||
ctx.state.selectedEntryIndex = index;
|
||||
ctx.state.currentEntryId = ctx.state.jimakuEntries[index]!.id;
|
||||
renderEntries();
|
||||
|
||||
if (ctx.state.currentEntryId !== null) {
|
||||
void loadFiles(ctx.state.currentEntryId, ctx.state.currentEpisodeFilter);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile(index: number): Promise<void> {
|
||||
if (index < 0 || index >= ctx.state.jimakuFiles.length) return;
|
||||
|
||||
ctx.state.selectedFileIndex = index;
|
||||
renderFiles();
|
||||
|
||||
if (ctx.state.currentEntryId === null) {
|
||||
setJimakuStatus('Select an entry first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = ctx.state.jimakuFiles[index]!;
|
||||
setJimakuStatus('Downloading subtitle...');
|
||||
|
||||
const result: JimakuDownloadResult = await window.electronAPI.jimakuDownloadFile({
|
||||
entryId: ctx.state.currentEntryId,
|
||||
url: file.url,
|
||||
name: file.name,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
setJimakuStatus(`Downloaded and loaded: ${result.path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const retry = result.error.retryAfter
|
||||
? ` Retry after ${result.error.retryAfter.toFixed(1)}s.`
|
||||
: '';
|
||||
setJimakuStatus(`${result.error.error}${retry}`, true);
|
||||
}
|
||||
|
||||
function isTextInputFocused(): boolean {
|
||||
const active = document.activeElement;
|
||||
if (!active) return false;
|
||||
const tag = active.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea';
|
||||
}
|
||||
|
||||
function openJimakuModal(): void {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
if (ctx.state.jimakuModalOpen) return;
|
||||
|
||||
ctx.state.jimakuModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.jimakuModal.classList.remove('hidden');
|
||||
ctx.dom.jimakuModal.setAttribute('aria-hidden', 'false');
|
||||
|
||||
setJimakuStatus('Loading media info...');
|
||||
resetJimakuLists();
|
||||
|
||||
window.electronAPI
|
||||
.getJimakuMediaInfo()
|
||||
.then((info: JimakuMediaInfo) => {
|
||||
ctx.dom.jimakuTitleInput.value = info.title || '';
|
||||
ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : '';
|
||||
ctx.dom.jimakuEpisodeInput.value = info.episode ? String(info.episode) : '';
|
||||
ctx.state.currentEpisodeFilter = info.episode ?? null;
|
||||
|
||||
if (info.confidence === 'high' && info.title && info.episode) {
|
||||
void performJimakuSearch();
|
||||
} else if (info.title) {
|
||||
setJimakuStatus('Check title/season/episode and press Search.');
|
||||
} else {
|
||||
setJimakuStatus('Enter title/season/episode and press Search.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setJimakuStatus('Failed to load media info.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function closeJimakuModal(): void {
|
||||
if (!ctx.state.jimakuModalOpen) return;
|
||||
|
||||
ctx.state.jimakuModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.jimakuModal.classList.add('hidden');
|
||||
ctx.dom.jimakuModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('jimaku');
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
resetJimakuLists();
|
||||
}
|
||||
|
||||
function handleJimakuKeydown(e: KeyboardEvent): boolean {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeJimakuModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTextInputFocused()) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void performJimakuSearch();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (ctx.state.jimakuFiles.length > 0) {
|
||||
ctx.state.selectedFileIndex = Math.min(
|
||||
ctx.state.jimakuFiles.length - 1,
|
||||
ctx.state.selectedFileIndex + 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.jimakuEntries.length > 0) {
|
||||
ctx.state.selectedEntryIndex = Math.min(
|
||||
ctx.state.jimakuEntries.length - 1,
|
||||
ctx.state.selectedEntryIndex + 1,
|
||||
);
|
||||
renderEntries();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (ctx.state.jimakuFiles.length > 0) {
|
||||
ctx.state.selectedFileIndex = Math.max(0, ctx.state.selectedFileIndex - 1);
|
||||
renderFiles();
|
||||
} else if (ctx.state.jimakuEntries.length > 0) {
|
||||
ctx.state.selectedEntryIndex = Math.max(0, ctx.state.selectedEntryIndex - 1);
|
||||
renderEntries();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (ctx.state.jimakuFiles.length > 0) {
|
||||
void selectFile(ctx.state.selectedFileIndex);
|
||||
} else if (ctx.state.jimakuEntries.length > 0) {
|
||||
selectEntry(ctx.state.selectedEntryIndex);
|
||||
} else {
|
||||
void performJimakuSearch();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.jimakuSearchButton.addEventListener('click', () => {
|
||||
void performJimakuSearch();
|
||||
});
|
||||
ctx.dom.jimakuCloseButton.addEventListener('click', () => {
|
||||
closeJimakuModal();
|
||||
});
|
||||
ctx.dom.jimakuBroadenButton.addEventListener('click', () => {
|
||||
if (ctx.state.currentEntryId !== null) {
|
||||
ctx.dom.jimakuBroadenButton.classList.add('hidden');
|
||||
void loadFiles(ctx.state.currentEntryId, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeJimakuModal,
|
||||
handleJimakuKeydown,
|
||||
openJimakuModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
299
src/renderer/modals/kiku.ts
Normal file
299
src/renderer/modals/kiku.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import type {
|
||||
KikuDuplicateCardInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuMergePreviewResponse,
|
||||
} from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
export function createKikuModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
function formatMediaMeta(card: KikuDuplicateCardInfo): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(card.hasAudio ? 'Audio: Yes' : 'Audio: No');
|
||||
parts.push(card.hasImage ? 'Image: Yes' : 'Image: No');
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
function updateKikuCardSelection(): void {
|
||||
ctx.dom.kikuCard1.classList.toggle('active', ctx.state.kikuSelectedCard === 1);
|
||||
ctx.dom.kikuCard2.classList.toggle('active', ctx.state.kikuSelectedCard === 2);
|
||||
}
|
||||
|
||||
function setKikuModalStep(step: 'select' | 'preview'): void {
|
||||
ctx.state.kikuModalStep = step;
|
||||
const isSelect = step === 'select';
|
||||
ctx.dom.kikuSelectionStep.classList.toggle('hidden', !isSelect);
|
||||
ctx.dom.kikuPreviewStep.classList.toggle('hidden', isSelect);
|
||||
ctx.dom.kikuHint.textContent = isSelect
|
||||
? 'Press 1 or 2 to select · Enter to continue · Esc to cancel'
|
||||
: 'Enter to confirm merge · Backspace to go back · Esc to cancel';
|
||||
}
|
||||
|
||||
function updateKikuPreviewToggle(): void {
|
||||
ctx.dom.kikuPreviewCompactButton.classList.toggle(
|
||||
'active',
|
||||
ctx.state.kikuPreviewMode === 'compact',
|
||||
);
|
||||
ctx.dom.kikuPreviewFullButton.classList.toggle('active', ctx.state.kikuPreviewMode === 'full');
|
||||
}
|
||||
|
||||
function renderKikuPreview(): void {
|
||||
const payload =
|
||||
ctx.state.kikuPreviewMode === 'compact'
|
||||
? ctx.state.kikuPreviewCompactData
|
||||
: ctx.state.kikuPreviewFullData;
|
||||
ctx.dom.kikuPreviewJson.textContent = payload ? JSON.stringify(payload, null, 2) : '{}';
|
||||
updateKikuPreviewToggle();
|
||||
}
|
||||
|
||||
function setKikuPreviewError(message: string | null): void {
|
||||
if (!message) {
|
||||
ctx.dom.kikuPreviewError.textContent = '';
|
||||
ctx.dom.kikuPreviewError.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.kikuPreviewError.textContent = message;
|
||||
ctx.dom.kikuPreviewError.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function openKikuFieldGroupingModal(data: {
|
||||
original: KikuDuplicateCardInfo;
|
||||
duplicate: KikuDuplicateCardInfo;
|
||||
}): void {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
if (ctx.state.kikuModalOpen) return;
|
||||
|
||||
ctx.state.kikuModalOpen = true;
|
||||
ctx.state.kikuOriginalData = data.original;
|
||||
ctx.state.kikuDuplicateData = data.duplicate;
|
||||
ctx.state.kikuSelectedCard = 1;
|
||||
|
||||
ctx.dom.kikuCard1Expression.textContent = data.original.expression;
|
||||
ctx.dom.kikuCard1Sentence.textContent = data.original.sentencePreview || '(no sentence)';
|
||||
ctx.dom.kikuCard1Meta.textContent = formatMediaMeta(data.original);
|
||||
|
||||
ctx.dom.kikuCard2Expression.textContent = data.duplicate.expression;
|
||||
ctx.dom.kikuCard2Sentence.textContent = data.duplicate.sentencePreview || '(current subtitle)';
|
||||
ctx.dom.kikuCard2Meta.textContent = formatMediaMeta(data.duplicate);
|
||||
|
||||
ctx.dom.kikuDeleteDuplicateCheckbox.checked = true;
|
||||
ctx.state.kikuPendingChoice = null;
|
||||
ctx.state.kikuPreviewCompactData = null;
|
||||
ctx.state.kikuPreviewFullData = null;
|
||||
ctx.state.kikuPreviewMode = 'compact';
|
||||
|
||||
renderKikuPreview();
|
||||
setKikuPreviewError(null);
|
||||
setKikuModalStep('select');
|
||||
updateKikuCardSelection();
|
||||
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.kikuModal.classList.remove('hidden');
|
||||
ctx.dom.kikuModal.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
function closeKikuFieldGroupingModal(): void {
|
||||
if (!ctx.state.kikuModalOpen) return;
|
||||
|
||||
ctx.state.kikuModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
ctx.dom.kikuModal.classList.add('hidden');
|
||||
ctx.dom.kikuModal.setAttribute('aria-hidden', 'true');
|
||||
|
||||
setKikuPreviewError(null);
|
||||
ctx.dom.kikuPreviewJson.textContent = '';
|
||||
|
||||
ctx.state.kikuPendingChoice = null;
|
||||
ctx.state.kikuPreviewCompactData = null;
|
||||
ctx.state.kikuPreviewFullData = null;
|
||||
ctx.state.kikuPreviewMode = 'compact';
|
||||
setKikuModalStep('select');
|
||||
ctx.state.kikuOriginalData = null;
|
||||
ctx.state.kikuDuplicateData = null;
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmKikuSelection(): Promise<void> {
|
||||
if (!ctx.state.kikuOriginalData || !ctx.state.kikuDuplicateData) return;
|
||||
|
||||
const keepData =
|
||||
ctx.state.kikuSelectedCard === 1 ? ctx.state.kikuOriginalData : ctx.state.kikuDuplicateData;
|
||||
const deleteData =
|
||||
ctx.state.kikuSelectedCard === 1 ? ctx.state.kikuDuplicateData : ctx.state.kikuOriginalData;
|
||||
|
||||
const choice: KikuFieldGroupingChoice = {
|
||||
keepNoteId: keepData.noteId,
|
||||
deleteNoteId: deleteData.noteId,
|
||||
deleteDuplicate: ctx.dom.kikuDeleteDuplicateCheckbox.checked,
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
ctx.state.kikuPendingChoice = choice;
|
||||
setKikuPreviewError(null);
|
||||
ctx.dom.kikuConfirmButton.disabled = true;
|
||||
|
||||
try {
|
||||
const preview: KikuMergePreviewResponse = await window.electronAPI.kikuBuildMergePreview({
|
||||
keepNoteId: choice.keepNoteId,
|
||||
deleteNoteId: choice.deleteNoteId,
|
||||
deleteDuplicate: choice.deleteDuplicate,
|
||||
});
|
||||
|
||||
if (!preview.ok) {
|
||||
setKikuPreviewError(preview.error || 'Failed to build merge preview');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.kikuPreviewCompactData = preview.compact || {};
|
||||
ctx.state.kikuPreviewFullData = preview.full || {};
|
||||
ctx.state.kikuPreviewMode = 'compact';
|
||||
renderKikuPreview();
|
||||
setKikuModalStep('preview');
|
||||
} finally {
|
||||
ctx.dom.kikuConfirmButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmKikuMerge(): void {
|
||||
if (!ctx.state.kikuPendingChoice) return;
|
||||
window.electronAPI.kikuFieldGroupingRespond(ctx.state.kikuPendingChoice);
|
||||
closeKikuFieldGroupingModal();
|
||||
}
|
||||
|
||||
function goBackFromKikuPreview(): void {
|
||||
setKikuPreviewError(null);
|
||||
setKikuModalStep('select');
|
||||
}
|
||||
|
||||
function cancelKikuFieldGrouping(): void {
|
||||
const choice: KikuFieldGroupingChoice = {
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
};
|
||||
|
||||
window.electronAPI.kikuFieldGroupingRespond(choice);
|
||||
closeKikuFieldGroupingModal();
|
||||
}
|
||||
|
||||
function handleKikuKeydown(e: KeyboardEvent): boolean {
|
||||
if (ctx.state.kikuModalStep === 'preview') {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelKikuFieldGrouping();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
goBackFromKikuPreview();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
confirmKikuMerge();
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelKikuFieldGrouping();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === '1') {
|
||||
e.preventDefault();
|
||||
ctx.state.kikuSelectedCard = 1;
|
||||
updateKikuCardSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === '2') {
|
||||
e.preventDefault();
|
||||
ctx.state.kikuSelectedCard = 2;
|
||||
updateKikuCardSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
ctx.state.kikuSelectedCard = ctx.state.kikuSelectedCard === 1 ? 2 : 1;
|
||||
updateKikuCardSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void confirmKikuSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.kikuCard1.addEventListener('click', () => {
|
||||
ctx.state.kikuSelectedCard = 1;
|
||||
updateKikuCardSelection();
|
||||
});
|
||||
ctx.dom.kikuCard1.addEventListener('dblclick', () => {
|
||||
ctx.state.kikuSelectedCard = 1;
|
||||
void confirmKikuSelection();
|
||||
});
|
||||
|
||||
ctx.dom.kikuCard2.addEventListener('click', () => {
|
||||
ctx.state.kikuSelectedCard = 2;
|
||||
updateKikuCardSelection();
|
||||
});
|
||||
ctx.dom.kikuCard2.addEventListener('dblclick', () => {
|
||||
ctx.state.kikuSelectedCard = 2;
|
||||
void confirmKikuSelection();
|
||||
});
|
||||
|
||||
ctx.dom.kikuConfirmButton.addEventListener('click', () => {
|
||||
void confirmKikuSelection();
|
||||
});
|
||||
ctx.dom.kikuCancelButton.addEventListener('click', () => {
|
||||
cancelKikuFieldGrouping();
|
||||
});
|
||||
ctx.dom.kikuBackButton.addEventListener('click', () => {
|
||||
goBackFromKikuPreview();
|
||||
});
|
||||
ctx.dom.kikuFinalConfirmButton.addEventListener('click', () => {
|
||||
confirmKikuMerge();
|
||||
});
|
||||
ctx.dom.kikuFinalCancelButton.addEventListener('click', () => {
|
||||
cancelKikuFieldGrouping();
|
||||
});
|
||||
|
||||
ctx.dom.kikuPreviewCompactButton.addEventListener('click', () => {
|
||||
ctx.state.kikuPreviewMode = 'compact';
|
||||
renderKikuPreview();
|
||||
});
|
||||
ctx.dom.kikuPreviewFullButton.addEventListener('click', () => {
|
||||
ctx.state.kikuPreviewMode = 'full';
|
||||
renderKikuPreview();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
cancelKikuFieldGrouping,
|
||||
closeKikuFieldGroupingModal,
|
||||
handleKikuKeydown,
|
||||
openKikuFieldGroupingModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
258
src/renderer/modals/runtime-options.ts
Normal file
258
src/renderer/modals/runtime-options.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { RuntimeOptionApplyResult, RuntimeOptionState, RuntimeOptionValue } from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
export function createRuntimeOptionsModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
function formatRuntimeOptionValue(value: RuntimeOptionValue): string {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'On' : 'Off';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function setRuntimeOptionsStatus(message: string, isError = false): void {
|
||||
ctx.dom.runtimeOptionsStatus.textContent = message;
|
||||
ctx.dom.runtimeOptionsStatus.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function getRuntimeOptionDisplayValue(option: RuntimeOptionState): RuntimeOptionValue {
|
||||
return ctx.state.runtimeOptionDraftValues.get(option.id) ?? option.value;
|
||||
}
|
||||
|
||||
function getSelectedRuntimeOption(): RuntimeOptionState | null {
|
||||
if (ctx.state.runtimeOptions.length === 0) return null;
|
||||
if (ctx.state.runtimeOptionSelectedIndex < 0) return null;
|
||||
if (ctx.state.runtimeOptionSelectedIndex >= ctx.state.runtimeOptions.length) {
|
||||
return null;
|
||||
}
|
||||
return ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex] ?? null;
|
||||
}
|
||||
|
||||
function renderRuntimeOptionsList(): void {
|
||||
ctx.dom.runtimeOptionsList.innerHTML = '';
|
||||
ctx.state.runtimeOptions.forEach((option, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'runtime-options-item';
|
||||
li.classList.toggle('active', index === ctx.state.runtimeOptionSelectedIndex);
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'runtime-options-label';
|
||||
label.textContent = option.label;
|
||||
|
||||
const value = document.createElement('div');
|
||||
value.className = 'runtime-options-value';
|
||||
value.textContent = `Value: ${formatRuntimeOptionValue(getRuntimeOptionDisplayValue(option))}`;
|
||||
value.title = 'Click to cycle value, right-click to cycle backward';
|
||||
|
||||
const allowed = document.createElement('div');
|
||||
allowed.className = 'runtime-options-allowed';
|
||||
allowed.textContent = `Allowed: ${option.allowedValues
|
||||
.map((entry) => formatRuntimeOptionValue(entry))
|
||||
.join(' | ')}`;
|
||||
|
||||
li.appendChild(label);
|
||||
li.appendChild(value);
|
||||
li.appendChild(allowed);
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
ctx.state.runtimeOptionSelectedIndex = index;
|
||||
renderRuntimeOptionsList();
|
||||
});
|
||||
li.addEventListener('dblclick', () => {
|
||||
ctx.state.runtimeOptionSelectedIndex = index;
|
||||
void applySelectedRuntimeOption();
|
||||
});
|
||||
|
||||
value.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
ctx.state.runtimeOptionSelectedIndex = index;
|
||||
cycleRuntimeDraftValue(1);
|
||||
});
|
||||
value.addEventListener('contextmenu', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ctx.state.runtimeOptionSelectedIndex = index;
|
||||
cycleRuntimeDraftValue(-1);
|
||||
});
|
||||
|
||||
ctx.dom.runtimeOptionsList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function updateRuntimeOptions(optionsList: RuntimeOptionState[]): void {
|
||||
const previousId =
|
||||
ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex]?.id ??
|
||||
ctx.state.runtimeOptions[0]?.id;
|
||||
|
||||
ctx.state.runtimeOptions = optionsList;
|
||||
ctx.state.runtimeOptionDraftValues.clear();
|
||||
|
||||
for (const option of ctx.state.runtimeOptions) {
|
||||
ctx.state.runtimeOptionDraftValues.set(option.id, option.value);
|
||||
}
|
||||
|
||||
const nextIndex = ctx.state.runtimeOptions.findIndex((option) => option.id === previousId);
|
||||
ctx.state.runtimeOptionSelectedIndex = nextIndex >= 0 ? nextIndex : 0;
|
||||
|
||||
renderRuntimeOptionsList();
|
||||
}
|
||||
|
||||
function cycleRuntimeDraftValue(direction: 1 | -1): void {
|
||||
const option = getSelectedRuntimeOption();
|
||||
if (!option || option.allowedValues.length === 0) return;
|
||||
|
||||
const currentValue = getRuntimeOptionDisplayValue(option);
|
||||
const currentIndex = option.allowedValues.findIndex((value) => value === currentValue);
|
||||
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||
const nextIndex =
|
||||
direction === 1
|
||||
? (safeIndex + 1) % option.allowedValues.length
|
||||
: (safeIndex - 1 + option.allowedValues.length) % option.allowedValues.length;
|
||||
|
||||
const nextValue = option.allowedValues[nextIndex];
|
||||
if (nextValue === undefined) return;
|
||||
ctx.state.runtimeOptionDraftValues.set(option.id, nextValue);
|
||||
renderRuntimeOptionsList();
|
||||
setRuntimeOptionsStatus(`Selected ${option.label}: ${formatRuntimeOptionValue(nextValue)}`);
|
||||
}
|
||||
|
||||
async function applySelectedRuntimeOption(): Promise<void> {
|
||||
const option = getSelectedRuntimeOption();
|
||||
if (!option) return;
|
||||
|
||||
const nextValue = getRuntimeOptionDisplayValue(option);
|
||||
const result: RuntimeOptionApplyResult = await window.electronAPI.setRuntimeOptionValue(
|
||||
option.id,
|
||||
nextValue,
|
||||
);
|
||||
if (!result.ok) {
|
||||
setRuntimeOptionsStatus(result.error || 'Failed to apply option', true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.option) {
|
||||
ctx.state.runtimeOptionDraftValues.set(result.option.id, result.option.value);
|
||||
}
|
||||
|
||||
const latest = await window.electronAPI.getRuntimeOptions();
|
||||
updateRuntimeOptions(latest);
|
||||
setRuntimeOptionsStatus(result.osdMessage || 'Option applied.');
|
||||
}
|
||||
|
||||
function closeRuntimeOptionsModal(): void {
|
||||
if (!ctx.state.runtimeOptionsModalOpen) return;
|
||||
|
||||
ctx.state.runtimeOptionsModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
ctx.dom.runtimeOptionsModal.classList.add('hidden');
|
||||
ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('runtime-options');
|
||||
|
||||
setRuntimeOptionsStatus('');
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
}
|
||||
|
||||
async function openRuntimeOptionsModal(): Promise<void> {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
|
||||
const optionsList = await window.electronAPI.getRuntimeOptions();
|
||||
updateRuntimeOptions(optionsList);
|
||||
|
||||
ctx.state.runtimeOptionsModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.runtimeOptionsModal.classList.remove('hidden');
|
||||
ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'false');
|
||||
|
||||
setRuntimeOptionsStatus(
|
||||
'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
|
||||
);
|
||||
}
|
||||
|
||||
function handleRuntimeOptionsKeydown(e: KeyboardEvent): boolean {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeRuntimeOptionsModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
e.key === 'ArrowDown' ||
|
||||
e.key === 'j' ||
|
||||
e.key === 'J' ||
|
||||
(e.ctrlKey && (e.key === 'n' || e.key === 'N'))
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (ctx.state.runtimeOptions.length > 0) {
|
||||
ctx.state.runtimeOptionSelectedIndex = Math.min(
|
||||
ctx.state.runtimeOptions.length - 1,
|
||||
ctx.state.runtimeOptionSelectedIndex + 1,
|
||||
);
|
||||
renderRuntimeOptionsList();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
e.key === 'ArrowUp' ||
|
||||
e.key === 'k' ||
|
||||
e.key === 'K' ||
|
||||
(e.ctrlKey && (e.key === 'p' || e.key === 'P'))
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (ctx.state.runtimeOptions.length > 0) {
|
||||
ctx.state.runtimeOptionSelectedIndex = Math.max(
|
||||
0,
|
||||
ctx.state.runtimeOptionSelectedIndex - 1,
|
||||
);
|
||||
renderRuntimeOptionsList();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight' || e.key === 'l' || e.key === 'L') {
|
||||
e.preventDefault();
|
||||
cycleRuntimeDraftValue(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft' || e.key === 'h' || e.key === 'H') {
|
||||
e.preventDefault();
|
||||
cycleRuntimeDraftValue(-1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void applySelectedRuntimeOption();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.runtimeOptionsClose.addEventListener('click', () => {
|
||||
closeRuntimeOptionsModal();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeRuntimeOptionsModal,
|
||||
handleRuntimeOptionsKeydown,
|
||||
openRuntimeOptionsModal,
|
||||
setRuntimeOptionsStatus,
|
||||
updateRuntimeOptions,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
759
src/renderer/modals/session-help.ts
Normal file
759
src/renderer/modals/session-help.ts
Normal file
@@ -0,0 +1,759 @@
|
||||
import type { Keybinding } from '../../types';
|
||||
import type { ShortcutsConfig } from '../../types';
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
type SessionHelpBindingInfo = {
|
||||
bindingKey: 'KeyH' | 'KeyK';
|
||||
fallbackUsed: boolean;
|
||||
fallbackUnavailable: boolean;
|
||||
};
|
||||
|
||||
type SessionHelpItem = {
|
||||
shortcut: string;
|
||||
action: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
type SessionHelpSection = {
|
||||
title: string;
|
||||
rows: SessionHelpItem[];
|
||||
};
|
||||
type RuntimeShortcutConfig = Omit<Required<ShortcutsConfig>, 'multiCopyTimeoutMs'>;
|
||||
|
||||
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
// Fallbacks mirror the session overlay's default subtitle/word color scheme.
|
||||
const FALLBACK_COLORS = {
|
||||
knownWordColor: '#a6da95',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
jlptN1Color: '#ed8796',
|
||||
jlptN2Color: '#f5a97f',
|
||||
jlptN3Color: '#f9e2af',
|
||||
jlptN4Color: '#a6e3a1',
|
||||
jlptN5Color: '#8aadf4',
|
||||
};
|
||||
|
||||
const KEY_NAME_MAP: Record<string, string> = {
|
||||
Space: 'Space',
|
||||
ArrowUp: '↑',
|
||||
ArrowDown: '↓',
|
||||
ArrowLeft: '←',
|
||||
ArrowRight: '→',
|
||||
Escape: 'Esc',
|
||||
Tab: 'Tab',
|
||||
Enter: 'Enter',
|
||||
CommandOrControl: 'Cmd/Ctrl',
|
||||
Ctrl: 'Ctrl',
|
||||
Control: 'Ctrl',
|
||||
Command: 'Cmd',
|
||||
Cmd: 'Cmd',
|
||||
Shift: 'Shift',
|
||||
Alt: 'Alt',
|
||||
Super: 'Meta',
|
||||
Meta: 'Meta',
|
||||
Backspace: 'Backspace',
|
||||
};
|
||||
|
||||
function normalizeColor(value: unknown, fallback: string): string {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const next = value.trim();
|
||||
return HEX_COLOR_RE.test(next) ? next : fallback;
|
||||
}
|
||||
|
||||
function normalizeKeyToken(token: string): string {
|
||||
if (KEY_NAME_MAP[token]) return KEY_NAME_MAP[token];
|
||||
if (token.startsWith('Key')) return token.slice(3);
|
||||
if (token.startsWith('Digit')) return token.slice(5);
|
||||
if (token.startsWith('Numpad')) return token.slice(6);
|
||||
return token;
|
||||
}
|
||||
|
||||
function formatKeybinding(rawBinding: string): string {
|
||||
const parts = rawBinding.split('+');
|
||||
const key = parts.pop();
|
||||
if (!key) return rawBinding;
|
||||
const normalized = [...parts, normalizeKeyToken(key)];
|
||||
return normalized.join(' + ');
|
||||
}
|
||||
|
||||
const OVERLAY_SHORTCUTS: Array<{
|
||||
key: keyof RuntimeShortcutConfig;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: 'copySubtitle', label: 'Copy subtitle' },
|
||||
{ key: 'copySubtitleMultiple', label: 'Copy subtitle (multi)' },
|
||||
{
|
||||
key: 'updateLastCardFromClipboard',
|
||||
label: 'Update last card from clipboard',
|
||||
},
|
||||
{ key: 'triggerFieldGrouping', label: 'Trigger field grouping' },
|
||||
{ key: 'triggerSubsync', label: 'Open subtitle sync controls' },
|
||||
{ key: 'mineSentence', label: 'Mine sentence' },
|
||||
{ key: 'mineSentenceMultiple', label: 'Mine sentence (multi)' },
|
||||
{ key: 'toggleSecondarySub', label: 'Toggle secondary subtitle mode' },
|
||||
{ key: 'markAudioCard', label: 'Mark audio card' },
|
||||
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
|
||||
{ key: 'openJimaku', label: 'Open jimaku' },
|
||||
{ key: 'toggleVisibleOverlayGlobal', label: 'Show/hide visible overlay' },
|
||||
{ key: 'toggleInvisibleOverlayGlobal', label: 'Show/hide invisible overlay' },
|
||||
];
|
||||
|
||||
function buildOverlayShortcutSections(shortcuts: RuntimeShortcutConfig): SessionHelpSection[] {
|
||||
const rows: SessionHelpItem[] = [];
|
||||
|
||||
for (const shortcut of OVERLAY_SHORTCUTS) {
|
||||
const keybind = shortcuts[shortcut.key];
|
||||
if (typeof keybind !== 'string') continue;
|
||||
if (keybind.trim().length === 0) continue;
|
||||
|
||||
rows.push({
|
||||
shortcut: formatKeybinding(keybind),
|
||||
action: shortcut.label,
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
return [{ title: 'Overlay shortcuts', rows }];
|
||||
}
|
||||
|
||||
function describeCommand(command: (string | number)[]): string {
|
||||
const first = command[0];
|
||||
if (typeof first !== 'string') return 'Unknown action';
|
||||
|
||||
if (first === 'cycle' && command[1] === 'pause') return 'Toggle playback';
|
||||
if (first === 'seek' && typeof command[1] === 'number') {
|
||||
return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`;
|
||||
}
|
||||
if (first === 'sub-seek' && typeof command[1] === 'number') {
|
||||
return `Shift subtitle by ${command[1]} ms`;
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
|
||||
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
|
||||
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
|
||||
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
|
||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
const [, rawId, rawDirection] = first.split(':');
|
||||
return `Cycle runtime option ${rawId || 'option'} ${rawDirection === 'prev' ? 'previous' : 'next'}`;
|
||||
}
|
||||
|
||||
return `MPV command: ${command.map((entry) => String(entry)).join(' ')}`;
|
||||
}
|
||||
|
||||
function sectionForCommand(command: (string | number)[]): string {
|
||||
const first = command[0];
|
||||
if (typeof first !== 'string') return 'Other shortcuts';
|
||||
|
||||
if (
|
||||
first === 'cycle' ||
|
||||
first === 'seek' ||
|
||||
first === 'sub-seek' ||
|
||||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
|
||||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
|
||||
) {
|
||||
return 'Playback and navigation';
|
||||
}
|
||||
|
||||
if (first === 'show-text' || first === 'show-progress' || first.startsWith('osd')) {
|
||||
return 'Visual feedback';
|
||||
}
|
||||
|
||||
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
|
||||
return 'Subtitle sync';
|
||||
}
|
||||
|
||||
if (
|
||||
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
|
||||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
|
||||
) {
|
||||
return 'Runtime settings';
|
||||
}
|
||||
|
||||
if (first === 'quit') return 'System actions';
|
||||
return 'Other shortcuts';
|
||||
}
|
||||
|
||||
function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] {
|
||||
const grouped = new Map<string, SessionHelpItem[]>();
|
||||
|
||||
for (const binding of keybindings) {
|
||||
const section = sectionForCommand(binding.command ?? []);
|
||||
const row: SessionHelpItem = {
|
||||
shortcut: formatKeybinding(binding.key),
|
||||
action: describeCommand(binding.command ?? []),
|
||||
};
|
||||
grouped.set(section, [...(grouped.get(section) ?? []), row]);
|
||||
}
|
||||
|
||||
const sectionOrder = [
|
||||
'Playback and navigation',
|
||||
'Visual feedback',
|
||||
'Subtitle sync',
|
||||
'Runtime settings',
|
||||
'System actions',
|
||||
'Other shortcuts',
|
||||
];
|
||||
const sectionEntries = Array.from(grouped.entries()).sort((a, b) => {
|
||||
const aIdx = sectionOrder.indexOf(a[0]);
|
||||
const bIdx = sectionOrder.indexOf(b[0]);
|
||||
if (aIdx === -1 && bIdx === -1) return a[0].localeCompare(b[0]);
|
||||
if (aIdx === -1) return 1;
|
||||
if (bIdx === -1) return -1;
|
||||
return aIdx - bIdx;
|
||||
});
|
||||
|
||||
return sectionEntries.map(([title, rows]) => ({ title, rows }));
|
||||
}
|
||||
|
||||
function buildColorSection(style: {
|
||||
knownWordColor?: unknown;
|
||||
nPlusOneColor?: unknown;
|
||||
jlptColors?: {
|
||||
N1?: unknown;
|
||||
N2?: unknown;
|
||||
N3?: unknown;
|
||||
N4?: unknown;
|
||||
N5?: unknown;
|
||||
};
|
||||
}): SessionHelpSection {
|
||||
return {
|
||||
title: 'Color legend',
|
||||
rows: [
|
||||
{
|
||||
shortcut: 'Known words',
|
||||
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
|
||||
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
|
||||
},
|
||||
{
|
||||
shortcut: 'N+1 words',
|
||||
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
|
||||
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N1',
|
||||
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
|
||||
color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N2',
|
||||
action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
|
||||
color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N3',
|
||||
action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
|
||||
color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N4',
|
||||
action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
|
||||
color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N5',
|
||||
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
|
||||
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function filterSections(sections: SessionHelpSection[], query: string): SessionHelpSection[] {
|
||||
const normalize = (value: string): string =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/commandorcontrol/gu, 'ctrl')
|
||||
.replace(/cmd\/ctrl/gu, 'ctrl')
|
||||
.replace(/[\s+\-_/]/gu, '');
|
||||
const normalized = normalize(query);
|
||||
if (!normalized) return sections;
|
||||
|
||||
return sections
|
||||
.map((section) => {
|
||||
if (normalize(section.title).includes(normalized)) {
|
||||
return section;
|
||||
}
|
||||
|
||||
const rows = section.rows.filter(
|
||||
(row) =>
|
||||
normalize(row.shortcut).includes(normalized) ||
|
||||
normalize(row.action).includes(normalized),
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
return { ...section, rows };
|
||||
})
|
||||
.filter((section): section is SessionHelpSection => section !== null)
|
||||
.filter((section) => section.rows.length > 0);
|
||||
}
|
||||
|
||||
function formatBindingHint(info: SessionHelpBindingInfo): string {
|
||||
if (info.bindingKey === 'KeyK' && info.fallbackUsed) {
|
||||
return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)';
|
||||
}
|
||||
return 'Y-H';
|
||||
}
|
||||
|
||||
function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'session-help-item';
|
||||
button.tabIndex = -1;
|
||||
button.dataset.sessionHelpIndex = String(globalIndex);
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'session-help-item-left';
|
||||
const shortcut = document.createElement('span');
|
||||
shortcut.className = 'session-help-key';
|
||||
shortcut.textContent = row.shortcut;
|
||||
left.appendChild(shortcut);
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'session-help-item-right';
|
||||
const action = document.createElement('span');
|
||||
action.className = 'session-help-action';
|
||||
action.textContent = row.action;
|
||||
right.appendChild(action);
|
||||
|
||||
if (row.color) {
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'session-help-color-dot';
|
||||
dot.style.backgroundColor = row.color;
|
||||
right.insertBefore(dot, action);
|
||||
}
|
||||
|
||||
button.appendChild(left);
|
||||
button.appendChild(right);
|
||||
return button;
|
||||
}
|
||||
|
||||
const SECTION_ICON: Record<string, string> = {
|
||||
'MPV shortcuts': '⚙',
|
||||
'Playback and navigation': '▶',
|
||||
'Visual feedback': '◉',
|
||||
'Subtitle sync': '⟲',
|
||||
'Runtime settings': '⚙',
|
||||
'System actions': '◆',
|
||||
'Other shortcuts': '…',
|
||||
'Overlay shortcuts (configurable)': '✦',
|
||||
'Overlay shortcuts': '✦',
|
||||
'Color legend': '◈',
|
||||
};
|
||||
|
||||
function createSectionNode(
|
||||
section: SessionHelpSection,
|
||||
sectionIndex: number,
|
||||
globalIndexMap: number[],
|
||||
): HTMLElement {
|
||||
const sectionNode = document.createElement('section');
|
||||
sectionNode.className = 'session-help-section';
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'session-help-section-title';
|
||||
const icon = SECTION_ICON[section.title] ?? '•';
|
||||
title.textContent = `${icon} ${section.title}`;
|
||||
sectionNode.appendChild(title);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'session-help-item-list';
|
||||
|
||||
section.rows.forEach((row, rowIndex) => {
|
||||
const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex;
|
||||
const button = createShortcutRow(row, globalIndex);
|
||||
list.appendChild(button);
|
||||
});
|
||||
|
||||
sectionNode.appendChild(list);
|
||||
return sectionNode;
|
||||
}
|
||||
|
||||
export function createSessionHelpModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let priorFocus: Element | null = null;
|
||||
let openBinding: SessionHelpBindingInfo = {
|
||||
bindingKey: 'KeyH',
|
||||
fallbackUsed: false,
|
||||
fallbackUnavailable: false,
|
||||
};
|
||||
let helpFilterValue = '';
|
||||
let helpSections: SessionHelpSection[] = [];
|
||||
let focusGuard: ((event: FocusEvent) => void) | null = null;
|
||||
let windowFocusGuard: (() => void) | null = null;
|
||||
let modalPointerFocusGuard: ((event: Event) => void) | null = null;
|
||||
let isRecoveringModalFocus = false;
|
||||
let lastFocusRecoveryAt = 0;
|
||||
|
||||
function getItems(): HTMLButtonElement[] {
|
||||
return Array.from(
|
||||
ctx.dom.sessionHelpContent.querySelectorAll('.session-help-item'),
|
||||
) as HTMLButtonElement[];
|
||||
}
|
||||
|
||||
function setSelected(index: number): void {
|
||||
const items = getItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
const wrappedIndex = index % items.length;
|
||||
const next = wrappedIndex < 0 ? wrappedIndex + items.length : wrappedIndex;
|
||||
ctx.state.sessionHelpSelectedIndex = next;
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
item.classList.toggle('active', idx === next);
|
||||
item.tabIndex = idx === next ? 0 : -1;
|
||||
});
|
||||
const activeItem = items[next];
|
||||
if (!activeItem) return;
|
||||
activeItem.focus({ preventScroll: true });
|
||||
activeItem.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
|
||||
function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && ctx.dom.sessionHelpModal.contains(target);
|
||||
}
|
||||
|
||||
function focusFallbackTarget(): boolean {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
const items = getItems();
|
||||
const firstItem = items.find((item) => item.offsetParent !== null);
|
||||
if (firstItem) {
|
||||
firstItem.focus({ preventScroll: true });
|
||||
return document.activeElement === firstItem;
|
||||
}
|
||||
|
||||
if (ctx.dom.sessionHelpClose instanceof HTMLElement) {
|
||||
ctx.dom.sessionHelpClose.focus({ preventScroll: true });
|
||||
return document.activeElement === ctx.dom.sessionHelpClose;
|
||||
}
|
||||
|
||||
window.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
function enforceModalFocus(): void {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
if (!isSessionHelpModalFocusTarget(document.activeElement)) {
|
||||
if (isRecoveringModalFocus) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastFocusRecoveryAt < 120) return;
|
||||
|
||||
isRecoveringModalFocus = true;
|
||||
lastFocusRecoveryAt = now;
|
||||
focusFallbackTarget();
|
||||
|
||||
window.setTimeout(() => {
|
||||
isRecoveringModalFocus = false;
|
||||
}, 120);
|
||||
}
|
||||
}
|
||||
|
||||
function isFilterInputFocused(): boolean {
|
||||
return document.activeElement === ctx.dom.sessionHelpFilter;
|
||||
}
|
||||
|
||||
function focusFilterInput(): void {
|
||||
ctx.dom.sessionHelpFilter.focus({ preventScroll: true });
|
||||
ctx.dom.sessionHelpFilter.select();
|
||||
}
|
||||
|
||||
function applyFilterAndRender(): void {
|
||||
const sections = filterSections(helpSections, helpFilterValue);
|
||||
const indexOffsets: number[] = [];
|
||||
let running = 0;
|
||||
for (const section of sections) {
|
||||
indexOffsets.push(running);
|
||||
running += section.rows.length;
|
||||
}
|
||||
|
||||
ctx.dom.sessionHelpContent.innerHTML = '';
|
||||
sections.forEach((section, sectionIndex) => {
|
||||
const sectionNode = createSectionNode(section, sectionIndex, indexOffsets);
|
||||
ctx.dom.sessionHelpContent.appendChild(sectionNode);
|
||||
});
|
||||
|
||||
if (getItems().length === 0) {
|
||||
ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results');
|
||||
ctx.dom.sessionHelpContent.textContent = helpFilterValue
|
||||
? 'No matching shortcuts found.'
|
||||
: 'No active session shortcuts found.';
|
||||
ctx.state.sessionHelpSelectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.sessionHelpContent.classList.remove('session-help-content-no-results');
|
||||
|
||||
if (isFilterInputFocused()) return;
|
||||
|
||||
setSelected(0);
|
||||
}
|
||||
|
||||
function requestOverlayFocus(): void {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
}
|
||||
|
||||
function addPointerFocusListener(): void {
|
||||
if (modalPointerFocusGuard) return;
|
||||
|
||||
modalPointerFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
ctx.dom.sessionHelpModal.addEventListener('pointerdown', modalPointerFocusGuard);
|
||||
ctx.dom.sessionHelpModal.addEventListener('click', modalPointerFocusGuard);
|
||||
}
|
||||
|
||||
function removePointerFocusListener(): void {
|
||||
if (!modalPointerFocusGuard) return;
|
||||
ctx.dom.sessionHelpModal.removeEventListener('pointerdown', modalPointerFocusGuard);
|
||||
ctx.dom.sessionHelpModal.removeEventListener('click', modalPointerFocusGuard);
|
||||
modalPointerFocusGuard = null;
|
||||
}
|
||||
|
||||
function startFocusRecoveryGuards(): void {
|
||||
if (windowFocusGuard) return;
|
||||
|
||||
windowFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
window.addEventListener('blur', windowFocusGuard);
|
||||
window.addEventListener('focus', windowFocusGuard);
|
||||
}
|
||||
|
||||
function stopFocusRecoveryGuards(): void {
|
||||
if (!windowFocusGuard) return;
|
||||
window.removeEventListener('blur', windowFocusGuard);
|
||||
window.removeEventListener('focus', windowFocusGuard);
|
||||
windowFocusGuard = null;
|
||||
}
|
||||
|
||||
function showRenderError(message: string): void {
|
||||
helpSections = [];
|
||||
helpFilterValue = '';
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results');
|
||||
ctx.dom.sessionHelpContent.textContent = message;
|
||||
ctx.state.sessionHelpSelectedIndex = 0;
|
||||
}
|
||||
|
||||
async function render(): Promise<boolean> {
|
||||
try {
|
||||
const [keybindings, styleConfig, shortcuts] = await Promise.all([
|
||||
window.electronAPI.getKeybindings(),
|
||||
window.electronAPI.getSubtitleStyle(),
|
||||
window.electronAPI.getConfiguredShortcuts(),
|
||||
]);
|
||||
|
||||
const bindingSections = buildBindingSections(keybindings);
|
||||
if (bindingSections.length > 0) {
|
||||
const playback = bindingSections.find(
|
||||
(section) => section.title === 'Playback and navigation',
|
||||
);
|
||||
if (playback) {
|
||||
playback.title = 'MPV shortcuts';
|
||||
}
|
||||
}
|
||||
|
||||
const shortcutSections = buildOverlayShortcutSections(shortcuts);
|
||||
if (shortcutSections.length > 0) {
|
||||
shortcutSections[0]!.title = 'Overlay shortcuts (configurable)';
|
||||
}
|
||||
const colorSection = buildColorSection(styleConfig ?? {});
|
||||
helpSections = [...bindingSections, ...shortcutSections, colorSection];
|
||||
applyFilterAndRender();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unable to load session help data.';
|
||||
showRenderError(`Session help failed to load: ${message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> {
|
||||
openBinding = opening;
|
||||
priorFocus = document.activeElement;
|
||||
|
||||
const dataLoaded = await render();
|
||||
|
||||
ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`;
|
||||
if (openBinding.fallbackUnavailable) {
|
||||
ctx.dom.sessionHelpWarning.textContent =
|
||||
'Both Y-H and Y-K are bound; Y-K remains the fallback for this session.';
|
||||
} else if (openBinding.fallbackUsed) {
|
||||
ctx.dom.sessionHelpWarning.textContent = 'Y-H is already bound; using Y-K as fallback.';
|
||||
} else {
|
||||
ctx.dom.sessionHelpWarning.textContent = '';
|
||||
}
|
||||
if (dataLoaded) {
|
||||
ctx.dom.sessionHelpStatus.textContent =
|
||||
'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.';
|
||||
} else {
|
||||
ctx.dom.sessionHelpStatus.textContent =
|
||||
'Session help data is unavailable right now. Press Esc to close.';
|
||||
ctx.dom.sessionHelpWarning.textContent =
|
||||
'Unable to load latest shortcut settings from the runtime.';
|
||||
}
|
||||
|
||||
ctx.state.sessionHelpModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.sessionHelpModal.classList.remove('hidden');
|
||||
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'false');
|
||||
ctx.dom.sessionHelpModal.setAttribute('tabindex', '-1');
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
helpFilterValue = '';
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
|
||||
if (focusGuard === null) {
|
||||
focusGuard = (event: FocusEvent) => {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
if (!isSessionHelpModalFocusTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
enforceModalFocus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('focusin', focusGuard);
|
||||
}
|
||||
|
||||
addPointerFocusListener();
|
||||
startFocusRecoveryGuards();
|
||||
requestOverlayFocus();
|
||||
window.focus();
|
||||
enforceModalFocus();
|
||||
}
|
||||
|
||||
function closeSessionHelpModal(): void {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
|
||||
ctx.state.sessionHelpModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.sessionHelpModal.classList.add('hidden');
|
||||
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'true');
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
if (focusGuard) {
|
||||
document.removeEventListener('focusin', focusGuard);
|
||||
focusGuard = null;
|
||||
}
|
||||
removePointerFocusListener();
|
||||
stopFocusRecoveryGuards();
|
||||
|
||||
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
|
||||
priorFocus.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.dom.overlay instanceof HTMLElement) {
|
||||
// Overlay remains `tabindex="-1"` to allow programmatic focus for fallback.
|
||||
ctx.dom.overlay.focus({ preventScroll: true });
|
||||
}
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
}
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
helpFilterValue = '';
|
||||
window.focus();
|
||||
}
|
||||
|
||||
function handleSessionHelpKeydown(e: KeyboardEvent): boolean {
|
||||
if (!ctx.state.sessionHelpModalOpen) return false;
|
||||
|
||||
if (isFilterInputFocused()) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (!helpFilterValue) {
|
||||
closeSessionHelpModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
helpFilterValue = '';
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
applyFilterAndRender();
|
||||
focusFallbackTarget();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeSessionHelpModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
const items = getItems();
|
||||
if (items.length === 0) return true;
|
||||
|
||||
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
focusFilterInput();
|
||||
return true;
|
||||
}
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
if (key === 'arrowdown' || key === 'j' || key === 'l') {
|
||||
e.preventDefault();
|
||||
setSelected(ctx.state.sessionHelpSelectedIndex + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'arrowup' || key === 'k' || key === 'h') {
|
||||
e.preventDefault();
|
||||
setSelected(ctx.state.sessionHelpSelectedIndex - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.sessionHelpFilter.addEventListener('input', () => {
|
||||
helpFilterValue = ctx.dom.sessionHelpFilter.value;
|
||||
applyFilterAndRender();
|
||||
});
|
||||
|
||||
ctx.dom.sessionHelpFilter.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
focusFallbackTarget();
|
||||
}
|
||||
});
|
||||
|
||||
ctx.dom.sessionHelpContent.addEventListener('click', (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const row = target.closest('.session-help-item') as HTMLElement | null;
|
||||
if (!row) return;
|
||||
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? '', 10);
|
||||
if (!Number.isFinite(index)) return;
|
||||
setSelected(index);
|
||||
});
|
||||
|
||||
ctx.dom.sessionHelpClose.addEventListener('click', () => {
|
||||
closeSessionHelpModal();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeSessionHelpModal,
|
||||
handleSessionHelpKeydown,
|
||||
openSessionHelpModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
142
src/renderer/modals/subsync.ts
Normal file
142
src/renderer/modals/subsync.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { SubsyncManualPayload } from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
export function createSubsyncModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
function setSubsyncStatus(message: string, isError = false): void {
|
||||
ctx.dom.subsyncStatus.textContent = message;
|
||||
ctx.dom.subsyncStatus.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function updateSubsyncSourceVisibility(): void {
|
||||
const useAlass = ctx.dom.subsyncEngineAlass.checked;
|
||||
ctx.dom.subsyncSourceLabel.classList.toggle('hidden', !useAlass);
|
||||
}
|
||||
|
||||
function renderSubsyncSourceTracks(): void {
|
||||
ctx.dom.subsyncSourceSelect.innerHTML = '';
|
||||
for (const track of ctx.state.subsyncSourceTracks) {
|
||||
const option = document.createElement('option');
|
||||
option.value = String(track.id);
|
||||
option.textContent = track.label;
|
||||
ctx.dom.subsyncSourceSelect.appendChild(option);
|
||||
}
|
||||
ctx.dom.subsyncSourceSelect.disabled = ctx.state.subsyncSourceTracks.length === 0;
|
||||
}
|
||||
|
||||
function closeSubsyncModal(): void {
|
||||
if (!ctx.state.subsyncModalOpen) return;
|
||||
|
||||
ctx.state.subsyncModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
ctx.dom.subsyncModal.classList.add('hidden');
|
||||
ctx.dom.subsyncModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('subsync');
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
}
|
||||
|
||||
function openSubsyncModal(payload: SubsyncManualPayload): void {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
|
||||
ctx.state.subsyncSubmitting = false;
|
||||
ctx.dom.subsyncRunButton.disabled = false;
|
||||
ctx.state.subsyncSourceTracks = payload.sourceTracks;
|
||||
|
||||
const hasSources = ctx.state.subsyncSourceTracks.length > 0;
|
||||
ctx.dom.subsyncEngineAlass.checked = hasSources;
|
||||
ctx.dom.subsyncEngineFfsubsync.checked = !hasSources;
|
||||
|
||||
renderSubsyncSourceTracks();
|
||||
updateSubsyncSourceVisibility();
|
||||
|
||||
setSubsyncStatus(
|
||||
hasSources
|
||||
? 'Choose engine and source, then run.'
|
||||
: 'No source subtitles available for alass. Use ffsubsync.',
|
||||
false,
|
||||
);
|
||||
|
||||
ctx.state.subsyncModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.subsyncModal.classList.remove('hidden');
|
||||
ctx.dom.subsyncModal.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
async function runSubsyncManualFromModal(): Promise<void> {
|
||||
if (ctx.state.subsyncSubmitting) return;
|
||||
|
||||
const engine = ctx.dom.subsyncEngineAlass.checked ? 'alass' : 'ffsubsync';
|
||||
const sourceTrackId =
|
||||
engine === 'alass' && ctx.dom.subsyncSourceSelect.value
|
||||
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
|
||||
: null;
|
||||
|
||||
if (engine === 'alass' && !Number.isFinite(sourceTrackId)) {
|
||||
setSubsyncStatus('Select a source subtitle track for alass.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.subsyncSubmitting = true;
|
||||
ctx.dom.subsyncRunButton.disabled = true;
|
||||
|
||||
closeSubsyncModal();
|
||||
try {
|
||||
await window.electronAPI.runSubsyncManual({
|
||||
engine,
|
||||
sourceTrackId,
|
||||
});
|
||||
} finally {
|
||||
ctx.state.subsyncSubmitting = false;
|
||||
ctx.dom.subsyncRunButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubsyncKeydown(e: KeyboardEvent): boolean {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeSubsyncModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void runSubsyncManualFromModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.subsyncCloseButton.addEventListener('click', () => {
|
||||
closeSubsyncModal();
|
||||
});
|
||||
ctx.dom.subsyncEngineAlass.addEventListener('change', () => {
|
||||
updateSubsyncSourceVisibility();
|
||||
});
|
||||
ctx.dom.subsyncEngineFfsubsync.addEventListener('change', () => {
|
||||
updateSubsyncSourceVisibility();
|
||||
});
|
||||
ctx.dom.subsyncRunButton.addEventListener('click', () => {
|
||||
void runSubsyncManualFromModal();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeSubsyncModal,
|
||||
handleSubsyncKeydown,
|
||||
openSubsyncModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user