From cf86817cd8ef2a14407af634315bc785d2ca5e86 Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 8 Apr 2026 01:40:38 -0700 Subject: [PATCH] Fix overlay subtitle drop routing --- changes/fix-overlay-subtitle-drop-routing.md | 4 +++ src/core/services/overlay-drop.test.ts | 38 ++++++++++++++++++++ src/core/services/overlay-drop.ts | 33 ++++++++++++++--- src/renderer/renderer.ts | 26 ++++++++++---- 4 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 changes/fix-overlay-subtitle-drop-routing.md diff --git a/changes/fix-overlay-subtitle-drop-routing.md b/changes/fix-overlay-subtitle-drop-routing.md new file mode 100644 index 00000000..0711dee9 --- /dev/null +++ b/changes/fix-overlay-subtitle-drop-routing.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed overlay drag-and-drop routing so dropping external subtitle files like `.ass` onto mpv still loads them when the overlay is visible. diff --git a/src/core/services/overlay-drop.test.ts b/src/core/services/overlay-drop.test.ts index a1e0806e..dfc765fa 100644 --- a/src/core/services/overlay-drop.test.ts +++ b/src/core/services/overlay-drop.test.ts @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildMpvLoadfileCommands, + buildMpvSubtitleAddCommands, + collectDroppedSubtitlePaths, collectDroppedVideoPaths, parseClipboardVideoPath, type DropDataTransferLike, @@ -41,6 +43,33 @@ test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates', assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']); }); +test('collectDroppedSubtitlePaths keeps supported dropped subtitle paths in order', () => { + const transfer = makeTransfer({ + files: [ + { path: '/subs/ep02.ass' }, + { path: '/subs/readme.txt' }, + { path: '/subs/ep03.SRT' }, + ], + }); + + const result = collectDroppedSubtitlePaths(transfer); + + assert.deepEqual(result, ['/subs/ep02.ass', '/subs/ep03.SRT']); +}); + +test('collectDroppedSubtitlePaths parses text/uri-list entries and de-duplicates', () => { + const transfer = makeTransfer({ + getData: (format: string) => + format === 'text/uri-list' + ? '#comment\nfile:///tmp/ep01.ass\nfile:///tmp/ep01.ass\nfile:///tmp/ep02.vtt\nfile:///tmp/readme.md\n' + : '', + }); + + const result = collectDroppedSubtitlePaths(transfer); + + assert.deepEqual(result, ['/tmp/ep01.ass', '/tmp/ep02.vtt']); +}); + test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => { const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false); @@ -59,6 +88,15 @@ test('buildMpvLoadfileCommands uses append mode when shift-drop is used', () => ]); }); +test('buildMpvSubtitleAddCommands selects first subtitle and adds remainder', () => { + const commands = buildMpvSubtitleAddCommands(['/tmp/ep01.ass', '/tmp/ep02.srt']); + + assert.deepEqual(commands, [ + ['sub-add', '/tmp/ep01.ass', 'select'], + ['sub-add', '/tmp/ep02.srt'], + ]); +}); + test('parseClipboardVideoPath accepts quoted local paths', () => { assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv'); }); diff --git a/src/core/services/overlay-drop.ts b/src/core/services/overlay-drop.ts index b899748b..109e43bb 100644 --- a/src/core/services/overlay-drop.ts +++ b/src/core/services/overlay-drop.ts @@ -22,6 +22,8 @@ const VIDEO_EXTENSIONS = new Set([ '.wmv', ]); +const SUBTITLE_EXTENSIONS = new Set(['.ass', '.srt', '.ssa', '.sub', '.vtt']); + function getPathExtension(pathValue: string): string { const normalized = pathValue.split(/[?#]/, 1)[0] ?? ''; const dot = normalized.lastIndexOf('.'); @@ -32,7 +34,11 @@ function isSupportedVideoPath(pathValue: string): boolean { return VIDEO_EXTENSIONS.has(getPathExtension(pathValue)); } -function parseUriList(data: string): string[] { +function isSupportedSubtitlePath(pathValue: string): boolean { + return SUBTITLE_EXTENSIONS.has(getPathExtension(pathValue)); +} + +function parseUriList(data: string, isSupportedPath: (pathValue: string) => boolean): string[] { if (!data.trim()) return []; const out: string[] = []; @@ -47,7 +53,7 @@ function parseUriList(data: string): string[] { if (/^\/[A-Za-z]:\//.test(filePath)) { filePath = filePath.slice(1); } - if (filePath && isSupportedVideoPath(filePath)) { + if (filePath && isSupportedPath(filePath)) { out.push(filePath); } } catch { @@ -87,6 +93,19 @@ export function parseClipboardVideoPath(text: string): string | null { export function collectDroppedVideoPaths( dataTransfer: DropDataTransferLike | null | undefined, +): string[] { + return collectDroppedPaths(dataTransfer, isSupportedVideoPath); +} + +export function collectDroppedSubtitlePaths( + dataTransfer: DropDataTransferLike | null | undefined, +): string[] { + return collectDroppedPaths(dataTransfer, isSupportedSubtitlePath); +} + +function collectDroppedPaths( + dataTransfer: DropDataTransferLike | null | undefined, + isSupportedPath: (pathValue: string) => boolean, ): string[] { if (!dataTransfer) return []; @@ -96,7 +115,7 @@ export function collectDroppedVideoPaths( const addPath = (candidate: string | null | undefined): void => { if (!candidate) return; const trimmed = candidate.trim(); - if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return; + if (!trimmed || !isSupportedPath(trimmed) || seen.has(trimmed)) return; seen.add(trimmed); out.push(trimmed); }; @@ -109,7 +128,7 @@ export function collectDroppedVideoPaths( } if (typeof dataTransfer.getData === 'function') { - for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'))) { + for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'), isSupportedPath)) { addPath(pathValue); } } @@ -130,3 +149,9 @@ export function buildMpvLoadfileCommands( index === 0 ? 'replace' : 'append', ]); } + +export function buildMpvSubtitleAddCommands(paths: string[]): Array<(string | number)[]> { + return paths.map((pathValue, index) => + index === 0 ? ['sub-add', pathValue, 'select'] : ['sub-add', pathValue], + ); +} diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 1ab8fd5d..9d420cfe 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -55,6 +55,8 @@ import { resolveRendererDom } from './utils/dom.js'; import { resolvePlatformInfo } from './utils/platform.js'; import { buildMpvLoadfileCommands, + buildMpvSubtitleAddCommands, + collectDroppedSubtitlePaths, collectDroppedVideoPaths, } from '../core/services/overlay-drop.js'; @@ -706,18 +708,28 @@ function setupDragDropToMpvQueue(): void { if (!event.dataTransfer) return; event.preventDefault(); - const droppedPaths = collectDroppedVideoPaths(event.dataTransfer); - const loadCommands = buildMpvLoadfileCommands(droppedPaths, event.shiftKey); + const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer); + const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer); + const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, event.shiftKey); + const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths); for (const command of loadCommands) { window.electronAPI.sendMpvCommand(command); } + for (const command of subtitleCommands) { + window.electronAPI.sendMpvCommand(command); + } + const osdParts: string[] = []; if (loadCommands.length > 0) { const action = event.shiftKey ? 'Queued' : 'Loaded'; - window.electronAPI.sendMpvCommand([ - 'show-text', - `${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`, - '1500', - ]); + osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`); + } + if (subtitleCommands.length > 0) { + osdParts.push( + `Loaded ${subtitleCommands.length} subtitle file${subtitleCommands.length === 1 ? '' : 's'}`, + ); + } + if (osdParts.length > 0) { + window.electronAPI.sendMpvCommand(['show-text', osdParts.join(' | '), '1500']); } clearDropInteractive();