import type { JimakuApiResponse, JimakuDownloadResult, JimakuEntry, JimakuFileEntry, JimakuMediaInfo, } from '../../types'; import type { ModalStateReader, RendererContext } from '../context'; export function createJimakuModal( ctx: RendererContext, options: { modalStateReader: Pick; 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 { 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 = 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 { setJimakuStatus('Loading files...'); ctx.state.jimakuFiles = []; ctx.state.selectedFileIndex = 0; ctx.dom.jimakuFilesList.innerHTML = ''; ctx.dom.jimakuFilesSection.classList.add('hidden'); const response: JimakuApiResponse = 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 { 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, }; }