Fix overlay subtitle drop routing

This commit is contained in:
2026-04-08 01:40:38 -07:00
parent 3f7de73734
commit cf86817cd8
4 changed files with 90 additions and 11 deletions

View File

@@ -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.

View File

@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
buildMpvLoadfileCommands, buildMpvLoadfileCommands,
buildMpvSubtitleAddCommands,
collectDroppedSubtitlePaths,
collectDroppedVideoPaths, collectDroppedVideoPaths,
parseClipboardVideoPath, parseClipboardVideoPath,
type DropDataTransferLike, 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']); 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', () => { test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => {
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false); 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', () => { test('parseClipboardVideoPath accepts quoted local paths', () => {
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv'); assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
}); });

View File

@@ -22,6 +22,8 @@ const VIDEO_EXTENSIONS = new Set([
'.wmv', '.wmv',
]); ]);
const SUBTITLE_EXTENSIONS = new Set(['.ass', '.srt', '.ssa', '.sub', '.vtt']);
function getPathExtension(pathValue: string): string { function getPathExtension(pathValue: string): string {
const normalized = pathValue.split(/[?#]/, 1)[0] ?? ''; const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
const dot = normalized.lastIndexOf('.'); const dot = normalized.lastIndexOf('.');
@@ -32,7 +34,11 @@ function isSupportedVideoPath(pathValue: string): boolean {
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue)); 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 []; if (!data.trim()) return [];
const out: string[] = []; const out: string[] = [];
@@ -47,7 +53,7 @@ function parseUriList(data: string): string[] {
if (/^\/[A-Za-z]:\//.test(filePath)) { if (/^\/[A-Za-z]:\//.test(filePath)) {
filePath = filePath.slice(1); filePath = filePath.slice(1);
} }
if (filePath && isSupportedVideoPath(filePath)) { if (filePath && isSupportedPath(filePath)) {
out.push(filePath); out.push(filePath);
} }
} catch { } catch {
@@ -87,6 +93,19 @@ export function parseClipboardVideoPath(text: string): string | null {
export function collectDroppedVideoPaths( export function collectDroppedVideoPaths(
dataTransfer: DropDataTransferLike | null | undefined, 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[] { ): string[] {
if (!dataTransfer) return []; if (!dataTransfer) return [];
@@ -96,7 +115,7 @@ export function collectDroppedVideoPaths(
const addPath = (candidate: string | null | undefined): void => { const addPath = (candidate: string | null | undefined): void => {
if (!candidate) return; if (!candidate) return;
const trimmed = candidate.trim(); const trimmed = candidate.trim();
if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return; if (!trimmed || !isSupportedPath(trimmed) || seen.has(trimmed)) return;
seen.add(trimmed); seen.add(trimmed);
out.push(trimmed); out.push(trimmed);
}; };
@@ -109,7 +128,7 @@ export function collectDroppedVideoPaths(
} }
if (typeof dataTransfer.getData === 'function') { 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); addPath(pathValue);
} }
} }
@@ -130,3 +149,9 @@ export function buildMpvLoadfileCommands(
index === 0 ? 'replace' : 'append', 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],
);
}

View File

@@ -55,6 +55,8 @@ import { resolveRendererDom } from './utils/dom.js';
import { resolvePlatformInfo } from './utils/platform.js'; import { resolvePlatformInfo } from './utils/platform.js';
import { import {
buildMpvLoadfileCommands, buildMpvLoadfileCommands,
buildMpvSubtitleAddCommands,
collectDroppedSubtitlePaths,
collectDroppedVideoPaths, collectDroppedVideoPaths,
} from '../core/services/overlay-drop.js'; } from '../core/services/overlay-drop.js';
@@ -706,18 +708,28 @@ function setupDragDropToMpvQueue(): void {
if (!event.dataTransfer) return; if (!event.dataTransfer) return;
event.preventDefault(); event.preventDefault();
const droppedPaths = collectDroppedVideoPaths(event.dataTransfer); const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer);
const loadCommands = buildMpvLoadfileCommands(droppedPaths, event.shiftKey); const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer);
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, event.shiftKey);
const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths);
for (const command of loadCommands) { for (const command of loadCommands) {
window.electronAPI.sendMpvCommand(command); window.electronAPI.sendMpvCommand(command);
} }
for (const command of subtitleCommands) {
window.electronAPI.sendMpvCommand(command);
}
const osdParts: string[] = [];
if (loadCommands.length > 0) { if (loadCommands.length > 0) {
const action = event.shiftKey ? 'Queued' : 'Loaded'; const action = event.shiftKey ? 'Queued' : 'Loaded';
window.electronAPI.sendMpvCommand([ osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`);
'show-text', }
`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`, if (subtitleCommands.length > 0) {
'1500', osdParts.push(
]); `Loaded ${subtitleCommands.length} subtitle file${subtitleCommands.length === 1 ? '' : 's'}`,
);
}
if (osdParts.length > 0) {
window.electronAPI.sendMpvCommand(['show-text', osdParts.join(' | '), '1500']);
} }
clearDropInteractive(); clearDropInteractive();