feat(animetosho): add English/Japanese subtitle download integration (#159)

This commit is contained in:
2026-07-12 00:48:12 -07:00
committed by GitHub
parent 6ab3d823a4
commit 4b7f750919
80 changed files with 2647 additions and 13 deletions
+2
View File
@@ -90,6 +90,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openAnimetosho: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -491,6 +492,7 @@ function createKeyboardHandlerHarness() {
handleSubsyncKeydown: () => false,
handleKikuKeydown: () => false,
handleJimakuKeydown: () => false,
handleAnimetoshoKeydown: () => false,
handleControllerSelectKeydown: () => {
controllerSelectKeydownCount += 1;
return true;
+5
View File
@@ -16,6 +16,7 @@ export function createKeyboardHandlers(
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
handleAnimetoshoKeydown: (e: KeyboardEvent) => boolean;
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
@@ -1119,6 +1120,10 @@ export function createKeyboardHandlers(
options.handleJimakuKeydown(e);
return;
}
if (ctx.state.animetoshoModalOpen) {
options.handleAnimetoshoKeydown(e);
return;
}
if (ctx.state.youtubePickerModalOpen) {
if (options.handleYoutubePickerKeydown(e)) {
return;
+38
View File
@@ -116,6 +116,44 @@
</div>
</div>
</div>
<div id="animetoshoModal" class="modal hidden" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">Animetosho Subtitles</div>
<button id="animetoshoClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div class="animetosho-tabs">
<button id="animetoshoTabEnglish" class="animetosho-tab active" type="button">
English
</button>
<button id="animetoshoTabJapanese" class="animetosho-tab" type="button">
Japanese
</button>
</div>
<div class="jimaku-form">
<label class="jimaku-field">
<span>Title</span>
<input id="animetoshoTitle" type="text" placeholder="Anime title" />
</label>
<label class="jimaku-field">
<span>Episode</span>
<input id="animetoshoEpisode" type="number" min="1" placeholder="1" />
</label>
<button id="animetoshoSearch" class="jimaku-button" type="button">Search</button>
</div>
<div id="animetoshoStatus" class="jimaku-status"></div>
<div id="animetoshoEntriesSection" class="jimaku-section hidden">
<div class="jimaku-section-title">Releases</div>
<ul id="animetoshoEntries" class="jimaku-list"></ul>
</div>
<div id="animetoshoFilesSection" class="jimaku-section hidden">
<div class="jimaku-section-title">Subtitle tracks</div>
<ul id="animetoshoFiles" class="jimaku-list"></ul>
</div>
</div>
</div>
</div>
<div id="youtubePickerModal" class="modal hidden" aria-hidden="true">
<div class="modal-content youtube-picker-content">
<div class="modal-header">
+388
View File
@@ -0,0 +1,388 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AnimetoshoSubtitleFile, ElectronAPI } from '../../types';
import { createRendererState } from '../state.js';
import { createAnimetoshoModal } from './animetosho.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) {
tokens.add(entry);
}
},
remove: (...entries: string[]) => {
for (const entry of entries) {
tokens.delete(entry);
}
},
contains: (entry: string) => tokens.has(entry),
};
}
function createElementStub() {
const classList = createClassList();
return {
textContent: '',
className: '',
style: {},
classList,
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
addEventListener: () => {},
};
}
function createListStub() {
return {
innerHTML: '',
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
};
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
const ENGLISH_TRACK: AnimetoshoSubtitleFile = {
attachmentId: 1955356,
filename: 'episode01.eng.ass',
lang: 'eng',
trackName: 'English subs',
size: 33075,
url: 'https://animetosho.org/storage/attach/001dd61c/1955356.xz',
sourceFilename: 'episode01.mkv',
};
const JAPANESE_TRACK: AnimetoshoSubtitleFile = {
attachmentId: 1955400,
filename: 'episode01.jpn.ass',
lang: 'jpn',
trackName: 'Japanese subs',
size: 41000,
url: 'https://animetosho.org/storage/attach/001dd648/1955400.xz',
sourceFilename: 'episode01.mkv',
};
const GERMAN_TRACK: AnimetoshoSubtitleFile = {
attachmentId: 1955500,
filename: 'episode01.ger.ass',
lang: 'ger',
trackName: 'Deutsch',
size: 28000,
url: 'https://animetosho.org/storage/attach/001dd6ac/1955500.xz',
sourceFilename: 'episode01.mkv',
};
interface ModalHarness {
modal: ReturnType<typeof createAnimetoshoModal>;
state: ReturnType<typeof createRendererState>;
downloadQueries: unknown[];
modalCloseNotifications: string[];
overlayClassList: ReturnType<typeof createClassList>;
animetoshoModalClassList: ReturnType<typeof createClassList>;
restoreGlobals: () => void;
}
function createModalHarness(
files: AnimetoshoSubtitleFile[],
options: {
secondaryLanguages?: string[];
listFiles?: (entryId: number) => Promise<unknown>;
} = {},
): ModalHarness {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, 'window');
const hadDocument = Object.prototype.hasOwnProperty.call(globalThis, 'document');
const previousWindow = globals.window;
const previousDocument = globals.document;
const modalCloseNotifications: string[] = [];
const downloadQueries: unknown[] = [];
const electronAPI = {
animetoshoDownloadFile: async (query: unknown) => {
downloadQueries.push(query);
return { ok: true, path: '/tmp/subtitles/episode01.en.ass' };
},
animetoshoGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
animetoshoListFiles: async ({ entryId }: { entryId: number }) =>
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
getJimakuMediaInfo: async () => ({
title: '',
season: null,
episode: null,
confidence: 'low',
filename: '',
rawTitle: '',
}),
notifyOverlayModalClosed: (modal: string) => {
modalCloseNotifications.push(modal);
},
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
const overlayClassList = createClassList(['interactive']);
const animetoshoModalClassList = createClassList();
const state = createRendererState();
state.animetoshoModalOpen = true;
state.currentAnimetoshoEntryId = 606713;
state.selectedAnimetoshoFileIndex = 0;
state.animetoshoFiles = files;
const ctx = {
dom: {
overlay: { classList: overlayClassList },
animetoshoModal: {
classList: animetoshoModalClassList,
setAttribute: () => {},
},
animetoshoTitleInput: { value: '' },
animetoshoEpisodeInput: { value: '' },
animetoshoSearchButton: { addEventListener: () => {} },
animetoshoCloseButton: { addEventListener: () => {} },
animetoshoTabEnglishButton: {
textContent: 'English',
classList: createClassList(['active']),
addEventListener: () => {},
},
animetoshoTabJapaneseButton: {
textContent: 'Japanese',
classList: createClassList(),
addEventListener: () => {},
},
animetoshoStatus: { textContent: '', style: { color: '' } },
animetoshoEntriesSection: { classList: createClassList(['hidden']) },
animetoshoEntriesList: createListStub(),
animetoshoFilesSection: { classList: createClassList() },
animetoshoFilesList: createListStub(),
},
state,
};
const modal = createAnimetoshoModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
return {
modal,
state,
downloadQueries,
modalCloseNotifications,
overlayClassList,
animetoshoModalClassList,
restoreGlobals: () => {
const target = globalThis as unknown as Record<string, unknown>;
if (hadWindow) {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
} else {
delete target.window;
}
if (hadDocument) {
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: previousDocument,
});
} else {
delete target.document;
}
},
};
}
function pressKey(harness: ModalHarness, key: string): boolean {
let prevented = false;
harness.modal.handleAnimetoshoKeydown({
key,
preventDefault: () => {
prevented = true;
},
} as KeyboardEvent);
return prevented;
}
test('successful Animetosho subtitle selection closes modal', async () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
const prevented = pressKey(harness, 'Enter');
await flushAsyncWork();
assert.equal(prevented, true);
assert.equal(harness.state.animetoshoModalOpen, false);
assert.equal(harness.animetoshoModalClassList.contains('hidden'), true);
assert.equal(harness.overlayClassList.contains('interactive'), false);
assert.deepEqual(harness.modalCloseNotifications, ['animetosho']);
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: ENGLISH_TRACK.url,
name: ENGLISH_TRACK.filename,
lang: 'eng',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('English tab hides non-English languages, not just Japanese', async () => {
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK]);
try {
// With German visible this would move selection onto it; English-only
// filtering must clamp to the single English track instead.
pressKey(harness, 'ArrowDown');
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: ENGLISH_TRACK.url,
name: ENGLISH_TRACK.filename,
lang: 'eng',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('Japanese tab filters tracks so Enter downloads the Japanese one', async () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
assert.equal(harness.state.animetoshoActiveTab, 'en');
pressKey(harness, 'ArrowRight');
assert.equal(harness.state.animetoshoActiveTab, 'ja');
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: JAPANESE_TRACK.url,
name: JAPANESE_TRACK.filename,
lang: 'jpn',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('secondary tab follows configured secondarySub languages', async () => {
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK], {
secondaryLanguages: ['de'],
});
try {
// Re-open through the API so the modal fetches the configured languages.
harness.state.animetoshoModalOpen = false;
harness.modal.openAnimetoshoModal();
await flushAsyncWork();
harness.state.animetoshoFiles = [GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK];
harness.state.currentAnimetoshoEntryId = 606713;
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: GERMAN_TRACK.url,
name: GERMAN_TRACK.filename,
lang: 'ger',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('a slow release response does not overwrite the newly selected release', async () => {
const STALE_TRACK: AnimetoshoSubtitleFile = {
...ENGLISH_TRACK,
attachmentId: 999,
filename: 'stale.eng.ass',
};
const SECOND_ENGLISH_TRACK: AnimetoshoSubtitleFile = {
...ENGLISH_TRACK,
attachmentId: 1955357,
filename: 'episode01.eng.sdh.ass',
};
const resolvers: Array<(value: unknown) => void> = [];
const harness = createModalHarness([], {
listFiles: (entryId) =>
new Promise((resolve) => {
if (entryId === 1) {
// Entry 1 answers late, after the user has moved on to entry 2.
resolvers.push(() => resolve({ ok: true, data: [STALE_TRACK] }));
} else {
// Two tracks, so the modal does not auto-download a lone match.
resolve({ ok: true, data: [ENGLISH_TRACK, SECOND_ENGLISH_TRACK] });
}
}),
});
try {
harness.state.animetoshoEntries = [
{ id: 1, title: 'slow release', timestamp: null, totalSize: null, numFiles: 1 },
{ id: 2, title: 'fast release', timestamp: null, totalSize: null, numFiles: 1 },
];
harness.modal.selectAnimetoshoEntry(0);
harness.modal.selectAnimetoshoEntry(1);
await flushAsyncWork();
// Entry 2's tracks are on screen; now entry 1 finally answers.
assert.deepEqual(
harness.state.animetoshoFiles.map((file) => file.attachmentId),
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
);
resolvers.forEach((resolve) => resolve(undefined));
await flushAsyncWork();
assert.equal(harness.state.currentAnimetoshoEntryId, 2);
assert.deepEqual(
harness.state.animetoshoFiles.map((file) => file.attachmentId),
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
);
} finally {
harness.restoreGlobals();
}
});
test('ArrowLeft switches back to the English tab', () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
pressKey(harness, 'ArrowRight');
assert.equal(harness.state.animetoshoActiveTab, 'ja');
pressKey(harness, 'ArrowLeft');
assert.equal(harness.state.animetoshoActiveTab, 'en');
} finally {
harness.restoreGlobals();
}
});
+487
View File
@@ -0,0 +1,487 @@
import type {
AnimetoshoApiResponse,
AnimetoshoDownloadResult,
AnimetoshoEntry,
AnimetoshoSubtitleFile,
JimakuMediaInfo,
} from '../../types';
import {
animetoshoTrackMatchesLanguages,
describeAnimetoshoTabLanguages,
normalizeAnimetoshoLangCode,
} from '../../animetosho/lang.js';
import type { ModalStateReader, RendererContext } from '../context';
export function createAnimetoshoModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function setAnimetoshoStatus(message: string, isError = false): void {
ctx.dom.animetoshoStatus.textContent = message;
ctx.dom.animetoshoStatus.style.color = isError
? 'rgba(255, 120, 120, 0.95)'
: 'rgba(255, 255, 255, 0.8)';
}
function resetAnimetoshoLists(): void {
ctx.state.animetoshoEntries = [];
ctx.state.animetoshoFiles = [];
ctx.state.selectedAnimetoshoEntryIndex = 0;
ctx.state.selectedAnimetoshoFileIndex = 0;
ctx.state.currentAnimetoshoEntryId = null;
ctx.dom.animetoshoEntriesList.innerHTML = '';
ctx.dom.animetoshoFilesList.innerHTML = '';
ctx.dom.animetoshoEntriesSection.classList.add('hidden');
ctx.dom.animetoshoFilesSection.classList.add('hidden');
}
// Defaults to English until the configured secondarySub languages arrive.
let secondaryLanguages: string[] = ['en'];
function secondaryTabLabel(): string {
return describeAnimetoshoTabLanguages(secondaryLanguages);
}
function isJapaneseTrack(file: AnimetoshoSubtitleFile): boolean {
return normalizeAnimetoshoLangCode(file.lang) === 'ja';
}
function getVisibleFiles(): AnimetoshoSubtitleFile[] {
if (ctx.state.animetoshoActiveTab === 'ja') {
return ctx.state.animetoshoFiles.filter(isJapaneseTrack);
}
return ctx.state.animetoshoFiles.filter(
(file) =>
!isJapaneseTrack(file) && animetoshoTrackMatchesLanguages(file.lang, secondaryLanguages),
);
}
function renderTabs(): void {
if (ctx.state.animetoshoActiveTab === 'ja') {
ctx.dom.animetoshoTabEnglishButton.classList.remove('active');
ctx.dom.animetoshoTabJapaneseButton.classList.add('active');
} else {
ctx.dom.animetoshoTabEnglishButton.classList.add('active');
ctx.dom.animetoshoTabJapaneseButton.classList.remove('active');
}
}
function describeEmptyTab(): string {
const hiddenCount = ctx.state.animetoshoFiles.length;
if (ctx.state.animetoshoActiveTab === 'ja') {
return hiddenCount > 0
? `No Japanese tracks in this release. Switch to the ${secondaryTabLabel()} tab.`
: 'No Japanese tracks in this release.';
}
return hiddenCount > 0
? `No ${secondaryTabLabel()} tracks in this release. Switch to the Japanese tab.`
: `No ${secondaryTabLabel()} tracks in this release.`;
}
function setActiveTab(tab: 'en' | 'ja'): void {
if (ctx.state.animetoshoActiveTab === tab) return;
ctx.state.animetoshoActiveTab = tab;
ctx.state.selectedAnimetoshoFileIndex = 0;
renderTabs();
if (ctx.state.animetoshoFiles.length === 0) return;
renderFiles();
if (getVisibleFiles().length === 0) {
setAnimetoshoStatus(describeEmptyTab());
} else {
setAnimetoshoStatus('Select a subtitle track.');
}
}
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 renderEntries(): void {
ctx.dom.animetoshoEntriesList.innerHTML = '';
if (ctx.state.animetoshoEntries.length === 0) {
ctx.dom.animetoshoEntriesSection.classList.add('hidden');
return;
}
ctx.dom.animetoshoEntriesSection.classList.remove('hidden');
ctx.state.animetoshoEntries.forEach((entry, index) => {
const li = document.createElement('li');
li.textContent = entry.title;
const details: string[] = [];
if (entry.totalSize !== null) details.push(formatBytes(entry.totalSize));
if (entry.numFiles !== null) {
details.push(`${entry.numFiles} file${entry.numFiles === 1 ? '' : 's'}`);
}
if (details.length > 0) {
const sub = document.createElement('div');
sub.className = 'jimaku-subtext';
sub.textContent = details.join(' • ');
li.appendChild(sub);
}
if (index === ctx.state.selectedAnimetoshoEntryIndex) {
li.classList.add('active');
}
li.addEventListener('click', () => {
selectEntry(index);
});
ctx.dom.animetoshoEntriesList.appendChild(li);
});
}
function renderFiles(): void {
ctx.dom.animetoshoFilesList.innerHTML = '';
const visibleFiles = getVisibleFiles();
if (visibleFiles.length === 0) {
ctx.dom.animetoshoFilesSection.classList.add('hidden');
return;
}
ctx.dom.animetoshoFilesSection.classList.remove('hidden');
visibleFiles.forEach((file, index) => {
const li = document.createElement('li');
li.textContent = file.filename;
const details: string[] = [];
if (file.lang) details.push(file.lang);
if (file.trackName) details.push(file.trackName);
details.push(formatBytes(file.size));
const sub = document.createElement('div');
sub.className = 'jimaku-subtext';
sub.textContent = details.filter(Boolean).join(' • ');
li.appendChild(sub);
if (index === ctx.state.selectedAnimetoshoFileIndex) {
li.classList.add('active');
}
li.addEventListener('click', () => {
void selectFile(index);
});
ctx.dom.animetoshoFilesList.appendChild(li);
});
}
function getSearchQuery(): string {
const title = ctx.dom.animetoshoTitleInput.value.trim();
if (!title) return '';
const episodeValue = ctx.dom.animetoshoEpisodeInput.value
? Number.parseInt(ctx.dom.animetoshoEpisodeInput.value, 10)
: null;
if (episodeValue !== null && Number.isFinite(episodeValue)) {
return `${title} ${String(episodeValue).padStart(2, '0')}`;
}
return title;
}
async function performAnimetoshoSearch(): Promise<void> {
const query = getSearchQuery();
if (!query) {
setAnimetoshoStatus('Enter a title before searching.', true);
return;
}
resetAnimetoshoLists();
setAnimetoshoStatus('Searching Animetosho...');
const response: AnimetoshoApiResponse<AnimetoshoEntry[]> =
await window.electronAPI.animetoshoSearchEntries({ query });
if (!response.ok) {
setAnimetoshoStatus(response.error.error, true);
return;
}
ctx.state.animetoshoEntries = response.data;
ctx.state.selectedAnimetoshoEntryIndex = 0;
if (ctx.state.animetoshoEntries.length === 0) {
setAnimetoshoStatus('No releases found.');
return;
}
setAnimetoshoStatus('Select a release.');
renderEntries();
if (ctx.state.animetoshoEntries.length === 1) {
selectEntry(0);
}
}
async function loadFiles(entryId: number): Promise<void> {
setAnimetoshoStatus('Loading subtitle tracks...');
ctx.state.animetoshoFiles = [];
ctx.state.selectedAnimetoshoFileIndex = 0;
ctx.dom.animetoshoFilesList.innerHTML = '';
ctx.dom.animetoshoFilesSection.classList.add('hidden');
const response: AnimetoshoApiResponse<AnimetoshoSubtitleFile[]> =
await window.electronAPI.animetoshoListFiles({ entryId });
// The user may have picked another release while this was in flight.
if (ctx.state.currentAnimetoshoEntryId !== entryId) return;
if (!response.ok) {
setAnimetoshoStatus(response.error.error, true);
return;
}
ctx.state.animetoshoFiles = response.data;
if (ctx.state.animetoshoFiles.length === 0) {
const entry = ctx.state.animetoshoEntries.find((candidate) => candidate.id === entryId);
// The feed API omits per-file attachment data for multi-file torrents.
if (entry && entry.numFiles !== null && entry.numFiles > 1) {
setAnimetoshoStatus(
'Batch releases are not supported. Pick a single-episode release instead.',
);
} else {
setAnimetoshoStatus('No text subtitle tracks in this release. Try another one.');
}
return;
}
const visibleFiles = getVisibleFiles();
if (visibleFiles.length === 0) {
setAnimetoshoStatus(describeEmptyTab());
return;
}
setAnimetoshoStatus('Select a subtitle track.');
renderFiles();
if (visibleFiles.length === 1) {
await selectFile(0);
}
}
function selectEntry(index: number): void {
if (index < 0 || index >= ctx.state.animetoshoEntries.length) return;
ctx.state.selectedAnimetoshoEntryIndex = index;
ctx.state.currentAnimetoshoEntryId = ctx.state.animetoshoEntries[index]!.id;
renderEntries();
if (ctx.state.currentAnimetoshoEntryId !== null) {
void loadFiles(ctx.state.currentAnimetoshoEntryId);
}
}
async function selectFile(index: number): Promise<void> {
const visibleFiles = getVisibleFiles();
if (index < 0 || index >= visibleFiles.length) return;
ctx.state.selectedAnimetoshoFileIndex = index;
renderFiles();
if (ctx.state.currentAnimetoshoEntryId === null) {
setAnimetoshoStatus('Select a release first.', true);
return;
}
const file = visibleFiles[index]!;
setAnimetoshoStatus('Downloading subtitle...');
const result: AnimetoshoDownloadResult = await window.electronAPI.animetoshoDownloadFile({
entryId: ctx.state.currentAnimetoshoEntryId,
url: file.url,
name: file.filename,
lang: file.lang,
});
if (result.ok) {
setAnimetoshoStatus(`Downloaded and loaded: ${result.path}`);
closeAnimetoshoModal();
return;
}
setAnimetoshoStatus(result.error.error, true);
}
function isTextInputFocused(): boolean {
const active = document.activeElement;
if (!active) return false;
const tag = active.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea';
}
async function loadSecondaryLanguages(): Promise<void> {
try {
const languages = await window.electronAPI.animetoshoGetSecondaryLanguages();
secondaryLanguages = languages.length > 0 ? languages : ['en'];
} catch {
secondaryLanguages = ['en'];
}
ctx.dom.animetoshoTabEnglishButton.textContent = secondaryTabLabel();
// Tracks may already be on screen if the languages arrived late.
if (ctx.state.animetoshoFiles.length > 0) {
renderFiles();
}
}
function openAnimetoshoModal(): void {
if (ctx.state.animetoshoModalOpen) return;
ctx.state.animetoshoModalOpen = true;
ctx.state.animetoshoActiveTab = 'en';
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.animetoshoModal.classList.remove('hidden');
ctx.dom.animetoshoModal.setAttribute('aria-hidden', 'false');
setAnimetoshoStatus('Loading media info...');
resetAnimetoshoLists();
renderTabs();
const secondaryLanguagesReady = loadSecondaryLanguages();
window.electronAPI
.getJimakuMediaInfo()
.then(async (info: JimakuMediaInfo) => {
ctx.dom.animetoshoTitleInput.value = info.title || '';
ctx.dom.animetoshoEpisodeInput.value = info.episode ? String(info.episode) : '';
if (info.confidence === 'high' && info.title && info.episode) {
await secondaryLanguagesReady;
void performAnimetoshoSearch();
} else if (info.title) {
setAnimetoshoStatus('Check title/episode and press Search.');
} else {
setAnimetoshoStatus('Enter title/episode and press Search.');
}
})
.catch(() => {
setAnimetoshoStatus('Failed to load media info.', true);
});
}
function closeAnimetoshoModal(): void {
if (!ctx.state.animetoshoModalOpen) return;
ctx.state.animetoshoModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.animetoshoModal.classList.add('hidden');
ctx.dom.animetoshoModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('animetosho');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
resetAnimetoshoLists();
}
function handleAnimetoshoKeydown(e: KeyboardEvent): boolean {
if (e.key === 'Escape') {
e.preventDefault();
closeAnimetoshoModal();
return true;
}
if (isTextInputFocused()) {
if (e.key === 'Enter') {
e.preventDefault();
void performAnimetoshoSearch();
}
return true;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
setActiveTab('en');
return true;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
setActiveTab('ja');
return true;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
const visibleFiles = getVisibleFiles();
if (visibleFiles.length > 0) {
ctx.state.selectedAnimetoshoFileIndex = Math.min(
visibleFiles.length - 1,
ctx.state.selectedAnimetoshoFileIndex + 1,
);
renderFiles();
} else if (ctx.state.animetoshoEntries.length > 0) {
ctx.state.selectedAnimetoshoEntryIndex = Math.min(
ctx.state.animetoshoEntries.length - 1,
ctx.state.selectedAnimetoshoEntryIndex + 1,
);
renderEntries();
}
return true;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (getVisibleFiles().length > 0) {
ctx.state.selectedAnimetoshoFileIndex = Math.max(
0,
ctx.state.selectedAnimetoshoFileIndex - 1,
);
renderFiles();
} else if (ctx.state.animetoshoEntries.length > 0) {
ctx.state.selectedAnimetoshoEntryIndex = Math.max(
0,
ctx.state.selectedAnimetoshoEntryIndex - 1,
);
renderEntries();
}
return true;
}
if (e.key === 'Enter') {
e.preventDefault();
if (getVisibleFiles().length > 0) {
void selectFile(ctx.state.selectedAnimetoshoFileIndex);
} else if (ctx.state.animetoshoEntries.length > 0) {
selectEntry(ctx.state.selectedAnimetoshoEntryIndex);
} else {
void performAnimetoshoSearch();
}
return true;
}
return true;
}
function wireDomEvents(): void {
ctx.dom.animetoshoSearchButton.addEventListener('click', () => {
void performAnimetoshoSearch();
});
ctx.dom.animetoshoCloseButton.addEventListener('click', () => {
closeAnimetoshoModal();
});
ctx.dom.animetoshoTabEnglishButton.addEventListener('click', () => {
setActiveTab('en');
});
ctx.dom.animetoshoTabJapaneseButton.addEventListener('click', () => {
setActiveTab('ja');
});
}
return {
closeAnimetoshoModal,
handleAnimetoshoKeydown,
openAnimetoshoModal,
selectAnimetoshoEntry: selectEntry,
wireDomEvents,
};
}
@@ -104,6 +104,7 @@ function describeCommand(command: (string | number)[]): string {
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
if (first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) return 'Open animetosho';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
@@ -148,6 +149,7 @@ function sectionForCommand(command: (string | number)[]): string {
if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first === SPECIAL_COMMANDS.JIMAKU_OPEN ||
first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN ||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) {
@@ -221,6 +223,8 @@ function describeSessionAction(
return 'Open controller debug';
case 'openJimaku':
return 'Open jimaku';
case 'openAnimetosho':
return 'Open animetosho';
case 'openYoutubePicker':
return 'Open YouTube subtitle picker';
case 'openPlaylistBrowser':
@@ -260,6 +264,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
return 'Subtitle sync';
case 'openRuntimeOptions':
case 'openJimaku':
case 'openAnimetosho':
case 'openCharacterDictionaryManager':
case 'openControllerSelect':
case 'openControllerDebug':
+17
View File
@@ -32,6 +32,7 @@ import { createControllerStatusIndicator } from './controller-status-indicator.j
import { createControllerDebugModal } from './modals/controller-debug.js';
import { createControllerSelectModal } from './modals/controller-select.js';
import { createJimakuModal } from './modals/jimaku.js';
import { createAnimetoshoModal } from './modals/animetosho.js';
import { createKikuModal } from './modals/kiku.js';
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
@@ -93,6 +94,7 @@ function isAnyModalOpen(): boolean {
ctx.state.controllerSelectModalOpen ||
ctx.state.controllerDebugModalOpen ||
ctx.state.jimakuModalOpen ||
ctx.state.animetoshoModalOpen ||
ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
ctx.state.characterDictionaryModalOpen ||
@@ -172,6 +174,10 @@ const jimakuModal = createJimakuModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const animetoshoModal = createAnimetoshoModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
applyYPercent: positioning.applyYPercent,
@@ -199,6 +205,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleAnimetoshoKeydown: animetoshoModal.handleAnimetoshoKeydown,
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
@@ -272,6 +279,9 @@ function dismissActiveUiAfterError(): void {
if (ctx.state.jimakuModalOpen) {
jimakuModal.closeJimakuModal();
}
if (ctx.state.animetoshoModalOpen) {
animetoshoModal.closeAnimetoshoModal();
}
if (ctx.state.youtubePickerModalOpen) {
youtubePickerModal.closeYoutubePickerModal();
}
@@ -527,6 +537,12 @@ function registerModalOpenHandlers(): void {
window.electronAPI.notifyOverlayModalOpened('jimaku');
});
});
window.electronAPI.onOpenAnimetosho(() => {
runGuarded('animetosho:open', () => {
animetoshoModal.openAnimetoshoModal();
window.electronAPI.notifyOverlayModalOpened('animetosho');
});
});
window.electronAPI.onOpenYoutubeTrackPicker((payload) => {
runGuarded('youtube:picker-open', () => {
youtubePickerModal.openYoutubePickerModal(payload);
@@ -761,6 +777,7 @@ async function init(): Promise<void> {
});
jimakuModal.wireDomEvents();
animetoshoModal.wireDomEvents();
youtubePickerModal.wireDomEvents();
playlistBrowserModal.wireDomEvents();
kikuModal.wireDomEvents();
+18
View File
@@ -4,6 +4,8 @@ import type {
ControllerButtonSnapshot,
ControllerDeviceInfo,
ResolvedControllerConfig,
AnimetoshoEntry,
AnimetoshoSubtitleFile,
JimakuEntry,
JimakuFileEntry,
KikuDuplicateCardInfo,
@@ -48,6 +50,14 @@ export type RendererState = {
currentEpisodeFilter: number | null;
currentEntryId: number | null;
animetoshoModalOpen: boolean;
animetoshoActiveTab: 'en' | 'ja';
animetoshoEntries: AnimetoshoEntry[];
animetoshoFiles: AnimetoshoSubtitleFile[];
selectedAnimetoshoEntryIndex: number;
selectedAnimetoshoFileIndex: number;
currentAnimetoshoEntryId: number | null;
youtubePickerModalOpen: boolean;
youtubePickerPayload: YoutubePickerOpenPayload | null;
youtubePickerPrimaryTrackId: string | null;
@@ -164,6 +174,14 @@ export function createRendererState(): RendererState {
currentEpisodeFilter: null,
currentEntryId: null,
animetoshoModalOpen: false,
animetoshoActiveTab: 'en',
animetoshoEntries: [],
animetoshoFiles: [],
selectedAnimetoshoEntryIndex: 0,
selectedAnimetoshoFileIndex: 0,
currentAnimetoshoEntryId: null,
youtubePickerModalOpen: false,
youtubePickerPayload: null,
youtubePickerPrimaryTrackId: null,
+44
View File
@@ -811,6 +811,50 @@ body:focus-visible,
z-index: 1100;
}
#animetoshoModal {
z-index: 1100;
}
#animetoshoModal .jimaku-form {
grid-template-columns: 1fr 120px auto;
}
.animetosho-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.animetosho-tab {
min-width: 0;
min-height: 34px;
padding: 7px 8px;
border-radius: 7px;
border: 1px solid rgba(110, 115, 141, 0.22);
background: rgba(49, 50, 68, 0.76);
color: var(--ctp-subtext1);
font-size: 12px;
font-weight: 700;
line-height: 1.15;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.animetosho-tab:hover,
.animetosho-tab:focus-visible {
border-color: rgba(138, 173, 244, 0.48);
color: var(--ctp-text);
outline: none;
}
.animetosho-tab.active {
border-color: rgba(238, 212, 159, 0.62);
background: rgba(238, 212, 159, 0.16);
color: var(--ctp-yellow);
}
#youtubePickerModal {
z-index: 1110;
}
+26
View File
@@ -22,6 +22,19 @@ export type RendererDom = {
jimakuFilesList: HTMLUListElement;
jimakuBroadenButton: HTMLButtonElement;
animetoshoModal: HTMLDivElement;
animetoshoTitleInput: HTMLInputElement;
animetoshoEpisodeInput: HTMLInputElement;
animetoshoSearchButton: HTMLButtonElement;
animetoshoCloseButton: HTMLButtonElement;
animetoshoTabEnglishButton: HTMLButtonElement;
animetoshoTabJapaneseButton: HTMLButtonElement;
animetoshoStatus: HTMLDivElement;
animetoshoEntriesSection: HTMLDivElement;
animetoshoEntriesList: HTMLUListElement;
animetoshoFilesSection: HTMLDivElement;
animetoshoFilesList: HTMLUListElement;
youtubePickerModal: HTMLDivElement;
youtubePickerTitle: HTMLDivElement;
youtubePickerPrimarySelect: HTMLSelectElement;
@@ -154,6 +167,19 @@ export function resolveRendererDom(): RendererDom {
jimakuFilesList: getRequiredElement<HTMLUListElement>('jimakuFiles'),
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>('jimakuBroaden'),
animetoshoModal: getRequiredElement<HTMLDivElement>('animetoshoModal'),
animetoshoTitleInput: getRequiredElement<HTMLInputElement>('animetoshoTitle'),
animetoshoEpisodeInput: getRequiredElement<HTMLInputElement>('animetoshoEpisode'),
animetoshoSearchButton: getRequiredElement<HTMLButtonElement>('animetoshoSearch'),
animetoshoCloseButton: getRequiredElement<HTMLButtonElement>('animetoshoClose'),
animetoshoTabEnglishButton: getRequiredElement<HTMLButtonElement>('animetoshoTabEnglish'),
animetoshoTabJapaneseButton: getRequiredElement<HTMLButtonElement>('animetoshoTabJapanese'),
animetoshoStatus: getRequiredElement<HTMLDivElement>('animetoshoStatus'),
animetoshoEntriesSection: getRequiredElement<HTMLDivElement>('animetoshoEntriesSection'),
animetoshoEntriesList: getRequiredElement<HTMLUListElement>('animetoshoEntries'),
animetoshoFilesSection: getRequiredElement<HTMLDivElement>('animetoshoFilesSection'),
animetoshoFilesList: getRequiredElement<HTMLUListElement>('animetoshoFiles'),
youtubePickerModal: getRequiredElement<HTMLDivElement>('youtubePickerModal'),
youtubePickerTitle: getRequiredElement<HTMLDivElement>('youtubePickerTitle'),
youtubePickerPrimarySelect: getRequiredElement<HTMLSelectElement>('youtubePickerPrimarySelect'),