mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
Add MPV overlay queue controls
This commit is contained in:
@@ -86,6 +86,7 @@ export {
|
||||
sanitizeMpvSubtitleRenderMetrics,
|
||||
} from './mpv-render-metrics';
|
||||
export { createOverlayContentMeasurementStore } from './overlay-content-measurement';
|
||||
export { parseClipboardVideoPath } from './overlay-drop';
|
||||
export { handleMpvCommandFromIpc } from './ipc-command';
|
||||
export { createFieldGroupingOverlayRuntime } from './field-grouping-overlay';
|
||||
export { createNumericShortcutRuntime } from './numeric-shortcut';
|
||||
|
||||
@@ -45,6 +45,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
calls.push('retryAnilistQueueNow');
|
||||
return { ok: true, message: 'done' };
|
||||
},
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
|
||||
});
|
||||
|
||||
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface IpcServiceDeps {
|
||||
openAnilistSetup: () => void;
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
}
|
||||
|
||||
interface WindowLike {
|
||||
@@ -97,6 +98,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
openAnilistSetup: () => void;
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
}
|
||||
|
||||
export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcServiceDeps {
|
||||
@@ -157,6 +159,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
openAnilistSetup: options.openAnilistSetup,
|
||||
getAnilistQueueStatus: options.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: options.retryAnilistQueueNow,
|
||||
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -314,4 +317,8 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
|
||||
ipcMain.handle('anilist:retry-now', async () => {
|
||||
return await deps.retryAnilistQueueNow();
|
||||
});
|
||||
|
||||
ipcMain.handle('clipboard:append-video-to-queue', () => {
|
||||
return deps.appendClipboardVideoToQueue();
|
||||
});
|
||||
}
|
||||
|
||||
69
src/core/services/overlay-drop.test.ts
Normal file
69
src/core/services/overlay-drop.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildMpvLoadfileCommands,
|
||||
collectDroppedVideoPaths,
|
||||
parseClipboardVideoPath,
|
||||
type DropDataTransferLike,
|
||||
} from './overlay-drop';
|
||||
|
||||
function makeTransfer(data: Partial<DropDataTransferLike>): DropDataTransferLike {
|
||||
return {
|
||||
files: data.files,
|
||||
getData: data.getData,
|
||||
};
|
||||
}
|
||||
|
||||
test('collectDroppedVideoPaths keeps supported dropped file paths in order', () => {
|
||||
const transfer = makeTransfer({
|
||||
files: [
|
||||
{ path: '/videos/ep02.mkv' },
|
||||
{ path: '/videos/notes.txt' },
|
||||
{ path: '/videos/ep03.MP4' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = collectDroppedVideoPaths(transfer);
|
||||
|
||||
assert.deepEqual(result, ['/videos/ep02.mkv', '/videos/ep03.MP4']);
|
||||
});
|
||||
|
||||
test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates', () => {
|
||||
const transfer = makeTransfer({
|
||||
getData: (format: string) =>
|
||||
format === 'text/uri-list'
|
||||
? '#comment\nfile:///tmp/ep01.mkv\nfile:///tmp/ep01.mkv\nfile:///tmp/ep02.webm\nfile:///tmp/readme.md\n'
|
||||
: '',
|
||||
});
|
||||
|
||||
const result = collectDroppedVideoPaths(transfer);
|
||||
|
||||
assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']);
|
||||
});
|
||||
|
||||
test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => {
|
||||
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false);
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['loadfile', '/tmp/ep01.mkv', 'replace'],
|
||||
['loadfile', '/tmp/ep02.mkv', 'append'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildMpvLoadfileCommands uses append mode when shift-drop is used', () => {
|
||||
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], true);
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['loadfile', '/tmp/ep01.mkv', 'append'],
|
||||
['loadfile', '/tmp/ep02.mkv', 'append'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseClipboardVideoPath accepts quoted local paths', () => {
|
||||
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
|
||||
});
|
||||
|
||||
test('parseClipboardVideoPath accepts file URI and rejects non-video', () => {
|
||||
assert.equal(parseClipboardVideoPath('file:///tmp/ep11.mp4'), '/tmp/ep11.mp4');
|
||||
assert.equal(parseClipboardVideoPath('/tmp/notes.txt'), null);
|
||||
});
|
||||
130
src/core/services/overlay-drop.ts
Normal file
130
src/core/services/overlay-drop.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
export type DropFileLike = { path?: string } | { name: string };
|
||||
|
||||
export interface DropDataTransferLike {
|
||||
files?: ArrayLike<DropFileLike>;
|
||||
getData?: (format: string) => string;
|
||||
}
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set([
|
||||
'.3gp',
|
||||
'.avi',
|
||||
'.flv',
|
||||
'.m2ts',
|
||||
'.m4v',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp4',
|
||||
'.mpeg',
|
||||
'.mpg',
|
||||
'.mts',
|
||||
'.ts',
|
||||
'.webm',
|
||||
'.wmv',
|
||||
]);
|
||||
|
||||
function getPathExtension(pathValue: string): string {
|
||||
const normalized = pathValue.split(/[?#]/, 1)[0];
|
||||
const dot = normalized.lastIndexOf('.');
|
||||
return dot >= 0 ? normalized.slice(dot).toLowerCase() : '';
|
||||
}
|
||||
|
||||
function isSupportedVideoPath(pathValue: string): boolean {
|
||||
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
|
||||
}
|
||||
|
||||
function parseUriList(data: string): string[] {
|
||||
if (!data.trim()) return [];
|
||||
const out: string[] = [];
|
||||
|
||||
for (const line of data.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
if (!trimmed.toLowerCase().startsWith('file://')) continue;
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
let filePath = decodeURIComponent(parsed.pathname);
|
||||
if (/^\/[A-Za-z]:\//.test(filePath)) {
|
||||
filePath = filePath.slice(1);
|
||||
}
|
||||
if (filePath && isSupportedVideoPath(filePath)) {
|
||||
out.push(filePath);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseClipboardVideoPath(text: string): string | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const unquoted =
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
? trimmed.slice(1, -1).trim()
|
||||
: trimmed;
|
||||
if (!unquoted) return null;
|
||||
|
||||
if (unquoted.toLowerCase().startsWith('file://')) {
|
||||
try {
|
||||
const parsed = new URL(unquoted);
|
||||
let filePath = decodeURIComponent(parsed.pathname);
|
||||
if (/^\/[A-Za-z]:\//.test(filePath)) {
|
||||
filePath = filePath.slice(1);
|
||||
}
|
||||
return filePath && isSupportedVideoPath(filePath) ? filePath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return isSupportedVideoPath(unquoted) ? unquoted : null;
|
||||
}
|
||||
|
||||
export function collectDroppedVideoPaths(dataTransfer: DropDataTransferLike | null | undefined): string[] {
|
||||
if (!dataTransfer) return [];
|
||||
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const addPath = (candidate: string | null | undefined): void => {
|
||||
if (!candidate) return;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
};
|
||||
|
||||
if (dataTransfer.files) {
|
||||
for (let i = 0; i < dataTransfer.files.length; i += 1) {
|
||||
const file = dataTransfer.files[i] as { path?: string } | undefined;
|
||||
addPath(file?.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof dataTransfer.getData === 'function') {
|
||||
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'))) {
|
||||
addPath(pathValue);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildMpvLoadfileCommands(
|
||||
paths: string[],
|
||||
append: boolean,
|
||||
): Array<(string | number)[]> {
|
||||
if (append) {
|
||||
return paths.map((pathValue) => ['loadfile', pathValue, 'append']);
|
||||
}
|
||||
return paths.map((pathValue, index) => [
|
||||
'loadfile',
|
||||
pathValue,
|
||||
index === 0 ? 'replace' : 'append',
|
||||
]);
|
||||
}
|
||||
26
src/main.ts
26
src/main.ts
@@ -111,6 +111,7 @@ import {
|
||||
JellyfinRemoteSessionService,
|
||||
mineSentenceCard as mineSentenceCardCore,
|
||||
openYomitanSettingsWindow,
|
||||
parseClipboardVideoPath,
|
||||
playNextSubtitleRuntime,
|
||||
registerGlobalShortcuts as registerGlobalShortcutsCore,
|
||||
replayCurrentSubtitleRuntime,
|
||||
@@ -2862,6 +2863,30 @@ async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promis
|
||||
return runSubsyncManualFromIpcRuntime(request, getSubsyncRuntimeServiceParams());
|
||||
}
|
||||
|
||||
function appendClipboardVideoToQueue(): { ok: boolean; message: string } {
|
||||
const mpvClient = appState.mpvClient;
|
||||
if (!mpvClient || !mpvClient.connected) {
|
||||
return { ok: false, message: 'MPV is not connected.' };
|
||||
}
|
||||
|
||||
const clipboardText = clipboard.readText();
|
||||
const parsedPath = parseClipboardVideoPath(clipboardText);
|
||||
if (!parsedPath) {
|
||||
showMpvOsd('Clipboard does not contain a supported video path.');
|
||||
return { ok: false, message: 'Clipboard does not contain a supported video path.' };
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(parsedPath);
|
||||
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
|
||||
showMpvOsd('Clipboard path is not a readable file.');
|
||||
return { ok: false, message: 'Clipboard path is not a readable file.' };
|
||||
}
|
||||
|
||||
sendMpvCommandRuntime(mpvClient, ['loadfile', resolvedPath, 'append']);
|
||||
showMpvOsd(`Queued from clipboard: ${path.basename(resolvedPath)}`);
|
||||
return { ok: true, message: `Queued ${resolvedPath}` };
|
||||
}
|
||||
|
||||
registerIpcRuntimeServices({
|
||||
runtimeOptions: {
|
||||
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||
@@ -2922,6 +2947,7 @@ registerIpcRuntimeServices({
|
||||
openAnilistSetup: () => openAnilistSetupWindow(),
|
||||
getAnilistQueueStatus: () => getAnilistQueueStatusSnapshot(),
|
||||
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
|
||||
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
|
||||
},
|
||||
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({
|
||||
patchAnkiConnectEnabled: (enabled: boolean) => {
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
|
||||
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
|
||||
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
||||
}
|
||||
|
||||
export interface AnkiJimakuIpcRuntimeServiceDepsParams {
|
||||
@@ -227,6 +228,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
openAnilistSetup: params.openAnilistSetup,
|
||||
getAnilistQueueStatus: params.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: params.retryAnilistQueueNow,
|
||||
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
SubsyncManualPayload,
|
||||
SubsyncManualRunRequest,
|
||||
SubsyncResult,
|
||||
ClipboardAppendResult,
|
||||
KikuFieldGroupingRequestData,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuMergePreviewRequest,
|
||||
@@ -227,6 +228,8 @@ const electronAPI: ElectronAPI = {
|
||||
callback();
|
||||
});
|
||||
},
|
||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||
ipcRenderer.invoke('clipboard:append-video-to-queue'),
|
||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => {
|
||||
ipcRenderer.send('overlay:modal-closed', modal);
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ export function createKeyboardHandlers(
|
||||
setInvisiblePositionEditMode: (enabled: boolean) => void;
|
||||
applyInvisibleSubtitleOffsetPosition: () => void;
|
||||
updateInvisiblePositionEditHud: () => void;
|
||||
appendClipboardVideoToQueue: () => void;
|
||||
},
|
||||
) {
|
||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||
@@ -257,6 +258,18 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.altKey &&
|
||||
!e.shiftKey &&
|
||||
e.code === 'KeyA' &&
|
||||
!e.repeat
|
||||
) {
|
||||
e.preventDefault();
|
||||
options.appendClipboardVideoToQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
const keyString = keyEventToString(e);
|
||||
const command = ctx.state.keybindingsMap.get(keyString);
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import { createRendererState } from './state.js';
|
||||
import { createSubtitleRenderer } from './subtitle-render.js';
|
||||
import { resolveRendererDom } from './utils/dom.js';
|
||||
import { resolvePlatformInfo } from './utils/platform.js';
|
||||
import { buildMpvLoadfileCommands, collectDroppedVideoPaths } from '../core/services/overlay-drop.js';
|
||||
|
||||
const ctx = {
|
||||
dom: resolveRendererDom(),
|
||||
@@ -111,6 +112,9 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
setInvisiblePositionEditMode: positioning.setInvisiblePositionEditMode,
|
||||
applyInvisibleSubtitleOffsetPosition: positioning.applyInvisibleSubtitleOffsetPosition,
|
||||
updateInvisiblePositionEditHud: positioning.updateInvisiblePositionEditHud,
|
||||
appendClipboardVideoToQueue: () => {
|
||||
void window.electronAPI.appendClipboardVideoToQueue();
|
||||
},
|
||||
});
|
||||
const mouseHandlers = createMouseHandlers(ctx, {
|
||||
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
||||
@@ -178,6 +182,7 @@ async function init(): Promise<void> {
|
||||
mouseHandlers.setupResizeHandler();
|
||||
mouseHandlers.setupSelectionObserver();
|
||||
mouseHandlers.setupYomitanObserver();
|
||||
setupDragDropToMpvQueue();
|
||||
window.addEventListener('resize', () => {
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
@@ -242,6 +247,69 @@ async function init(): Promise<void> {
|
||||
measurementReporter.emitNow();
|
||||
}
|
||||
|
||||
function setupDragDropToMpvQueue(): void {
|
||||
let dragDepth = 0;
|
||||
|
||||
const setDropInteractive = (): void => {
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearDropInteractive = (): void => {
|
||||
dragDepth = 0;
|
||||
if (isAnyModalOpen() || ctx.state.isOverSubtitle || ctx.state.invisiblePositionEditMode) {
|
||||
return;
|
||||
}
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('dragenter', (event: DragEvent) => {
|
||||
if (!event.dataTransfer) return;
|
||||
dragDepth += 1;
|
||||
setDropInteractive();
|
||||
});
|
||||
|
||||
document.addEventListener('dragover', (event: DragEvent) => {
|
||||
if (dragDepth <= 0 || !event.dataTransfer) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
|
||||
document.addEventListener('dragleave', () => {
|
||||
if (dragDepth <= 0) return;
|
||||
dragDepth -= 1;
|
||||
if (dragDepth === 0) {
|
||||
clearDropInteractive();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('drop', (event: DragEvent) => {
|
||||
if (!event.dataTransfer) return;
|
||||
event.preventDefault();
|
||||
|
||||
const droppedPaths = collectDroppedVideoPaths(event.dataTransfer);
|
||||
const loadCommands = buildMpvLoadfileCommands(droppedPaths, event.shiftKey);
|
||||
for (const command of loadCommands) {
|
||||
window.electronAPI.sendMpvCommand(command);
|
||||
}
|
||||
if (loadCommands.length > 0) {
|
||||
const action = event.shiftKey ? 'Queued' : 'Loaded';
|
||||
window.electronAPI.sendMpvCommand([
|
||||
'show-text',
|
||||
`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`,
|
||||
'1500',
|
||||
]);
|
||||
}
|
||||
|
||||
clearDropInteractive();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
|
||||
@@ -586,6 +586,11 @@ export interface SubsyncResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ClipboardAppendResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SubtitleData {
|
||||
text: string;
|
||||
tokens: MergedToken[] | null;
|
||||
@@ -755,6 +760,7 @@ export interface ElectronAPI {
|
||||
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
|
||||
onOpenRuntimeOptions: (callback: () => void) => void;
|
||||
onOpenJimaku: (callback: () => void) => void;
|
||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void;
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user