mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Improve startup dictionary sync UX and default playback keybindings
- Add default `f` fullscreen overlay binding and switch default AniSkip skip key to `Tab` - Make character-dictionary auto-sync non-blocking at startup with tokenization gating for Yomitan mutations - Add ordered startup OSD progress (checking/generating/updating/importing), refresh current subtitle on sync completion, and extend regression tests
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
notifyCharacterDictionaryAutoSyncStatus,
|
||||
type CharacterDictionaryAutoSyncNotificationEvent,
|
||||
} from './character-dictionary-auto-sync-notifications';
|
||||
|
||||
function makeEvent(
|
||||
phase: CharacterDictionaryAutoSyncNotificationEvent['phase'],
|
||||
message: string,
|
||||
): CharacterDictionaryAutoSyncNotificationEvent {
|
||||
return {
|
||||
phase,
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
test('auto sync notifications send osd updates for progress phases', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('checking', 'checking'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'osd:checking',
|
||||
'osd:generating',
|
||||
'osd:syncing',
|
||||
'osd:importing',
|
||||
'osd:ready',
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto sync notifications never send desktop notifications', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('failed', 'failed'), {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['osd:syncing', 'osd:importing', 'osd:ready', 'osd:failed']);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync';
|
||||
import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer';
|
||||
|
||||
export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent;
|
||||
|
||||
export interface CharacterDictionaryAutoSyncNotificationDeps {
|
||||
getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined;
|
||||
showOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
startupOsdSequencer?: {
|
||||
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean {
|
||||
return type !== 'none';
|
||||
}
|
||||
|
||||
export function notifyCharacterDictionaryAutoSyncStatus(
|
||||
event: CharacterDictionaryAutoSyncNotificationEvent,
|
||||
deps: CharacterDictionaryAutoSyncNotificationDeps,
|
||||
): void {
|
||||
const type = deps.getNotificationType();
|
||||
if (shouldShowOsd(type)) {
|
||||
if (deps.startupOsdSequencer) {
|
||||
deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
|
||||
phase: event.phase,
|
||||
message: event.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
deps.showOsd(event.message);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,14 @@ function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-'));
|
||||
}
|
||||
|
||||
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test('auto sync imports merged dictionary and persists MRU state', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const imported: string[] = [];
|
||||
@@ -267,3 +275,296 @@ test('auto sync evicts least recently used media from merged set', async () => {
|
||||
};
|
||||
assert.deepEqual(state.activeMediaIds, [4, 3, 2]);
|
||||
});
|
||||
|
||||
test('auto sync invokes completion callback after successful sync', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const completions: Array<{ mediaId: number; mediaTitle: string; changed: boolean }> = [];
|
||||
let importedRevision: string | null = null;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 3,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
entryCount: 2560,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: '/tmp/merged.zip',
|
||||
revision: 'rev-101291',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 2560,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async () => {
|
||||
importedRevision = 'rev-101291';
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
onSyncComplete: (completion) => {
|
||||
completions.push(completion);
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(completions, [
|
||||
{
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
changed: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto sync emits progress events for start import and completion', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const events: Array<{
|
||||
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
|
||||
mediaId?: number;
|
||||
mediaTitle?: string;
|
||||
message: string;
|
||||
changed?: boolean;
|
||||
}> = [];
|
||||
let importedRevision: string | null = null;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 3,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async (_targetPath, progress) => {
|
||||
progress?.onChecking?.({
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
});
|
||||
progress?.onGenerating?.({
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
});
|
||||
return {
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
entryCount: 2560,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
};
|
||||
},
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: '/tmp/merged.zip',
|
||||
revision: 'rev-101291',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 2560,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async () => {
|
||||
importedRevision = 'rev-101291';
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
onSyncStatus: (event) => {
|
||||
events.push(event);
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
phase: 'checking',
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
message: 'Checking character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||
},
|
||||
{
|
||||
phase: 'generating',
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
message: 'Generating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||
},
|
||||
{
|
||||
phase: 'syncing',
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
message: 'Updating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||
},
|
||||
{
|
||||
phase: 'importing',
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
message: 'Importing character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||
},
|
||||
{
|
||||
phase: 'ready',
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
message: 'Character dictionary ready for Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
changed: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto sync emits checking before snapshot resolves and skips generating on cache hit', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const events: Array<{
|
||||
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
|
||||
mediaId?: number;
|
||||
mediaTitle?: string;
|
||||
message: string;
|
||||
changed?: boolean;
|
||||
}> = [];
|
||||
const snapshotDeferred = createDeferred<{
|
||||
mediaId: number;
|
||||
mediaTitle: string;
|
||||
entryCount: number;
|
||||
fromCache: boolean;
|
||||
updatedAt: number;
|
||||
}>();
|
||||
let importedRevision: string | null = null;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 3,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async (_targetPath, progress) => {
|
||||
progress?.onChecking?.({
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
});
|
||||
return await snapshotDeferred.promise;
|
||||
},
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: '/tmp/merged.zip',
|
||||
revision: 'rev-101291',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 2560,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async () => {
|
||||
importedRevision = 'rev-101291';
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
onSyncStatus: (event) => {
|
||||
events.push(event);
|
||||
},
|
||||
});
|
||||
|
||||
const syncPromise = runtime.runSyncNow();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
phase: 'checking',
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
message: 'Checking character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||
},
|
||||
]);
|
||||
|
||||
snapshotDeferred.resolve({
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
entryCount: 2560,
|
||||
fromCache: true,
|
||||
updatedAt: 1000,
|
||||
});
|
||||
await syncPromise;
|
||||
|
||||
assert.equal(
|
||||
events.some((event) => event.phase === 'generating'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('auto sync waits for tokenization-ready gate before Yomitan mutations', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const gate = (() => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
})();
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 3,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
entryCount: 2560,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async () => {
|
||||
calls.push('build');
|
||||
return {
|
||||
zipPath: '/tmp/merged.zip',
|
||||
revision: 'rev-101291',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 2560,
|
||||
};
|
||||
},
|
||||
waitForYomitanMutationReady: async () => {
|
||||
calls.push('wait');
|
||||
await gate.promise;
|
||||
},
|
||||
getYomitanDictionaryInfo: async () => {
|
||||
calls.push('info');
|
||||
return [];
|
||||
},
|
||||
importYomitanDictionary: async () => {
|
||||
calls.push('import');
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => {
|
||||
calls.push('settings');
|
||||
return true;
|
||||
},
|
||||
now: () => 1000,
|
||||
});
|
||||
|
||||
const syncPromise = runtime.runSyncNow();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, ['build', 'wait']);
|
||||
|
||||
gate.resolve();
|
||||
await syncPromise;
|
||||
|
||||
assert.deepEqual(calls, ['build', 'wait', 'info', 'import', 'settings']);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { AnilistCharacterDictionaryProfileScope } from '../../types';
|
||||
import type {
|
||||
CharacterDictionarySnapshotProgressCallbacks,
|
||||
CharacterDictionarySnapshotResult,
|
||||
MergedCharacterDictionaryBuildResult,
|
||||
} from '../character-dictionary-runtime';
|
||||
@@ -23,11 +24,23 @@ export interface CharacterDictionaryAutoSyncConfig {
|
||||
profileScope: AnilistCharacterDictionaryProfileScope;
|
||||
}
|
||||
|
||||
export interface CharacterDictionaryAutoSyncStatusEvent {
|
||||
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
|
||||
mediaId?: number;
|
||||
mediaTitle?: string;
|
||||
message: string;
|
||||
changed?: boolean;
|
||||
}
|
||||
|
||||
export interface CharacterDictionaryAutoSyncRuntimeDeps {
|
||||
userDataPath: string;
|
||||
getConfig: () => CharacterDictionaryAutoSyncConfig;
|
||||
getOrCreateCurrentSnapshot: (targetPath?: string) => Promise<CharacterDictionarySnapshotResult>;
|
||||
getOrCreateCurrentSnapshot: (
|
||||
targetPath?: string,
|
||||
progress?: CharacterDictionarySnapshotProgressCallbacks,
|
||||
) => Promise<CharacterDictionarySnapshotResult>;
|
||||
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
||||
waitForYomitanMutationReady?: () => Promise<void>;
|
||||
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
|
||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
||||
@@ -41,6 +54,8 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
|
||||
operationTimeoutMs?: number;
|
||||
logInfo?: (message: string) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
onSyncStatus?: (event: CharacterDictionaryAutoSyncStatusEvent) => void;
|
||||
onSyncComplete?: (result: { mediaId: number; mediaTitle: string; changed: boolean }) => void;
|
||||
}
|
||||
|
||||
function ensureDir(dirPath: string): void {
|
||||
@@ -92,6 +107,33 @@ function arraysEqual(left: number[], right: number[]): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildSyncingMessage(mediaTitle: string): string {
|
||||
return `Updating character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
function buildCheckingMessage(mediaTitle: string): string {
|
||||
return `Checking character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
function buildGeneratingMessage(mediaTitle: string): string {
|
||||
return `Generating character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
function buildImportingMessage(mediaTitle: string): string {
|
||||
return `Importing character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
function buildReadyMessage(mediaTitle: string): string {
|
||||
return `Character dictionary ready for ${mediaTitle}`;
|
||||
}
|
||||
|
||||
function buildFailedMessage(mediaTitle: string | null, errorMessage: string): string {
|
||||
if (mediaTitle) {
|
||||
return `Character dictionary sync failed for ${mediaTitle}: ${errorMessage}`;
|
||||
}
|
||||
return `Character dictionary sync failed: ${errorMessage}`;
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
deps: CharacterDictionaryAutoSyncRuntimeDeps,
|
||||
): {
|
||||
@@ -133,84 +175,150 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
return;
|
||||
}
|
||||
|
||||
deps.logInfo?.('[dictionary:auto-sync] syncing current anime snapshot');
|
||||
const snapshot = await deps.getOrCreateCurrentSnapshot();
|
||||
const state = readAutoSyncState(statePath);
|
||||
const nextActiveMediaIds = [
|
||||
snapshot.mediaId,
|
||||
...state.activeMediaIds.filter((mediaId) => mediaId !== snapshot.mediaId),
|
||||
].slice(0, Math.max(1, Math.floor(config.maxLoaded)));
|
||||
deps.logInfo?.(
|
||||
`[dictionary:auto-sync] active AniList media set: ${nextActiveMediaIds.join(', ')}`,
|
||||
);
|
||||
let currentMediaId: number | undefined;
|
||||
let currentMediaTitle: string | null = null;
|
||||
|
||||
const retainedChanged = !arraysEqual(nextActiveMediaIds, state.activeMediaIds);
|
||||
let merged: MergedCharacterDictionaryBuildResult | null = null;
|
||||
if (
|
||||
retainedChanged ||
|
||||
!state.mergedRevision ||
|
||||
!state.mergedDictionaryTitle ||
|
||||
!snapshot.fromCache
|
||||
) {
|
||||
deps.logInfo?.('[dictionary:auto-sync] rebuilding merged dictionary for active anime set');
|
||||
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
|
||||
}
|
||||
try {
|
||||
deps.logInfo?.('[dictionary:auto-sync] syncing current anime snapshot');
|
||||
const snapshot = await deps.getOrCreateCurrentSnapshot(undefined, {
|
||||
onChecking: ({ mediaId, mediaTitle }) => {
|
||||
currentMediaId = mediaId;
|
||||
currentMediaTitle = mediaTitle;
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'checking',
|
||||
mediaId,
|
||||
mediaTitle,
|
||||
message: buildCheckingMessage(mediaTitle),
|
||||
});
|
||||
},
|
||||
onGenerating: ({ mediaId, mediaTitle }) => {
|
||||
currentMediaId = mediaId;
|
||||
currentMediaTitle = mediaTitle;
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'generating',
|
||||
mediaId,
|
||||
mediaTitle,
|
||||
message: buildGeneratingMessage(mediaTitle),
|
||||
});
|
||||
},
|
||||
});
|
||||
currentMediaId = snapshot.mediaId;
|
||||
currentMediaTitle = snapshot.mediaTitle;
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'syncing',
|
||||
mediaId: snapshot.mediaId,
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
message: buildSyncingMessage(snapshot.mediaTitle),
|
||||
});
|
||||
const state = readAutoSyncState(statePath);
|
||||
const nextActiveMediaIds = [
|
||||
snapshot.mediaId,
|
||||
...state.activeMediaIds.filter((mediaId) => mediaId !== snapshot.mediaId),
|
||||
].slice(0, Math.max(1, Math.floor(config.maxLoaded)));
|
||||
deps.logInfo?.(
|
||||
`[dictionary:auto-sync] active AniList media set: ${nextActiveMediaIds.join(', ')}`,
|
||||
);
|
||||
|
||||
const dictionaryTitle = merged?.dictionaryTitle ?? state.mergedDictionaryTitle;
|
||||
const revision = merged?.revision ?? state.mergedRevision;
|
||||
if (!dictionaryTitle || !revision) {
|
||||
throw new Error('Merged character dictionary state is incomplete.');
|
||||
}
|
||||
|
||||
const dictionaryInfo = await withOperationTimeout(
|
||||
'getYomitanDictionaryInfo',
|
||||
deps.getYomitanDictionaryInfo(),
|
||||
);
|
||||
const existing = dictionaryInfo.find((entry) => entry.title === dictionaryTitle) ?? null;
|
||||
const existingRevision =
|
||||
existing && (typeof existing.revision === 'string' || typeof existing.revision === 'number')
|
||||
? String(existing.revision)
|
||||
: null;
|
||||
const shouldImport =
|
||||
merged !== null ||
|
||||
existing === null ||
|
||||
existingRevision === null ||
|
||||
existingRevision !== revision;
|
||||
|
||||
if (shouldImport) {
|
||||
if (existing !== null) {
|
||||
await withOperationTimeout(
|
||||
`deleteYomitanDictionary(${dictionaryTitle})`,
|
||||
deps.deleteYomitanDictionary(dictionaryTitle),
|
||||
);
|
||||
}
|
||||
if (merged === null) {
|
||||
const retainedChanged = !arraysEqual(nextActiveMediaIds, state.activeMediaIds);
|
||||
let merged: MergedCharacterDictionaryBuildResult | null = null;
|
||||
if (
|
||||
retainedChanged ||
|
||||
!state.mergedRevision ||
|
||||
!state.mergedDictionaryTitle ||
|
||||
!snapshot.fromCache
|
||||
) {
|
||||
deps.logInfo?.('[dictionary:auto-sync] rebuilding merged dictionary for active anime set');
|
||||
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
|
||||
}
|
||||
deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`);
|
||||
const imported = await withOperationTimeout(
|
||||
`importYomitanDictionary(${path.basename(merged.zipPath)})`,
|
||||
deps.importYomitanDictionary(merged.zipPath),
|
||||
);
|
||||
if (!imported) {
|
||||
throw new Error(`Failed to import dictionary ZIP: ${merged.zipPath}`);
|
||||
|
||||
const dictionaryTitle = merged?.dictionaryTitle ?? state.mergedDictionaryTitle;
|
||||
const revision = merged?.revision ?? state.mergedRevision;
|
||||
if (!dictionaryTitle || !revision) {
|
||||
throw new Error('Merged character dictionary state is incomplete.');
|
||||
}
|
||||
|
||||
await deps.waitForYomitanMutationReady?.();
|
||||
|
||||
const dictionaryInfo = await withOperationTimeout(
|
||||
'getYomitanDictionaryInfo',
|
||||
deps.getYomitanDictionaryInfo(),
|
||||
);
|
||||
const existing = dictionaryInfo.find((entry) => entry.title === dictionaryTitle) ?? null;
|
||||
const existingRevision =
|
||||
existing && (typeof existing.revision === 'string' || typeof existing.revision === 'number')
|
||||
? String(existing.revision)
|
||||
: null;
|
||||
const shouldImport =
|
||||
merged !== null ||
|
||||
existing === null ||
|
||||
existingRevision === null ||
|
||||
existingRevision !== revision;
|
||||
let changed = merged !== null;
|
||||
|
||||
if (shouldImport) {
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'importing',
|
||||
mediaId: snapshot.mediaId,
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
message: buildImportingMessage(snapshot.mediaTitle),
|
||||
});
|
||||
if (existing !== null) {
|
||||
await withOperationTimeout(
|
||||
`deleteYomitanDictionary(${dictionaryTitle})`,
|
||||
deps.deleteYomitanDictionary(dictionaryTitle),
|
||||
);
|
||||
}
|
||||
if (merged === null) {
|
||||
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
|
||||
}
|
||||
deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`);
|
||||
const imported = await withOperationTimeout(
|
||||
`importYomitanDictionary(${path.basename(merged.zipPath)})`,
|
||||
deps.importYomitanDictionary(merged.zipPath),
|
||||
);
|
||||
if (!imported) {
|
||||
throw new Error(`Failed to import dictionary ZIP: ${merged.zipPath}`);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
deps.logInfo?.(`[dictionary:auto-sync] applying Yomitan settings for ${dictionaryTitle}`);
|
||||
const settingsUpdated = await withOperationTimeout(
|
||||
`upsertYomitanDictionarySettings(${dictionaryTitle})`,
|
||||
deps.upsertYomitanDictionarySettings(dictionaryTitle, config.profileScope),
|
||||
);
|
||||
changed = changed || settingsUpdated === true;
|
||||
|
||||
writeAutoSyncState(statePath, {
|
||||
activeMediaIds: nextActiveMediaIds,
|
||||
mergedRevision: merged?.revision ?? revision,
|
||||
mergedDictionaryTitle: merged?.dictionaryTitle ?? dictionaryTitle,
|
||||
});
|
||||
deps.logInfo?.(
|
||||
`[dictionary:auto-sync] synced AniList ${snapshot.mediaId}: ${dictionaryTitle} (${snapshot.entryCount} entries)`,
|
||||
);
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'ready',
|
||||
mediaId: snapshot.mediaId,
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
message: buildReadyMessage(snapshot.mediaTitle),
|
||||
changed,
|
||||
});
|
||||
deps.onSyncComplete?.({
|
||||
mediaId: snapshot.mediaId,
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
changed,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error)?.message ?? String(error);
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'failed',
|
||||
mediaId: currentMediaId,
|
||||
mediaTitle: currentMediaTitle ?? undefined,
|
||||
message: buildFailedMessage(currentMediaTitle, errorMessage),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
deps.logInfo?.(`[dictionary:auto-sync] applying Yomitan settings for ${dictionaryTitle}`);
|
||||
await withOperationTimeout(
|
||||
`upsertYomitanDictionarySettings(${dictionaryTitle})`,
|
||||
deps.upsertYomitanDictionarySettings(dictionaryTitle, config.profileScope),
|
||||
);
|
||||
|
||||
writeAutoSyncState(statePath, {
|
||||
activeMediaIds: nextActiveMediaIds,
|
||||
mergedRevision: merged?.revision ?? revision,
|
||||
mergedDictionaryTitle: merged?.dictionaryTitle ?? dictionaryTitle,
|
||||
});
|
||||
deps.logInfo?.(
|
||||
`[dictionary:auto-sync] synced AniList ${snapshot.mediaId}: ${dictionaryTitle} (${snapshot.entryCount} entries)`,
|
||||
);
|
||||
};
|
||||
|
||||
const enqueueSync = (): void => {
|
||||
|
||||
42
src/main/runtime/current-media-tokenization-gate.test.ts
Normal file
42
src/main/runtime/current-media-tokenization-gate.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createCurrentMediaTokenizationGate } from './current-media-tokenization-gate';
|
||||
|
||||
test('current media tokenization gate waits until current path is marked ready', async () => {
|
||||
const gate = createCurrentMediaTokenizationGate();
|
||||
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
|
||||
|
||||
let resolved = false;
|
||||
const waitPromise = gate.waitUntilReady('/tmp/video-1.mkv').then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(resolved, false);
|
||||
|
||||
gate.markReady('/tmp/video-1.mkv');
|
||||
await waitPromise;
|
||||
assert.equal(resolved, true);
|
||||
});
|
||||
|
||||
test('current media tokenization gate resolves old waiters when media changes', async () => {
|
||||
const gate = createCurrentMediaTokenizationGate();
|
||||
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
|
||||
|
||||
let resolved = false;
|
||||
const waitPromise = gate.waitUntilReady('/tmp/video-1.mkv').then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
gate.updateCurrentMediaPath('/tmp/video-2.mkv');
|
||||
await waitPromise;
|
||||
assert.equal(resolved, true);
|
||||
});
|
||||
|
||||
test('current media tokenization gate returns immediately for ready media', async () => {
|
||||
const gate = createCurrentMediaTokenizationGate();
|
||||
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
|
||||
gate.markReady('/tmp/video-1.mkv');
|
||||
|
||||
await gate.waitUntilReady('/tmp/video-1.mkv');
|
||||
});
|
||||
70
src/main/runtime/current-media-tokenization-gate.ts
Normal file
70
src/main/runtime/current-media-tokenization-gate.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
function normalizeMediaPath(mediaPath: string | null | undefined): string | null {
|
||||
if (typeof mediaPath !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = mediaPath.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function createCurrentMediaTokenizationGate(): {
|
||||
updateCurrentMediaPath: (mediaPath: string | null | undefined) => void;
|
||||
markReady: (mediaPath: string | null | undefined) => void;
|
||||
waitUntilReady: (mediaPath: string | null | undefined) => Promise<void>;
|
||||
} {
|
||||
let currentMediaPath: string | null = null;
|
||||
let readyMediaPath: string | null = null;
|
||||
let pendingMediaPath: string | null = null;
|
||||
let pendingPromise: Promise<void> | null = null;
|
||||
let resolvePending: (() => void) | null = null;
|
||||
|
||||
const resolvePendingWaiter = (): void => {
|
||||
resolvePending?.();
|
||||
resolvePending = null;
|
||||
pendingPromise = null;
|
||||
pendingMediaPath = null;
|
||||
};
|
||||
|
||||
const ensurePendingPromise = (mediaPath: string): Promise<void> => {
|
||||
if (pendingMediaPath === mediaPath && pendingPromise) {
|
||||
return pendingPromise;
|
||||
}
|
||||
resolvePendingWaiter();
|
||||
pendingMediaPath = mediaPath;
|
||||
pendingPromise = new Promise<void>((resolve) => {
|
||||
resolvePending = resolve;
|
||||
});
|
||||
return pendingPromise;
|
||||
};
|
||||
|
||||
return {
|
||||
updateCurrentMediaPath: (mediaPath) => {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||
if (normalizedPath === currentMediaPath) {
|
||||
return;
|
||||
}
|
||||
currentMediaPath = normalizedPath;
|
||||
readyMediaPath = null;
|
||||
resolvePendingWaiter();
|
||||
if (normalizedPath) {
|
||||
ensurePendingPromise(normalizedPath);
|
||||
}
|
||||
},
|
||||
markReady: (mediaPath) => {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||
if (!normalizedPath) {
|
||||
return;
|
||||
}
|
||||
readyMediaPath = normalizedPath;
|
||||
if (pendingMediaPath === normalizedPath) {
|
||||
resolvePendingWaiter();
|
||||
}
|
||||
},
|
||||
waitUntilReady: async (mediaPath) => {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath) ?? currentMediaPath;
|
||||
if (!normalizedPath || readyMediaPath === normalizedPath) {
|
||||
return;
|
||||
}
|
||||
await ensurePendingPromise(normalizedPath);
|
||||
},
|
||||
};
|
||||
}
|
||||
134
src/main/runtime/startup-osd-sequencer.test.ts
Normal file
134
src/main/runtime/startup-osd-sequencer.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createStartupOsdSequencer,
|
||||
type StartupOsdSequencerCharacterDictionaryEvent,
|
||||
} from './startup-osd-sequencer';
|
||||
|
||||
function makeDictionaryEvent(
|
||||
phase: StartupOsdSequencerCharacterDictionaryEvent['phase'],
|
||||
message: string,
|
||||
): StartupOsdSequencerCharacterDictionaryEvent {
|
||||
return {
|
||||
phase,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
test('startup OSD keeps dictionary progress hidden until tokenization and annotation loading finish', () => {
|
||||
const osdMessages: string[] = [];
|
||||
const sequencer = createStartupOsdSequencer({
|
||||
showOsd: (message) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
sequencer.notifyCharacterDictionaryStatus(
|
||||
makeDictionaryEvent('syncing', 'Updating character dictionary for Frieren...'),
|
||||
);
|
||||
sequencer.showAnnotationLoading('Loading subtitle annotations |');
|
||||
sequencer.markTokenizationReady();
|
||||
|
||||
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
|
||||
|
||||
sequencer.showAnnotationLoading('Loading subtitle annotations /');
|
||||
assert.deepEqual(osdMessages, [
|
||||
'Loading subtitle annotations |',
|
||||
'Loading subtitle annotations /',
|
||||
]);
|
||||
|
||||
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||
assert.deepEqual(osdMessages, [
|
||||
'Loading subtitle annotations |',
|
||||
'Loading subtitle annotations /',
|
||||
'Updating character dictionary for Frieren...',
|
||||
]);
|
||||
});
|
||||
|
||||
test('startup OSD buffers checking behind annotations and replaces it with later generating progress', () => {
|
||||
const osdMessages: string[] = [];
|
||||
const sequencer = createStartupOsdSequencer({
|
||||
showOsd: (message) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
sequencer.notifyCharacterDictionaryStatus(
|
||||
makeDictionaryEvent('checking', 'Checking character dictionary for Frieren...'),
|
||||
);
|
||||
sequencer.showAnnotationLoading('Loading subtitle annotations |');
|
||||
sequencer.markTokenizationReady();
|
||||
sequencer.notifyCharacterDictionaryStatus(
|
||||
makeDictionaryEvent('generating', 'Generating character dictionary for Frieren...'),
|
||||
);
|
||||
|
||||
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
|
||||
|
||||
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||
|
||||
assert.deepEqual(osdMessages, [
|
||||
'Loading subtitle annotations |',
|
||||
'Generating character dictionary for Frieren...',
|
||||
]);
|
||||
});
|
||||
|
||||
test('startup OSD skips buffered dictionary ready messages when progress completed before it became visible', () => {
|
||||
const osdMessages: string[] = [];
|
||||
const sequencer = createStartupOsdSequencer({
|
||||
showOsd: (message) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
sequencer.notifyCharacterDictionaryStatus(
|
||||
makeDictionaryEvent('syncing', 'Updating character dictionary for Frieren...'),
|
||||
);
|
||||
sequencer.notifyCharacterDictionaryStatus(
|
||||
makeDictionaryEvent('ready', 'Character dictionary ready for Frieren'),
|
||||
);
|
||||
sequencer.markTokenizationReady();
|
||||
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||
|
||||
assert.deepEqual(osdMessages, ['Subtitle annotations loaded']);
|
||||
});
|
||||
|
||||
test('startup OSD shows dictionary failure after annotation loading completes', () => {
|
||||
const osdMessages: string[] = [];
|
||||
const sequencer = createStartupOsdSequencer({
|
||||
showOsd: (message) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
sequencer.showAnnotationLoading('Loading subtitle annotations |');
|
||||
sequencer.notifyCharacterDictionaryStatus(
|
||||
makeDictionaryEvent('failed', 'Character dictionary sync failed for Frieren: boom'),
|
||||
);
|
||||
sequencer.markTokenizationReady();
|
||||
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||
|
||||
assert.deepEqual(osdMessages, [
|
||||
'Loading subtitle annotations |',
|
||||
'Character dictionary sync failed for Frieren: boom',
|
||||
]);
|
||||
});
|
||||
|
||||
test('startup OSD reset requires the next media to wait for tokenization again', () => {
|
||||
const osdMessages: string[] = [];
|
||||
const sequencer = createStartupOsdSequencer({
|
||||
showOsd: (message) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
sequencer.markTokenizationReady();
|
||||
sequencer.reset();
|
||||
sequencer.notifyCharacterDictionaryStatus(
|
||||
makeDictionaryEvent('syncing', 'Updating character dictionary for Frieren...'),
|
||||
);
|
||||
|
||||
assert.deepEqual(osdMessages, []);
|
||||
|
||||
sequencer.markTokenizationReady();
|
||||
assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']);
|
||||
});
|
||||
106
src/main/runtime/startup-osd-sequencer.ts
Normal file
106
src/main/runtime/startup-osd-sequencer.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
export interface StartupOsdSequencerCharacterDictionaryEvent {
|
||||
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function createStartupOsdSequencer(deps: { showOsd: (message: string) => void }): {
|
||||
reset: () => void;
|
||||
markTokenizationReady: () => void;
|
||||
showAnnotationLoading: (message: string) => void;
|
||||
markAnnotationLoadingComplete: (message: string) => void;
|
||||
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
|
||||
} {
|
||||
let tokenizationReady = false;
|
||||
let annotationLoadingMessage: string | null = null;
|
||||
let pendingDictionaryProgress: StartupOsdSequencerCharacterDictionaryEvent | null = null;
|
||||
let pendingDictionaryFailure: StartupOsdSequencerCharacterDictionaryEvent | null = null;
|
||||
let dictionaryProgressShown = false;
|
||||
|
||||
const canShowDictionaryStatus = (): boolean =>
|
||||
tokenizationReady && annotationLoadingMessage === null;
|
||||
|
||||
const flushBufferedDictionaryStatus = (): boolean => {
|
||||
if (!canShowDictionaryStatus()) {
|
||||
return false;
|
||||
}
|
||||
if (pendingDictionaryProgress) {
|
||||
deps.showOsd(pendingDictionaryProgress.message);
|
||||
dictionaryProgressShown = true;
|
||||
return true;
|
||||
}
|
||||
if (pendingDictionaryFailure) {
|
||||
deps.showOsd(pendingDictionaryFailure.message);
|
||||
pendingDictionaryFailure = null;
|
||||
dictionaryProgressShown = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
reset: () => {
|
||||
tokenizationReady = false;
|
||||
annotationLoadingMessage = null;
|
||||
pendingDictionaryProgress = null;
|
||||
pendingDictionaryFailure = null;
|
||||
dictionaryProgressShown = false;
|
||||
},
|
||||
markTokenizationReady: () => {
|
||||
tokenizationReady = true;
|
||||
if (annotationLoadingMessage !== null) {
|
||||
deps.showOsd(annotationLoadingMessage);
|
||||
return;
|
||||
}
|
||||
flushBufferedDictionaryStatus();
|
||||
},
|
||||
showAnnotationLoading: (message) => {
|
||||
annotationLoadingMessage = message;
|
||||
if (tokenizationReady) {
|
||||
deps.showOsd(message);
|
||||
}
|
||||
},
|
||||
markAnnotationLoadingComplete: (message) => {
|
||||
annotationLoadingMessage = null;
|
||||
if (!tokenizationReady) {
|
||||
return;
|
||||
}
|
||||
if (flushBufferedDictionaryStatus()) {
|
||||
return;
|
||||
}
|
||||
deps.showOsd(message);
|
||||
},
|
||||
notifyCharacterDictionaryStatus: (event) => {
|
||||
if (
|
||||
event.phase === 'checking' ||
|
||||
event.phase === 'generating' ||
|
||||
event.phase === 'syncing' ||
|
||||
event.phase === 'importing'
|
||||
) {
|
||||
pendingDictionaryProgress = event;
|
||||
pendingDictionaryFailure = null;
|
||||
if (canShowDictionaryStatus()) {
|
||||
deps.showOsd(event.message);
|
||||
dictionaryProgressShown = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pendingDictionaryProgress = null;
|
||||
if (event.phase === 'failed') {
|
||||
if (canShowDictionaryStatus()) {
|
||||
deps.showOsd(event.message);
|
||||
} else {
|
||||
pendingDictionaryFailure = event;
|
||||
}
|
||||
dictionaryProgressShown = false;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingDictionaryFailure = null;
|
||||
if (canShowDictionaryStatus() && dictionaryProgressShown) {
|
||||
deps.showOsd(event.message);
|
||||
}
|
||||
dictionaryProgressShown = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -80,6 +80,8 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
||||
ensureJlptDictionaryLookup: () => Promise<void>;
|
||||
ensureFrequencyDictionaryLookup: () => Promise<void>;
|
||||
showMpvOsd?: (message: string) => void;
|
||||
showLoadingOsd?: (message: string) => void;
|
||||
showLoadedOsd?: (message: string) => void;
|
||||
shouldShowOsdNotification?: () => boolean;
|
||||
setInterval?: (callback: () => void, delayMs: number) => unknown;
|
||||
clearInterval?: (timer: unknown) => void;
|
||||
@@ -90,6 +92,8 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
||||
let loadingOsdFrame = 0;
|
||||
let loadingOsdTimer: unknown = null;
|
||||
const showMpvOsd = deps.showMpvOsd;
|
||||
const showLoadingOsd = deps.showLoadingOsd ?? showMpvOsd;
|
||||
const showLoadedOsd = deps.showLoadedOsd ?? showMpvOsd;
|
||||
const setIntervalHandler =
|
||||
deps.setInterval ??
|
||||
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
|
||||
@@ -99,7 +103,7 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
||||
const spinnerFrames = ['|', '/', '-', '\\'];
|
||||
|
||||
const beginLoadingOsd = (): boolean => {
|
||||
if (!showMpvOsd) {
|
||||
if (!showLoadingOsd) {
|
||||
return false;
|
||||
}
|
||||
loadingOsdDepth += 1;
|
||||
@@ -108,13 +112,13 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
||||
}
|
||||
|
||||
loadingOsdFrame = 0;
|
||||
showMpvOsd(`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame]}`);
|
||||
showLoadingOsd(`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame]}`);
|
||||
loadingOsdFrame += 1;
|
||||
loadingOsdTimer = setIntervalHandler(() => {
|
||||
if (!showMpvOsd) {
|
||||
if (!showLoadingOsd) {
|
||||
return;
|
||||
}
|
||||
showMpvOsd(
|
||||
showLoadingOsd(
|
||||
`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame % spinnerFrames.length]}`,
|
||||
);
|
||||
loadingOsdFrame += 1;
|
||||
@@ -123,7 +127,7 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
||||
};
|
||||
|
||||
const endLoadingOsd = (): void => {
|
||||
if (!showMpvOsd) {
|
||||
if (!showLoadedOsd) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -136,7 +140,7 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
||||
clearIntervalHandler(loadingOsdTimer);
|
||||
loadingOsdTimer = null;
|
||||
}
|
||||
showMpvOsd('Subtitle annotations loaded');
|
||||
showLoadedOsd('Subtitle annotations loaded');
|
||||
};
|
||||
|
||||
return async (options?: { showLoadingOsd?: boolean }): Promise<void> => {
|
||||
|
||||
Reference in New Issue
Block a user