mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 05:16:23 -07:00
feat(jimaku): add live-action subtitle search (#251)
This commit is contained in:
@@ -119,6 +119,8 @@ test('successful Jimaku subtitle selection closes modal', async () => {
|
||||
classList: jimakuBroadenButtonClassList,
|
||||
addEventListener: () => {},
|
||||
},
|
||||
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
@@ -147,3 +149,427 @@ test('successful Jimaku subtitle selection closes modal', async () => {
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('switching to the Live action tab re-runs the search with the live action category', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const searchQueries: Array<{ query: string; category?: string }> = [];
|
||||
const electronAPI = {
|
||||
jimakuSearchEntries: async (query: { query: string; category?: string }) => {
|
||||
searchQueries.push(query);
|
||||
return { ok: true, data: [] };
|
||||
},
|
||||
} as unknown as ElectronAPI;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { electronAPI },
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.jimakuModalOpen = true;
|
||||
const animeTabClassList = createClassList(['active']);
|
||||
const liveActionTabClassList = createClassList();
|
||||
const status = { textContent: '', style: { color: '' } };
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(['interactive']) },
|
||||
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
|
||||
jimakuTitleInput: { value: 'Shinzanmono' },
|
||||
jimakuSeasonInput: { value: '' },
|
||||
jimakuEpisodeInput: { value: '3' },
|
||||
jimakuSearchButton: { addEventListener: () => {} },
|
||||
jimakuCloseButton: { addEventListener: () => {} },
|
||||
jimakuStatus: status,
|
||||
jimakuEntriesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuEntriesList: createListStub(),
|
||||
jimakuFilesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuFilesList: createListStub(),
|
||||
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
|
||||
jimakuTabAnimeButton: { classList: animeTabClassList, setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: liveActionTabClassList, setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const jimakuModal = createJimakuModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowRight',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(state.jimakuActiveTab, 'liveAction');
|
||||
assert.equal(liveActionTabClassList.contains('active'), true);
|
||||
assert.equal(animeTabClassList.contains('active'), false);
|
||||
assert.deepEqual(searchQueries, [{ query: 'Shinzanmono', category: 'liveAction' }]);
|
||||
assert.equal(status.textContent, 'No live action entries found. Try the Anime tab.');
|
||||
|
||||
// Same tab again is a no-op: no duplicate request.
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowRight',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
assert.equal(searchQueries.length, 1);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow reply from a superseded search does not overwrite the newer results', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const pending: Array<(entries: unknown[]) => void> = [];
|
||||
const electronAPI = {
|
||||
jimakuSearchEntries: () =>
|
||||
new Promise((resolve) => {
|
||||
pending.push((entries) => resolve({ ok: true, data: entries }));
|
||||
}),
|
||||
jimakuListFiles: async () => ({ ok: true, data: [] }),
|
||||
} as unknown as ElectronAPI;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { electronAPI },
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.jimakuModalOpen = true;
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(['interactive']) },
|
||||
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
|
||||
jimakuTitleInput: { value: 'Shinzanmono' },
|
||||
jimakuSeasonInput: { value: '' },
|
||||
jimakuEpisodeInput: { value: '' },
|
||||
jimakuSearchButton: { addEventListener: () => {} },
|
||||
jimakuCloseButton: { addEventListener: () => {} },
|
||||
jimakuStatus: { textContent: '', style: { color: '' } },
|
||||
jimakuEntriesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuEntriesList: createListStub(),
|
||||
jimakuFilesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuFilesList: createListStub(),
|
||||
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
|
||||
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const jimakuModal = createJimakuModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
// Anime -> Live action -> Anime, all before any reply arrives.
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowRight',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowLeft',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
assert.equal(pending.length, 2);
|
||||
|
||||
// The stale live action reply lands after the newer anime search was issued.
|
||||
pending[0]!([{ id: 1, name: 'Stale live action entry' }]);
|
||||
await flushAsyncWork();
|
||||
assert.equal(state.jimakuEntries.length, 0);
|
||||
|
||||
pending[1]!([
|
||||
{ id: 2, name: 'Anime A' },
|
||||
{ id: 3, name: 'Anime B' },
|
||||
]);
|
||||
await flushAsyncWork();
|
||||
assert.deepEqual(
|
||||
state.jimakuEntries.map((entry) => entry.id),
|
||||
[2, 3],
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('closing the modal discards an in-flight search reply', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
let resolveSearch!: (entries: unknown[]) => void;
|
||||
let listFilesCalls = 0;
|
||||
const electronAPI = {
|
||||
jimakuSearchEntries: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveSearch = (entries) => resolve({ ok: true, data: entries });
|
||||
}),
|
||||
jimakuListFiles: async () => {
|
||||
listFilesCalls += 1;
|
||||
return { ok: true, data: [] };
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
} as unknown as ElectronAPI;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { electronAPI },
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.jimakuModalOpen = true;
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(['interactive']) },
|
||||
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
|
||||
jimakuTitleInput: { value: 'Shinzanmono' },
|
||||
jimakuSeasonInput: { value: '' },
|
||||
jimakuEpisodeInput: { value: '' },
|
||||
jimakuSearchButton: { addEventListener: () => {} },
|
||||
jimakuCloseButton: { addEventListener: () => {} },
|
||||
jimakuStatus: { textContent: '', style: { color: '' } },
|
||||
jimakuEntriesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuEntriesList: createListStub(),
|
||||
jimakuFilesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuFilesList: createListStub(),
|
||||
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
|
||||
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const jimakuModal = createJimakuModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
jimakuModal.closeJimakuModal();
|
||||
|
||||
// A single entry would normally auto-select and fetch its files.
|
||||
resolveSearch([{ id: 7, name: 'Only entry' }]);
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(state.jimakuEntries.length, 0);
|
||||
assert.equal(state.currentEntryId, null);
|
||||
assert.equal(listFilesCalls, 0);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow files reply for a previously selected entry is ignored', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const pending = new Map<number, (files: unknown[]) => void>();
|
||||
const electronAPI = {
|
||||
jimakuListFiles: (query: { entryId: number }) =>
|
||||
new Promise((resolve) => {
|
||||
pending.set(query.entryId, (files) => resolve({ ok: true, data: files }));
|
||||
}),
|
||||
} as unknown as ElectronAPI;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { electronAPI },
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.jimakuModalOpen = true;
|
||||
state.jimakuEntries = [
|
||||
{ id: 1, name: 'Entry A' },
|
||||
{ id: 2, name: 'Entry B' },
|
||||
];
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(['interactive']) },
|
||||
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
|
||||
jimakuTitleInput: { value: '' },
|
||||
jimakuSeasonInput: { value: '' },
|
||||
jimakuEpisodeInput: { value: '' },
|
||||
jimakuSearchButton: { addEventListener: () => {} },
|
||||
jimakuCloseButton: { addEventListener: () => {} },
|
||||
jimakuStatus: { textContent: '', style: { color: '' } },
|
||||
jimakuEntriesSection: { classList: createClassList() },
|
||||
jimakuEntriesList: createListStub(),
|
||||
jimakuFilesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuFilesList: createListStub(),
|
||||
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
|
||||
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const jimakuModal = createJimakuModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
// Select entry A, then move to entry B before A's files arrive.
|
||||
jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent);
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowDown',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
assert.equal(state.currentEntryId, 2);
|
||||
|
||||
pending.get(1)!([
|
||||
{ name: 'a.srt', url: 'https://jimaku.cc/a.srt', size: 1, last_modified: '' },
|
||||
]);
|
||||
await flushAsyncWork();
|
||||
assert.equal(state.jimakuFiles.length, 0);
|
||||
|
||||
pending.get(2)!([
|
||||
{ name: 'b1.srt', url: 'https://jimaku.cc/b1.srt', size: 1, last_modified: '' },
|
||||
{ name: 'b2.srt', url: 'https://jimaku.cc/b2.srt', size: 1, last_modified: '' },
|
||||
]);
|
||||
await flushAsyncWork();
|
||||
assert.deepEqual(
|
||||
state.jimakuFiles.map((file) => file.name),
|
||||
['b1.srt', 'b2.srt'],
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('media info arriving after the modal closed does not fill inputs or search', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
let resolveMediaInfo!: (info: unknown) => void;
|
||||
let searchCalls = 0;
|
||||
const electronAPI = {
|
||||
getJimakuMediaInfo: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveMediaInfo = resolve;
|
||||
}),
|
||||
jimakuSearchEntries: async () => {
|
||||
searchCalls += 1;
|
||||
return { ok: true, data: [] };
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
} as unknown as ElectronAPI;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { electronAPI },
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
const titleInput = { value: '' };
|
||||
const status = { textContent: '', style: { color: '' } };
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
jimakuModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
|
||||
jimakuTitleInput: titleInput,
|
||||
jimakuSeasonInput: { value: '' },
|
||||
jimakuEpisodeInput: { value: '' },
|
||||
jimakuSearchButton: { addEventListener: () => {} },
|
||||
jimakuCloseButton: { addEventListener: () => {} },
|
||||
jimakuStatus: status,
|
||||
jimakuEntriesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuEntriesList: createListStub(),
|
||||
jimakuFilesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuFilesList: createListStub(),
|
||||
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
|
||||
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const jimakuModal = createJimakuModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
jimakuModal.openJimakuModal();
|
||||
await flushAsyncWork();
|
||||
jimakuModal.closeJimakuModal();
|
||||
|
||||
resolveMediaInfo({
|
||||
title: 'Shinzanmono',
|
||||
season: 1,
|
||||
episode: 3,
|
||||
confidence: 'high',
|
||||
filename: 'Shinzanmono S01E03.mkv',
|
||||
rawTitle: 'Shinzanmono S01E03',
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(titleInput.value, '');
|
||||
assert.equal(searchCalls, 0);
|
||||
assert.equal(status.textContent, 'Loading media info...');
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
JimakuMediaInfo,
|
||||
JimakuSearchCategory,
|
||||
} from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
@@ -21,7 +22,12 @@ export function createJimakuModal(
|
||||
: 'rgba(255, 255, 255, 0.8)';
|
||||
}
|
||||
|
||||
// Bumped whenever the lists are reset (new search, tab switch, open, close)
|
||||
// so any in-flight entries or files reply for the old state is discarded.
|
||||
let searchGeneration = 0;
|
||||
|
||||
function resetJimakuLists(): void {
|
||||
searchGeneration += 1;
|
||||
ctx.state.jimakuEntries = [];
|
||||
ctx.state.jimakuFiles = [];
|
||||
ctx.state.selectedEntryIndex = 0;
|
||||
@@ -35,6 +41,33 @@ export function createJimakuModal(
|
||||
ctx.dom.jimakuBroadenButton.classList.add('hidden');
|
||||
}
|
||||
|
||||
function renderTabs(): void {
|
||||
const liveActionActive = ctx.state.jimakuActiveTab === 'liveAction';
|
||||
const active = liveActionActive
|
||||
? ctx.dom.jimakuTabLiveActionButton
|
||||
: ctx.dom.jimakuTabAnimeButton;
|
||||
const inactive = liveActionActive
|
||||
? ctx.dom.jimakuTabAnimeButton
|
||||
: ctx.dom.jimakuTabLiveActionButton;
|
||||
active.classList.add('active');
|
||||
active.setAttribute('aria-selected', 'true');
|
||||
inactive.classList.remove('active');
|
||||
inactive.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
|
||||
// Tabs map to Jimaku's anime / live-action catalogues, so switching re-runs
|
||||
// the search server-side instead of filtering a shared result list.
|
||||
function setActiveTab(tab: JimakuSearchCategory): void {
|
||||
if (ctx.state.jimakuActiveTab === tab) return;
|
||||
ctx.state.jimakuActiveTab = tab;
|
||||
renderTabs();
|
||||
if (getSearchQuery().query) {
|
||||
void performJimakuSearch();
|
||||
} else {
|
||||
resetJimakuLists();
|
||||
}
|
||||
}
|
||||
|
||||
function formatEntryLabel(entry: JimakuEntry): string {
|
||||
if (entry.english_name && entry.english_name !== entry.name) {
|
||||
return `${entry.name} / ${entry.english_name}`;
|
||||
@@ -133,9 +166,12 @@ export function createJimakuModal(
|
||||
setJimakuStatus('Searching Jimaku...');
|
||||
ctx.state.currentEpisodeFilter = episode;
|
||||
|
||||
const category = ctx.state.jimakuActiveTab;
|
||||
const generation = searchGeneration;
|
||||
const response: JimakuApiResponse<JimakuEntry[]> = await window.electronAPI.jimakuSearchEntries(
|
||||
{ query },
|
||||
{ query, category },
|
||||
);
|
||||
if (generation !== searchGeneration) return;
|
||||
if (!response.ok) {
|
||||
const retry = response.error.retryAfter
|
||||
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
|
||||
@@ -148,7 +184,11 @@ export function createJimakuModal(
|
||||
ctx.state.selectedEntryIndex = 0;
|
||||
|
||||
if (ctx.state.jimakuEntries.length === 0) {
|
||||
setJimakuStatus('No entries found.');
|
||||
setJimakuStatus(
|
||||
category === 'anime'
|
||||
? 'No anime entries found. Try the Live action tab.'
|
||||
: 'No live action entries found. Try the Anime tab.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,12 +207,15 @@ export function createJimakuModal(
|
||||
ctx.dom.jimakuFilesList.innerHTML = '';
|
||||
ctx.dom.jimakuFilesSection.classList.add('hidden');
|
||||
|
||||
const generation = searchGeneration;
|
||||
const response: JimakuApiResponse<JimakuFileEntry[]> = await window.electronAPI.jimakuListFiles(
|
||||
{
|
||||
entryId,
|
||||
episode,
|
||||
},
|
||||
);
|
||||
// The user may have picked another entry or reset the modal meanwhile.
|
||||
if (generation !== searchGeneration || ctx.state.currentEntryId !== entryId) return;
|
||||
if (!response.ok) {
|
||||
const retry = response.error.retryAfter
|
||||
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
|
||||
@@ -262,10 +305,15 @@ export function createJimakuModal(
|
||||
|
||||
setJimakuStatus('Loading media info...');
|
||||
resetJimakuLists();
|
||||
renderTabs();
|
||||
|
||||
// Media info can resolve after the user already closed the modal or
|
||||
// started their own search; a stale reply must not touch the inputs.
|
||||
const generation = searchGeneration;
|
||||
window.electronAPI
|
||||
.getJimakuMediaInfo()
|
||||
.then((info: JimakuMediaInfo) => {
|
||||
if (generation !== searchGeneration) return;
|
||||
ctx.dom.jimakuTitleInput.value = info.title || '';
|
||||
ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : '';
|
||||
ctx.dom.jimakuEpisodeInput.value = info.episode ? String(info.episode) : '';
|
||||
@@ -280,6 +328,7 @@ export function createJimakuModal(
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (generation !== searchGeneration) return;
|
||||
setJimakuStatus('Failed to load media info.', true);
|
||||
});
|
||||
}
|
||||
@@ -315,6 +364,18 @@ export function createJimakuModal(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
setActiveTab('anime');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
setActiveTab('liveAction');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (ctx.state.jimakuFiles.length > 0) {
|
||||
@@ -367,6 +428,12 @@ export function createJimakuModal(
|
||||
ctx.dom.jimakuCloseButton.addEventListener('click', () => {
|
||||
closeJimakuModal();
|
||||
});
|
||||
ctx.dom.jimakuTabAnimeButton.addEventListener('click', () => {
|
||||
setActiveTab('anime');
|
||||
});
|
||||
ctx.dom.jimakuTabLiveActionButton.addEventListener('click', () => {
|
||||
setActiveTab('liveAction');
|
||||
});
|
||||
ctx.dom.jimakuBroadenButton.addEventListener('click', () => {
|
||||
if (ctx.state.currentEntryId !== null) {
|
||||
ctx.dom.jimakuBroadenButton.classList.add('hidden');
|
||||
|
||||
Reference in New Issue
Block a user