feat(subtitle-sidebar): add sidebar config surface (#28)

This commit is contained in:
2026-03-21 23:37:42 -07:00
committed by GitHub
parent eddf6f0456
commit 3a01cffc6b
66 changed files with 5241 additions and 426 deletions

View File

@@ -63,6 +63,7 @@ export interface MainIpcRuntimeServiceDepsParams {
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
@@ -212,6 +213,7 @@ export function createMainIpcRuntimeServiceDeps(
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
getPlaybackPaused: params.getPlaybackPaused,
getSubtitlePosition: params.getSubtitlePosition,
getSubtitleStyle: params.getSubtitleStyle,

View File

@@ -36,6 +36,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
return {
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
subtitleStyle: resolveSubtitleStyleForRenderer(config),
subtitleSidebar: config.subtitleSidebar,
secondarySubMode: config.secondarySub.defaultMode,
};
}

View File

@@ -82,6 +82,7 @@ test('media path change handler reports stop for empty path and probes media key
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => 'show:1',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -97,6 +98,7 @@ test('media path change handler reports stop for empty path and probes media key
assert.deepEqual(calls, [
'flush-playback',
'path:',
'reset-sidebar-layout',
'stopped',
'restore-mpv-sub',
'reset:show:1',
@@ -113,6 +115,7 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -128,35 +131,7 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
assert.deepEqual(calls, [
'path:/tmp/video.mkv',
'reset:null',
'sync',
'dict-sync',
'autoplay:/tmp/video.mkv',
'presence',
]);
});
test('media path change handler ignores playback flush for non-empty path', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
refreshDiscordPresence: () => calls.push('presence'),
});
handler({ path: '/tmp/video.mkv' });
assert.ok(!calls.includes('flush-playback'));
assert.deepEqual(calls, [
'path:/tmp/video.mkv',
'reset-sidebar-layout',
'reset:null',
'sync',
'dict-sync',

View File

@@ -46,6 +46,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
updateCurrentMediaPath: (path: string) => void;
reportJellyfinRemoteStopped: () => void;
restoreMpvSubVisibility: () => void;
resetSubtitleSidebarEmbeddedLayout: () => void;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -62,6 +63,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath);
}
deps.updateCurrentMediaPath(normalizedPath);
deps.resetSubtitleSidebarEmbeddedLayout();
if (!normalizedPath) {
deps.reportJellyfinRemoteStopped();
deps.restoreMpvSubVisibility();

View File

@@ -9,6 +9,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
hasInitialJellyfinPlayArg: () => false,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
@@ -67,6 +68,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
},
});
handlers.get('connection-change')?.({ connected: true });
handlers.get('subtitle-change')?.({ text: 'line' });
handlers.get('subtitle-track-change')?.({ sid: 3 });
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
@@ -76,6 +78,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
handlers.get('pause-change')?.({ paused: true });
assert.ok(calls.includes('set-sub:line'));
assert.ok(calls.includes('reset-sidebar-layout'));
assert.ok(calls.includes('broadcast-sub:line'));
assert.ok(calls.includes('subtitle-change:line'));
assert.ok(calls.includes('subtitle-track-change'));

View File

@@ -21,6 +21,7 @@ type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandl
export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
resetSubtitleSidebarEmbeddedLayout: () => void;
scheduleCharacterDictionarySync?: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
@@ -83,6 +84,12 @@ export function createBindMpvMainEventHandlersHandler(deps: {
isMpvConnected: () => deps.isMpvConnected(),
quitApp: () => deps.quitApp(),
});
const handleMpvConnectionChangeWithSidebarReset = ({ connected }: { connected: boolean }): void => {
if (connected) {
deps.resetSubtitleSidebarEmbeddedLayout();
}
handleMpvConnectionChange({ connected });
};
const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({
recordImmersionSubtitleLine: (text, start, end) =>
deps.recordImmersionSubtitleLine(text, start, end),
@@ -110,6 +117,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path),
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey),
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
@@ -150,7 +158,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
});
createBindMpvClientEventHandlers({
onConnectionChange: handleMpvConnectionChange,
onConnectionChange: handleMpvConnectionChangeWithSidebarReset,
onSubtitleChange: handleMpvSubtitleChange,
onSubtitleAssChange: handleMpvSubtitleAssChange,
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,

View File

@@ -47,6 +47,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => 'media-key',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -82,6 +83,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.broadcastSecondarySubtitle('sec');
deps.updateCurrentMediaPath('/tmp/video');
deps.restoreMpvSubVisibility();
deps.resetSubtitleSidebarEmbeddedLayout();
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
deps.resetAnilistMediaTracking('media-key');
deps.maybeProbeAnilistDuration('media-key');
@@ -112,6 +114,5 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('immersion-time:12.25'));
assert.ok(calls.includes('immersion-time:18.75'));
assert.ok(calls.includes('reset-sidebar-layout'));
});

View File

@@ -50,6 +50,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibility: () => void;
resetSubtitleSidebarEmbeddedLayout?: () => void;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -146,6 +147,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout?.(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey: string | null) =>
deps.resetAnilistMediaTracking(mediaKey),

