From 2174e689a2541f176de381762f41e4a5ae253b90 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 15 Aug 2026 00:15:38 -0700 Subject: [PATCH] fix(overlay): support native Wayland file drag-and-drop (#199) --- .../fix-native-wayland-overlay-file-drop.md | 4 ++++ src/core/services/overlay-drop.test.ts | 24 +++++++++++++++++++ src/core/services/overlay-drop.ts | 8 +++++-- src/preload.ts | 3 ++- src/renderer/renderer.ts | 7 ++++-- src/types/runtime.ts | 1 + 6 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 changes/fix-native-wayland-overlay-file-drop.md diff --git a/changes/fix-native-wayland-overlay-file-drop.md b/changes/fix-native-wayland-overlay-file-drop.md new file mode 100644 index 00000000..d368159f --- /dev/null +++ b/changes/fix-native-wayland-overlay-file-drop.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed native Wayland drag-and-drop from file managers such as Thunar so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv. diff --git a/src/core/services/overlay-drop.test.ts b/src/core/services/overlay-drop.test.ts index e3e10522..efa93ce8 100644 --- a/src/core/services/overlay-drop.test.ts +++ b/src/core/services/overlay-drop.test.ts @@ -30,6 +30,16 @@ test('collectDroppedVideoPaths keeps supported dropped file paths in order', () assert.deepEqual(result, ['/videos/ep02.mkv', '/videos/ep03.MP4']); }); +test('collectDroppedVideoPaths accepts paths resolved from standard Web File objects', () => { + const transfer = makeTransfer({ + files: [{ name: 'ep02.mkv' }, { name: 'notes.txt' }], + }); + + const result = collectDroppedVideoPaths(transfer, ['/videos/ep02.mkv', '/videos/notes.txt']); + + assert.deepEqual(result, ['/videos/ep02.mkv']); +}); + test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates', () => { const transfer = makeTransfer({ getData: (format: string) => @@ -53,6 +63,20 @@ test('collectDroppedSubtitlePaths keeps supported dropped subtitle paths in orde assert.deepEqual(result, ['/subs/ep02.ass', '/subs/ep03.SRT']); }); +test('collectDroppedSubtitlePaths accepts paths resolved from standard Web File objects', () => { + const transfer = makeTransfer({ + files: [{ name: 'ep02.ass' }, { name: 'readme.txt' }, { name: 'ep03.SRT' }], + }); + + const result = collectDroppedSubtitlePaths(transfer, [ + '/subs/ep02.ass', + '/subs/readme.txt', + '/subs/ep03.SRT', + ]); + + 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) => diff --git a/src/core/services/overlay-drop.ts b/src/core/services/overlay-drop.ts index 109e43bb..129c169f 100644 --- a/src/core/services/overlay-drop.ts +++ b/src/core/services/overlay-drop.ts @@ -93,18 +93,21 @@ export function parseClipboardVideoPath(text: string): string | null { export function collectDroppedVideoPaths( dataTransfer: DropDataTransferLike | null | undefined, + resolvedFilePaths: ArrayLike = [], ): string[] { - return collectDroppedPaths(dataTransfer, isSupportedVideoPath); + return collectDroppedPaths(dataTransfer, resolvedFilePaths, isSupportedVideoPath); } export function collectDroppedSubtitlePaths( dataTransfer: DropDataTransferLike | null | undefined, + resolvedFilePaths: ArrayLike = [], ): string[] { - return collectDroppedPaths(dataTransfer, isSupportedSubtitlePath); + return collectDroppedPaths(dataTransfer, resolvedFilePaths, isSupportedSubtitlePath); } function collectDroppedPaths( dataTransfer: DropDataTransferLike | null | undefined, + resolvedFilePaths: ArrayLike, isSupportedPath: (pathValue: string) => boolean, ): string[] { if (!dataTransfer) return []; @@ -124,6 +127,7 @@ function collectDroppedPaths( for (let i = 0; i < dataTransfer.files.length; i += 1) { const file = dataTransfer.files[i] as { path?: string } | undefined; addPath(file?.path); + addPath(resolvedFilePaths[i]); } } diff --git a/src/preload.ts b/src/preload.ts index eb447050..a48fdb0c 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; +import { contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron'; import { resolveOverlayLayerFromArgv } from './preload-args'; import type { SubtitleData, @@ -248,6 +248,7 @@ const onSecondarySubtitleModeEvent = createLatestValueIpcListenerWithPayload overlayLayer, + getPathForFile: (file: File) => webUtils.getPathForFile(file), onSubtitle: (callback: (data: SubtitleData) => void) => { onSubtitleSetEvent(callback); }, diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 21159cbc..5ffacdad 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -899,8 +899,11 @@ function setupDragDropToMpvQueue(): void { if (!event.dataTransfer) return; event.preventDefault(); - const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer); - const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer); + const resolvedFilePaths = Array.from(event.dataTransfer.files, (file) => + window.electronAPI.getPathForFile(file), + ); + const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer, resolvedFilePaths); + const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer, resolvedFilePaths); const appendDroppedVideos = event.shiftKey; const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, appendDroppedVideos); const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths); diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 3b2dbce6..9fcc76c8 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -425,6 +425,7 @@ export interface SessionNumericSelectionStartPayload { export interface ElectronAPI { getOverlayLayer: () => 'visible' | 'modal' | null; + getPathForFile: (file: File) => string; onSubtitle: (callback: (data: SubtitleData) => void) => void; onOverlayPointerRecoveryRequested: (callback: () => void) => void; onOverlayNotification: (callback: (payload: OverlayNotificationEventPayload) => void) => void;