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) ## Current Pipeline (Warm State)
``` ```text
MPV subtitle change (0ms) MPV subtitle change (0ms)
-> IPC to main (5ms) -> IPC to main (5ms)
-> Cache check (2ms) -> Cache check (2ms)
@@ -25,7 +25,7 @@ MPV subtitle change (0ms)
## Target Pipeline ## Target Pipeline
``` ```text
MPV subtitle change (0ms) MPV subtitle change (0ms)
-> IPC to main (5ms) -> IPC to main (5ms)
-> Cache check (2ms) -> Cache check (2ms)
@@ -142,7 +142,7 @@ Collapse the 4 sequential annotation passes (`applyKnownWordMarking` -> `applyFr
### Current Flow (4 passes, 4 array copies) ### Current Flow (4 passes, 4 array copies)
``` ```text
tokens (already have frequencyRank values from parser-level applyFrequencyRanks) tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
-> applyKnownWordMarking() // .map() -> new array -> applyKnownWordMarking() // .map() -> new array
-> applyFrequencyMarking() // .map() -> new array (POS-based filtering only) -> applyFrequencyMarking() // .map() -> new array (POS-based filtering only)

View File

@@ -17,6 +17,7 @@
## File Structure ## File Structure
### New Files ### New Files
| File | Responsibility | | File | Responsibility |
|------|---------------| |------|---------------|
| `src/core/services/subtitle-cue-parser.ts` | Parse SRT/VTT/ASS files into `SubtitleCue[]` (timing + text) | | `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 | | `src/core/services/subtitle-prefetch.test.ts` | Tests for prefetch service |
### Modified Files ### Modified Files
| File | Change | | File | Change |
|------|--------| |------|--------|
| `src/core/services/subtitle-processing-controller.ts` | Add `preCacheTokenization` + `isCacheFull` to public interface | | `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/renderer/subtitle-render.ts` | `cloneNode` template + `replaceChildren()` |
| `src/main.ts` | Wire up prefetch service | | `src/main.ts` | Wire up prefetch service |
--- ---
## Chunk 1: Batched Annotation Pass + DOM Template Pooling ## 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 ### Task 10: Full test suite and type check
- [ ] **Step 1: Run full test suite** - [ ] **Step 1: Run handoff type check**
Run: `bun run test` Run: `bun run typecheck`
Expected: 500+ pass, 1 pre-existing fail in immersion-tracker-service.
- [ ] **Step 2: Run TypeScript type check**
Run: `bun run tsc`
Expected: No type errors. 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` Run: `git log --oneline main..HEAD`
Expected: ~8 focused commits, one per logical change. Expected: ~8 focused commits, one per logical change.

View File

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

View File

@@ -60,6 +60,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
emitSubtitleAssChange: (payload) => state.events.push(payload), emitSubtitleAssChange: (payload) => state.events.push(payload),
emitSubtitleTiming: (payload) => state.events.push(payload), emitSubtitleTiming: (payload) => state.events.push(payload),
emitSecondarySubtitleChange: (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, getCurrentSubText: () => state.subText,
setCurrentSubText: (text) => { setCurrentSubText: (text) => {
state.subText = text; state.subText = text;
@@ -120,6 +122,24 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]); 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 () => { test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
const { deps, state } = createDeps({ const { deps, state } = createDeps({
isVisibleOverlayVisible: () => true, isVisibleOverlayVisible: () => true,

View File

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

View File

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

View File

@@ -186,6 +186,21 @@ test('parseAssCues handles hour timestamps', () => {
assert.equal(cues[0]!.endTime, 5405.0); 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', () => { test('parseSubtitleCues auto-detects SRT format', () => {
const content = [ const content = [
'1', '1',
@@ -244,3 +259,16 @@ test('parseSubtitleCues returns cues sorted by start time', () => {
assert.equal(cues[0]!.text, '一番目'); assert.equal(cues[0]!.text, '一番目');
assert.equal(cues[1]!.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_OVERRIDE_TAG_PATTERN = /\{[^}]*\}/g;
const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/; 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 { function parseAssTimestamp(raw: string): number | null {
const match = ASS_TIMING_PATTERN.exec(raw.trim()); const match = ASS_TIMING_PATTERN.exec(raw.trim());
@@ -73,12 +75,20 @@ export function parseAssCues(content: string): SubtitleCue[] {
const cues: SubtitleCue[] = []; const cues: SubtitleCue[] = [];
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
let inEventsSection = false; let inEventsSection = false;
let startFieldIndex = -1;
let endFieldIndex = -1;
let textFieldIndex = -1;
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (trimmed.startsWith('[') && trimmed.endsWith(']')) { if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
inEventsSection = trimmed.toLowerCase() === '[events]'; inEventsSection = trimmed.toLowerCase() === '[events]';
if (!inEventsSection) {
startFieldIndex = -1;
endFieldIndex = -1;
textFieldIndex = -1;
}
continue; continue;
} }
@@ -86,34 +96,45 @@ export function parseAssCues(content: string): SubtitleCue[] {
continue; 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; continue;
} }
// Split on first 9 commas (ASS v4+ has 10 fields; last is Text which can contain commas) if (!trimmed.startsWith(ASS_DIALOGUE_PREFIX)) {
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) {
continue; continue;
} }
const startTime = parseAssTimestamp(fields[1]!); if (startFieldIndex < 0 || endFieldIndex < 0 || textFieldIndex < 0) {
const endTime = parseAssTimestamp(fields[2]!); 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) { if (startTime === null || endTime === null) {
continue; continue;
} }
const rawText = remaining.replace(ASS_OVERRIDE_TAG_PATTERN, '').trim(); const rawText = fields
.slice(textFieldIndex)
.join(',')
.replace(ASS_OVERRIDE_TAG_PATTERN, '')
.trim();
if (rawText) { if (rawText) {
cues.push({ startTime, endTime, text: rawText }); cues.push({ startTime, endTime, text: rawText });
} }
@@ -122,8 +143,15 @@ export function parseAssCues(content: string): SubtitleCue[] {
return cues; return cues;
} }
function detectSubtitleFormat(filename: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null { function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''; 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 === 'srt') return 'srt';
if (ext === 'vtt') return 'vtt'; if (ext === 'vtt') return 'vtt';
if (ext === 'ass' || ext === 'ssa') return 'ass'; 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'); 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 () => { test('prefetch service pause/resume halts and continues tokenization', async () => {
const cues = makeCues(20); const cues = makeCues(20);
let tokenizeCalls = 0; let tokenizeCalls = 0;

View File

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

View File

@@ -421,6 +421,10 @@ import { resolveConfigDir } from './config/path-resolution';
import { parseSubtitleCues } from './core/services/subtitle-cue-parser'; import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch'; import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch';
import type { SubtitlePrefetchService } 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') { if (process.platform === 'linux') {
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
@@ -1077,9 +1081,17 @@ const subtitleProcessingController = createSubtitleProcessingController(
); );
let subtitlePrefetchService: SubtitlePrefetchService | null = null; let subtitlePrefetchService: SubtitlePrefetchService | null = null;
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let lastObservedTimePos = 0; let lastObservedTimePos = 0;
const SEEK_THRESHOLD_SECONDS = 3; const SEEK_THRESHOLD_SECONDS = 3;
function clearScheduledSubtitlePrefetchRefresh(): void {
if (subtitlePrefetchRefreshTimer) {
clearTimeout(subtitlePrefetchRefreshTimer);
subtitlePrefetchRefreshTimer = null;
}
}
async function initSubtitlePrefetch( async function initSubtitlePrefetch(
externalFilename: string, externalFilename: string,
currentTimePos: number, 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( const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
createBuildOverlayShortcutsRuntimeMainDepsHandler({ createBuildOverlayShortcutsRuntimeMainDepsHandler({
getConfiguredShortcuts: () => getConfiguredShortcuts(), getConfiguredShortcuts: () => getConfiguredShortcuts(),
@@ -2896,42 +2939,13 @@ const {
autoPlayReadySignalMediaPath = null; autoPlayReadySignalMediaPath = null;
currentMediaTokenizationGate.updateCurrentMediaPath(path); currentMediaTokenizationGate.updateCurrentMediaPath(path);
startupOsdSequencer.reset(); startupOsdSequencer.reset();
clearScheduledSubtitlePrefetchRefresh();
subtitlePrefetchService?.stop(); subtitlePrefetchService?.stop();
subtitlePrefetchService = null; subtitlePrefetchService = null;
if (path) { if (path) {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
// Attempt to initialize subtitle prefetch for external subtitle tracks.
// Delay slightly to allow MPV's track-list to be populated. // Delay slightly to allow MPV's track-list to be populated.
setTimeout(() => { scheduleSubtitlePrefetchRefresh(500);
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);
} }
mediaRuntime.updateCurrentMediaPath(path); mediaRuntime.updateCurrentMediaPath(path);
}, },
@@ -2982,6 +2996,12 @@ const {
} }
lastObservedTimePos = time; lastObservedTimePos = time;
}, },
onSubtitleTrackChange: () => {
scheduleSubtitlePrefetchRefresh();
},
onSubtitleTrackListChange: () => {
scheduleSubtitlePrefetchRefresh();
},
updateSubtitleRenderMetrics: (patch) => { updateSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>); 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'); return fs.promises.readFile(filePath, 'utf8');
} }

View File

@@ -71,6 +71,8 @@ test('mpv event bindings register all expected events', () => {
onSubtitleChange: () => {}, onSubtitleChange: () => {},
onSubtitleAssChange: () => {}, onSubtitleAssChange: () => {},
onSecondarySubtitleChange: () => {}, onSecondarySubtitleChange: () => {},
onSubtitleTrackChange: () => {},
onSubtitleTrackListChange: () => {},
onSubtitleTiming: () => {}, onSubtitleTiming: () => {},
onMediaPathChange: () => {}, onMediaPathChange: () => {},
onMediaTitleChange: () => {}, onMediaTitleChange: () => {},
@@ -92,6 +94,8 @@ test('mpv event bindings register all expected events', () => {
'subtitle-change', 'subtitle-change',
'subtitle-ass-change', 'subtitle-ass-change',
'secondary-subtitle-change', 'secondary-subtitle-change',
'subtitle-track-change',
'subtitle-track-list-change',
'subtitle-timing', 'subtitle-timing',
'media-path-change', 'media-path-change',
'media-title-change', 'media-title-change',

View File

@@ -3,6 +3,8 @@ type MpvBindingEventName =
| 'subtitle-change' | 'subtitle-change'
| 'subtitle-ass-change' | 'subtitle-ass-change'
| 'secondary-subtitle-change' | 'secondary-subtitle-change'
| 'subtitle-track-change'
| 'subtitle-track-list-change'
| 'subtitle-timing' | 'subtitle-timing'
| 'media-path-change' | 'media-path-change'
| 'media-title-change' | 'media-title-change'
@@ -69,6 +71,8 @@ export function createBindMpvClientEventHandlers(deps: {
onSubtitleChange: (payload: { text: string }) => void; onSubtitleChange: (payload: { text: string }) => void;
onSubtitleAssChange: (payload: { text: string }) => void; onSubtitleAssChange: (payload: { text: string }) => void;
onSecondarySubtitleChange: (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; onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
onMediaPathChange: (payload: { path: string | null }) => void; onMediaPathChange: (payload: { path: string | null }) => void;
onMediaTitleChange: (payload: { title: 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-change', deps.onSubtitleChange);
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange); mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange); 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('subtitle-timing', deps.onSubtitleTiming);
mpvClient.on('media-path-change', deps.onMediaPathChange); mpvClient.on('media-path-change', deps.onMediaPathChange);
mpvClient.on('media-title-change', deps.onMediaTitleChange); 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}`), setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`),
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`), broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${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}`), updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), 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-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-path-change')?.({ path: '' });
handlers.get('media-title-change')?.({ title: 'Episode 1' }); handlers.get('media-title-change')?.({ title: 'Episode 1' });
handlers.get('time-pos-change')?.({ time: 2.5 }); 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('set-sub:line'));
assert.ok(calls.includes('broadcast-sub:line')); assert.ok(calls.includes('broadcast-sub:line'));
assert.ok(calls.includes('subtitle-change: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('media-title:Episode 1'));
assert.ok(calls.includes('restore-mpv-sub')); assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('reset-guess-state')); assert.ok(calls.includes('reset-guess-state'));

View File

@@ -42,6 +42,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
setCurrentSubAssText: (text: string) => void; setCurrentSubAssText: (text: string) => void;
broadcastSubtitleAss: (text: string) => void; broadcastSubtitleAss: (text: string) => void;
broadcastSecondarySubtitle: (text: string) => void; broadcastSecondarySubtitle: (text: string) => void;
onSubtitleTrackChange?: (sid: number | null) => void;
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
updateCurrentMediaPath: (path: string) => void; updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibility: () => void; restoreMpvSubVisibility: () => void;
@@ -146,6 +148,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
onSubtitleChange: handleMpvSubtitleChange, onSubtitleChange: handleMpvSubtitleChange,
onSubtitleAssChange: handleMpvSubtitleAssChange, onSubtitleAssChange: handleMpvSubtitleAssChange,
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange, onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
onSubtitleTrackChange: ({ sid }) => deps.onSubtitleTrackChange?.(sid),
onSubtitleTrackListChange: ({ trackList }) => deps.onSubtitleTrackListChange?.(trackList),
onSubtitleTiming: handleMpvSubtitleTiming, onSubtitleTiming: handleMpvSubtitleTiming,
onMediaPathChange: handleMpvMediaPathChange, onMediaPathChange: handleMpvMediaPathChange,
onMediaTitleChange: handleMpvMediaTitleChange, onMediaTitleChange: handleMpvMediaTitleChange,

View File

@@ -35,6 +35,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
logSubtitleTimingError: (message: string, error: unknown) => void; logSubtitleTimingError: (message: string, error: unknown) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
onSubtitleChange: (text: string) => void; onSubtitleChange: (text: string) => void;
onSubtitleTrackChange?: (sid: number | null) => void;
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
updateCurrentMediaPath: (path: string) => void; updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibility: () => void; restoreMpvSubVisibility: () => void;
getCurrentAnilistMediaKey: () => string | null; getCurrentAnilistMediaKey: () => string | null;
@@ -101,6 +103,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
broadcastSubtitle: (payload: { text: string; tokens: null }) => broadcastSubtitle: (payload: { text: string; tokens: null }) =>
deps.broadcastToOverlayWindows('subtitle:set', payload), deps.broadcastToOverlayWindows('subtitle:set', payload),
onSubtitleChange: (text: string) => deps.onSubtitleChange(text), 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(), refreshDiscordPresence: () => deps.refreshDiscordPresence(),
setCurrentSubAssText: (text: string) => { setCurrentSubAssText: (text: string) => {
deps.appState.currentSubAssText = text; 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;
}