View File

@@ -1,7 +1,7 @@
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 type { SubtitleCue } from '../../types';
import { createSubtitlePrefetchInitController } from './subtitle-prefetch-init';
function createDeferred<T>(): {
@@ -112,3 +112,125 @@ test('cancelPendingInit prevents an in-flight load from attaching a stale servic
assert.equal(currentService, null);
assert.deepEqual(started, []);
});
test('subtitle prefetch init publishes parsed cues and clears them on cancel', async () => {
const deferred = createDeferred<string>();
let currentService: SubtitlePrefetchService | null = null;
const cueUpdates: Array<SubtitleCue[] | null> = [];
const controller = createSubtitlePrefetchInitController({
getCurrentService: () => currentService,
setCurrentService: (service) => {
currentService = service;
},
loadSubtitleSourceText: async () => await deferred.promise,
parseSubtitleCues: () => [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
],
createSubtitlePrefetchService: () => ({
start: () => {},
stop: () => {},
onSeek: () => {},
pause: () => {},
resume: () => {},
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (cues) => {
cueUpdates.push(cues);
},
});
const initPromise = controller.initSubtitlePrefetch('episode.ass', 12);
deferred.resolve('content');
await initPromise;
controller.cancelPendingInit();
assert.deepEqual(cueUpdates, [
[
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
],
null,
]);
});
test('subtitle prefetch init publishes the provided stable source key instead of the load path', async () => {
const deferred = createDeferred<string>();
let currentService: SubtitlePrefetchService | null = null;
const sourceUpdates: Array<string | null> = [];
const controller = createSubtitlePrefetchInitController({
getCurrentService: () => currentService,
setCurrentService: (service) => {
currentService = service;
},
loadSubtitleSourceText: async () => await deferred.promise,
parseSubtitleCues: () => [{ startTime: 1, endTime: 2, text: 'first' }],
createSubtitlePrefetchService: () => ({
start: () => {},
stop: () => {},
onSeek: () => {},
pause: () => {},
resume: () => {},
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (_cues, source) => {
sourceUpdates.push(source);
},
});
const initPromise = controller.initSubtitlePrefetch(
'/tmp/subminer-sidebar-123/track_7.ass',
12,
'internal:/media/episode01.mkv:track:3:ff:7',
);
deferred.resolve('content');
await initPromise;
assert.deepEqual(sourceUpdates, ['internal:/media/episode01.mkv:track:3:ff:7']);
});
test('subtitle prefetch init clears parsed cues when initialization fails', async () => {
const cueUpdates: Array<SubtitleCue[] | null> = [];
let currentService: SubtitlePrefetchService | null = null;
const controller = createSubtitlePrefetchInitController({
getCurrentService: () => currentService,
setCurrentService: (service) => {
currentService = service;
},
loadSubtitleSourceText: async () => {
throw new Error('boom');
},
parseSubtitleCues: () => [{ startTime: 1, endTime: 2, text: 'first' }],
createSubtitlePrefetchService: () => ({
start: () => {},
stop: () => {},
onSeek: () => {},
pause: () => {},
resume: () => {},
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (cues) => {
cueUpdates.push(cues);
},
});
await controller.initSubtitlePrefetch('episode.ass', 12);
assert.deepEqual(cueUpdates, [null]);
});

View File

@@ -1,9 +1,9 @@
import type { SubtitleCue } from '../../core/services/subtitle-cue-parser';
import type {
SubtitlePrefetchService,
SubtitlePrefetchServiceDeps,
} from '../../core/services/subtitle-prefetch';
import type { SubtitleData } from '../../types';
import type { SubtitleCue } from '../../types';
export interface SubtitlePrefetchInitControllerDeps {
getCurrentService: () => SubtitlePrefetchService | null;
@@ -16,11 +16,16 @@ export interface SubtitlePrefetchInitControllerDeps {
isCacheFull: () => boolean;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
}
export interface SubtitlePrefetchInitController {
cancelPendingInit: () => void;
initSubtitlePrefetch: (externalFilename: string, currentTimePos: number) => Promise<void>;
initSubtitlePrefetch: (
sourcePath: string,
currentTimePos: number,
sourceKey?: string,
) => Promise<void>;
}
export function createSubtitlePrefetchInitController(
@@ -32,24 +37,29 @@ export function createSubtitlePrefetchInitController(
initRevision += 1;
deps.getCurrentService()?.stop();
deps.setCurrentService(null);
deps.onParsedSubtitleCuesChanged?.(null, null);
};
const initSubtitlePrefetch = async (
externalFilename: string,
sourcePath: string,
currentTimePos: number,
sourceKey = sourcePath,
): Promise<void> => {
const revision = ++initRevision;
deps.getCurrentService()?.stop();
deps.setCurrentService(null);
try {
const content = await deps.loadSubtitleSourceText(externalFilename);
const content = await deps.loadSubtitleSourceText(sourcePath);
if (revision !== initRevision) {
return;
}
const cues = deps.parseSubtitleCues(content, externalFilename);
const cues = deps.parseSubtitleCues(content, sourcePath);
if (revision !== initRevision || cues.length === 0) {
if (revision === initRevision) {
deps.onParsedSubtitleCuesChanged?.(null, null);
}
return;
}
@@ -65,12 +75,14 @@ export function createSubtitlePrefetchInitController(
}
deps.setCurrentService(nextService);
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
nextService.start(currentTimePos);
deps.logInfo(
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`,
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${sourcePath}`,
);
} catch (error) {
if (revision === initRevision) {
deps.onParsedSubtitleCuesChanged?.(null, null);
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
}
}

View File

@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildSubtitleSidebarSourceKey,
getActiveExternalSubtitleSource,
resolveSubtitleSourcePath,
} from './subtitle-prefetch-source';
@@ -17,6 +18,15 @@ test('getActiveExternalSubtitleSource returns the active external subtitle path'
assert.equal(source, 'https://host/subs.ass');
});
test('getActiveExternalSubtitleSource normalizes integer-like string track ids', () => {
const source = getActiveExternalSubtitleSource(
[{ type: 'sub', id: '2', external: true, 'external-filename': ' /tmp/subs.ass ' }],
'2',
);
assert.equal(source, '/tmp/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' }],
@@ -48,3 +58,38 @@ test('resolveSubtitleSourcePath returns the original source for malformed file U
assert.equal(resolveSubtitleSourcePath(source), source);
});
test('buildSubtitleSidebarSourceKey uses a stable identifier for internal subtitle tracks', () => {
const firstKey = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
id: 3,
'ff-index': 7,
title: 'English',
lang: 'eng',
codec: 'ass',
});
const secondKey = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
id: 3,
'ff-index': 7,
title: 'English',
lang: 'eng',
codec: 'ass',
});
assert.equal(firstKey, secondKey);
assert.equal(firstKey, 'internal:/media/episode01.mkv:track:3:ff:7');
});
test('buildSubtitleSidebarSourceKey normalizes integer-like string track metadata', () => {
const key = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
id: '3',
'ff-index': '7',
});
assert.equal(key, 'internal:/media/episode01.mkv:track:3:ff:7');
});
test('buildSubtitleSidebarSourceKey falls back to source path when no track metadata is available', () => {
const key = buildSubtitleSidebarSourceKey('/media/episode01.mkv', null, '/tmp/subtitle.ass');
assert.equal(key, '/tmp/subtitle.ass');
});

View File

@@ -1,5 +1,16 @@
import { fileURLToPath } from 'node:url';
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isInteger(parsed) ? parsed : null;
}
return null;
}
export function getActiveExternalSubtitleSource(
trackListRaw: unknown,
sidRaw: unknown,
@@ -8,9 +19,8 @@ export function getActiveExternalSubtitleSource(
return null;
}
const sid =
typeof sidRaw === 'number' ? sidRaw : typeof sidRaw === 'string' ? Number(sidRaw) : null;
if (sid == null || !Number.isFinite(sid)) {
const sid = parseTrackId(sidRaw);
if (sid === null) {
return null;
}
@@ -19,7 +29,7 @@ export function getActiveExternalSubtitleSource(
return false;
}
const track = entry as Record<string, unknown>;
return track.type === 'sub' && track.id === sid && track.external === true;
return track.type === 'sub' && parseTrackId(track.id) === sid && track.external === true;
}) as Record<string, unknown> | undefined;
const externalFilename =
@@ -40,3 +50,21 @@ export function resolveSubtitleSourcePath(source: string): string {
return source;
}
}
export function buildSubtitleSidebarSourceKey(
videoPath: string,
track: unknown,
fallbackSourcePath?: string,
): string {
const normalizedVideoPath = videoPath.trim();
if (track && typeof track === 'object' && normalizedVideoPath) {
const subtitleTrack = track as Record<string, unknown>;
const trackId = parseTrackId(subtitleTrack.id);
const ffIndex = parseTrackId(subtitleTrack['ff-index']);
if (trackId !== null || ffIndex !== null) {
return `internal:${normalizedVideoPath}:track:${trackId ?? 'unknown'}:ff:${ffIndex ?? 'unknown'}`;
}
}
return fallbackSourcePath ?? normalizedVideoPath;
}

View File

@@ -9,6 +9,7 @@ import type {
KikuFieldGroupingChoice,
JlptLevel,
FrequencyDictionaryLookup,
SubtitleCue,
} from '../types';
import type { CliArgs } from '../cli/args';
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
@@ -158,6 +159,8 @@ export interface AppState {
currentSubText: string;
currentSubAssText: string;
currentSubtitleData: SubtitleData | null;
activeParsedSubtitleCues: SubtitleCue[];
activeParsedSubtitleSource: string | null;
windowTracker: BaseWindowTracker | null;
subtitlePosition: SubtitlePosition | null;
currentMediaPath: string | null;
@@ -238,6 +241,8 @@ export function createAppState(values: AppStateInitialValues): AppState {
currentSubText: '',
currentSubAssText: '',
currentSubtitleData: null,
activeParsedSubtitleCues: [],
activeParsedSubtitleSource: null,
windowTracker: null,
subtitlePosition: null,
currentMediaPath: null,