import type { CharacterDictionaryCandidate, CharacterDictionarySelectionSnapshot, } from '../../types'; import type { ModalStateReader, RendererContext } from '../context'; function clampIndex(index: number, length: number): number { if (length <= 0) return 0; return Math.min(Math.max(index, 0), length - 1); } function formatCandidate(candidate: CharacterDictionaryCandidate | null): string { if (!candidate) return 'None'; const episodes = candidate.episodes === null ? '?' : String(candidate.episodes); return `${candidate.id} - ${candidate.title} (${episodes} episodes)`; } function buildSummary(snapshot: CharacterDictionarySelectionSnapshot): string { const guess = snapshot.guessTitle ?? 'No active title'; return `Series key: ${snapshot.seriesKey} · Guess: ${guess}`; } export function createCharacterDictionaryModal( ctx: RendererContext, options: { modalStateReader: Pick; syncSettingsModalSubtitleSuppression: () => void; }, ) { let hasSearched = false; function setStatus(message: string, isError = false): void { ctx.state.characterDictionaryStatus = message; ctx.dom.characterDictionaryStatus.textContent = message; ctx.dom.characterDictionaryStatus.classList.toggle('error', isError); } function setSelection( snapshot: CharacterDictionarySelectionSnapshot, seedSearchInput = false, ): void { const previousId = ctx.state.characterDictionarySelection?.candidates[ctx.state.characterDictionarySelectedIndex] ?.id; ctx.state.characterDictionarySelection = snapshot; if (seedSearchInput) { ctx.dom.characterDictionarySearchInput.value = snapshot.guessTitle ?? ''; } const nextIndex = snapshot.candidates.findIndex((candidate) => candidate.id === previousId); ctx.state.characterDictionarySelectedIndex = clampIndex( nextIndex >= 0 ? nextIndex : 0, snapshot.candidates.length, ); render(); } function renderCandidate(candidate: CharacterDictionaryCandidate, index: number): HTMLLIElement { const isOverride = candidate.id === ctx.state.characterDictionarySelection?.override?.id; const item = document.createElement('li'); item.className = 'character-dictionary-candidate'; item.classList.toggle('active', index === ctx.state.characterDictionarySelectedIndex); const main = document.createElement('div'); main.className = 'runtime-options-label'; main.textContent = candidate.title; const meta = document.createElement('div'); meta.className = 'runtime-options-allowed'; const episodeLabel = candidate.episodes === null ? '?' : String(candidate.episodes); meta.textContent = `AniList ${candidate.id} · ${episodeLabel} episodes`; const button = document.createElement('button'); button.className = 'character-dictionary-use'; button.type = 'button'; button.textContent = isOverride ? 'Selected' : 'Use'; button.disabled = isOverride; button.addEventListener('click', (event) => { event.stopPropagation(); if (isOverride) return; ctx.state.characterDictionarySelectedIndex = index; void applySelectedCandidate(); }); const body = document.createElement('div'); body.className = 'character-dictionary-candidate-body'; body.append(main, meta); item.append(body, button); item.addEventListener('click', () => { ctx.state.characterDictionarySelectedIndex = index; render(); }); item.addEventListener('dblclick', () => { ctx.state.characterDictionarySelectedIndex = index; void applySelectedCandidate(); }); return item; } function render(): void { const snapshot = ctx.state.characterDictionarySelection; ctx.dom.characterDictionaryCandidates.replaceChildren(); if (!snapshot) { ctx.dom.characterDictionarySummary.textContent = ''; ctx.dom.characterDictionaryCurrent.textContent = ''; return; } ctx.dom.characterDictionarySummary.textContent = buildSummary(snapshot); ctx.dom.characterDictionaryCurrent.textContent = `Current: ${formatCandidate( snapshot.current, )} · Override: ${formatCandidate(snapshot.override)}`; if (snapshot.candidates.length === 0) { const empty = document.createElement('li'); empty.className = 'character-dictionary-empty'; empty.textContent = hasSearched ? 'No AniList candidates found.' : 'Search AniList to show candidates.'; ctx.dom.characterDictionaryCandidates.append(empty); return; } ctx.dom.characterDictionaryCandidates.replaceChildren( ...snapshot.candidates.map((candidate, index) => renderCandidate(candidate, index)), ); } async function refreshSelection(searchTitle?: string): Promise { const snapshot = await window.electronAPI.getCharacterDictionarySelection(searchTitle); hasSearched = searchTitle !== ''; setSelection(snapshot, searchTitle === ''); setStatus( searchTitle === '' ? 'Enter a title to search AniList.' : snapshot.override ? `Override active: ${formatCandidate(snapshot.override)}` : 'Select the correct AniList entry.', ); } async function searchCandidates(): Promise { const searchTitle = ctx.dom.characterDictionarySearchInput.value.trim(); if (!searchTitle) { setStatus('Enter a title to search AniList.', true); return; } ctx.dom.characterDictionarySearchButton.disabled = true; setStatus(`Searching AniList for ${searchTitle}...`); try { await refreshSelection(searchTitle); } catch (error) { setStatus(error instanceof Error ? error.message : String(error), true); } finally { ctx.dom.characterDictionarySearchButton.disabled = false; } } async function applySelectedCandidate(): Promise { const snapshot = ctx.state.characterDictionarySelection; const candidate = snapshot?.candidates[ctx.state.characterDictionarySelectedIndex]; if (!candidate) return; if (candidate.id === snapshot?.override?.id) return; setStatus(`Saving override for ${candidate.title}...`); try { const result = await window.electronAPI.setCharacterDictionarySelection(candidate.id); if (!result.ok) { setStatus('Failed to save override', true); return; } await refreshSelection(ctx.dom.characterDictionarySearchInput.value.trim()); const staleLabel = result.staleMediaIds.length > 0 ? ` Removed stale: ${result.staleMediaIds.join(', ')}.` : ''; setStatus(`Override saved: ${formatCandidate(result.selected)}.${staleLabel}`); } catch (error) { setStatus(error instanceof Error ? error.message : String(error), true); } } function showShell(): void { ctx.state.characterDictionaryModalOpen = true; options.syncSettingsModalSubtitleSuppression(); ctx.dom.overlay.classList.add('interactive'); ctx.dom.characterDictionaryModal.classList.remove('hidden'); ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'false'); window.electronAPI.notifyOverlayModalOpened('character-dictionary'); setStatus('Loading character dictionary selector...'); } async function openCharacterDictionaryModal(): Promise { if (!ctx.state.characterDictionaryModalOpen) { showShell(); } else { window.electronAPI.notifyOverlayModalOpened('character-dictionary'); setStatus('Refreshing AniList candidates...'); } try { await refreshSelection(''); } catch (error) { setStatus(error instanceof Error ? error.message : String(error), true); } } function closeCharacterDictionaryModal(): void { if (!ctx.state.characterDictionaryModalOpen) return; ctx.state.characterDictionaryModalOpen = false; ctx.state.characterDictionarySelection = null; options.syncSettingsModalSubtitleSuppression(); ctx.dom.characterDictionaryModal.classList.add('hidden'); ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'true'); ctx.dom.characterDictionaryCandidates.replaceChildren(); hasSearched = false; window.electronAPI.notifyOverlayModalClosed('character-dictionary'); setStatus(''); if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { ctx.dom.overlay.classList.remove('interactive'); } } function moveSelection(delta: -1 | 1): void { const length = ctx.state.characterDictionarySelection?.candidates.length ?? 0; if (length <= 0) return; ctx.state.characterDictionarySelectedIndex = clampIndex( ctx.state.characterDictionarySelectedIndex + delta, length, ); render(); } function handleCharacterDictionaryKeydown(e: KeyboardEvent): boolean { if (e.key === 'Escape') { e.preventDefault(); closeCharacterDictionaryModal(); return true; } if (e.target === ctx.dom.characterDictionarySearchInput) { if (e.key === 'Enter') { e.preventDefault(); void searchCandidates(); return true; } return false; } if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') { e.preventDefault(); moveSelection(1); return true; } if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') { e.preventDefault(); moveSelection(-1); return true; } if (e.key === 'Enter') { e.preventDefault(); void applySelectedCandidate(); return true; } return false; } function wireDomEvents(): void { ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal); ctx.dom.characterDictionarySearchButton.addEventListener('click', () => { void searchCandidates(); }); ctx.dom.characterDictionarySearchInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void searchCandidates(); } }); } return { openCharacterDictionaryModal, closeCharacterDictionaryModal, handleCharacterDictionaryKeydown, wireDomEvents, }; }