Add MPV overlay queue controls

This commit is contained in:
2026-02-18 01:55:01 -08:00
parent 3803d4d47b
commit fd49e73762
21 changed files with 391 additions and 78 deletions

View File

@@ -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';

View File

@@ -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' });

View File

@@ -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();
});
}

View 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);
});

View 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',
]);
}