fix: address coderabbit review findings

This commit is contained in:
2026-03-15 14:26:30 -07:00
parent 5c31be99b5
commit 87bf3cef0c
18 changed files with 330 additions and 65 deletions

View File

@@ -9,7 +9,7 @@ Minimize the time between a subtitle line appearing and annotations being displa
## Current Pipeline (Warm State)
```
```text
MPV subtitle change (0ms)
-> IPC to main (5ms)
-> Cache check (2ms)
@@ -25,7 +25,7 @@ MPV subtitle change (0ms)
## Target Pipeline
```
```text
MPV subtitle change (0ms)
-> IPC to main (5ms)
-> Cache check (2ms)
@@ -142,7 +142,7 @@ Collapse the 4 sequential annotation passes (`applyKnownWordMarking` -> `applyFr
### Current Flow (4 passes, 4 array copies)
```
```text
tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
-> applyKnownWordMarking() // .map() -> new array
-> applyFrequencyMarking() // .map() -> new array (POS-based filtering only)

View File

@@ -17,6 +17,7 @@
## File Structure
### New Files
| File | Responsibility |
|------|---------------|
| `src/core/services/subtitle-cue-parser.ts` | Parse SRT/VTT/ASS files into `SubtitleCue[]` (timing + text) |
@@ -25,6 +26,7 @@
| `src/core/services/subtitle-prefetch.test.ts` | Tests for prefetch service |
### Modified Files
| File | Change |
|------|--------|
| `src/core/services/subtitle-processing-controller.ts` | Add `preCacheTokenization` + `isCacheFull` to public interface |
@@ -34,6 +36,7 @@
| `src/renderer/subtitle-render.ts` | `cloneNode` template + `replaceChildren()` |
| `src/main.ts` | Wire up prefetch service |
---
## Chunk 1: Batched Annotation Pass + DOM Template Pooling
@@ -1554,17 +1557,32 @@ subtitle processing, and restarts on cache invalidation."
### Task 10: Full test suite and type check
- [ ] **Step 1: Run full test suite**
- [ ] **Step 1: Run handoff type check**
Run: `bun run test`
Expected: 500+ pass, 1 pre-existing fail in immersion-tracker-service.
- [ ] **Step 2: Run TypeScript type check**
Run: `bun run tsc`
Run: `bun run typecheck`
Expected: No type errors.
- [ ] **Step 3: Review all commits on the branch**
- [ ] **Step 2: Run fast test lane**
Run: `bun run test:fast`
Expected: Fast unit/integration tests pass.
- [ ] **Step 3: Run environment-sensitive test lane**
Run: `bun run test:env`
Expected: Environment/runtime-sensitive tests pass.
- [ ] **Step 4: Run production build**
Run: `bun run build`
Expected: Production build succeeds.
- [ ] **Step 5: Run dist smoke test**
Run: `bun run test:smoke:dist`
Expected: Dist smoke tests pass.
- [ ] **Step 6: Review all commits on the branch**
Run: `git log --oneline main..HEAD`
Expected: ~8 focused commits, one per logical change.

View File

@@ -63,6 +63,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'media-title',
'secondary-sub-visibility',
'sub-visibility',
'sid',
'track-list',
];
const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [

View File

@@ -60,6 +60,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
emitSubtitleAssChange: (payload) => state.events.push(payload),
emitSubtitleTiming: (payload) => state.events.push(payload),
emitSecondarySubtitleChange: (payload) => state.events.push(payload),
emitSubtitleTrackChange: (payload) => state.events.push(payload),
emitSubtitleTrackListChange: (payload) => state.events.push(payload),
getCurrentSubText: () => state.subText,
setCurrentSubText: (text) => {
state.subText = text;
@@ -120,6 +122,24 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
});
test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
const { deps, state } = createDeps({
emitSubtitleTrackChange: (payload) => state.events.push(payload),
emitSubtitleTrackListChange: (payload) => state.events.push(payload),
});
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'sid', data: '3' },
deps,
);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'track-list', data: [{ type: 'sub', id: 3 }] },
deps,
);
assert.deepEqual(state.events, [{ sid: 3 }, { trackList: [{ type: 'sub', id: 3 }] }]);
});
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
const { deps, state } = createDeps({
isVisibleOverlayVisible: () => true,

View File

@@ -52,6 +52,8 @@ export interface MpvProtocolHandleMessageDeps {
emitSubtitleAssChange: (payload: { text: string }) => void;
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
emitSecondarySubtitleChange: (payload: { text: string }) => void;
emitSubtitleTrackChange: (payload: { sid: number | null }) => void;
emitSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
getCurrentSubText: () => string;
setCurrentSubText: (text: string) => void;
setCurrentSubStart: (value: number) => void;
@@ -160,6 +162,18 @@ export async function dispatchMpvProtocolMessage(
const nextSubText = (msg.data as string) || '';
deps.setCurrentSecondarySubText(nextSubText);
deps.emitSecondarySubtitleChange({ text: nextSubText });
} else if (msg.name === 'sid') {
const sid =
typeof msg.data === 'number'
? msg.data
: typeof msg.data === 'string'
? Number(msg.data)
: null;
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isFinite(sid) ? sid : null });
} else if (msg.name === 'track-list') {
deps.emitSubtitleTrackListChange({
trackList: Array.isArray(msg.data) ? (msg.data as unknown[]) : null,
});
} else if (msg.name === 'aid') {
deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null);
deps.syncCurrentAudioStreamIndex();

View File

@@ -118,6 +118,8 @@ export interface MpvIpcClientEventMap {
'duration-change': { duration: number };
'pause-change': { paused: boolean };
'secondary-subtitle-change': { text: string };
'subtitle-track-change': { sid: number | null };
'subtitle-track-list-change': { trackList: unknown[] | null };
'media-path-change': { path: string };
'media-title-change': { title: string | null };
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
@@ -325,6 +327,12 @@ export class MpvIpcClient implements MpvClient {
emitSecondarySubtitleChange: (payload) => {
this.emit('secondary-subtitle-change', payload);
},
emitSubtitleTrackChange: (payload) => {
this.emit('subtitle-track-change', payload);
},
emitSubtitleTrackListChange: (payload) => {
this.emit('subtitle-track-list-change', payload);
},
getCurrentSubText: () => this.currentSubText,
setCurrentSubText: (text: string) => {
this.currentSubText = text;

View File

@@ -186,6 +186,21 @@ test('parseAssCues handles hour timestamps', () => {
assert.equal(cues[0]!.endTime, 5405.0);
});
test('parseAssCues respects dynamic field ordering from the Format row', () => {
const content = [
'[Events]',
'Format: Layer, Style, Start, End, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,Default,0:00:01.00,0:00:04.00,,0,0,0,,順番が違う',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues.length, 1);
assert.equal(cues[0]!.startTime, 1.0);
assert.equal(cues[0]!.endTime, 4.0);
assert.equal(cues[0]!.text, '順番が違う');
});
test('parseSubtitleCues auto-detects SRT format', () => {
const content = [
'1',
@@ -244,3 +259,16 @@ test('parseSubtitleCues returns cues sorted by start time', () => {
assert.equal(cues[0]!.text, '一番目');
assert.equal(cues[1]!.text, '二番目');
});
test('parseSubtitleCues detects subtitle formats from remote URLs', () => {
const assContent = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,URLテスト',
].join('\n');
const cues = parseSubtitleCues(assContent, 'https://host/subs.ass?lang=ja#track');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, 'URLテスト');
});

View File

@@ -56,6 +56,8 @@ export function parseSrtCues(content: string): SubtitleCue[] {
const ASS_OVERRIDE_TAG_PATTERN = /\{[^}]*\}/g;
const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/;
const ASS_FORMAT_PREFIX = 'Format:';
const ASS_DIALOGUE_PREFIX = 'Dialogue:';
function parseAssTimestamp(raw: string): number | null {
const match = ASS_TIMING_PATTERN.exec(raw.trim());
@@ -73,12 +75,20 @@ export function parseAssCues(content: string): SubtitleCue[] {
const cues: SubtitleCue[] = [];
const lines = content.split(/\r?\n/);
let inEventsSection = false;
let startFieldIndex = -1;
let endFieldIndex = -1;
let textFieldIndex = -1;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
inEventsSection = trimmed.toLowerCase() === '[events]';
if (!inEventsSection) {
startFieldIndex = -1;
endFieldIndex = -1;
textFieldIndex = -1;
}
continue;
}
@@ -86,34 +96,45 @@ export function parseAssCues(content: string): SubtitleCue[] {
continue;
}
if (!trimmed.startsWith('Dialogue:')) {
if (trimmed.startsWith(ASS_FORMAT_PREFIX)) {
const formatFields = trimmed
.slice(ASS_FORMAT_PREFIX.length)
.split(',')
.map((field) => field.trim().toLowerCase());
startFieldIndex = formatFields.indexOf('start');
endFieldIndex = formatFields.indexOf('end');
textFieldIndex = formatFields.indexOf('text');
continue;
}
// Split on first 9 commas (ASS v4+ has 10 fields; last is Text which can contain commas)
const afterPrefix = trimmed.slice('Dialogue:'.length);
const fields: string[] = [];
let remaining = afterPrefix;
for (let fieldIndex = 0; fieldIndex < 9; fieldIndex += 1) {
const commaIndex = remaining.indexOf(',');
if (commaIndex < 0) {
break;
}
fields.push(remaining.slice(0, commaIndex));
remaining = remaining.slice(commaIndex + 1);
}
if (fields.length < 9) {
if (!trimmed.startsWith(ASS_DIALOGUE_PREFIX)) {
continue;
}
const startTime = parseAssTimestamp(fields[1]!);
const endTime = parseAssTimestamp(fields[2]!);
if (startFieldIndex < 0 || endFieldIndex < 0 || textFieldIndex < 0) {
continue;
}
const fields = trimmed.slice(ASS_DIALOGUE_PREFIX.length).split(',');
if (
startFieldIndex >= fields.length ||
endFieldIndex >= fields.length ||
textFieldIndex >= fields.length
) {
continue;
}
const startTime = parseAssTimestamp(fields[startFieldIndex]!);
const endTime = parseAssTimestamp(fields[endFieldIndex]!);
if (startTime === null || endTime === null) {
continue;
}
const rawText = remaining.replace(ASS_OVERRIDE_TAG_PATTERN, '').trim();
const rawText = fields
.slice(textFieldIndex)
.join(',')
.replace(ASS_OVERRIDE_TAG_PATTERN, '')
.trim();
if (rawText) {
cues.push({ startTime, endTime, text: rawText });
}
@@ -122,8 +143,15 @@ export function parseAssCues(content: string): SubtitleCue[] {
return cues;
}
function detectSubtitleFormat(filename: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null {
const normalizedSource = (() => {
try {
return /^[a-z]+:\/\//i.test(source) ? new URL(source).pathname : source;
} catch {
return source;
}
})().split(/[?#]/, 1)[0];
const ext = normalizedSource.split('.').pop()?.toLowerCase() ?? '';
if (ext === 'srt') return 'srt';
if (ext === 'vtt') return 'vtt';
if (ext === 'ass' || ext === 'ssa') return 'ass';

View File

@@ -173,6 +173,29 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
assert.ok(hasPostSeekCue, 'Should have cached cues after seek position');
});
test('prefetch service still warms the priority window when cache is full', async () => {
const cues = makeCues(20);
const cachedTexts: string[] = [];
const service = createSubtitlePrefetchService({
cues,
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
preCacheTokenization: (text) => {
cachedTexts.push(text);
},
isCacheFull: () => true,
priorityWindowSize: 3,
});
service.start(0);
for (let i = 0; i < 10; i += 1) {
await flushMicrotasks();
}
service.stop();
assert.deepEqual(cachedTexts.slice(0, 3), ['line-0', 'line-1', 'line-2']);
});
test('prefetch service pause/resume halts and continues tokenization', async () => {
const cues = makeCues(20);
let tokenizeCalls = 0;

View File

@@ -58,6 +58,7 @@ export function createSubtitlePrefetchService(
async function tokenizeCueList(
cuesToProcess: SubtitleCue[],
runId: number,
options: { allowWhenCacheFull?: boolean } = {},
): Promise<void> {
for (const cue of cuesToProcess) {
if (stopped || runId !== currentRunId) {
@@ -73,7 +74,7 @@ export function createSubtitlePrefetchService(
return;
}
if (deps.isCacheFull()) {
if (!options.allowWhenCacheFull && deps.isCacheFull()) {
return;
}
@@ -96,7 +97,7 @@ export function createSubtitlePrefetchService(
// Phase 1: Priority window
const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize);
await tokenizeCueList(priorityCues, runId);
await tokenizeCueList(priorityCues, runId, { allowWhenCacheFull: true });
if (stopped || runId !== currentRunId) {
return;

View File

@@ -421,6 +421,10 @@ import { resolveConfigDir } from './config/path-resolution';
import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch';
import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch';
import {
getActiveExternalSubtitleSource,
resolveSubtitleSourcePath,
} from './main/runtime/subtitle-prefetch-source';
if (process.platform === 'linux') {
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
@@ -1077,9 +1081,17 @@ const subtitleProcessingController = createSubtitleProcessingController(
);
let subtitlePrefetchService: SubtitlePrefetchService | null = null;
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let lastObservedTimePos = 0;
const SEEK_THRESHOLD_SECONDS = 3;
function clearScheduledSubtitlePrefetchRefresh(): void {
if (subtitlePrefetchRefreshTimer) {
clearTimeout(subtitlePrefetchRefreshTimer);
subtitlePrefetchRefreshTimer = null;
}
}
async function initSubtitlePrefetch(
externalFilename: string,
currentTimePos: number,
@@ -1111,6 +1123,37 @@ async function initSubtitlePrefetch(
}
}
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
const client = appState.mpvClient;
if (!client?.connected) {
return;
}
try {
const [trackListRaw, sidRaw] = await Promise.all([
client.requestProperty('track-list'),
client.requestProperty('sid'),
]);
const externalFilename = getActiveExternalSubtitleSource(trackListRaw, sidRaw);
if (!externalFilename) {
subtitlePrefetchService?.stop();
subtitlePrefetchService = null;
return;
}
await initSubtitlePrefetch(externalFilename, lastObservedTimePos);
} catch {
// Track list query failed; skip subtitle prefetch refresh.
}
}
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
clearScheduledSubtitlePrefetchRefresh();
subtitlePrefetchRefreshTimer = setTimeout(() => {
subtitlePrefetchRefreshTimer = null;
void refreshSubtitlePrefetchFromActiveTrack();
}, delayMs);
}
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
createBuildOverlayShortcutsRuntimeMainDepsHandler({
getConfiguredShortcuts: () => getConfiguredShortcuts(),
@@ -2896,42 +2939,13 @@ const {
autoPlayReadySignalMediaPath = null;
currentMediaTokenizationGate.updateCurrentMediaPath(path);
startupOsdSequencer.reset();
clearScheduledSubtitlePrefetchRefresh();
subtitlePrefetchService?.stop();
subtitlePrefetchService = null;
if (path) {
ensureImmersionTrackerStarted();
// Attempt to initialize subtitle prefetch for external subtitle tracks.
// Delay slightly to allow MPV's track-list to be populated.
setTimeout(() => {
const client = appState.mpvClient;
if (!client?.connected) return;
void (async () => {
try {
const [trackListRaw, sidRaw] = await Promise.all([
client.requestProperty('track-list'),
client.requestProperty('sid'),
]);
if (!Array.isArray(trackListRaw) || sidRaw == null) return;
const sid = typeof sidRaw === 'number' ? sidRaw : typeof sidRaw === 'string' ? Number(sidRaw) : null;
if (sid == null || !Number.isFinite(sid)) return;
const activeTrack = trackListRaw.find(
(entry: unknown) => {
if (!entry || typeof entry !== 'object') return false;
const t = entry as Record<string, unknown>;
return t.type === 'sub' && t.id === sid && t.external === true;
},
) as Record<string, unknown> | undefined;
if (!activeTrack) return;
const externalFilename = typeof activeTrack['external-filename'] === 'string'
? (activeTrack['external-filename'] as string).trim()
: '';
if (!externalFilename) return;
void initSubtitlePrefetch(externalFilename, lastObservedTimePos);
} catch {
// Track list query failed — not critical, skip prefetch.
}
})();
}, 500);
scheduleSubtitlePrefetchRefresh(500);
}
mediaRuntime.updateCurrentMediaPath(path);
},
@@ -2982,6 +2996,12 @@ const {
}
lastObservedTimePos = time;
},
onSubtitleTrackChange: () => {
scheduleSubtitlePrefetchRefresh();
},
onSubtitleTrackListChange: () => {
scheduleSubtitlePrefetchRefresh();
},
updateSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
},
@@ -3590,7 +3610,7 @@ async function loadSubtitleSourceText(source: string): Promise<string> {
}
}
const filePath = source.startsWith('file://') ? decodeURI(new URL(source).pathname) : source;
const filePath = resolveSubtitleSourcePath(source);
return fs.promises.readFile(filePath, 'utf8');
}

View File

@@ -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',

View File

@@ -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);

View File

@@ -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'));

View File

@@ -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;
@@ -146,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,

View File

@@ -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;
@@ -101,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;

View File

@@ -0,0 +1,41 @@
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');
});

View File

@@ -0,0 +1,34 @@
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 {
return source.startsWith('file://') ? fileURLToPath(new URL(source)) : source;
}