diff --git a/docs/architecture/2026-03-15-renderer-performance-design.md b/docs/architecture/2026-03-15-renderer-performance-design.md index 31d8bfb..42dfa07 100644 --- a/docs/architecture/2026-03-15-renderer-performance-design.md +++ b/docs/architecture/2026-03-15-renderer-performance-design.md @@ -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) diff --git a/docs/superpowers/plans/2026-03-15-renderer-performance.md b/docs/superpowers/plans/2026-03-15-renderer-performance.md index 9089d4c..8cf7d9f 100644 --- a/docs/superpowers/plans/2026-03-15-renderer-performance.md +++ b/docs/superpowers/plans/2026-03-15-renderer-performance.md @@ -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. diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts index 089270f..e8b81ce 100644 --- a/src/core/services/mpv-properties.ts +++ b/src/core/services/mpv-properties.ts @@ -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 = [ diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 251b1a9..e2cd13e 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -60,6 +60,8 @@ function createDeps(overrides: Partial = {}): { 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, diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 028d084..03768c3 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -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(); diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index a5164e0..54a7667 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -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 }; @@ -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; diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts index 7702312..be7fae9 100644 --- a/src/core/services/subtitle-cue-parser.test.ts +++ b/src/core/services/subtitle-cue-parser.test.ts @@ -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テスト'); +}); diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index 1b0d75c..d3d2ae3 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -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'; diff --git a/src/core/services/subtitle-prefetch.test.ts b/src/core/services/subtitle-prefetch.test.ts index 718e522..bb76bb1 100644 --- a/src/core/services/subtitle-prefetch.test.ts +++ b/src/core/services/subtitle-prefetch.test.ts @@ -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; diff --git a/src/core/services/subtitle-prefetch.ts b/src/core/services/subtitle-prefetch.ts index cf6d479..4258e94 100644 --- a/src/core/services/subtitle-prefetch.ts +++ b/src/core/services/subtitle-prefetch.ts @@ -58,6 +58,7 @@ export function createSubtitlePrefetchService( async function tokenizeCueList( cuesToProcess: SubtitleCue[], runId: number, + options: { allowWhenCacheFull?: boolean } = {}, ): Promise { 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; diff --git a/src/main.ts b/src/main.ts index fc3bc88..49a0229 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 | 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 { + 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; - return t.type === 'sub' && t.id === sid && t.external === true; - }, - ) as Record | 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); }, @@ -3590,7 +3610,7 @@ async function loadSubtitleSourceText(source: string): Promise { } } - const filePath = source.startsWith('file://') ? decodeURI(new URL(source).pathname) : source; + const filePath = resolveSubtitleSourcePath(source); return fs.promises.readFile(filePath, 'utf8'); } diff --git a/src/main/runtime/mpv-client-event-bindings.test.ts b/src/main/runtime/mpv-client-event-bindings.test.ts index fa56d57..2126193 100644 --- a/src/main/runtime/mpv-client-event-bindings.test.ts +++ b/src/main/runtime/mpv-client-event-bindings.test.ts @@ -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', diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts index 9a6b747..c1b759e 100644 --- a/src/main/runtime/mpv-client-event-bindings.ts +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -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); diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index 256b4ee..44c3552 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -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')); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index b27ffed..4783308 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -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, diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 86fb919..669f27d 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -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; diff --git a/src/main/runtime/subtitle-prefetch-source.test.ts b/src/main/runtime/subtitle-prefetch-source.test.ts new file mode 100644 index 0000000..fa00137 --- /dev/null +++ b/src/main/runtime/subtitle-prefetch-source.test.ts @@ -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'); +}); diff --git a/src/main/runtime/subtitle-prefetch-source.ts b/src/main/runtime/subtitle-prefetch-source.ts new file mode 100644 index 0000000..a7d74c1 --- /dev/null +++ b/src/main/runtime/subtitle-prefetch-source.ts @@ -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; + return track.type === 'sub' && track.id === sid && track.external === true; + }) as Record | 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; +}