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, }; }