mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-06 19:21:31 -07:00
refactor(tsukihime): swap Animetosho backend for TsukiHime API (#165)
This commit is contained in:
@@ -90,7 +90,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
|
||||
openCharacterDictionaryManager: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openAnimetosho: null,
|
||||
openTsukihime: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
@@ -492,7 +492,7 @@ function createKeyboardHandlerHarness() {
|
||||
handleSubsyncKeydown: () => false,
|
||||
handleKikuKeydown: () => false,
|
||||
handleJimakuKeydown: () => false,
|
||||
handleAnimetoshoKeydown: () => false,
|
||||
handleTsukihimeKeydown: () => false,
|
||||
handleControllerSelectKeydown: () => {
|
||||
controllerSelectKeydownCount += 1;
|
||||
return true;
|
||||
|
||||
@@ -16,7 +16,7 @@ export function createKeyboardHandlers(
|
||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleAnimetoshoKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
||||
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||
@@ -1120,8 +1120,8 @@ export function createKeyboardHandlers(
|
||||
options.handleJimakuKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.animetoshoModalOpen) {
|
||||
options.handleAnimetoshoKeydown(e);
|
||||
if (ctx.state.tsukihimeModalOpen) {
|
||||
options.handleTsukihimeKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.youtubePickerModalOpen) {
|
||||
|
||||
+26
-14
@@ -116,40 +116,52 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="animetoshoModal" class="modal hidden" aria-hidden="true">
|
||||
<div id="tsukihimeModal" 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 class="modal-title">TsukiHime Subtitles</div>
|
||||
<button id="tsukihimeClose" 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">
|
||||
<div class="tsukihime-tabs" role="tablist">
|
||||
<button
|
||||
id="tsukihimeTabSecondary"
|
||||
class="tsukihime-tab active"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button id="animetoshoTabJapanese" class="animetosho-tab" type="button">
|
||||
<button
|
||||
id="tsukihimeTabPrimary"
|
||||
class="tsukihime-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
>
|
||||
Japanese
|
||||
</button>
|
||||
</div>
|
||||
<div class="jimaku-form">
|
||||
<label class="jimaku-field">
|
||||
<span>Title</span>
|
||||
<input id="animetoshoTitle" type="text" placeholder="Anime title" />
|
||||
<input id="tsukihimeTitle" type="text" placeholder="Anime title" />
|
||||
</label>
|
||||
<label class="jimaku-field">
|
||||
<span>Episode</span>
|
||||
<input id="animetoshoEpisode" type="number" min="1" placeholder="1" />
|
||||
<input id="tsukihimeEpisode" type="number" min="1" placeholder="1" />
|
||||
</label>
|
||||
<button id="animetoshoSearch" class="jimaku-button" type="button">Search</button>
|
||||
<button id="tsukihimeSearch" class="jimaku-button" type="button">Search</button>
|
||||
</div>
|
||||
<div id="animetoshoStatus" class="jimaku-status"></div>
|
||||
<div id="animetoshoEntriesSection" class="jimaku-section hidden">
|
||||
<div id="tsukihimeStatus" class="jimaku-status"></div>
|
||||
<div id="tsukihimeEntriesSection" class="jimaku-section hidden">
|
||||
<div class="jimaku-section-title">Releases</div>
|
||||
<ul id="animetoshoEntries" class="jimaku-list"></ul>
|
||||
<ul id="tsukihimeEntries" class="jimaku-list"></ul>
|
||||
</div>
|
||||
<div id="animetoshoFilesSection" class="jimaku-section hidden">
|
||||
<div id="tsukihimeFilesSection" class="jimaku-section hidden">
|
||||
<div class="jimaku-section-title">Subtitle tracks</div>
|
||||
<ul id="animetoshoFiles" class="jimaku-list"></ul>
|
||||
<ul id="tsukihimeFiles" class="jimaku-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -1,487 +0,0 @@
|
||||
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,7 +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.TSUKIHIME_OPEN) return 'Open TsukiHime';
|
||||
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';
|
||||
@@ -149,7 +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.TSUKIHIME_OPEN ||
|
||||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
|
||||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
|
||||
) {
|
||||
@@ -223,8 +223,8 @@ function describeSessionAction(
|
||||
return 'Open controller debug';
|
||||
case 'openJimaku':
|
||||
return 'Open jimaku';
|
||||
case 'openAnimetosho':
|
||||
return 'Open animetosho';
|
||||
case 'openTsukihime':
|
||||
return 'Open TsukiHime';
|
||||
case 'openYoutubePicker':
|
||||
return 'Open YouTube subtitle picker';
|
||||
case 'openPlaylistBrowser':
|
||||
@@ -264,7 +264,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
||||
return 'Subtitle sync';
|
||||
case 'openRuntimeOptions':
|
||||
case 'openJimaku':
|
||||
case 'openAnimetosho':
|
||||
case 'openTsukihime':
|
||||
case 'openCharacterDictionaryManager':
|
||||
case 'openControllerSelect':
|
||||
case 'openControllerDebug':
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { TsukihimeSubtitleFile, ElectronAPI } from '../../types';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createTsukihimeModal } from './tsukihime.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 createTabStub(active: boolean) {
|
||||
const attributes = new Map<string, string>();
|
||||
return {
|
||||
textContent: '',
|
||||
classList: createClassList(active ? ['active'] : []),
|
||||
attributes,
|
||||
setAttribute(name: string, value: string) {
|
||||
attributes.set(name, value);
|
||||
},
|
||||
addEventListener: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function flushAsyncWork(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
const ENGLISH_TRACK: TsukihimeSubtitleFile = {
|
||||
attachmentId: 1955356,
|
||||
filename: 'episode01.eng.ass',
|
||||
lang: 'eng',
|
||||
trackName: 'English subs',
|
||||
size: 33075,
|
||||
url: 'https://storage.tsukihime.org/attach/001dd61c/1955356.xz',
|
||||
sourceFilename: 'episode01.mkv',
|
||||
};
|
||||
|
||||
const JAPANESE_TRACK: TsukihimeSubtitleFile = {
|
||||
attachmentId: 1955400,
|
||||
filename: 'episode01.jpn.ass',
|
||||
lang: 'jpn',
|
||||
trackName: 'Japanese subs',
|
||||
size: 41000,
|
||||
url: 'https://storage.tsukihime.org/attach/001dd648/1955400.xz',
|
||||
sourceFilename: 'episode01.mkv',
|
||||
};
|
||||
|
||||
const GERMAN_TRACK: TsukihimeSubtitleFile = {
|
||||
attachmentId: 1955500,
|
||||
filename: 'episode01.ger.ass',
|
||||
lang: 'ger',
|
||||
trackName: 'Deutsch',
|
||||
size: 28000,
|
||||
url: 'https://storage.tsukihime.org/attach/001dd6ac/1955500.xz',
|
||||
sourceFilename: 'episode01.mkv',
|
||||
};
|
||||
|
||||
interface ModalHarness {
|
||||
modal: ReturnType<typeof createTsukihimeModal>;
|
||||
state: ReturnType<typeof createRendererState>;
|
||||
downloadQueries: unknown[];
|
||||
modalCloseNotifications: string[];
|
||||
overlayClassList: ReturnType<typeof createClassList>;
|
||||
tsukihimeModalClassList: ReturnType<typeof createClassList>;
|
||||
titleInput: { value: string };
|
||||
status: { textContent: string; style: { color: string } };
|
||||
entriesList: ReturnType<typeof createListStub>;
|
||||
filesList: ReturnType<typeof createListStub>;
|
||||
secondaryTab: ReturnType<typeof createTabStub>;
|
||||
primaryTab: ReturnType<typeof createTabStub>;
|
||||
restoreGlobals: () => void;
|
||||
}
|
||||
|
||||
function createModalHarness(
|
||||
files: TsukihimeSubtitleFile[],
|
||||
options: {
|
||||
secondaryLanguages?: string[];
|
||||
downloadFile?: (query: unknown) => Promise<unknown>;
|
||||
listFiles?: (entryId: number) => Promise<unknown>;
|
||||
searchEntries?: (query: unknown) => 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 = {
|
||||
tsukihimeDownloadFile: async (query: unknown) => {
|
||||
downloadQueries.push(query);
|
||||
if (options.downloadFile) return options.downloadFile(query);
|
||||
return { ok: true, path: '/tmp/subtitles/episode01.en.ass' };
|
||||
},
|
||||
tsukihimeGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
|
||||
tsukihimeListFiles: async ({ entryId }: { entryId: number }) =>
|
||||
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
|
||||
tsukihimeSearchEntries: async (query: unknown) =>
|
||||
options.searchEntries ? options.searchEntries(query) : { 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 tsukihimeModalClassList = createClassList();
|
||||
const state = createRendererState();
|
||||
state.tsukihimeModalOpen = true;
|
||||
state.currentTsukihimeEntryId = 606713;
|
||||
state.selectedTsukihimeFileIndex = 0;
|
||||
state.tsukihimeFiles = files;
|
||||
const secondaryTab = createTabStub(true);
|
||||
secondaryTab.textContent = 'English';
|
||||
const primaryTab = createTabStub(false);
|
||||
primaryTab.textContent = 'Japanese';
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: overlayClassList },
|
||||
tsukihimeModal: {
|
||||
classList: tsukihimeModalClassList,
|
||||
setAttribute: () => {},
|
||||
},
|
||||
tsukihimeTitleInput: { value: '' },
|
||||
tsukihimeEpisodeInput: { value: '' },
|
||||
tsukihimeSearchButton: { addEventListener: () => {} },
|
||||
tsukihimeCloseButton: { addEventListener: () => {} },
|
||||
tsukihimeTabSecondaryButton: secondaryTab,
|
||||
tsukihimeTabPrimaryButton: primaryTab,
|
||||
tsukihimeStatus: { textContent: '', style: { color: '' } },
|
||||
tsukihimeEntriesSection: { classList: createClassList(['hidden']) },
|
||||
tsukihimeEntriesList: createListStub(),
|
||||
tsukihimeFilesSection: { classList: createClassList() },
|
||||
tsukihimeFilesList: createListStub(),
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createTsukihimeModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
return {
|
||||
modal,
|
||||
state,
|
||||
downloadQueries,
|
||||
modalCloseNotifications,
|
||||
overlayClassList,
|
||||
tsukihimeModalClassList,
|
||||
titleInput: ctx.dom.tsukihimeTitleInput,
|
||||
status: ctx.dom.tsukihimeStatus,
|
||||
entriesList: ctx.dom.tsukihimeEntriesList,
|
||||
filesList: ctx.dom.tsukihimeFilesList,
|
||||
secondaryTab,
|
||||
primaryTab,
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('TsukiHime language tabs expose tab semantics in the renderer markup', () => {
|
||||
const html = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer', 'index.html'), 'utf8');
|
||||
const tabs = html.match(/<div class="tsukihime-tabs"[\s\S]*?<\/div>/)?.[0];
|
||||
|
||||
assert.ok(tabs);
|
||||
assert.match(tabs, /<div class="tsukihime-tabs" role="tablist">/);
|
||||
assert.match(tabs, /id="tsukihimeTabSecondary"[\s\S]*?role="tab"[\s\S]*?aria-selected="true"/);
|
||||
assert.match(tabs, /id="tsukihimeTabPrimary"[\s\S]*?role="tab"[\s\S]*?aria-selected="false"/);
|
||||
});
|
||||
|
||||
test('switching TsukiHime language tabs synchronizes aria-selected', () => {
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
||||
try {
|
||||
pressKey(harness, 'ArrowRight');
|
||||
|
||||
assert.equal(harness.secondaryTab.attributes.get('aria-selected'), 'false');
|
||||
assert.equal(harness.primaryTab.attributes.get('aria-selected'), 'true');
|
||||
|
||||
pressKey(harness, 'ArrowLeft');
|
||||
|
||||
assert.equal(harness.secondaryTab.attributes.get('aria-selected'), 'true');
|
||||
assert.equal(harness.primaryTab.attributes.get('aria-selected'), 'false');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
function pressKey(harness: ModalHarness, key: string): boolean {
|
||||
let prevented = false;
|
||||
harness.modal.handleTsukihimeKeydown({
|
||||
key,
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
} as KeyboardEvent);
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test('successful Tsukihime 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.tsukihimeModalOpen, false);
|
||||
assert.equal(harness.tsukihimeModalClassList.contains('hidden'), true);
|
||||
assert.equal(harness.overlayClassList.contains('interactive'), false);
|
||||
assert.deepEqual(harness.modalCloseNotifications, ['tsukihime']);
|
||||
assert.deepEqual(harness.downloadQueries, [
|
||||
{
|
||||
entryId: 606713,
|
||||
url: ENGLISH_TRACK.url,
|
||||
name: ENGLISH_TRACK.filename,
|
||||
lang: 'eng',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a download from a prior modal session cannot close a reopened modal', async () => {
|
||||
let resolveDownload!: (value: unknown) => void;
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK], {
|
||||
downloadFile: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveDownload = resolve;
|
||||
}),
|
||||
});
|
||||
try {
|
||||
pressKey(harness, 'Enter');
|
||||
harness.modal.closeTsukihimeModal();
|
||||
harness.modal.openTsukihimeModal();
|
||||
await flushAsyncWork();
|
||||
harness.state.currentTsukihimeEntryId = 606713;
|
||||
harness.status.textContent = 'Fresh modal session';
|
||||
|
||||
resolveDownload({ ok: true, path: '/tmp/subtitles/stale.ass' });
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(harness.state.tsukihimeModalOpen, true);
|
||||
assert.equal(harness.status.textContent, 'Fresh modal session');
|
||||
assert.deepEqual(harness.modalCloseNotifications, ['tsukihime']);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a download cannot affect a newly selected release', async () => {
|
||||
let resolveDownload!: (value: unknown) => void;
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK], {
|
||||
downloadFile: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveDownload = resolve;
|
||||
}),
|
||||
});
|
||||
try {
|
||||
pressKey(harness, 'Enter');
|
||||
harness.state.tsukihimeEntries = [
|
||||
{
|
||||
id: 999,
|
||||
title: 'new release',
|
||||
timestamp: null,
|
||||
totalSize: null,
|
||||
numFiles: 1,
|
||||
sublangs: [],
|
||||
},
|
||||
];
|
||||
harness.modal.selectTsukihimeEntry(0);
|
||||
await flushAsyncWork();
|
||||
const currentStatus = harness.status.textContent;
|
||||
|
||||
resolveDownload({ ok: false, error: { error: 'stale failure' } });
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(harness.state.tsukihimeModalOpen, true);
|
||||
assert.equal(harness.state.currentTsukihimeEntryId, 999);
|
||||
assert.equal(harness.status.textContent, currentStatus);
|
||||
assert.deepEqual(harness.modalCloseNotifications, []);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('secondary tab hides languages outside its configured defaults', 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('primary tab defaults to Japanese tracks', async () => {
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
||||
try {
|
||||
assert.equal(harness.state.tsukihimeActiveTab, 'secondary');
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.equal(harness.state.tsukihimeActiveTab, 'primary');
|
||||
|
||||
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.tsukihimeModalOpen = false;
|
||||
harness.modal.openTsukihimeModal();
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.state.tsukihimeFiles = [GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK];
|
||||
harness.state.currentTsukihimeEntryId = 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('primary tab remains Japanese when the release includes other languages', async () => {
|
||||
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK], {
|
||||
secondaryLanguages: ['en'],
|
||||
});
|
||||
try {
|
||||
harness.state.tsukihimeModalOpen = false;
|
||||
harness.modal.openTsukihimeModal();
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.state.tsukihimeFiles = [GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK];
|
||||
harness.state.currentTsukihimeEntryId = 606713;
|
||||
|
||||
pressKey(harness, 'ArrowRight');
|
||||
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('a slow release response does not overwrite the newly selected release', async () => {
|
||||
const STALE_TRACK: TsukihimeSubtitleFile = {
|
||||
...ENGLISH_TRACK,
|
||||
attachmentId: 999,
|
||||
filename: 'stale.eng.ass',
|
||||
};
|
||||
const SECOND_ENGLISH_TRACK: TsukihimeSubtitleFile = {
|
||||
...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.tsukihimeEntries = [
|
||||
{ id: 1, title: 'slow release', timestamp: null, totalSize: null, numFiles: 1, sublangs: [] },
|
||||
{ id: 2, title: 'fast release', timestamp: null, totalSize: null, numFiles: 1, sublangs: [] },
|
||||
];
|
||||
|
||||
harness.modal.selectTsukihimeEntry(0);
|
||||
harness.modal.selectTsukihimeEntry(1);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Entry 2's tracks are on screen; now entry 1 finally answers.
|
||||
assert.deepEqual(
|
||||
harness.state.tsukihimeFiles.map((file) => file.attachmentId),
|
||||
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
|
||||
);
|
||||
|
||||
resolvers.forEach((resolve) => resolve(undefined));
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(harness.state.currentTsukihimeEntryId, 2);
|
||||
assert.deepEqual(
|
||||
harness.state.tsukihimeFiles.map((file) => file.attachmentId),
|
||||
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
|
||||
);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('ArrowLeft switches back to the secondary-language tab', () => {
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
||||
try {
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.equal(harness.state.tsukihimeActiveTab, 'primary');
|
||||
pressKey(harness, 'ArrowLeft');
|
||||
assert.equal(harness.state.tsukihimeActiveTab, 'secondary');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('searching reports TsukiHime as the backend and lists sublangs per release', async () => {
|
||||
const harness = createModalHarness([], {
|
||||
searchEntries: async () => ({
|
||||
ok: true,
|
||||
data: [
|
||||
{
|
||||
id: 12255,
|
||||
title: '[DKB] Futsutsuka na Akujo - S01E01 [Multi-Subs]',
|
||||
timestamp: null,
|
||||
totalSize: 423868898,
|
||||
numFiles: 1,
|
||||
sublangs: ['en-US', 'ja'],
|
||||
},
|
||||
{
|
||||
id: 12256,
|
||||
title: 'release without langs',
|
||||
timestamp: null,
|
||||
totalSize: null,
|
||||
numFiles: null,
|
||||
sublangs: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
try {
|
||||
harness.state.tsukihimeFiles = [];
|
||||
harness.state.currentTsukihimeEntryId = null;
|
||||
harness.titleInput.value = 'Futsutsuka na Akujo';
|
||||
|
||||
const seenStatuses: string[] = [];
|
||||
const status = harness.status;
|
||||
Object.defineProperty(status, 'textContent', {
|
||||
get: () => seenStatuses.at(-1) ?? '',
|
||||
set: (value: string) => {
|
||||
seenStatuses.push(value);
|
||||
},
|
||||
});
|
||||
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(
|
||||
seenStatuses.some((message) => message.includes('TsukiHime')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
seenStatuses.some((message) => message.includes('Tsukihime')),
|
||||
false,
|
||||
);
|
||||
|
||||
const firstEntry = harness.entriesList.children[0] as {
|
||||
children: Array<{ textContent: string }>;
|
||||
};
|
||||
assert.equal(firstEntry.children.length, 1);
|
||||
assert.match(firstEntry.children[0]!.textContent, /en-US, ja/);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('renderFiles omits the size detail when the API does not report one', () => {
|
||||
const zeroSizeTrack: TsukihimeSubtitleFile = {
|
||||
...ENGLISH_TRACK,
|
||||
size: 0,
|
||||
};
|
||||
const harness = createModalHarness([zeroSizeTrack, JAPANESE_TRACK]);
|
||||
try {
|
||||
pressKey(harness, 'ArrowDown');
|
||||
|
||||
const firstFile = harness.filesList.children[0] as {
|
||||
children: Array<{ textContent: string }>;
|
||||
};
|
||||
assert.equal(firstFile.children.length, 1);
|
||||
assert.equal(firstFile.children[0]!.textContent.includes('0 B'), false);
|
||||
assert.match(firstFile.children[0]!.textContent, /English subs/);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
import type {
|
||||
TsukihimeApiResponse,
|
||||
TsukihimeDownloadResult,
|
||||
TsukihimeEntry,
|
||||
TsukihimeSubtitleFile,
|
||||
JimakuMediaInfo,
|
||||
} from '../../types';
|
||||
import {
|
||||
tsukihimeTrackMatchesLanguages,
|
||||
describeTsukihimeTabLanguages,
|
||||
normalizeTsukihimeLangCode,
|
||||
} from '../../tsukihime/lang.js';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
export function createTsukihimeModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
function setTsukihimeStatus(message: string, isError = false): void {
|
||||
ctx.dom.tsukihimeStatus.textContent = message;
|
||||
ctx.dom.tsukihimeStatus.style.color = isError
|
||||
? 'rgba(255, 120, 120, 0.95)'
|
||||
: 'rgba(255, 255, 255, 0.8)';
|
||||
}
|
||||
|
||||
function resetTsukihimeLists(): void {
|
||||
ctx.state.tsukihimeEntries = [];
|
||||
ctx.state.tsukihimeFiles = [];
|
||||
ctx.state.selectedTsukihimeEntryIndex = 0;
|
||||
ctx.state.selectedTsukihimeFileIndex = 0;
|
||||
ctx.state.currentTsukihimeEntryId = null;
|
||||
|
||||
ctx.dom.tsukihimeEntriesList.innerHTML = '';
|
||||
ctx.dom.tsukihimeFilesList.innerHTML = '';
|
||||
ctx.dom.tsukihimeEntriesSection.classList.add('hidden');
|
||||
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Defaults to English until the configured secondary languages arrive.
|
||||
let secondaryLanguages: string[] = ['en'];
|
||||
let activeDownloadToken = 0;
|
||||
|
||||
function secondaryTabLabel(): string {
|
||||
return describeTsukihimeTabLanguages(secondaryLanguages);
|
||||
}
|
||||
|
||||
function isPrimaryTrack(file: TsukihimeSubtitleFile): boolean {
|
||||
return normalizeTsukihimeLangCode(file.lang) === 'ja';
|
||||
}
|
||||
|
||||
function getVisibleFiles(): TsukihimeSubtitleFile[] {
|
||||
if (ctx.state.tsukihimeActiveTab === 'primary') {
|
||||
return ctx.state.tsukihimeFiles.filter(isPrimaryTrack);
|
||||
}
|
||||
return ctx.state.tsukihimeFiles.filter(
|
||||
(file) =>
|
||||
!isPrimaryTrack(file) && tsukihimeTrackMatchesLanguages(file.lang, secondaryLanguages),
|
||||
);
|
||||
}
|
||||
|
||||
function renderTabs(): void {
|
||||
const primaryActive = ctx.state.tsukihimeActiveTab === 'primary';
|
||||
ctx.dom.tsukihimeTabSecondaryButton.setAttribute(
|
||||
'aria-selected',
|
||||
primaryActive ? 'false' : 'true',
|
||||
);
|
||||
ctx.dom.tsukihimeTabPrimaryButton.setAttribute(
|
||||
'aria-selected',
|
||||
primaryActive ? 'true' : 'false',
|
||||
);
|
||||
if (primaryActive) {
|
||||
ctx.dom.tsukihimeTabSecondaryButton.classList.remove('active');
|
||||
ctx.dom.tsukihimeTabPrimaryButton.classList.add('active');
|
||||
} else {
|
||||
ctx.dom.tsukihimeTabSecondaryButton.classList.add('active');
|
||||
ctx.dom.tsukihimeTabPrimaryButton.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function describeEmptyTab(): string {
|
||||
const hiddenCount = ctx.state.tsukihimeFiles.length;
|
||||
if (ctx.state.tsukihimeActiveTab === 'primary') {
|
||||
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: 'secondary' | 'primary'): void {
|
||||
if (ctx.state.tsukihimeActiveTab === tab) return;
|
||||
ctx.state.tsukihimeActiveTab = tab;
|
||||
ctx.state.selectedTsukihimeFileIndex = 0;
|
||||
renderTabs();
|
||||
|
||||
if (ctx.state.tsukihimeFiles.length === 0) return;
|
||||
renderFiles();
|
||||
if (getVisibleFiles().length === 0) {
|
||||
setTsukihimeStatus(describeEmptyTab());
|
||||
} else {
|
||||
setTsukihimeStatus('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.tsukihimeEntriesList.innerHTML = '';
|
||||
if (ctx.state.tsukihimeEntries.length === 0) {
|
||||
ctx.dom.tsukihimeEntriesSection.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.tsukihimeEntriesSection.classList.remove('hidden');
|
||||
ctx.state.tsukihimeEntries.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 (entry.sublangs?.length) details.push(`subs: ${entry.sublangs.join(', ')}`);
|
||||
if (details.length > 0) {
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'jimaku-subtext';
|
||||
sub.textContent = details.join(' • ');
|
||||
li.appendChild(sub);
|
||||
}
|
||||
|
||||
if (index === ctx.state.selectedTsukihimeEntryIndex) {
|
||||
li.classList.add('active');
|
||||
}
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
selectEntry(index);
|
||||
});
|
||||
|
||||
ctx.dom.tsukihimeEntriesList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFiles(): void {
|
||||
ctx.dom.tsukihimeFilesList.innerHTML = '';
|
||||
const visibleFiles = getVisibleFiles();
|
||||
if (visibleFiles.length === 0) {
|
||||
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.tsukihimeFilesSection.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);
|
||||
// TsukiHime does not report attachment sizes; skip a meaningless "0 B".
|
||||
if (file.size > 0) 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.selectedTsukihimeFileIndex) {
|
||||
li.classList.add('active');
|
||||
}
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
void selectFile(index);
|
||||
});
|
||||
|
||||
ctx.dom.tsukihimeFilesList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function getSearchQuery(): string {
|
||||
const title = ctx.dom.tsukihimeTitleInput.value.trim();
|
||||
if (!title) return '';
|
||||
const episodeValue = ctx.dom.tsukihimeEpisodeInput.value
|
||||
? Number.parseInt(ctx.dom.tsukihimeEpisodeInput.value, 10)
|
||||
: null;
|
||||
if (episodeValue !== null && Number.isFinite(episodeValue)) {
|
||||
return `${title} ${String(episodeValue).padStart(2, '0')}`;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
async function performTsukihimeSearch(): Promise<void> {
|
||||
const query = getSearchQuery();
|
||||
if (!query) {
|
||||
setTsukihimeStatus('Enter a title before searching.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
resetTsukihimeLists();
|
||||
setTsukihimeStatus('Searching TsukiHime...');
|
||||
|
||||
const response: TsukihimeApiResponse<TsukihimeEntry[]> =
|
||||
await window.electronAPI.tsukihimeSearchEntries({ query });
|
||||
if (!response.ok) {
|
||||
setTsukihimeStatus(response.error.error, true);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.tsukihimeEntries = response.data;
|
||||
ctx.state.selectedTsukihimeEntryIndex = 0;
|
||||
|
||||
if (ctx.state.tsukihimeEntries.length === 0) {
|
||||
setTsukihimeStatus('No releases found.');
|
||||
return;
|
||||
}
|
||||
|
||||
setTsukihimeStatus('Select a release.');
|
||||
renderEntries();
|
||||
if (ctx.state.tsukihimeEntries.length === 1) {
|
||||
selectEntry(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles(entryId: number): Promise<void> {
|
||||
setTsukihimeStatus('Loading subtitle tracks...');
|
||||
ctx.state.tsukihimeFiles = [];
|
||||
ctx.state.selectedTsukihimeFileIndex = 0;
|
||||
|
||||
ctx.dom.tsukihimeFilesList.innerHTML = '';
|
||||
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
|
||||
|
||||
const response: TsukihimeApiResponse<TsukihimeSubtitleFile[]> =
|
||||
await window.electronAPI.tsukihimeListFiles({ entryId });
|
||||
// The user may have picked another release while this was in flight.
|
||||
if (ctx.state.currentTsukihimeEntryId !== entryId) return;
|
||||
if (!response.ok) {
|
||||
setTsukihimeStatus(response.error.error, true);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.tsukihimeFiles = response.data;
|
||||
if (ctx.state.tsukihimeFiles.length === 0) {
|
||||
const entry = ctx.state.tsukihimeEntries.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) {
|
||||
setTsukihimeStatus(
|
||||
'Batch releases are not supported. Pick a single-episode release instead.',
|
||||
);
|
||||
} else {
|
||||
setTsukihimeStatus('No text subtitle tracks in this release. Try another one.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleFiles = getVisibleFiles();
|
||||
if (visibleFiles.length === 0) {
|
||||
setTsukihimeStatus(describeEmptyTab());
|
||||
return;
|
||||
}
|
||||
|
||||
setTsukihimeStatus('Select a subtitle track.');
|
||||
renderFiles();
|
||||
if (visibleFiles.length === 1) {
|
||||
await selectFile(0);
|
||||
}
|
||||
}
|
||||
|
||||
function selectEntry(index: number): void {
|
||||
if (index < 0 || index >= ctx.state.tsukihimeEntries.length) return;
|
||||
|
||||
ctx.state.selectedTsukihimeEntryIndex = index;
|
||||
ctx.state.currentTsukihimeEntryId = ctx.state.tsukihimeEntries[index]!.id;
|
||||
renderEntries();
|
||||
|
||||
if (ctx.state.currentTsukihimeEntryId !== null) {
|
||||
void loadFiles(ctx.state.currentTsukihimeEntryId);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile(index: number): Promise<void> {
|
||||
const visibleFiles = getVisibleFiles();
|
||||
if (index < 0 || index >= visibleFiles.length) return;
|
||||
|
||||
ctx.state.selectedTsukihimeFileIndex = index;
|
||||
renderFiles();
|
||||
|
||||
if (ctx.state.currentTsukihimeEntryId === null) {
|
||||
setTsukihimeStatus('Select a release first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const entryId = ctx.state.currentTsukihimeEntryId;
|
||||
const file = visibleFiles[index]!;
|
||||
const downloadToken = ++activeDownloadToken;
|
||||
setTsukihimeStatus('Downloading subtitle...');
|
||||
|
||||
const result: TsukihimeDownloadResult = await window.electronAPI.tsukihimeDownloadFile({
|
||||
entryId,
|
||||
url: file.url,
|
||||
name: file.filename,
|
||||
lang: file.lang,
|
||||
});
|
||||
if (downloadToken !== activeDownloadToken || ctx.state.currentTsukihimeEntryId !== entryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
setTsukihimeStatus(`Downloaded and loaded: ${result.path}`);
|
||||
closeTsukihimeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
setTsukihimeStatus(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 configuredSecondary = await window.electronAPI.tsukihimeGetSecondaryLanguages();
|
||||
secondaryLanguages = configuredSecondary.length > 0 ? configuredSecondary : ['en'];
|
||||
} catch {
|
||||
secondaryLanguages = ['en'];
|
||||
}
|
||||
ctx.dom.tsukihimeTabSecondaryButton.textContent = secondaryTabLabel();
|
||||
ctx.dom.tsukihimeTabPrimaryButton.textContent = 'Japanese';
|
||||
// Tracks may already be on screen if the languages arrived late.
|
||||
if (ctx.state.tsukihimeFiles.length > 0) {
|
||||
renderFiles();
|
||||
}
|
||||
}
|
||||
|
||||
function openTsukihimeModal(): void {
|
||||
if (ctx.state.tsukihimeModalOpen) return;
|
||||
|
||||
ctx.state.tsukihimeModalOpen = true;
|
||||
ctx.state.tsukihimeActiveTab = 'secondary';
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.tsukihimeModal.classList.remove('hidden');
|
||||
ctx.dom.tsukihimeModal.setAttribute('aria-hidden', 'false');
|
||||
|
||||
setTsukihimeStatus('Loading media info...');
|
||||
resetTsukihimeLists();
|
||||
renderTabs();
|
||||
|
||||
const secondaryLanguagesReady = loadSecondaryLanguages();
|
||||
|
||||
window.electronAPI
|
||||
.getJimakuMediaInfo()
|
||||
.then(async (info: JimakuMediaInfo) => {
|
||||
ctx.dom.tsukihimeTitleInput.value = info.title || '';
|
||||
ctx.dom.tsukihimeEpisodeInput.value = info.episode ? String(info.episode) : '';
|
||||
|
||||
if (info.confidence === 'high' && info.title && info.episode) {
|
||||
await secondaryLanguagesReady;
|
||||
void performTsukihimeSearch();
|
||||
} else if (info.title) {
|
||||
setTsukihimeStatus('Check title/episode and press Search.');
|
||||
} else {
|
||||
setTsukihimeStatus('Enter title/episode and press Search.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setTsukihimeStatus('Failed to load media info.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function closeTsukihimeModal(): void {
|
||||
if (!ctx.state.tsukihimeModalOpen) return;
|
||||
|
||||
activeDownloadToken += 1;
|
||||
ctx.state.tsukihimeModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.tsukihimeModal.classList.add('hidden');
|
||||
ctx.dom.tsukihimeModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('tsukihime');
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
resetTsukihimeLists();
|
||||
}
|
||||
|
||||
function handleTsukihimeKeydown(e: KeyboardEvent): boolean {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeTsukihimeModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTextInputFocused()) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void performTsukihimeSearch();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
setActiveTab('secondary');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
setActiveTab('primary');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const visibleFiles = getVisibleFiles();
|
||||
if (visibleFiles.length > 0) {
|
||||
ctx.state.selectedTsukihimeFileIndex = Math.min(
|
||||
visibleFiles.length - 1,
|
||||
ctx.state.selectedTsukihimeFileIndex + 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.tsukihimeEntries.length > 0) {
|
||||
ctx.state.selectedTsukihimeEntryIndex = Math.min(
|
||||
ctx.state.tsukihimeEntries.length - 1,
|
||||
ctx.state.selectedTsukihimeEntryIndex + 1,
|
||||
);
|
||||
renderEntries();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (getVisibleFiles().length > 0) {
|
||||
ctx.state.selectedTsukihimeFileIndex = Math.max(
|
||||
0,
|
||||
ctx.state.selectedTsukihimeFileIndex - 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.tsukihimeEntries.length > 0) {
|
||||
ctx.state.selectedTsukihimeEntryIndex = Math.max(
|
||||
0,
|
||||
ctx.state.selectedTsukihimeEntryIndex - 1,
|
||||
);
|
||||
renderEntries();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (getVisibleFiles().length > 0) {
|
||||
void selectFile(ctx.state.selectedTsukihimeFileIndex);
|
||||
} else if (ctx.state.tsukihimeEntries.length > 0) {
|
||||
selectEntry(ctx.state.selectedTsukihimeEntryIndex);
|
||||
} else {
|
||||
void performTsukihimeSearch();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.tsukihimeSearchButton.addEventListener('click', () => {
|
||||
void performTsukihimeSearch();
|
||||
});
|
||||
ctx.dom.tsukihimeCloseButton.addEventListener('click', () => {
|
||||
closeTsukihimeModal();
|
||||
});
|
||||
ctx.dom.tsukihimeTabSecondaryButton.addEventListener('click', () => {
|
||||
setActiveTab('secondary');
|
||||
});
|
||||
ctx.dom.tsukihimeTabPrimaryButton.addEventListener('click', () => {
|
||||
setActiveTab('primary');
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeTsukihimeModal,
|
||||
handleTsukihimeKeydown,
|
||||
openTsukihimeModal,
|
||||
selectTsukihimeEntry: selectEntry,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
+11
-11
@@ -33,7 +33,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 { createTsukihimeModal } from './modals/tsukihime.js';
|
||||
import { createKikuModal } from './modals/kiku.js';
|
||||
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
|
||||
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
||||
@@ -102,9 +102,9 @@ const modalDescriptors = [
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'animetosho',
|
||||
isOpen: () => ctx.state.animetoshoModalOpen,
|
||||
close: () => animetoshoModal.closeAnimetoshoModal(),
|
||||
id: 'tsukihime',
|
||||
isOpen: () => ctx.state.tsukihimeModalOpen,
|
||||
close: () => tsukihimeModal.closeTsukihimeModal(),
|
||||
suppressesSubtitles: false,
|
||||
},
|
||||
{
|
||||
@@ -229,7 +229,7 @@ const jimakuModal = createJimakuModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const animetoshoModal = createAnimetoshoModal(ctx, {
|
||||
const tsukihimeModal = createTsukihimeModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
@@ -260,7 +260,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||
handleAnimetoshoKeydown: animetoshoModal.handleAnimetoshoKeydown,
|
||||
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
|
||||
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
||||
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
|
||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||
@@ -545,10 +545,10 @@ function registerModalOpenHandlers(): void {
|
||||
window.electronAPI.notifyOverlayModalOpened('jimaku');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenAnimetosho(() => {
|
||||
runGuarded('animetosho:open', () => {
|
||||
animetoshoModal.openAnimetoshoModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('animetosho');
|
||||
window.electronAPI.onOpenTsukihime(() => {
|
||||
runGuarded('tsukihime:open', () => {
|
||||
tsukihimeModal.openTsukihimeModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('tsukihime');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenYoutubeTrackPicker((payload) => {
|
||||
@@ -785,7 +785,7 @@ async function init(): Promise<void> {
|
||||
});
|
||||
|
||||
jimakuModal.wireDomEvents();
|
||||
animetoshoModal.wireDomEvents();
|
||||
tsukihimeModal.wireDomEvents();
|
||||
youtubePickerModal.wireDomEvents();
|
||||
playlistBrowserModal.wireDomEvents();
|
||||
kikuModal.wireDomEvents();
|
||||
|
||||
+16
-16
@@ -4,8 +4,8 @@ import type {
|
||||
ControllerButtonSnapshot,
|
||||
ControllerDeviceInfo,
|
||||
ResolvedControllerConfig,
|
||||
AnimetoshoEntry,
|
||||
AnimetoshoSubtitleFile,
|
||||
TsukihimeEntry,
|
||||
TsukihimeSubtitleFile,
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
KikuDuplicateCardInfo,
|
||||
@@ -50,13 +50,13 @@ 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;
|
||||
tsukihimeModalOpen: boolean;
|
||||
tsukihimeActiveTab: 'secondary' | 'primary';
|
||||
tsukihimeEntries: TsukihimeEntry[];
|
||||
tsukihimeFiles: TsukihimeSubtitleFile[];
|
||||
selectedTsukihimeEntryIndex: number;
|
||||
selectedTsukihimeFileIndex: number;
|
||||
currentTsukihimeEntryId: number | null;
|
||||
|
||||
youtubePickerModalOpen: boolean;
|
||||
youtubePickerPayload: YoutubePickerOpenPayload | null;
|
||||
@@ -174,13 +174,13 @@ export function createRendererState(): RendererState {
|
||||
currentEpisodeFilter: null,
|
||||
currentEntryId: null,
|
||||
|
||||
animetoshoModalOpen: false,
|
||||
animetoshoActiveTab: 'en',
|
||||
animetoshoEntries: [],
|
||||
animetoshoFiles: [],
|
||||
selectedAnimetoshoEntryIndex: 0,
|
||||
selectedAnimetoshoFileIndex: 0,
|
||||
currentAnimetoshoEntryId: null,
|
||||
tsukihimeModalOpen: false,
|
||||
tsukihimeActiveTab: 'secondary',
|
||||
tsukihimeEntries: [],
|
||||
tsukihimeFiles: [],
|
||||
selectedTsukihimeEntryIndex: 0,
|
||||
selectedTsukihimeFileIndex: 0,
|
||||
currentTsukihimeEntryId: null,
|
||||
|
||||
youtubePickerModalOpen: false,
|
||||
youtubePickerPayload: null,
|
||||
|
||||
@@ -811,21 +811,21 @@ body:focus-visible,
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
#animetoshoModal {
|
||||
#tsukihimeModal {
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
#animetoshoModal .jimaku-form {
|
||||
#tsukihimeModal .jimaku-form {
|
||||
grid-template-columns: 1fr 120px auto;
|
||||
}
|
||||
|
||||
.animetosho-tabs {
|
||||
.tsukihime-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.animetosho-tab {
|
||||
.tsukihime-tab {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 7px 8px;
|
||||
@@ -842,14 +842,14 @@ body:focus-visible,
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.animetosho-tab:hover,
|
||||
.animetosho-tab:focus-visible {
|
||||
.tsukihime-tab:hover,
|
||||
.tsukihime-tab:focus-visible {
|
||||
border-color: rgba(138, 173, 244, 0.48);
|
||||
color: var(--ctp-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.animetosho-tab.active {
|
||||
.tsukihime-tab.active {
|
||||
border-color: rgba(238, 212, 159, 0.62);
|
||||
background: rgba(238, 212, 159, 0.16);
|
||||
color: var(--ctp-yellow);
|
||||
|
||||
+24
-24
@@ -22,18 +22,18 @@ 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;
|
||||
tsukihimeModal: HTMLDivElement;
|
||||
tsukihimeTitleInput: HTMLInputElement;
|
||||
tsukihimeEpisodeInput: HTMLInputElement;
|
||||
tsukihimeSearchButton: HTMLButtonElement;
|
||||
tsukihimeCloseButton: HTMLButtonElement;
|
||||
tsukihimeTabSecondaryButton: HTMLButtonElement;
|
||||
tsukihimeTabPrimaryButton: HTMLButtonElement;
|
||||
tsukihimeStatus: HTMLDivElement;
|
||||
tsukihimeEntriesSection: HTMLDivElement;
|
||||
tsukihimeEntriesList: HTMLUListElement;
|
||||
tsukihimeFilesSection: HTMLDivElement;
|
||||
tsukihimeFilesList: HTMLUListElement;
|
||||
|
||||
youtubePickerModal: HTMLDivElement;
|
||||
youtubePickerTitle: HTMLDivElement;
|
||||
@@ -167,18 +167,18 @@ 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'),
|
||||
tsukihimeModal: getRequiredElement<HTMLDivElement>('tsukihimeModal'),
|
||||
tsukihimeTitleInput: getRequiredElement<HTMLInputElement>('tsukihimeTitle'),
|
||||
tsukihimeEpisodeInput: getRequiredElement<HTMLInputElement>('tsukihimeEpisode'),
|
||||
tsukihimeSearchButton: getRequiredElement<HTMLButtonElement>('tsukihimeSearch'),
|
||||
tsukihimeCloseButton: getRequiredElement<HTMLButtonElement>('tsukihimeClose'),
|
||||
tsukihimeTabSecondaryButton: getRequiredElement<HTMLButtonElement>('tsukihimeTabSecondary'),
|
||||
tsukihimeTabPrimaryButton: getRequiredElement<HTMLButtonElement>('tsukihimeTabPrimary'),
|
||||
tsukihimeStatus: getRequiredElement<HTMLDivElement>('tsukihimeStatus'),
|
||||
tsukihimeEntriesSection: getRequiredElement<HTMLDivElement>('tsukihimeEntriesSection'),
|
||||
tsukihimeEntriesList: getRequiredElement<HTMLUListElement>('tsukihimeEntries'),
|
||||
tsukihimeFilesSection: getRequiredElement<HTMLDivElement>('tsukihimeFilesSection'),
|
||||
tsukihimeFilesList: getRequiredElement<HTMLUListElement>('tsukihimeFiles'),
|
||||
|
||||
youtubePickerModal: getRequiredElement<HTMLDivElement>('youtubePickerModal'),
|
||||
youtubePickerTitle: getRequiredElement<HTMLDivElement>('youtubePickerTitle'),
|
||||
|
||||
Reference in New Issue
Block a user