mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|