mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 00:11:27 -07:00
Feature/renderer performance (#24)
This commit is contained in:
@@ -71,6 +71,8 @@ test('mpv event bindings register all expected events', () => {
|
||||
onSubtitleChange: () => {},
|
||||
onSubtitleAssChange: () => {},
|
||||
onSecondarySubtitleChange: () => {},
|
||||
onSubtitleTrackChange: () => {},
|
||||
onSubtitleTrackListChange: () => {},
|
||||
onSubtitleTiming: () => {},
|
||||
onMediaPathChange: () => {},
|
||||
onMediaTitleChange: () => {},
|
||||
@@ -92,6 +94,8 @@ test('mpv event bindings register all expected events', () => {
|
||||
'subtitle-change',
|
||||
'subtitle-ass-change',
|
||||
'secondary-subtitle-change',
|
||||
'subtitle-track-change',
|
||||
'subtitle-track-list-change',
|
||||
'subtitle-timing',
|
||||
'media-path-change',
|
||||
'media-title-change',
|
||||
|
||||
@@ -3,6 +3,8 @@ type MpvBindingEventName =
|
||||
| 'subtitle-change'
|
||||
| 'subtitle-ass-change'
|
||||
| 'secondary-subtitle-change'
|
||||
| 'subtitle-track-change'
|
||||
| 'subtitle-track-list-change'
|
||||
| 'subtitle-timing'
|
||||
| 'media-path-change'
|
||||
| 'media-title-change'
|
||||
@@ -69,6 +71,8 @@ export function createBindMpvClientEventHandlers(deps: {
|
||||
onSubtitleChange: (payload: { text: string }) => void;
|
||||
onSubtitleAssChange: (payload: { text: string }) => void;
|
||||
onSecondarySubtitleChange: (payload: { text: string }) => void;
|
||||
onSubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||
onSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
|
||||
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
||||
onMediaPathChange: (payload: { path: string | null }) => void;
|
||||
onMediaTitleChange: (payload: { title: string | null }) => void;
|
||||
@@ -83,6 +87,8 @@ export function createBindMpvClientEventHandlers(deps: {
|
||||
mpvClient.on('subtitle-change', deps.onSubtitleChange);
|
||||
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
|
||||
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
|
||||
mpvClient.on('subtitle-track-change', deps.onSubtitleTrackChange);
|
||||
mpvClient.on('subtitle-track-list-change', deps.onSubtitleTrackListChange);
|
||||
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
|
||||
mpvClient.on('media-path-change', deps.onMediaPathChange);
|
||||
mpvClient.on('media-title-change', deps.onMediaTitleChange);
|
||||
|
||||
@@ -90,11 +90,13 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
||||
recordPlaybackPosition: (time: number) => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
}) {
|
||||
return ({ time }: { time: number }): void => {
|
||||
deps.recordPlaybackPosition(time);
|
||||
deps.reportJellyfinRemoteProgress(false);
|
||||
deps.refreshDiscordPresence();
|
||||
deps.onTimePosUpdate?.(time);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`),
|
||||
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
|
||||
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
|
||||
onSubtitleTrackChange: () => calls.push('subtitle-track-change'),
|
||||
onSubtitleTrackListChange: () => calls.push('subtitle-track-list-change'),
|
||||
|
||||
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
@@ -65,6 +67,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
});
|
||||
|
||||
handlers.get('subtitle-change')?.({ text: 'line' });
|
||||
handlers.get('subtitle-track-change')?.({ sid: 3 });
|
||||
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
||||
handlers.get('media-path-change')?.({ path: '' });
|
||||
handlers.get('media-title-change')?.({ title: 'Episode 1' });
|
||||
handlers.get('time-pos-change')?.({ time: 2.5 });
|
||||
@@ -73,6 +77,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
assert.ok(calls.includes('set-sub:line'));
|
||||
assert.ok(calls.includes('broadcast-sub:line'));
|
||||
assert.ok(calls.includes('subtitle-change:line'));
|
||||
assert.ok(calls.includes('subtitle-track-change'));
|
||||
assert.ok(calls.includes('subtitle-track-list-change'));
|
||||
assert.ok(calls.includes('media-title:Episode 1'));
|
||||
assert.ok(calls.includes('restore-mpv-sub'));
|
||||
assert.ok(calls.includes('reset-guess-state'));
|
||||
|
||||
@@ -42,6 +42,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
setCurrentSubAssText: (text: string) => void;
|
||||
broadcastSubtitleAss: (text: string) => void;
|
||||
broadcastSecondarySubtitle: (text: string) => void;
|
||||
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
restoreMpvSubVisibility: () => void;
|
||||
@@ -59,6 +61,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
recordPlaybackPosition: (time: number) => void;
|
||||
recordMediaDuration: (durationSec: number) => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
recordPauseState: (paused: boolean) => void;
|
||||
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
||||
@@ -124,6 +127,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
|
||||
});
|
||||
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
|
||||
recordPauseState: (paused) => deps.recordPauseState(paused),
|
||||
@@ -144,6 +148,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
onSubtitleChange: handleMpvSubtitleChange,
|
||||
onSubtitleAssChange: handleMpvSubtitleAssChange,
|
||||
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
|
||||
onSubtitleTrackChange: ({ sid }) => deps.onSubtitleTrackChange?.(sid),
|
||||
onSubtitleTrackListChange: ({ trackList }) => deps.onSubtitleTrackListChange?.(trackList),
|
||||
onSubtitleTiming: handleMpvSubtitleTiming,
|
||||
onMediaPathChange: handleMpvMediaPathChange,
|
||||
onMediaTitleChange: handleMpvMediaTitleChange,
|
||||
|
||||
@@ -35,6 +35,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
restoreMpvSubVisibility: () => void;
|
||||
getCurrentAnilistMediaKey: () => string | null;
|
||||
@@ -47,6 +49,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
updateCurrentMediaTitle: (title: string) => void;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
ensureImmersionTrackerInitialized: () => void;
|
||||
@@ -100,6 +103,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
broadcastSubtitle: (payload: { text: string; tokens: null }) =>
|
||||
deps.broadcastToOverlayWindows('subtitle:set', payload),
|
||||
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
|
||||
onSubtitleTrackChange: deps.onSubtitleTrackChange
|
||||
? (sid: number | null) => deps.onSubtitleTrackChange!(sid)
|
||||
: undefined,
|
||||
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
|
||||
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
|
||||
: undefined,
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
setCurrentSubAssText: (text: string) => {
|
||||
deps.appState.currentSubAssText = text;
|
||||
@@ -134,6 +143,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
},
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
onTimePosUpdate: deps.onTimePosUpdate ? (time: number) => deps.onTimePosUpdate!(time) : undefined,
|
||||
recordPauseState: (paused: boolean) => {
|
||||
deps.appState.playbackPaused = paused;
|
||||
deps.ensureImmersionTrackerInitialized();
|
||||
|
||||
114
src/main/runtime/subtitle-prefetch-init.test.ts
Normal file
114
src/main/runtime/subtitle-prefetch-init.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SubtitleCue } from '../../core/services/subtitle-cue-parser';
|
||||
import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch';
|
||||
import { createSubtitlePrefetchInitController } from './subtitle-prefetch-init';
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function flushMicrotasks(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test('latest subtitle prefetch init wins over stale async loads', async () => {
|
||||
const loads = new Map<string, ReturnType<typeof createDeferred<string>>>();
|
||||
const started: string[] = [];
|
||||
const stopped: string[] = [];
|
||||
let currentService: SubtitlePrefetchService | null = null;
|
||||
|
||||
const controller = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => currentService,
|
||||
setCurrentService: (service) => {
|
||||
currentService = service;
|
||||
},
|
||||
loadSubtitleSourceText: async (source) => {
|
||||
const deferred = createDeferred<string>();
|
||||
loads.set(source, deferred);
|
||||
return await deferred.promise;
|
||||
},
|
||||
parseSubtitleCues: (_content, filename): SubtitleCue[] => [
|
||||
{ startTime: 0, endTime: 1, text: filename },
|
||||
],
|
||||
createSubtitlePrefetchService: ({ cues }) => ({
|
||||
start: () => {
|
||||
started.push(cues[0]!.text);
|
||||
},
|
||||
stop: () => {
|
||||
stopped.push(cues[0]!.text);
|
||||
},
|
||||
onSeek: () => {},
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
const firstInit = controller.initSubtitlePrefetch('old.ass', 1);
|
||||
const secondInit = controller.initSubtitlePrefetch('new.ass', 2);
|
||||
|
||||
loads.get('new.ass')!.resolve('new');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(started, ['new.ass']);
|
||||
|
||||
loads.get('old.ass')!.resolve('old');
|
||||
await Promise.all([firstInit, secondInit]);
|
||||
|
||||
assert.deepEqual(started, ['new.ass']);
|
||||
assert.deepEqual(stopped, []);
|
||||
});
|
||||
|
||||
test('cancelPendingInit prevents an in-flight load from attaching a stale service', async () => {
|
||||
const deferred = createDeferred<string>();
|
||||
let currentService: SubtitlePrefetchService | null = null;
|
||||
const started: string[] = [];
|
||||
|
||||
const controller = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => currentService,
|
||||
setCurrentService: (service) => {
|
||||
currentService = service;
|
||||
},
|
||||
loadSubtitleSourceText: async () => await deferred.promise,
|
||||
parseSubtitleCues: (_content, filename): SubtitleCue[] => [
|
||||
{ startTime: 0, endTime: 1, text: filename },
|
||||
],
|
||||
createSubtitlePrefetchService: ({ cues }) => ({
|
||||
start: () => {
|
||||
started.push(cues[0]!.text);
|
||||
},
|
||||
stop: () => {},
|
||||
onSeek: () => {},
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
const initPromise = controller.initSubtitlePrefetch('stale.ass', 1);
|
||||
controller.cancelPendingInit();
|
||||
deferred.resolve('stale');
|
||||
await initPromise;
|
||||
|
||||
assert.equal(currentService, null);
|
||||
assert.deepEqual(started, []);
|
||||
});
|
||||
83
src/main/runtime/subtitle-prefetch-init.ts
Normal file
83
src/main/runtime/subtitle-prefetch-init.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { SubtitleCue } from '../../core/services/subtitle-cue-parser';
|
||||
import type {
|
||||
SubtitlePrefetchService,
|
||||
SubtitlePrefetchServiceDeps,
|
||||
} from '../../core/services/subtitle-prefetch';
|
||||
import type { SubtitleData } from '../../types';
|
||||
|
||||
export interface SubtitlePrefetchInitControllerDeps {
|
||||
getCurrentService: () => SubtitlePrefetchService | null;
|
||||
setCurrentService: (service: SubtitlePrefetchService | null) => void;
|
||||
loadSubtitleSourceText: (source: string) => Promise<string>;
|
||||
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
|
||||
createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService;
|
||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
isCacheFull: () => boolean;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface SubtitlePrefetchInitController {
|
||||
cancelPendingInit: () => void;
|
||||
initSubtitlePrefetch: (externalFilename: string, currentTimePos: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createSubtitlePrefetchInitController(
|
||||
deps: SubtitlePrefetchInitControllerDeps,
|
||||
): SubtitlePrefetchInitController {
|
||||
let initRevision = 0;
|
||||
|
||||
const cancelPendingInit = (): void => {
|
||||
initRevision += 1;
|
||||
deps.getCurrentService()?.stop();
|
||||
deps.setCurrentService(null);
|
||||
};
|
||||
|
||||
const initSubtitlePrefetch = async (
|
||||
externalFilename: string,
|
||||
currentTimePos: number,
|
||||
): Promise<void> => {
|
||||
const revision = ++initRevision;
|
||||
deps.getCurrentService()?.stop();
|
||||
deps.setCurrentService(null);
|
||||
|
||||
try {
|
||||
const content = await deps.loadSubtitleSourceText(externalFilename);
|
||||
if (revision !== initRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cues = deps.parseSubtitleCues(content, externalFilename);
|
||||
if (revision !== initRevision || cues.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextService = deps.createSubtitlePrefetchService({
|
||||
cues,
|
||||
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
|
||||
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
|
||||
isCacheFull: () => deps.isCacheFull(),
|
||||
});
|
||||
|
||||
if (revision !== initRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.setCurrentService(nextService);
|
||||
nextService.start(currentTimePos);
|
||||
deps.logInfo(
|
||||
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (revision === initRevision) {
|
||||
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cancelPendingInit,
|
||||
initSubtitlePrefetch,
|
||||
};
|
||||
}
|
||||
47
src/main/runtime/subtitle-prefetch-source.test.ts
Normal file
47
src/main/runtime/subtitle-prefetch-source.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getActiveExternalSubtitleSource,
|
||||
resolveSubtitleSourcePath,
|
||||
} from './subtitle-prefetch-source';
|
||||
|
||||
test('getActiveExternalSubtitleSource returns the active external subtitle path', () => {
|
||||
const source = getActiveExternalSubtitleSource(
|
||||
[
|
||||
{ type: 'sub', id: 1, external: false },
|
||||
{ type: 'sub', id: 2, external: true, 'external-filename': ' https://host/subs.ass ' },
|
||||
],
|
||||
'2',
|
||||
);
|
||||
|
||||
assert.equal(source, 'https://host/subs.ass');
|
||||
});
|
||||
|
||||
test('getActiveExternalSubtitleSource returns null when the selected track is not external', () => {
|
||||
const source = getActiveExternalSubtitleSource(
|
||||
[{ type: 'sub', id: 2, external: false, 'external-filename': '/tmp/subs.ass' }],
|
||||
2,
|
||||
);
|
||||
|
||||
assert.equal(source, null);
|
||||
});
|
||||
|
||||
test('resolveSubtitleSourcePath converts file URLs with spaces into filesystem paths', () => {
|
||||
const fileUrl = process.platform === 'win32'
|
||||
? 'file:///C:/Users/test/Sub%20Folder/subs.ass'
|
||||
: 'file:///tmp/Sub%20Folder/subs.ass';
|
||||
|
||||
const resolved = resolveSubtitleSourcePath(fileUrl);
|
||||
|
||||
assert.ok(resolved.endsWith('/Sub Folder/subs.ass') || resolved.endsWith('\\Sub Folder\\subs.ass'));
|
||||
});
|
||||
|
||||
test('resolveSubtitleSourcePath leaves non-file sources unchanged', () => {
|
||||
assert.equal(resolveSubtitleSourcePath('/tmp/subs.ass'), '/tmp/subs.ass');
|
||||
});
|
||||
|
||||
test('resolveSubtitleSourcePath returns the original source for malformed file URLs', () => {
|
||||
const source = 'file://invalid[path';
|
||||
|
||||
assert.equal(resolveSubtitleSourcePath(source), source);
|
||||
});
|
||||
42
src/main/runtime/subtitle-prefetch-source.ts
Normal file
42
src/main/runtime/subtitle-prefetch-source.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export function getActiveExternalSubtitleSource(
|
||||
trackListRaw: unknown,
|
||||
sidRaw: unknown,
|
||||
): string | null {
|
||||
if (!Array.isArray(trackListRaw) || sidRaw == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sid =
|
||||
typeof sidRaw === 'number' ? sidRaw : typeof sidRaw === 'string' ? Number(sidRaw) : null;
|
||||
if (sid == null || !Number.isFinite(sid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeTrack = trackListRaw.find((entry: unknown) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const track = entry as Record<string, unknown>;
|
||||
return track.type === 'sub' && track.id === sid && track.external === true;
|
||||
}) as Record<string, unknown> | undefined;
|
||||
|
||||
const externalFilename =
|
||||
typeof activeTrack?.['external-filename'] === 'string'
|
||||
? activeTrack['external-filename'].trim()
|
||||
: '';
|
||||
return externalFilename || null;
|
||||
}
|
||||
|
||||
export function resolveSubtitleSourcePath(source: string): string {
|
||||
if (!source.startsWith('file://')) {
|
||||
return source;
|
||||
}
|
||||
|
||||
try {
|
||||
return fileURLToPath(new URL(source));
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user