diff --git a/README.md b/README.md index b6039ac..acd713c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - **N+1 Highlighting** — Marks known vocabulary from your Anki deck so you can spot new words at a glance - **Texthooker & WebSocket** — Built-in texthooker page with WebSocket streaming for external tools - **Subtitle Download & Sync** — Search Jimaku, sync with alass or ffsubsync — all from the player +- **Queue Control In-Player** — Drop videos on overlay to load/queue in mpv; `Ctrl/Cmd+A` appends clipboard path - **Keyboard-Driven** — Mine, copy, cycle display modes, and navigate from configurable shortcuts - **Japanese Tokenization** — MeCab-powered word boundary detection with smart grouping @@ -98,6 +99,13 @@ Use `subminer -h` for command-specific help pages (for example `sub - Use `--dev` and `--debug` only for app/dev-mode behavior; they are not tied to logging level. - Default logging remains `info` unless you pass `--log-level`. +### Overlay Queue Controls + +- Drag/drop video file(s) onto overlay: + - default: replace current playback with first file, append remaining dropped files + - hold `Shift`: append all dropped files +- Press `Ctrl/Cmd+A` to append the clipboard path when it points to a readable local video file. + ## MPV Plugin ```bash diff --git a/backlog/tasks/task-13 - Fix-macos-native-window-bounds-for-overlay-binding.md b/backlog/tasks/task-13 - Fix-macos-native-window-bounds-for-overlay-binding.md index 0bb3c87..ee25b3a 100644 --- a/backlog/tasks/task-13 - Fix-macos-native-window-bounds-for-overlay-binding.md +++ b/backlog/tasks/task-13 - Fix-macos-native-window-bounds-for-overlay-binding.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-02-11 15:45' -updated_date: '2026-02-11 16:36' +updated_date: '2026-02-18 09:29' labels: - bug - macos @@ -15,22 +15,19 @@ references: - src/window-trackers/macos-tracker.ts - scripts/get-mpv-window-macos.swift priority: high +ordinal: 58000 --- ## Description - Overlay windows on macOS are not properly aligned to the mpv window after switching from AppleScript window discovery to native Swift/CoreGraphics bounds retrieval. Implement a robust native bounds strategy that prefers Accessibility window geometry (matching app-window coordinates used previously) and falls back to filtered CoreGraphics windows when Accessibility data is unavailable. - ## Acceptance Criteria - - - [x] #1 Overlay bounds track the active mpv window with correct position and size on macOS. - [x] #2 Helper avoids selecting off-screen/non-primary mpv-related windows. - [x] #3 Build succeeds with the updated macOS helper. @@ -39,7 +36,6 @@ Implement a robust native bounds strategy that prefers Accessibility window geom ## Implementation Notes - Follow-up in progress after packaged app runtime showed fullscreen fallback behavior: - Added packaged-app helper path resolution in tracker (`process.resourcesPath/scripts/get-mpv-window-macos`). diff --git a/backlog/tasks/task-13 - Fix-second-instance-start-when-texthooker-only-instance-is-running.md b/backlog/tasks/task-13 - Fix-second-instance-start-when-texthooker-only-instance-is-running.md deleted file mode 100644 index ed1e74a..0000000 --- a/backlog/tasks/task-13 - Fix-second-instance-start-when-texthooker-only-instance-is-running.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -id: TASK-13 -title: Fix second-instance --start when texthooker-only instance is running -status: Done -assignee: [] -created_date: '2026-02-11 23:47' -updated_date: '2026-02-18 04:11' -labels: - - bugfix - - cli - - overlay -dependencies: [] -priority: high -ordinal: 51000 ---- - -## Description - - - -When SubMiner is already running in texthooker-only mode, a subsequent `--start` command from a second instance is currently ignored. This can leave users without an initialized overlay runtime even though startup commands were issued. Adjust CLI command handling so `--start` on second-instance initializes overlay runtime when it is not yet initialized, while preserving current ignore behavior when overlay runtime is already active. - - - -## Acceptance Criteria - - - -- [x] #1 Second-instance `--start` initializes overlay runtime when current instance has deferred/not-initialized overlay runtime. -- [x] #2 Second-instance `--start` remains ignored (existing behavior) when overlay runtime is already initialized. -- [x] #3 CLI command service tests cover both behaviors and pass. - - -## Implementation Notes - - - -Patched CLI second-instance `--start` handling in `src/core/services/cli-command-service.ts` to initialize overlay runtime when deferred. - -Added regression test for deferred-runtime start path and updated initialized-runtime second-instance tests in `src/core/services/cli-command-service.test.ts`. - - - -## Final Summary - - - -Fixed overlay startup regression path where a second-instance `--start` could be ignored even when the primary instance was running in texthooker-only/deferred overlay mode. - -Changes: - -- Updated `handleCliCommandService` logic so `ignoreStart` applies only when source is second-instance, `--start` is present, and overlay runtime is already initialized. -- Added explicit overlay-runtime initialization path for second-instance `--start` when runtime is not initialized. -- Kept existing behavior for already-initialized runtime (still logs and ignores redundant `--start`). -- Added and updated tests in `cli-command-service.test.ts` to validate both deferred and initialized second-instance startup behaviors. - -Validation: - -- `pnpm run build` succeeded. -- `node dist/core/services/cli-command-service.test.js` passed (11/11). - diff --git a/backlog/tasks/task-39 - Add-hot-reload-for-non-destructive-config-changes.md b/backlog/tasks/task-39 - Add-hot-reload-for-non-destructive-config-changes.md index 1ab3602..79b3089 100644 --- a/backlog/tasks/task-39 - Add-hot-reload-for-non-destructive-config-changes.md +++ b/backlog/tasks/task-39 - Add-hot-reload-for-non-destructive-config-changes.md @@ -1,21 +1,22 @@ --- id: TASK-39 title: Add hot-reload for non-destructive config changes -status: To Do +status: Done assignee: [] created_date: '2026-02-14 02:04' +updated_date: '2026-02-18 09:29' labels: - config - developer-experience - quality-of-life dependencies: [] priority: low +ordinal: 59000 --- ## Description - Watch the config file for changes and apply non-destructive updates (colors, font sizes, subtitle modes, overlay opacity, keybindings) without requiring an app restart. ## Motivation @@ -49,13 +50,11 @@ Currently all config is loaded at startup. Users tweaking visual settings (font ## Acceptance Criteria - - -- [ ] #1 Config file changes are detected automatically via file watcher. -- [ ] #2 Hot-reloadable fields are applied immediately without restart. -- [ ] #3 Restart-required fields trigger a user-visible notification. -- [ ] #4 File change events are debounced to handle editor save patterns. -- [ ] #5 Invalid config changes are rejected with an error notification, keeping the previous valid config. -- [ ] #6 Renderer receives updated styles/settings via IPC without full page reload. +- [x] #1 Config file changes are detected automatically via file watcher. +- [x] #2 Hot-reloadable fields are applied immediately without restart. +- [x] #3 Restart-required fields trigger a user-visible notification. +- [x] #4 File change events are debounced to handle editor save patterns. +- [x] #5 Invalid config changes are rejected with an error notification, keeping the previous valid config. +- [x] #6 Renderer receives updated styles/settings via IPC without full page reload. diff --git a/backlog/tasks/task-65 - Add-overlay-drag-drop-playlist-loading-and-clipboard-append-shortcut.md b/backlog/tasks/task-65 - Add-overlay-drag-drop-playlist-loading-and-clipboard-append-shortcut.md new file mode 100644 index 0000000..ff10fd5 --- /dev/null +++ b/backlog/tasks/task-65 - Add-overlay-drag-drop-playlist-loading-and-clipboard-append-shortcut.md @@ -0,0 +1,34 @@ +--- +id: TASK-65 +title: Add overlay drag-drop playlist loading and clipboard append shortcut +status: Done +assignee: [] +created_date: '2026-02-18 13:10' +updated_date: '2026-02-18 13:10' +labels: [] +dependencies: [] +--- + +## Description + +Implement direct playlist control from the overlay: + +- Drag/drop video files onto overlay: + - default drop: replace current playback with dropped set (first `replace`, remainder `append`) + - `Shift` + drop: append all dropped files +- `Ctrl/Cmd+A`: read clipboard text, if it resolves to a supported local video file path, append it to mpv playlist. + +## Implementation Steps + +- [x] Add TDD coverage for drop path parsing, command mode generation, and clipboard path parsing (`src/core/services/overlay-drop.test.ts`). +- [x] Implement drop/clipboard parser + mpv command-builder utility (`src/core/services/overlay-drop.ts`). +- [x] Wire renderer drag/drop handling and mpv command dispatch (`src/renderer/renderer.ts`). +- [x] Add IPC API for clipboard append flow (`src/types.ts`, `src/preload.ts`, `src/core/services/ipc.ts`, `src/main/dependencies.ts`). +- [x] Implement main-process clipboard validation + append behavior (`src/main.ts`). +- [x] Add fixed keyboard shortcut hook (`Ctrl/Cmd+A`) in renderer keyboard handler (`src/renderer/handlers/keyboard.ts`, `src/renderer/renderer.ts`). +- [x] Update docs for new interaction model (`docs/usage.md`, `docs/configuration.md`). + +## Verification + +- `bun run build` +- `node --test dist/core/services/overlay-drop.test.js dist/core/services/ipc.test.js` diff --git a/config.example.jsonc b/config.example.jsonc index 72ac35b..339f0bd 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -118,6 +118,8 @@ // ========================================== // Keyboard Shortcuts // Overlay keyboard shortcuts. Set a shortcut to null to disable. + // Fixed (non-configurable) overlay shortcuts: + // - Ctrl/Cmd+A: append clipboard video path to MPV playlist // ========================================== "shortcuts": { "toggleVisibleOverlayGlobal": "Alt+Shift+O", diff --git a/docs/configuration.md b/docs/configuration.md index ef1490c..05a243f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -263,6 +263,7 @@ When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but | `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) | | `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) | | `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) | +| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) | **Multi-line copy workflow:** diff --git a/docs/mining-workflow.md b/docs/mining-workflow.md index 3b2ad55..ecd2fcc 100644 --- a/docs/mining-workflow.md +++ b/docs/mining-workflow.md @@ -129,7 +129,7 @@ Configure in `ankiConnect.isKiku`. See [Anki Integration](/anki-integration#fiel SubMiner integrates with [Jimaku](https://jimaku.cc) to search and download subtitle files for anime directly from the overlay. -1. Open the Jimaku modal via the configured shortcut (`Ctrl+Alt+J` by default). +1. Open the Jimaku modal via the configured shortcut (`Ctrl+Shift+J` by default). 2. SubMiner auto-fills the search from the current video filename (title, season, episode). 3. Browse matching entries and select a subtitle file to download. 4. The subtitle is loaded into mpv as a new track. diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index 72ac35b..339f0bd 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -118,6 +118,8 @@ // ========================================== // Keyboard Shortcuts // Overlay keyboard shortcuts. Set a shortcut to null to disable. + // Fixed (non-configurable) overlay shortcuts: + // - Ctrl/Cmd+A: append clipboard video path to MPV playlist // ========================================== "shortcuts": { "toggleVisibleOverlayGlobal": "Alt+Shift+O", diff --git a/docs/usage.md b/docs/usage.md index 7f41622..e29ee98 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -174,9 +174,15 @@ Notes: | `Arrow keys` | Move invisible subtitles while edit mode is active | | `Enter` / `Ctrl+S` | Save invisible subtitle position in edit mode | | `Esc` | Cancel invisible subtitle position edit mode | +| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist | These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization. +### Drag-and-drop Queueing + +- Drag and drop one or more video files onto the overlay to replace current playback (`loadfile ... replace` for first file, then append remainder). +- Hold `Shift` while dropping to append all dropped files to the current MPV playlist. + ## How It Works 1. MPV runs with an IPC socket at `/tmp/subminer-socket` diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 080a1b8..6525976 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -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'; diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index edfb768..60953ec 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -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' }); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 489dec0..9d967af 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -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(); + }); } diff --git a/src/core/services/overlay-drop.test.ts b/src/core/services/overlay-drop.test.ts new file mode 100644 index 0000000..a1e0806 --- /dev/null +++ b/src/core/services/overlay-drop.test.ts @@ -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 { + 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); +}); diff --git a/src/core/services/overlay-drop.ts b/src/core/services/overlay-drop.ts new file mode 100644 index 0000000..93b57c4 --- /dev/null +++ b/src/core/services/overlay-drop.ts @@ -0,0 +1,130 @@ +export type DropFileLike = { path?: string } | { name: string }; + +export interface DropDataTransferLike { + files?: ArrayLike; + 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(); + + 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', + ]); +} diff --git a/src/main.ts b/src/main.ts index c0e6ab0..8c99ce0 100644 --- a/src/main.ts +++ b/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) => { diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index ddcfc81..b3bc11a 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -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, }; } diff --git a/src/preload.ts b/src/preload.ts index 3bda3c3..a02c605 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -36,6 +36,7 @@ import type { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult, + ClipboardAppendResult, KikuFieldGroupingRequestData, KikuFieldGroupingChoice, KikuMergePreviewRequest, @@ -227,6 +228,8 @@ const electronAPI: ElectronAPI = { callback(); }); }, + appendClipboardVideoToQueue: (): Promise => + ipcRenderer.invoke('clipboard:append-video-to-queue'), notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => { ipcRenderer.send('overlay:modal-closed', modal); }, diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 32ee22b..9c2ceee 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -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); diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index a11b99f..c13ec6b 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -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 { mouseHandlers.setupResizeHandler(); mouseHandlers.setupSelectionObserver(); mouseHandlers.setupYomitanObserver(); + setupDragDropToMpvQueue(); window.addEventListener('resize', () => { measurementReporter.schedule(); }); @@ -242,6 +247,69 @@ async function init(): Promise { 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 { diff --git a/src/types.ts b/src/types.ts index 3ec94ce..4745935 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; }