From 6ae3888b530a04e1e3ca9faa27e28fabc61933b3 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 30 Mar 2026 01:50:38 -0700 Subject: [PATCH] feat: add playlist browser overlay modal - Add overlay modal for browsing sibling video files and live mpv queue - Add IPC commands for playlist operations (add, remove, move, play) - Add playlist-browser-runtime and playlist-browser-sort modules - Add keyboard handler and preload bindings for playlist browser - Add default Ctrl+Alt+P keybinding to open the modal - Add HTML structure, renderer wiring, and state for the modal - Add changelog fragment and docs updates --- README.md | 6 + ...l-for-sibling-video-files-and-mpv-queue.md | 36 +- changes/260-playlist-browser.md | 5 + docs-site/configuration.md | 3 +- docs-site/shortcuts.md | 3 +- docs-site/usage.md | 2 + .../definitions/domain-registry.test.ts | 1 + src/config/definitions/shared.ts | 2 + src/core/services/ipc-command.test.ts | 12 + src/core/services/ipc-command.ts | 7 + src/core/services/ipc.test.ts | 153 ++++++- src/core/services/ipc.ts | 61 +++ src/main.ts | 35 ++ src/main/dependencies.ts | 12 + src/main/ipc-mpv-command.ts | 2 + .../composers/ipc-runtime-composer.test.ts | 15 + .../ipc-bridge-actions-main-deps.test.ts | 1 + src/main/runtime/ipc-bridge-actions.test.ts | 1 + .../runtime/ipc-mpv-command-main-deps.test.ts | 5 + src/main/runtime/ipc-mpv-command-main-deps.ts | 1 + .../runtime/playlist-browser-runtime.test.ts | 326 +++++++++++++ src/main/runtime/playlist-browser-runtime.ts | 314 +++++++++++++ .../runtime/playlist-browser-sort.test.ts | 50 ++ src/main/runtime/playlist-browser-sort.ts | 129 ++++++ src/preload.ts | 17 + src/renderer/handlers/keyboard.test.ts | 30 ++ src/renderer/handlers/keyboard.ts | 6 + src/renderer/index.html | 29 ++ src/renderer/modals/playlist-browser.test.ts | 430 ++++++++++++++++++ src/renderer/modals/playlist-browser.ts | 419 +++++++++++++++++ src/renderer/modals/session-help.ts | 2 + src/renderer/renderer.ts | 25 +- src/renderer/state.ts | 13 + src/renderer/utils/dom.ts | 14 + src/shared/ipc/contracts.ts | 7 + src/types/runtime.ts | 45 ++ 36 files changed, 2213 insertions(+), 6 deletions(-) create mode 100644 changes/260-playlist-browser.md create mode 100644 src/main/runtime/playlist-browser-runtime.test.ts create mode 100644 src/main/runtime/playlist-browser-runtime.ts create mode 100644 src/main/runtime/playlist-browser-sort.test.ts create mode 100644 src/main/runtime/playlist-browser-sort.ts create mode 100644 src/renderer/modals/playlist-browser.test.ts create mode 100644 src/renderer/modals/playlist-browser.ts diff --git a/README.md b/README.md index 1a4c2e3..70e5303 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
+### Playlist Browser + +Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback. + +
+ ### Integrations diff --git a/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md b/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md index a8d0997..58c9625 100644 --- a/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md +++ b/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md @@ -1,9 +1,11 @@ --- id: TASK-255 title: Add overlay playlist browser modal for sibling video files and mpv queue -status: To Do -assignee: [] +status: In Progress +assignee: + - codex created_date: '2026-03-30 05:46' +updated_date: '2026-03-30 08:34' labels: - feature - overlay @@ -27,3 +29,33 @@ Add an in-session overlay modal that opens from a keybinding during active playb - [ ] #5 Modal state stays in sync after playlist mutations so the rendered queue reflects mpv's current playlist order. - [ ] #6 Feature coverage includes automated tests for ordering/playlist behavior and docs or shortcut/help updates for the new modal. + +## Implementation Plan + + +1. Add playlist-browser domain types, IPC channels, overlay modal registration, special command, and default keybinding for Ctrl+Alt+P. +2. Write failing tests for best-effort episode sorting and main playlist-browser runtime snapshot/mutation behavior. +3. Implement playlist-browser main/runtime helpers for local sibling video discovery, mpv playlist normalization, and append/play/remove/move operations with refreshed snapshots. +4. Wire preload and main-process IPC handlers that expose snapshot and mutation methods to the renderer. +5. Write failing renderer and keyboard tests for modal open/close, split-pane interaction, keyboard controls, and degraded states. +6. Implement playlist-browser modal markup, DOM/state, renderer composition, keyboard routing, and session-help labeling. +7. Run targeted test lanes first, then the maintained verification gate relevant to the touched surfaces; update task notes/criteria as checks pass. + + +## Implementation Notes + + +Implemented overlay playlist browser modal with split directory/playlist panes, Ctrl+Alt+P keybinding, main/preload IPC, mpv queue mutations, and best-effort sibling episode sorting. + +Added tests for sort/runtime logic, IPC wiring, keyboard routing, and playlist-browser modal behavior. + +Verification: `bun run typecheck` passed; targeted playlist-browser and IPC tests passed; `bun run build` passed; `bun run test:smoke:dist` passed. + +Repo gate blockers outside this feature: `bun run test:fast` hits existing Bun `node:test` NotImplementedError cases plus unrelated immersion-tracker failures; `bun run test:env` fails in existing immersion-tracker sqlite tests. + +2026-03-30: Fixed playlist-browser local playback regression where subtitle track IDs leaked across episode jumps. `playPlaylistBrowserIndexRuntime` now reapplies local subtitle auto-selection defaults (`sub-auto=fuzzy`, `sid=auto`, `secondary-sid=auto`) before `playlist-play-index` for local filesystem targets only; remote playlist entries remain untouched. Added runtime regression tests for both paths. + +2026-03-30: Follow-up subtitle regression fix. Pre-jump `sid=auto` was ineffective because mpv resolved it against the current episode before `playlist-play-index`. Local playlist jumps now set `sub-auto=fuzzy`, switch episodes, then schedule a delayed rearm of `sid=auto` and `secondary-sid=auto` so selection happens against the new file's tracks. Added failing-first runtime coverage for delayed local rearm and remote no-op behavior. + +2026-03-30: Cleaned up playlist-browser runtime local-play subtitle-rearm flow by extracting focused helpers without changing behavior. Added public docs/readme coverage for the default `Ctrl+Alt+P` playlist browser keybinding and modal, plus changelog fragment `changes/260-playlist-browser.md`. Verification: `bun test src/main/runtime/playlist-browser-runtime.test.ts`, `bun run typecheck`, `bun run docs:test`, `bun run docs:build`, `bun run changelog:lint`, `bun run build`. + diff --git a/changes/260-playlist-browser.md b/changes/260-playlist-browser.md new file mode 100644 index 0000000..1c443a2 --- /dev/null +++ b/changes/260-playlist-browser.md @@ -0,0 +1,5 @@ +type: added +area: overlay + +- Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback. +- Added the default `Ctrl+Alt+P` keybinding to open the playlist browser and manage queue order without leaving playback. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 0b6e2b6..b26b9e2 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -471,6 +471,7 @@ See `config.example.jsonc` for detailed configuration options and more examples. | `Space` | `["cycle", "pause"]` | Toggle pause | | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | +| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser | | `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker | | `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | | `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | @@ -507,7 +508,7 @@ See `config.example.jsonc` for detailed configuration options and more examples. { "key": "Space", "command": null } ``` -**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:[:next|prev]` cycles a runtime option value. +**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:[:next|prev]` cycles a runtime option value. **Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.) diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 73521e2..2310f54 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -40,6 +40,7 @@ These control playback and subtitle display. They require overlay window focus. | `Space` | Toggle mpv pause | | `J` | Cycle primary subtitle track | | `Shift+J` | Cycle secondary subtitle track | +| `Ctrl+Alt+P` | Open playlist browser for current directory + queue | | `ArrowRight` | Seek forward 5 seconds | | `ArrowLeft` | Seek backward 5 seconds | | `ArrowUp` | Seek forward 60 seconds | @@ -56,7 +57,7 @@ These control playback and subtitle display. They require overlay window focus. | `Right-click + drag` | Reposition subtitles (on subtitle area) | | `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | -These keybindings can be overridden or disabled via the `keybindings` config array. +These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave). diff --git a/docs-site/usage.md b/docs-site/usage.md index b5287db..bbc6777 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -295,6 +295,8 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh `Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config. ::: +Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback. + Hovering over subtitle text pauses mpv by default; leaving resumes it. Disable with `subtitleStyle.autoPauseVideoOnHover: false`. To also pause while the Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup: true`. ### Drag-and-Drop diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index 4bc079f..17051fe 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -80,6 +80,7 @@ test('default keybindings include primary and secondary subtitle track cycling o assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']); assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']); assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyC'), ['__youtube-picker-open']); + assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyP'), ['__playlist-browser-open']); }); test('default keybindings include fullscreen on F', () => { diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts index 26ac978..3cf3424 100644 --- a/src/config/definitions/shared.ts +++ b/src/config/definitions/shared.ts @@ -47,6 +47,7 @@ export const SPECIAL_COMMANDS = { SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line', SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line', YOUTUBE_PICKER_OPEN: '__youtube-picker-open', + PLAYLIST_BROWSER_OPEN: '__playlist-browser-open', } as const; export const DEFAULT_KEYBINDINGS: NonNullable = [ @@ -66,6 +67,7 @@ export const DEFAULT_KEYBINDINGS: NonNullable = [ command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START], }, { key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] }, + { key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] }, { key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] }, { key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] }, { key: 'KeyQ', command: ['quit'] }, diff --git a/src/core/services/ipc-command.test.ts b/src/core/services/ipc-command.test.ts index f9c63b2..2296615 100644 --- a/src/core/services/ipc-command.test.ts +++ b/src/core/services/ipc-command.test.ts @@ -16,6 +16,7 @@ function createOptions(overrides: Partial { calls.push('subsync'); @@ -26,6 +27,9 @@ function createOptions(overrides: Partial { calls.push('youtube-picker'); }, + openPlaylistBrowser: () => { + calls.push('playlist-browser'); + }, runtimeOptionsCycle: () => ({ ok: true }), showMpvOsd: (text) => { osd.push(text); @@ -110,6 +114,14 @@ test('handleMpvCommandFromIpc dispatches special youtube picker open command', ( assert.deepEqual(osd, []); }); +test('handleMpvCommandFromIpc dispatches special playlist browser open command', () => { + const { options, calls, sentCommands, osd } = createOptions(); + handleMpvCommandFromIpc(['__playlist-browser-open'], options); + assert.deepEqual(calls, ['playlist-browser']); + assert.deepEqual(sentCommands, []); + assert.deepEqual(osd, []); +}); + test('handleMpvCommandFromIpc does not forward commands while disconnected', () => { const { options, sentCommands, osd } = createOptions({ isMpvConnected: () => false, diff --git a/src/core/services/ipc-command.ts b/src/core/services/ipc-command.ts index 166ac68..baec99e 100644 --- a/src/core/services/ipc-command.ts +++ b/src/core/services/ipc-command.ts @@ -15,10 +15,12 @@ export interface HandleMpvCommandFromIpcOptions { SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string; SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string; YOUTUBE_PICKER_OPEN: string; + PLAYLIST_BROWSER_OPEN: string; }; triggerSubsyncFromConfig: () => void; openRuntimeOptionsPalette: () => void; openYoutubeTrackPicker: () => void | Promise; + openPlaylistBrowser: () => void | Promise; runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; showMpvOsd: (text: string) => void; mpvReplaySubtitle: () => void; @@ -97,6 +99,11 @@ export function handleMpvCommandFromIpc( return; } + if (first === options.specialCommands.PLAYLIST_BROWSER_OPEN) { + void options.openPlaylistBrowser(); + return; + } + if ( first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START || first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index e5dae34..28ff2d4 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; -import type { SubtitleSidebarSnapshot } from '../../types'; +import type { PlaylistBrowserSnapshot, SubtitleSidebarSnapshot } from '../../types'; interface FakeIpcRegistrar { on: Map void>; @@ -148,6 +148,19 @@ function createRegisterIpcDeps(overrides: Partial = {}): IpcServ getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: null, + directoryAvailable: false, + directoryStatus: '', + directoryItems: [], + playlistItems: [], + playingIndex: null, + currentFilePath: null, + }), + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), immersionTracker: null, ...overrides, @@ -241,6 +254,19 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { return { ok: true, message: 'done' }; }, appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }), + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: '/tmp', + directoryAvailable: true, + directoryStatus: '/tmp', + directoryItems: [], + playlistItems: [], + playingIndex: 0, + currentFilePath: '/tmp/current.mkv', + }), + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'append' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'play' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'remove' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'move' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }); @@ -256,10 +282,83 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { ok: true, message: 'done', }); + assert.equal((await deps.getPlaylistBrowserSnapshot()).directoryAvailable, true); + assert.deepEqual(await deps.appendPlaylistBrowserFile('/tmp/new.mkv'), { + ok: true, + message: 'append', + }); + assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' }); + assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' }); + assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' }); assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']); assert.equal(deps.getPlaybackPaused(), true); }); +test('registerIpcHandlers exposes playlist browser snapshot and mutations', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const calls: Array<[string, unknown[]]> = []; + registerIpcHandlers( + createRegisterIpcDeps({ + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: '/tmp/videos', + directoryAvailable: true, + directoryStatus: '/tmp/videos', + directoryItems: [], + playlistItems: [], + playingIndex: 1, + currentFilePath: '/tmp/videos/ep2.mkv', + }), + appendPlaylistBrowserFile: async (filePath) => { + calls.push(['append', [filePath]]); + return { ok: true, message: 'append-ok' }; + }, + playPlaylistBrowserIndex: async (index) => { + calls.push(['play', [index]]); + return { ok: true, message: 'play-ok' }; + }, + removePlaylistBrowserIndex: async (index) => { + calls.push(['remove', [index]]); + return { ok: true, message: 'remove-ok' }; + }, + movePlaylistBrowserIndex: async (index, direction) => { + calls.push(['move', [index, direction]]); + return { ok: true, message: 'move-ok' }; + }, + }), + registrar, + ); + + const snapshot = (await handlers.handle.get(IPC_CHANNELS.request.getPlaylistBrowserSnapshot)?.( + {}, + )) as PlaylistBrowserSnapshot | undefined; + const append = await handlers.handle.get(IPC_CHANNELS.request.appendPlaylistBrowserFile)?.( + {}, + '/tmp/videos/ep3.mkv', + ); + const play = await handlers.handle.get(IPC_CHANNELS.request.playPlaylistBrowserIndex)?.({}, 2); + const remove = await handlers.handle.get(IPC_CHANNELS.request.removePlaylistBrowserIndex)?.( + {}, + 2, + ); + const move = await handlers.handle.get(IPC_CHANNELS.request.movePlaylistBrowserIndex)?.( + {}, + 2, + -1, + ); + + assert.equal(snapshot?.playingIndex, 1); + assert.deepEqual(append, { ok: true, message: 'append-ok' }); + assert.deepEqual(play, { ok: true, message: 'play-ok' }); + assert.deepEqual(remove, { ok: true, message: 'remove-ok' }); + assert.deepEqual(move, { ok: true, message: 'move-ok' }); + assert.deepEqual(calls, [ + ['append', ['/tmp/videos/ep3.mkv']], + ['play', [2]], + ['remove', [2]], + ['move', [2, -1]], + ]); +}); + test('registerIpcHandlers rejects malformed runtime-option payloads', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); const calls: Array<{ id: string; value: unknown }> = []; @@ -311,6 +410,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: null, + directoryAvailable: false, + directoryStatus: '', + directoryItems: [], + playlistItems: [], + playingIndex: null, + currentFilePath: null, + }), + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, @@ -618,6 +730,19 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: null, + directoryAvailable: false, + directoryStatus: '', + directoryItems: [], + playlistItems: [], + playingIndex: null, + currentFilePath: null, + }), + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, @@ -685,6 +810,19 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: null, + directoryAvailable: false, + directoryStatus: '', + directoryItems: [], + playlistItems: [], + playingIndex: null, + currentFilePath: null, + }), + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, @@ -755,6 +893,19 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: null, + directoryAvailable: false, + directoryStatus: '', + directoryItems: [], + playlistItems: [], + playingIndex: null, + currentFilePath: null, + }), + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index a20374f..ff78145 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -2,6 +2,8 @@ import electron from 'electron'; import type { IpcMainEvent } from 'electron'; import type { ControllerConfigUpdate, + PlaylistBrowserMutationResult, + PlaylistBrowserSnapshot, ControllerPreferenceUpdate, ResolvedControllerConfig, RuntimeOptionId, @@ -78,6 +80,14 @@ export interface IpcServiceDeps { getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; + getPlaylistBrowserSnapshot: () => Promise; + appendPlaylistBrowserFile: (filePath: string) => Promise; + playPlaylistBrowserIndex: (index: number) => Promise; + removePlaylistBrowserIndex: (index: number) => Promise; + movePlaylistBrowserIndex: ( + index: number, + direction: 1 | -1, + ) => Promise; immersionTracker?: { recordYomitanLookup: () => void; getSessionSummaries: (limit?: number) => Promise; @@ -183,6 +193,14 @@ export interface IpcDepsRuntimeOptions { getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; + getPlaylistBrowserSnapshot: () => Promise; + appendPlaylistBrowserFile: (filePath: string) => Promise; + playPlaylistBrowserIndex: (index: number) => Promise; + removePlaylistBrowserIndex: (index: number) => Promise; + movePlaylistBrowserIndex: ( + index: number, + direction: 1 | -1, + ) => Promise; getImmersionTracker?: () => IpcServiceDeps['immersionTracker']; } @@ -246,6 +264,11 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService getAnilistQueueStatus: options.getAnilistQueueStatus, retryAnilistQueueNow: options.retryAnilistQueueNow, appendClipboardVideoToQueue: options.appendClipboardVideoToQueue, + getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot, + appendPlaylistBrowserFile: options.appendPlaylistBrowserFile, + playPlaylistBrowserIndex: options.playPlaylistBrowserIndex, + removePlaylistBrowserIndex: options.removePlaylistBrowserIndex, + movePlaylistBrowserIndex: options.movePlaylistBrowserIndex, get immersionTracker() { return options.getImmersionTracker?.() ?? null; }, @@ -510,6 +533,44 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return deps.appendClipboardVideoToQueue(); }); + ipc.handle(IPC_CHANNELS.request.getPlaylistBrowserSnapshot, async () => { + return await deps.getPlaylistBrowserSnapshot(); + }); + + ipc.handle(IPC_CHANNELS.request.appendPlaylistBrowserFile, async (_event, filePath: unknown) => { + if (typeof filePath !== 'string' || filePath.trim().length === 0) { + return { ok: false, message: 'Invalid playlist browser file path.' }; + } + return await deps.appendPlaylistBrowserFile(filePath); + }); + + ipc.handle(IPC_CHANNELS.request.playPlaylistBrowserIndex, async (_event, index: unknown) => { + if (!Number.isSafeInteger(index) || (index as number) < 0) { + return { ok: false, message: 'Invalid playlist browser index.' }; + } + return await deps.playPlaylistBrowserIndex(index as number); + }); + + ipc.handle(IPC_CHANNELS.request.removePlaylistBrowserIndex, async (_event, index: unknown) => { + if (!Number.isSafeInteger(index) || (index as number) < 0) { + return { ok: false, message: 'Invalid playlist browser index.' }; + } + return await deps.removePlaylistBrowserIndex(index as number); + }); + + ipc.handle( + IPC_CHANNELS.request.movePlaylistBrowserIndex, + async (_event, index: unknown, direction: unknown) => { + if (!Number.isSafeInteger(index) || (index as number) < 0) { + return { ok: false, message: 'Invalid playlist browser index.' }; + } + if (direction !== 1 && direction !== -1) { + return { ok: false, message: 'Invalid playlist browser move direction.' }; + } + return await deps.movePlaylistBrowserIndex(index as number, direction as 1 | -1); + }, + ); + // Stats request handlers ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => { const tracker = deps.immersionTracker; diff --git a/src/main.ts b/src/main.ts index 78ce4b0..81101d8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -427,6 +427,13 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime'; import { createOverlayModalRuntimeService } from './main/overlay-runtime'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; +import { + appendPlaylistBrowserFileRuntime, + getPlaylistBrowserSnapshotRuntime, + movePlaylistBrowserIndexRuntime, + playPlaylistBrowserIndexRuntime, + removePlaylistBrowserIndexRuntime, +} from './main/runtime/playlist-browser-runtime'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createFrequencyDictionaryRuntimeService, @@ -1929,6 +1936,19 @@ function openRuntimeOptionsPalette(): void { overlayVisibilityComposer.openRuntimeOptionsPalette(); } +function openPlaylistBrowser(): void { + if (!appState.mpvClient?.connected) { + showMpvOsd('Playlist browser requires active playback.'); + return; + } + const opened = sendToActiveOverlayWindow(IPC_CHANNELS.event.playlistBrowserOpen, undefined, { + restoreOnModalClose: 'playlist-browser', + }); + if (!opened) { + showMpvOsd('Playlist browser overlay unavailable.'); + } +} + function getResolvedConfig() { return configService.getConfig(); } @@ -4109,11 +4129,16 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen showMpvOsd: (text) => showMpvOsd(text), }); +const playlistBrowserRuntimeDeps = { + getMpvClient: () => appState.mpvClient, +}; + const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ mpvCommandMainDeps: { triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), + openPlaylistBrowser: () => openPlaylistBrowser(), cycleRuntimeOption: (id, direction) => { if (!appState.runtimeOptionsManager) { return { ok: false, error: 'Runtime options manager unavailable' }; @@ -4290,6 +4315,16 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(), + getPlaylistBrowserSnapshot: () => + getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps), + appendPlaylistBrowserFile: (filePath) => + appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath), + playPlaylistBrowserIndex: (index) => + playPlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index), + removePlaylistBrowserIndex: (index) => + removePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index), + movePlaylistBrowserIndex: (index, direction) => + movePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index, direction), getImmersionTracker: () => appState.immersionTracker, }, ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({ diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index f5113ba..74a87a3 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -93,6 +93,11 @@ export interface MainIpcRuntimeServiceDepsParams { getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; + getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot']; + appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile']; + playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex']; + removePlaylistBrowserIndex: IpcDepsRuntimeOptions['removePlaylistBrowserIndex']; + movePlaylistBrowserIndex: IpcDepsRuntimeOptions['movePlaylistBrowserIndex']; getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker']; } @@ -193,6 +198,7 @@ export interface MpvCommandRuntimeServiceDepsParams { triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig']; openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette']; openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker']; + openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle']; mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle']; @@ -247,6 +253,11 @@ export function createMainIpcRuntimeServiceDeps( getAnilistQueueStatus: params.getAnilistQueueStatus, retryAnilistQueueNow: params.retryAnilistQueueNow, appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, + getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot, + appendPlaylistBrowserFile: params.appendPlaylistBrowserFile, + playPlaylistBrowserIndex: params.playPlaylistBrowserIndex, + removePlaylistBrowserIndex: params.removePlaylistBrowserIndex, + movePlaylistBrowserIndex: params.movePlaylistBrowserIndex, getImmersionTracker: params.getImmersionTracker, }; } @@ -358,6 +369,7 @@ export function createMpvCommandRuntimeServiceDeps( triggerSubsyncFromConfig: params.triggerSubsyncFromConfig, openRuntimeOptionsPalette: params.openRuntimeOptionsPalette, openYoutubeTrackPicker: params.openYoutubeTrackPicker, + openPlaylistBrowser: params.openPlaylistBrowser, runtimeOptionsCycle: params.runtimeOptionsCycle, showMpvOsd: params.showMpvOsd, mpvReplaySubtitle: params.mpvReplaySubtitle, diff --git a/src/main/ipc-mpv-command.ts b/src/main/ipc-mpv-command.ts index aefea49..4fa3417 100644 --- a/src/main/ipc-mpv-command.ts +++ b/src/main/ipc-mpv-command.ts @@ -13,6 +13,7 @@ export interface MpvCommandFromIpcRuntimeDeps { triggerSubsyncFromConfig: () => void; openRuntimeOptionsPalette: () => void; openYoutubeTrackPicker: () => void | Promise; + openPlaylistBrowser: () => void | Promise; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; showMpvOsd: (text: string) => void; replayCurrentSubtitle: () => void; @@ -35,6 +36,7 @@ export function handleMpvCommandFromIpcRuntime( triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, openYoutubeTrackPicker: deps.openYoutubeTrackPicker, + openPlaylistBrowser: deps.openPlaylistBrowser, runtimeOptionsCycle: deps.cycleRuntimeOption, showMpvOsd: deps.showMpvOsd, mpvReplaySubtitle: deps.replayCurrentSubtitle, diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index 6f404b2..9b3df02 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -11,6 +11,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b triggerSubsyncFromConfig: async () => {}, openRuntimeOptionsPalette: () => {}, openYoutubeTrackPicker: () => {}, + openPlaylistBrowser: () => {}, cycleRuntimeOption: () => ({ ok: true }), showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, @@ -68,6 +69,20 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b getAnilistQueueStatus: () => ({}) as never, retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + getPlaylistBrowserSnapshot: async () => + ({ + directoryPath: null, + directoryAvailable: false, + directoryStatus: '', + directoryItems: [], + playlistItems: [], + playingIndex: null, + currentFilePath: null, + }) as never, + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, ankiJimakuDeps: { diff --git a/src/main/runtime/ipc-bridge-actions-main-deps.test.ts b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts index 7dd5665..4d1a3d8 100644 --- a/src/main/runtime/ipc-bridge-actions-main-deps.test.ts +++ b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts @@ -14,6 +14,7 @@ test('ipc bridge action main deps builders map callbacks', async () => { triggerSubsyncFromConfig: async () => {}, openRuntimeOptionsPalette: () => {}, openYoutubeTrackPicker: () => {}, + openPlaylistBrowser: () => {}, cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, diff --git a/src/main/runtime/ipc-bridge-actions.test.ts b/src/main/runtime/ipc-bridge-actions.test.ts index d4e142a..5c237f1 100644 --- a/src/main/runtime/ipc-bridge-actions.test.ts +++ b/src/main/runtime/ipc-bridge-actions.test.ts @@ -11,6 +11,7 @@ test('handle mpv command handler forwards command and built deps', () => { triggerSubsyncFromConfig: () => {}, openRuntimeOptionsPalette: () => {}, openYoutubeTrackPicker: () => {}, + openPlaylistBrowser: () => {}, cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, diff --git a/src/main/runtime/ipc-mpv-command-main-deps.test.ts b/src/main/runtime/ipc-mpv-command-main-deps.test.ts index edb6186..ebd59a1 100644 --- a/src/main/runtime/ipc-mpv-command-main-deps.test.ts +++ b/src/main/runtime/ipc-mpv-command-main-deps.test.ts @@ -10,6 +10,9 @@ test('ipc mpv command main deps builder maps callbacks', () => { openYoutubeTrackPicker: () => { calls.push('youtube-picker'); }, + openPlaylistBrowser: () => { + calls.push('playlist-browser'); + }, cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), showMpvOsd: (text) => calls.push(`osd:${text}`), replayCurrentSubtitle: () => calls.push('replay'), @@ -26,6 +29,7 @@ test('ipc mpv command main deps builder maps callbacks', () => { deps.triggerSubsyncFromConfig(); deps.openRuntimeOptionsPalette(); void deps.openYoutubeTrackPicker(); + void deps.openPlaylistBrowser(); assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' }); deps.showMpvOsd('hello'); deps.replayCurrentSubtitle(); @@ -39,6 +43,7 @@ test('ipc mpv command main deps builder maps callbacks', () => { 'subsync', 'palette', 'youtube-picker', + 'playlist-browser', 'osd:hello', 'replay', 'next', diff --git a/src/main/runtime/ipc-mpv-command-main-deps.ts b/src/main/runtime/ipc-mpv-command-main-deps.ts index fafca8d..fd37317 100644 --- a/src/main/runtime/ipc-mpv-command-main-deps.ts +++ b/src/main/runtime/ipc-mpv-command-main-deps.ts @@ -7,6 +7,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler( triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(), + openPlaylistBrowser: () => deps.openPlaylistBrowser(), cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction), showMpvOsd: (text: string) => deps.showMpvOsd(text), replayCurrentSubtitle: () => deps.replayCurrentSubtitle(), diff --git a/src/main/runtime/playlist-browser-runtime.test.ts b/src/main/runtime/playlist-browser-runtime.test.ts new file mode 100644 index 0000000..c218992 --- /dev/null +++ b/src/main/runtime/playlist-browser-runtime.test.ts @@ -0,0 +1,326 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import type { PlaylistBrowserQueueItem } from '../../types'; +import { + appendPlaylistBrowserFileRuntime, + getPlaylistBrowserSnapshotRuntime, + movePlaylistBrowserIndexRuntime, + playPlaylistBrowserIndexRuntime, + removePlaylistBrowserIndexRuntime, +} from './playlist-browser-runtime'; + +type FakePlaylistEntry = { + current?: boolean; + playing?: boolean; + filename: string; + title?: string; + id?: number; +}; + +function createTempVideoDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-playlist-browser-')); +} + +function createFakeMpvClient(options: { + currentVideoPath: string; + playlist: FakePlaylistEntry[]; + connected?: boolean; +}) { + let playlist = options.playlist.map((item, index) => ({ + id: item.id ?? index + 1, + current: item.current ?? false, + playing: item.playing ?? item.current ?? false, + filename: item.filename, + title: item.title ?? null, + })); + const commands: Array<(string | number)[]> = []; + + const syncFlags = (): void => { + let playingIndex = playlist.findIndex((item) => item.current || item.playing); + if (playingIndex < 0 && playlist.length > 0) { + playingIndex = 0; + } + playlist = playlist.map((item, index) => ({ + ...item, + current: index === playingIndex, + playing: index === playingIndex, + })); + }; + + syncFlags(); + + return { + connected: options.connected ?? true, + currentVideoPath: options.currentVideoPath, + async requestProperty(name: string): Promise { + if (name === 'playlist') { + return playlist; + } + if (name === 'playlist-playing-pos') { + return playlist.findIndex((item) => item.current || item.playing); + } + if (name === 'path') { + return this.currentVideoPath; + } + throw new Error(`Unexpected property: ${name}`); + }, + send(payload: { command: unknown[] }): boolean { + const command = payload.command as (string | number)[]; + commands.push(command); + const [action, first, second] = command; + if (action === 'loadfile' && typeof first === 'string' && second === 'append') { + playlist.push({ + id: playlist.length + 1, + filename: first, + title: null, + current: false, + playing: false, + }); + syncFlags(); + return true; + } + if (action === 'playlist-play-index' && typeof first === 'number' && playlist[first]) { + playlist = playlist.map((item, index) => ({ + ...item, + current: index === first, + playing: index === first, + })); + this.currentVideoPath = playlist[first]!.filename; + return true; + } + if (action === 'playlist-remove' && typeof first === 'number' && playlist[first]) { + const removingCurrent = playlist[first]!.current || playlist[first]!.playing; + playlist.splice(first, 1); + if (removingCurrent) { + syncFlags(); + this.currentVideoPath = + playlist.find((item) => item.current || item.playing)?.filename ?? this.currentVideoPath; + } + return true; + } + if ( + action === 'playlist-move' && + typeof first === 'number' && + typeof second === 'number' && + playlist[first] + ) { + const [moved] = playlist.splice(first, 1); + playlist.splice(second, 0, moved!); + syncFlags(); + return true; + } + return true; + }, + getCommands(): Array<(string | number)[]> { + return commands; + }, + }; +} + +test('getPlaylistBrowserSnapshotRuntime lists sibling videos in best-effort episode order', async () => { + const dir = createTempVideoDir(); + const episode2 = path.join(dir, 'Show - S01E02.mkv'); + const episode1 = path.join(dir, 'Show - S01E01.mkv'); + const special = path.join(dir, 'Show - Special.mp4'); + const ignored = path.join(dir, 'notes.txt'); + fs.writeFileSync(episode2, ''); + fs.writeFileSync(episode1, ''); + fs.writeFileSync(special, ''); + fs.writeFileSync(ignored, ''); + + const mpvClient = createFakeMpvClient({ + currentVideoPath: episode2, + playlist: [ + { filename: episode1, current: false, playing: false, title: 'Episode 1' }, + { filename: episode2, current: true, playing: true, title: 'Episode 2' }, + ], + }); + + const snapshot = await getPlaylistBrowserSnapshotRuntime({ + getMpvClient: () => mpvClient, + }); + + assert.equal(snapshot.directoryAvailable, true); + assert.equal(snapshot.directoryPath, dir); + assert.equal(snapshot.currentFilePath, episode2); + assert.equal(snapshot.playingIndex, 1); + assert.deepEqual( + snapshot.directoryItems.map((item) => [item.basename, item.isCurrentFile]), + [ + ['Show - S01E01.mkv', false], + ['Show - S01E02.mkv', true], + ['Show - Special.mp4', false], + ], + ); + assert.deepEqual( + snapshot.playlistItems.map((item) => ({ + index: item.index, + displayLabel: item.displayLabel, + current: item.current, + })), + [ + { index: 0, displayLabel: 'Episode 1', current: false }, + { index: 1, displayLabel: 'Episode 2', current: true }, + ], + ); +}); + +test('getPlaylistBrowserSnapshotRuntime degrades directory pane for remote media', async () => { + const mpvClient = createFakeMpvClient({ + currentVideoPath: 'https://example.com/video.m3u8', + playlist: [{ filename: 'https://example.com/video.m3u8', current: true }], + }); + + const snapshot = await getPlaylistBrowserSnapshotRuntime({ + getMpvClient: () => mpvClient, + }); + + assert.equal(snapshot.directoryAvailable, false); + assert.equal(snapshot.directoryItems.length, 0); + assert.match(snapshot.directoryStatus, /local filesystem/i); + assert.equal(snapshot.playlistItems.length, 1); +}); + +test('playlist-browser mutation runtimes mutate queue and return refreshed snapshots', async () => { + const dir = createTempVideoDir(); + const episode1 = path.join(dir, 'Show - S01E01.mkv'); + const episode2 = path.join(dir, 'Show - S01E02.mkv'); + const episode3 = path.join(dir, 'Show - S01E03.mkv'); + fs.writeFileSync(episode1, ''); + fs.writeFileSync(episode2, ''); + fs.writeFileSync(episode3, ''); + + const mpvClient = createFakeMpvClient({ + currentVideoPath: episode1, + playlist: [ + { filename: episode1, current: true, title: 'Episode 1' }, + { filename: episode2, title: 'Episode 2' }, + ], + }); + + const scheduled: Array<{ callback: () => void; delayMs: number }> = []; + const deps = { + getMpvClient: () => mpvClient, + schedule: (callback: () => void, delayMs: number) => { + scheduled.push({ callback, delayMs }); + }, + }; + + const appendResult = await appendPlaylistBrowserFileRuntime(deps, episode3); + assert.equal(appendResult.ok, true); + assert.deepEqual(mpvClient.getCommands().at(-1), ['loadfile', episode3, 'append']); + assert.deepEqual( + appendResult.snapshot?.playlistItems.map((item) => item.path), + [episode1, episode2, episode3], + ); + + const moveResult = await movePlaylistBrowserIndexRuntime(deps, 2, -1); + assert.equal(moveResult.ok, true); + assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-move', 2, 1]); + assert.deepEqual( + moveResult.snapshot?.playlistItems.map((item) => item.path), + [episode1, episode3, episode2], + ); + + const playResult = await playPlaylistBrowserIndexRuntime(deps, 1); + assert.equal(playResult.ok, true); + assert.deepEqual(mpvClient.getCommands().slice(-2), [ + ['set_property', 'sub-auto', 'fuzzy'], + ['playlist-play-index', 1], + ]); + assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]); + scheduled[0]?.callback(); + assert.deepEqual(mpvClient.getCommands().slice(-2), [ + ['set_property', 'sid', 'auto'], + ['set_property', 'secondary-sid', 'auto'], + ]); + assert.equal(playResult.snapshot?.playingIndex, 1); + + const removeResult = await removePlaylistBrowserIndexRuntime(deps, 2); + assert.equal(removeResult.ok, true); + assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-remove', 2]); + assert.deepEqual( + removeResult.snapshot?.playlistItems.map((item) => item.path), + [episode1, episode3], + ); +}); + +test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', async () => { + const dir = createTempVideoDir(); + const episode1 = path.join(dir, 'Show - S01E01.mkv'); + const episode2 = path.join(dir, 'Show - S01E02.mkv'); + fs.writeFileSync(episode1, ''); + fs.writeFileSync(episode2, ''); + + const mpvClient = createFakeMpvClient({ + currentVideoPath: episode1, + playlist: [ + { filename: episode1, current: true }, + { filename: episode2 }, + ], + }); + + const deps = { + getMpvClient: () => mpvClient, + }; + + const moveUp = await movePlaylistBrowserIndexRuntime(deps, 0, -1); + assert.deepEqual(moveUp, { + ok: false, + message: 'Playlist item is already at the top.', + }); + + const moveDown = await movePlaylistBrowserIndexRuntime(deps, 1, 1); + assert.deepEqual(moveDown, { + ok: false, + message: 'Playlist item is already at the bottom.', + }); +}); + +test('getPlaylistBrowserSnapshotRuntime normalizes playlist labels from title then filename', async () => { + const dir = createTempVideoDir(); + const episode1 = path.join(dir, 'Show - S01E01.mkv'); + fs.writeFileSync(episode1, ''); + + const mpvClient = createFakeMpvClient({ + currentVideoPath: episode1, + playlist: [{ filename: episode1, current: true, title: '' }], + }); + + const snapshot = await getPlaylistBrowserSnapshotRuntime({ + getMpvClient: () => mpvClient, + }); + + const item = snapshot.playlistItems[0] as PlaylistBrowserQueueItem; + assert.equal(item.displayLabel, 'Show - S01E01.mkv'); + assert.equal(item.path, episode1); +}); + +test('playPlaylistBrowserIndexRuntime skips local subtitle reset for remote playlist entries', async () => { + const scheduled: Array<{ callback: () => void; delayMs: number }> = []; + const mpvClient = createFakeMpvClient({ + currentVideoPath: 'https://example.com/video-1.m3u8', + playlist: [ + { filename: 'https://example.com/video-1.m3u8', current: true, title: 'Episode 1' }, + { filename: 'https://example.com/video-2.m3u8', title: 'Episode 2' }, + ], + }); + + const result = await playPlaylistBrowserIndexRuntime( + { + getMpvClient: () => mpvClient, + schedule: (callback, delayMs) => { + scheduled.push({ callback, delayMs }); + }, + }, + 1, + ); + + assert.equal(result.ok, true); + assert.deepEqual(mpvClient.getCommands().slice(-1), [['playlist-play-index', 1]]); + assert.equal(scheduled.length, 0); +}); diff --git a/src/main/runtime/playlist-browser-runtime.ts b/src/main/runtime/playlist-browser-runtime.ts new file mode 100644 index 0000000..dcc385e --- /dev/null +++ b/src/main/runtime/playlist-browser-runtime.ts @@ -0,0 +1,314 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { + PlaylistBrowserDirectoryItem, + PlaylistBrowserMutationResult, + PlaylistBrowserQueueItem, + PlaylistBrowserSnapshot, +} from '../../types'; +import { isRemoteMediaPath } from '../../jimaku/utils'; +import { hasVideoExtension } from '../../shared/video-extensions'; +import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort'; + +type PlaylistLike = { + filename?: unknown; + title?: unknown; + id?: unknown; + current?: unknown; + playing?: unknown; +}; + +type MpvPlaylistBrowserClientLike = { + connected: boolean; + currentVideoPath?: string | null; + requestProperty?: (name: string) => Promise; + send: (payload: { command: unknown[]; request_id?: number }) => boolean; +}; + +export type PlaylistBrowserRuntimeDeps = { + getMpvClient: () => MpvPlaylistBrowserClientLike | null; + schedule?: (callback: () => void, delayMs: number) => void; +}; + +function trimToNull(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +async function readProperty( + client: MpvPlaylistBrowserClientLike | null, + name: string, +): Promise { + if (!client?.requestProperty) return null; + try { + return await client.requestProperty(name); + } catch { + return null; + } +} + +async function resolveCurrentFilePath( + client: MpvPlaylistBrowserClientLike | null, +): Promise { + const currentVideoPath = trimToNull(client?.currentVideoPath); + if (currentVideoPath) return currentVideoPath; + return trimToNull(await readProperty(client, 'path')); +} + +function resolveDirectorySnapshot( + currentFilePath: string | null, +): Pick { + if (!currentFilePath) { + return { + directoryAvailable: false, + directoryItems: [], + directoryPath: null, + directoryStatus: 'Current media path is unavailable.', + }; + } + + if (isRemoteMediaPath(currentFilePath)) { + return { + directoryAvailable: false, + directoryItems: [], + directoryPath: null, + directoryStatus: 'Directory browser requires a local filesystem video.', + }; + } + + const resolvedPath = path.resolve(currentFilePath); + const directoryPath = path.dirname(resolvedPath); + try { + const entries = fs.readdirSync(directoryPath, { withFileTypes: true }); + const videoPaths = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter((name) => hasVideoExtension(path.extname(name))) + .map((name) => path.join(directoryPath, name)); + + const directoryItems: PlaylistBrowserDirectoryItem[] = sortPlaylistBrowserDirectoryItems( + videoPaths, + ).map((item) => ({ + ...item, + isCurrentFile: item.path === resolvedPath, + })); + + return { + directoryAvailable: true, + directoryItems, + directoryPath, + directoryStatus: directoryPath, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + directoryAvailable: false, + directoryItems: [], + directoryPath, + directoryStatus: `Could not read parent directory: ${message}`, + }; + } +} + +function normalizePlaylistItems(raw: unknown): PlaylistBrowserQueueItem[] { + if (!Array.isArray(raw)) return []; + return raw.map((entry, index) => { + const item = (entry ?? {}) as PlaylistLike; + const filename = trimToNull(item.filename) ?? ''; + const title = trimToNull(item.title); + const normalizedPath = + filename && !isRemoteMediaPath(filename) ? path.resolve(filename) : trimToNull(filename); + return { + index, + id: typeof item.id === 'number' ? item.id : null, + filename, + title, + displayLabel: + title ?? (path.basename(filename || '') || filename || `Playlist item ${index + 1}`), + current: item.current === true, + playing: item.playing === true, + path: normalizedPath, + }; + }); +} + +function ensureConnectedClient( + deps: PlaylistBrowserRuntimeDeps, +): MpvPlaylistBrowserClientLike | { ok: false; message: string } { + const client = deps.getMpvClient(); + if (!client?.connected) { + return { + ok: false, + message: 'MPV is not connected.', + }; + } + return client; +} + +async function getPlaylistItemsFromClient( + client: MpvPlaylistBrowserClientLike | null, +): Promise { + return normalizePlaylistItems(await readProperty(client, 'playlist')); +} + +export async function getPlaylistBrowserSnapshotRuntime( + deps: PlaylistBrowserRuntimeDeps, +): Promise { + const client = deps.getMpvClient(); + const currentFilePath = await resolveCurrentFilePath(client); + const [playlistItems, playingPosValue] = await Promise.all([ + getPlaylistItemsFromClient(client), + readProperty(client, 'playlist-playing-pos'), + ]); + const playingIndex = + typeof playingPosValue === 'number' && Number.isInteger(playingPosValue) + ? playingPosValue + : playlistItems.findIndex((item) => item.current || item.playing); + + return { + ...resolveDirectorySnapshot(currentFilePath), + playlistItems, + playingIndex: playingIndex >= 0 ? playingIndex : null, + currentFilePath, + }; +} + +async function validatePlaylistIndex( + deps: PlaylistBrowserRuntimeDeps, + index: number, +): Promise< + | { ok: false; message: string } + | { ok: true; client: MpvPlaylistBrowserClientLike; playlistItems: PlaylistBrowserQueueItem[] } +> { + const client = ensureConnectedClient(deps); + if ('ok' in client) { + return client; + } + const playlistItems = await getPlaylistItemsFromClient(client); + if (!Number.isInteger(index) || index < 0 || index >= playlistItems.length) { + return { + ok: false, + message: 'Playlist item not found.', + }; + } + return { + ok: true, + client, + playlistItems, + }; +} + +async function buildMutationResult( + message: string, + deps: PlaylistBrowserRuntimeDeps, +): Promise { + return { + ok: true, + message, + snapshot: await getPlaylistBrowserSnapshotRuntime(deps), + }; +} + +function rearmLocalSubtitleSelection(client: MpvPlaylistBrowserClientLike): void { + client.send({ command: ['set_property', 'sid', 'auto'] }); + client.send({ command: ['set_property', 'secondary-sid', 'auto'] }); +} + +function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void { + client.send({ command: ['set_property', 'sub-auto', 'fuzzy'] }); +} + +function isLocalPlaylistItem(item: PlaylistBrowserQueueItem | null | undefined): item is PlaylistBrowserQueueItem { + return Boolean(item?.path && !isRemoteMediaPath(item.path)); +} + +function scheduleLocalSubtitleSelectionRearm( + deps: PlaylistBrowserRuntimeDeps, + client: MpvPlaylistBrowserClientLike, +): void { + (deps.schedule ?? setTimeout)(() => { + rearmLocalSubtitleSelection(client); + }, 400); +} + +export async function appendPlaylistBrowserFileRuntime( + deps: PlaylistBrowserRuntimeDeps, + filePath: string, +): Promise { + const client = ensureConnectedClient(deps); + if ('ok' in client) { + return client; + } + const resolvedPath = path.resolve(filePath); + if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) { + return { + ok: false, + message: 'Playlist browser file is not readable.', + }; + } + + client.send({ command: ['loadfile', resolvedPath, 'append'] }); + return buildMutationResult(`Queued ${path.basename(resolvedPath)}`, deps); +} + +export async function playPlaylistBrowserIndexRuntime( + deps: PlaylistBrowserRuntimeDeps, + index: number, +): Promise { + const result = await validatePlaylistIndex(deps, index); + if (!result.ok) { + return result; + } + + const targetItem = result.playlistItems[index] ?? null; + if (isLocalPlaylistItem(targetItem)) { + prepareLocalSubtitleAutoload(result.client); + } + result.client.send({ command: ['playlist-play-index', index] }); + if (isLocalPlaylistItem(targetItem)) { + scheduleLocalSubtitleSelectionRearm(deps, result.client); + } + return buildMutationResult(`Playing playlist item ${index + 1}`, deps); +} + +export async function removePlaylistBrowserIndexRuntime( + deps: PlaylistBrowserRuntimeDeps, + index: number, +): Promise { + const result = await validatePlaylistIndex(deps, index); + if (!result.ok) { + return result; + } + + result.client.send({ command: ['playlist-remove', index] }); + return buildMutationResult(`Removed playlist item ${index + 1}`, deps); +} + +export async function movePlaylistBrowserIndexRuntime( + deps: PlaylistBrowserRuntimeDeps, + index: number, + direction: -1 | 1, +): Promise { + const result = await validatePlaylistIndex(deps, index); + if (!result.ok) { + return result; + } + + const targetIndex = index + direction; + if (targetIndex < 0) { + return { + ok: false, + message: 'Playlist item is already at the top.', + }; + } + if (targetIndex >= result.playlistItems.length) { + return { + ok: false, + message: 'Playlist item is already at the bottom.', + }; + } + + result.client.send({ command: ['playlist-move', index, targetIndex] }); + return buildMutationResult(`Moved playlist item ${index + 1}`, deps); +} diff --git a/src/main/runtime/playlist-browser-sort.test.ts b/src/main/runtime/playlist-browser-sort.test.ts new file mode 100644 index 0000000..f84cc8c --- /dev/null +++ b/src/main/runtime/playlist-browser-sort.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; + +import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort'; + +test('sortPlaylistBrowserDirectoryItems prefers parsed season and episode order', () => { + const root = '/library/show'; + const items = sortPlaylistBrowserDirectoryItems([ + path.join(root, 'Show - S01E10.mkv'), + path.join(root, 'Show - S01E02.mkv'), + path.join(root, 'Show - S01E01.mkv'), + path.join(root, 'Show - Episode 7.mkv'), + path.join(root, 'Show - 01x03.mkv'), + ]); + + assert.deepEqual( + items.map((item) => item.basename), + [ + 'Show - S01E01.mkv', + 'Show - S01E02.mkv', + 'Show - 01x03.mkv', + 'Show - Episode 7.mkv', + 'Show - S01E10.mkv', + ], + ); + assert.deepEqual( + items.map((item) => item.episodeLabel), + ['S1E1', 'S1E2', 'S1E3', 'E7', 'S1E10'], + ); +}); + +test('sortPlaylistBrowserDirectoryItems falls back to deterministic natural ordering', () => { + const root = '/library/show'; + const items = sortPlaylistBrowserDirectoryItems([ + path.join(root, 'Show Part 10.mkv'), + path.join(root, 'Show Part 2.mkv'), + path.join(root, 'Show Part 1.mkv'), + path.join(root, 'Show Special.mkv'), + ]); + + assert.deepEqual( + items.map((item) => item.basename), + ['Show Part 1.mkv', 'Show Part 2.mkv', 'Show Part 10.mkv', 'Show Special.mkv'], + ); + assert.deepEqual( + items.map((item) => item.episodeLabel), + [null, null, null, null], + ); +}); diff --git a/src/main/runtime/playlist-browser-sort.ts b/src/main/runtime/playlist-browser-sort.ts new file mode 100644 index 0000000..b254a47 --- /dev/null +++ b/src/main/runtime/playlist-browser-sort.ts @@ -0,0 +1,129 @@ +import path from 'node:path'; + +type ParsedEpisodeKey = { + season: number | null; + episode: number; +}; + +type SortToken = string | number; + +export type PlaylistBrowserSortedDirectoryItem = { + path: string; + basename: string; + episodeLabel: string | null; +}; + +const COLLATOR = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', +}); + +function parseEpisodeKey(basename: string): ParsedEpisodeKey | null { + const name = basename.replace(/\.[^.]+$/, ''); + const seasonEpisode = name.match(/(?:^|[^a-z0-9])s(\d{1,2})\s*e(\d{1,3})(?:$|[^a-z0-9])/i); + if (seasonEpisode) { + return { + season: Number(seasonEpisode[1]), + episode: Number(seasonEpisode[2]), + }; + } + + const seasonByX = name.match(/(?:^|[^a-z0-9])(\d{1,2})x(\d{1,3})(?:$|[^a-z0-9])/i); + if (seasonByX) { + return { + season: Number(seasonByX[1]), + episode: Number(seasonByX[2]), + }; + } + + const namedEpisode = name.match( + /(?:^|[^a-z0-9])(?:ep|episode|第)\s*(\d{1,3})(?:\s*(?:話|episode|ep))?(?:$|[^a-z0-9])/i, + ); + if (namedEpisode) { + return { + season: null, + episode: Number(namedEpisode[1]), + }; + } + + return null; +} + +function buildEpisodeLabel(parsed: ParsedEpisodeKey | null): string | null { + if (!parsed) return null; + if (parsed.season !== null) { + return `S${parsed.season}E${parsed.episode}`; + } + return `E${parsed.episode}`; +} + +function tokenizeNaturalSort(basename: string): SortToken[] { + return basename + .toLowerCase() + .split(/(\d+)/) + .filter((token) => token.length > 0) + .map((token) => (/^\d+$/.test(token) ? Number(token) : token)); +} + +function compareNaturalTokens(left: SortToken[], right: SortToken[]): number { + const maxLength = Math.max(left.length, right.length); + for (let index = 0; index < maxLength; index += 1) { + const a = left[index]; + const b = right[index]; + if (a === undefined) return -1; + if (b === undefined) return 1; + if (typeof a === 'number' && typeof b === 'number') { + if (a !== b) return a - b; + continue; + } + const comparison = COLLATOR.compare(String(a), String(b)); + if (comparison !== 0) return comparison; + } + return 0; +} + +export function sortPlaylistBrowserDirectoryItems( + paths: string[], +): PlaylistBrowserSortedDirectoryItem[] { + return paths + .map((pathValue) => { + const basename = path.basename(pathValue); + const parsed = parseEpisodeKey(basename); + return { + path: pathValue, + basename, + parsed, + episodeLabel: buildEpisodeLabel(parsed), + naturalTokens: tokenizeNaturalSort(basename), + }; + }) + .sort((left, right) => { + if (left.parsed && right.parsed) { + if ( + left.parsed.season !== null && + right.parsed.season !== null && + left.parsed.season !== right.parsed.season + ) { + return left.parsed.season - right.parsed.season; + } + if (left.parsed.episode !== right.parsed.episode) { + return left.parsed.episode - right.parsed.episode; + } + } else if (left.parsed && !right.parsed) { + return -1; + } else if (!left.parsed && right.parsed) { + return 1; + } + + const naturalComparison = compareNaturalTokens(left.naturalTokens, right.naturalTokens); + if (naturalComparison !== 0) { + return naturalComparison; + } + return COLLATOR.compare(left.basename, right.basename); + }) + .map(({ path: itemPath, basename, episodeLabel }) => ({ + path: itemPath, + basename, + episodeLabel, + })); +} diff --git a/src/preload.ts b/src/preload.ts index 8d0299d..bc112f6 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -38,6 +38,8 @@ import type { SubsyncManualRunRequest, SubsyncResult, ClipboardAppendResult, + PlaylistBrowserMutationResult, + PlaylistBrowserSnapshot, KikuFieldGroupingRequestData, KikuFieldGroupingChoice, KikuMergePreviewRequest, @@ -126,6 +128,7 @@ const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload payload as YoutubePickerOpenPayload, ); +const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen); const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener( IPC_CHANNELS.event.youtubePickerCancel, ); @@ -322,11 +325,25 @@ const electronAPI: ElectronAPI = { onOpenRuntimeOptions: onOpenRuntimeOptionsEvent, onOpenJimaku: onOpenJimakuEvent, onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, + onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, appendClipboardVideoToQueue: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), + getPlaylistBrowserSnapshot: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getPlaylistBrowserSnapshot), + appendPlaylistBrowserFile: (pathValue: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.appendPlaylistBrowserFile, pathValue), + playPlaylistBrowserIndex: (index: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.playPlaylistBrowserIndex, index), + removePlaylistBrowserIndex: (index: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.removePlaylistBrowserIndex, index), + movePlaylistBrowserIndex: ( + index: number, + direction: 1 | -1, + ): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.movePlaylistBrowserIndex, index, direction), youtubePickerResolve: ( request: YoutubePickerResolveRequest, ): Promise => diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index a197098..2825bc8 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -294,6 +294,7 @@ function createKeyboardHandlerHarness() { let controllerSelectOpenCount = 0; let controllerDebugOpenCount = 0; let controllerSelectKeydownCount = 0; + let playlistBrowserKeydownCount = 0; const createWordNode = (left: number) => ({ classList: createClassList(), @@ -333,6 +334,10 @@ function createKeyboardHandlerHarness() { }, handleControllerDebugKeydown: () => false, handleYoutubePickerKeydown: () => false, + handlePlaylistBrowserKeydown: () => { + playlistBrowserKeydownCount += 1; + return true; + }, handleSessionHelpKeydown: () => false, openSessionHelpModal: () => {}, appendClipboardVideoToQueue: () => {}, @@ -352,6 +357,7 @@ function createKeyboardHandlerHarness() { controllerSelectOpenCount: () => controllerSelectOpenCount, controllerDebugOpenCount: () => controllerDebugOpenCount, controllerSelectKeydownCount: () => controllerSelectKeydownCount, + playlistBrowserKeydownCount: () => playlistBrowserKeydownCount, setWordCount: (count: number) => { wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70)); }, @@ -623,6 +629,30 @@ test('keyboard mode: controller select modal handles arrow keys before yomitan p } }); +test('keyboard mode: playlist browser modal handles arrow keys before yomitan popup', async () => { + const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } = + createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + ctx.state.playlistBrowserModalOpen = true; + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + + testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' }); + + assert.equal(playlistBrowserKeydownCount(), 1); + assert.equal( + testGlobals.commandEvents.some( + (event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown', + ), + false, + ); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: configured stats toggle works even while popup is open', async () => { const { handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index ac4b294..80313a7 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -16,6 +16,7 @@ export function createKeyboardHandlers( handleKikuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean; handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean; + handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean; handleControllerSelectKeydown: (e: KeyboardEvent) => boolean; handleControllerDebugKeydown: (e: KeyboardEvent) => boolean; handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; @@ -841,6 +842,11 @@ export function createKeyboardHandlers( return; } } + if (ctx.state.playlistBrowserModalOpen) { + if (options.handlePlaylistBrowserKeydown(e)) { + return; + } + } if (ctx.state.controllerSelectModalOpen) { options.handleControllerSelectKeydown(e); return; diff --git a/src/renderer/index.html b/src/renderer/index.html index c16b8ed..2221946 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -320,6 +320,35 @@ + diff --git a/src/renderer/modals/playlist-browser.test.ts b/src/renderer/modals/playlist-browser.test.ts new file mode 100644 index 0000000..b7413e9 --- /dev/null +++ b/src/renderer/modals/playlist-browser.test.ts @@ -0,0 +1,430 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { ElectronAPI, PlaylistBrowserSnapshot } from '../../types'; +import { createRendererState } from '../state.js'; +import { createPlaylistBrowserModal } from './playlist-browser.js'; + +function createClassList(initialTokens: string[] = []) { + const tokens = new Set(initialTokens); + return { + add: (...entries: string[]) => { + for (const entry of entries) tokens.add(entry); + }, + remove: (...entries: string[]) => { + for (const entry of entries) tokens.delete(entry); + }, + contains: (entry: string) => tokens.has(entry), + toggle: (entry: string, force?: boolean) => { + if (force === true) tokens.add(entry); + else if (force === false) tokens.delete(entry); + else if (tokens.has(entry)) tokens.delete(entry); + else tokens.add(entry); + }, + }; +} + +function createFakeElement() { + const attributes = new Map(); + return { + textContent: '', + innerHTML: '', + children: [] as unknown[], + listeners: new Map void>>(), + classList: createClassList(['hidden']), + appendChild(child: unknown) { + this.children.push(child); + return child; + }, + append(...children: unknown[]) { + this.children.push(...children); + }, + replaceChildren(...children: unknown[]) { + this.children = [...children]; + }, + addEventListener(type: string, listener: (event?: unknown) => void) { + const bucket = this.listeners.get(type) ?? []; + bucket.push(listener); + this.listeners.set(type, bucket); + }, + setAttribute(name: string, value: string) { + attributes.set(name, value); + }, + getAttribute(name: string) { + return attributes.get(name) ?? null; + }, + focus() {}, + }; +} + +function createPlaylistRow() { + return { + className: '', + classList: createClassList(), + dataset: {} as Record, + textContent: '', + children: [] as unknown[], + listeners: new Map void>>(), + append(...children: unknown[]) { + this.children.push(...children); + }, + appendChild(child: unknown) { + this.children.push(child); + return child; + }, + addEventListener(type: string, listener: (event?: unknown) => void) { + const bucket = this.listeners.get(type) ?? []; + bucket.push(listener); + this.listeners.set(type, bucket); + }, + setAttribute() {}, + }; +} + +function createListStub() { + return { + innerHTML: '', + children: [] as ReturnType[], + appendChild(child: ReturnType) { + this.children.push(child); + return child; + }, + replaceChildren(...children: ReturnType[]) { + this.children = [...children]; + }, + }; +} + +function createSnapshot(): PlaylistBrowserSnapshot { + return { + directoryPath: '/tmp/show', + directoryAvailable: true, + directoryStatus: '/tmp/show', + currentFilePath: '/tmp/show/Show - S01E02.mkv', + playingIndex: 1, + directoryItems: [ + { + path: '/tmp/show/Show - S01E01.mkv', + basename: 'Show - S01E01.mkv', + episodeLabel: 'S1E1', + isCurrentFile: false, + }, + { + path: '/tmp/show/Show - S01E02.mkv', + basename: 'Show - S01E02.mkv', + episodeLabel: 'S1E2', + isCurrentFile: true, + }, + ], + playlistItems: [ + { + index: 0, + id: 1, + filename: '/tmp/show/Show - S01E01.mkv', + title: 'Episode 1', + displayLabel: 'Episode 1', + current: false, + playing: false, + path: '/tmp/show/Show - S01E01.mkv', + }, + { + index: 1, + id: 2, + filename: '/tmp/show/Show - S01E02.mkv', + title: 'Episode 2', + displayLabel: 'Episode 2', + current: true, + playing: true, + path: '/tmp/show/Show - S01E02.mkv', + }, + ], + }; +} + +test('playlist browser modal opens with playlist-focused current item selection', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + const notifications: string[] = []; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + getPlaylistBrowserSnapshot: async () => createSnapshot(), + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), + focusMainWindow: async () => {}, + setIgnoreMouseEvents: () => {}, + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + } as unknown as ElectronAPI, + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => createPlaylistRow(), + }, + }); + + try { + const state = createRendererState(); + const directoryList = createListStub(); + const playlistList = createListStub(); + const ctx = { + state, + platform: { + shouldToggleMouseIgnore: false, + }, + dom: { + overlay: { + classList: createClassList(), + focus: () => {}, + }, + playlistBrowserModal: createFakeElement(), + playlistBrowserTitle: createFakeElement(), + playlistBrowserStatus: createFakeElement(), + playlistBrowserDirectoryList: directoryList, + playlistBrowserPlaylistList: playlistList, + playlistBrowserClose: createFakeElement(), + }, + }; + + const modal = createPlaylistBrowserModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + }); + + await modal.openPlaylistBrowserModal(); + + assert.equal(state.playlistBrowserModalOpen, true); + assert.equal(state.playlistBrowserActivePane, 'playlist'); + assert.equal(state.playlistBrowserSelectedPlaylistIndex, 1); + assert.equal(state.playlistBrowserSelectedDirectoryIndex, 1); + assert.equal(directoryList.children.length, 2); + assert.equal(playlistList.children.length, 2); + assert.equal(directoryList.children[0]?.children.length, 2); + assert.equal(playlistList.children[0]?.children.length, 2); + assert.deepEqual(notifications, ['open:playlist-browser']); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + +test('playlist browser modal keydown routes append, remove, reorder, tab switch, and play', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + const calls: Array<[string, unknown[]]> = []; + const notifications: string[] = []; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + getPlaylistBrowserSnapshot: async () => createSnapshot(), + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), + focusMainWindow: async () => {}, + setIgnoreMouseEvents: () => {}, + appendPlaylistBrowserFile: async (filePath: string) => { + calls.push(['append', [filePath]]); + return { ok: true, message: 'append-ok', snapshot: createSnapshot() }; + }, + playPlaylistBrowserIndex: async (index: number) => { + calls.push(['play', [index]]); + return { ok: true, message: 'play-ok', snapshot: createSnapshot() }; + }, + removePlaylistBrowserIndex: async (index: number) => { + calls.push(['remove', [index]]); + return { ok: true, message: 'remove-ok', snapshot: createSnapshot() }; + }, + movePlaylistBrowserIndex: async (index: number, direction: -1 | 1) => { + calls.push(['move', [index, direction]]); + return { ok: true, message: 'move-ok', snapshot: createSnapshot() }; + }, + } as unknown as ElectronAPI, + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => createPlaylistRow(), + }, + }); + + try { + const state = createRendererState(); + const ctx = { + state, + platform: { + shouldToggleMouseIgnore: false, + }, + dom: { + overlay: { + classList: createClassList(), + focus: () => {}, + }, + playlistBrowserModal: createFakeElement(), + playlistBrowserTitle: createFakeElement(), + playlistBrowserStatus: createFakeElement(), + playlistBrowserDirectoryList: createListStub(), + playlistBrowserPlaylistList: createListStub(), + playlistBrowserClose: createFakeElement(), + }, + }; + + const modal = createPlaylistBrowserModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + }); + + await modal.openPlaylistBrowserModal(); + + const preventDefault = () => {}; + state.playlistBrowserActivePane = 'directory'; + state.playlistBrowserSelectedDirectoryIndex = 0; + await modal.handlePlaylistBrowserKeydown({ + key: 'Enter', + code: 'Enter', + preventDefault, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as never); + + await modal.handlePlaylistBrowserKeydown({ + key: 'Tab', + code: 'Tab', + preventDefault, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as never); + assert.equal(state.playlistBrowserActivePane, 'playlist'); + + await modal.handlePlaylistBrowserKeydown({ + key: 'ArrowDown', + code: 'ArrowDown', + preventDefault, + ctrlKey: true, + metaKey: false, + shiftKey: false, + } as never); + + await modal.handlePlaylistBrowserKeydown({ + key: 'Delete', + code: 'Delete', + preventDefault, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as never); + + await modal.handlePlaylistBrowserKeydown({ + key: 'Enter', + code: 'Enter', + preventDefault, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as never); + + assert.deepEqual(calls, [ + ['append', ['/tmp/show/Show - S01E01.mkv']], + ['move', [1, 1]], + ['remove', [1]], + ['play', [1]], + ]); + assert.equal(state.playlistBrowserModalOpen, false); + assert.deepEqual(notifications, ['open:playlist-browser', 'close:playlist-browser']); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + +test('playlist browser keeps modal open when playing selected queue item fails', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + const notifications: string[] = []; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + getPlaylistBrowserSnapshot: async () => createSnapshot(), + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), + focusMainWindow: async () => {}, + setIgnoreMouseEvents: () => {}, + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + playPlaylistBrowserIndex: async () => ({ ok: false, message: 'play failed' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + } as unknown as ElectronAPI, + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => createPlaylistRow(), + }, + }); + + try { + const state = createRendererState(); + const playlistBrowserStatus = createFakeElement(); + const ctx = { + state, + platform: { + shouldToggleMouseIgnore: false, + }, + dom: { + overlay: { + classList: createClassList(), + focus: () => {}, + }, + playlistBrowserModal: createFakeElement(), + playlistBrowserTitle: createFakeElement(), + playlistBrowserStatus, + playlistBrowserDirectoryList: createListStub(), + playlistBrowserPlaylistList: createListStub(), + playlistBrowserClose: createFakeElement(), + }, + }; + + const modal = createPlaylistBrowserModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + }); + + await modal.openPlaylistBrowserModal(); + assert.equal(state.playlistBrowserModalOpen, true); + + await modal.handlePlaylistBrowserKeydown({ + key: 'Enter', + code: 'Enter', + preventDefault: () => {}, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as never); + + assert.equal(state.playlistBrowserModalOpen, true); + assert.equal(playlistBrowserStatus.textContent, 'play failed'); + assert.equal(playlistBrowserStatus.classList.contains('error'), true); + assert.deepEqual(notifications, ['open:playlist-browser']); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); diff --git a/src/renderer/modals/playlist-browser.ts b/src/renderer/modals/playlist-browser.ts new file mode 100644 index 0000000..99e6e0e --- /dev/null +++ b/src/renderer/modals/playlist-browser.ts @@ -0,0 +1,419 @@ +import type { + PlaylistBrowserDirectoryItem, + PlaylistBrowserMutationResult, + PlaylistBrowserQueueItem, + PlaylistBrowserSnapshot, +} from '../../types'; +import type { ModalStateReader, RendererContext } from '../context'; + +function clampIndex(index: number, length: number): number { + if (length <= 0) return 0; + return Math.min(Math.max(index, 0), length - 1); +} + +function createActionButton(label: string, onClick: () => void): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.className = 'playlist-browser-action'; + button.addEventListener('click', (event) => { + event.stopPropagation(); + onClick(); + }); + return button; +} + +function buildDefaultStatus(snapshot: PlaylistBrowserSnapshot): string { + const directoryCount = snapshot.directoryItems.length; + const playlistCount = snapshot.playlistItems.length; + if (!snapshot.directoryAvailable) { + return `${snapshot.directoryStatus} ${playlistCount > 0 ? `· ${playlistCount} queued` : ''}`.trim(); + } + return `${directoryCount} sibling videos · ${playlistCount} queued`; +} + +export function createPlaylistBrowserModal( + ctx: RendererContext, + options: { + modalStateReader: Pick; + syncSettingsModalSubtitleSuppression: () => void; + }, +) { + function setStatus(message: string, isError = false): void { + ctx.state.playlistBrowserStatus = message; + ctx.dom.playlistBrowserStatus.textContent = message; + ctx.dom.playlistBrowserStatus.classList.toggle('error', isError); + } + + function getSnapshot(): PlaylistBrowserSnapshot | null { + return ctx.state.playlistBrowserSnapshot; + } + + function syncSelection(snapshot: PlaylistBrowserSnapshot): void { + const directoryIndex = snapshot.directoryItems.findIndex((item) => item.isCurrentFile); + const playlistIndex = + snapshot.playingIndex ?? snapshot.playlistItems.findIndex((item) => item.current || item.playing); + ctx.state.playlistBrowserSelectedDirectoryIndex = clampIndex( + directoryIndex >= 0 ? directoryIndex : 0, + snapshot.directoryItems.length, + ); + ctx.state.playlistBrowserSelectedPlaylistIndex = clampIndex( + playlistIndex >= 0 ? playlistIndex : 0, + snapshot.playlistItems.length, + ); + } + + function renderDirectoryRow(item: PlaylistBrowserDirectoryItem, index: number): HTMLElement { + const row = document.createElement('li'); + row.className = 'playlist-browser-row'; + if (item.isCurrentFile) row.classList.add('current'); + if ( + ctx.state.playlistBrowserActivePane === 'directory' && + ctx.state.playlistBrowserSelectedDirectoryIndex === index + ) { + row.classList.add('active'); + } + + const main = document.createElement('div'); + main.className = 'playlist-browser-row-main'; + const label = document.createElement('div'); + label.className = 'playlist-browser-row-label'; + label.textContent = item.basename; + const meta = document.createElement('div'); + meta.className = 'playlist-browser-row-meta'; + meta.textContent = item.isCurrentFile + ? item.episodeLabel + ? `${item.episodeLabel} · Current file` + : 'Current file' + : item.episodeLabel ?? 'Video file'; + main.append(label, meta); + + const trailing = document.createElement('div'); + trailing.className = 'playlist-browser-row-trailing'; + if (item.episodeLabel) { + const badge = document.createElement('div'); + badge.className = 'playlist-browser-chip'; + badge.textContent = item.episodeLabel; + trailing.appendChild(badge); + } + trailing.appendChild( + createActionButton('Add', () => { + void appendDirectoryItem(item.path); + }), + ); + + row.append(main, trailing); + row.addEventListener('click', () => { + ctx.state.playlistBrowserActivePane = 'directory'; + ctx.state.playlistBrowserSelectedDirectoryIndex = index; + render(); + }); + row.addEventListener('dblclick', () => { + ctx.state.playlistBrowserSelectedDirectoryIndex = index; + void appendDirectoryItem(item.path); + }); + return row; + } + + function renderPlaylistRow(item: PlaylistBrowserQueueItem, index: number): HTMLElement { + const row = document.createElement('li'); + row.className = 'playlist-browser-row'; + if (item.current || item.playing) row.classList.add('current'); + if ( + ctx.state.playlistBrowserActivePane === 'playlist' && + ctx.state.playlistBrowserSelectedPlaylistIndex === index + ) { + row.classList.add('active'); + } + + const main = document.createElement('div'); + main.className = 'playlist-browser-row-main'; + const label = document.createElement('div'); + label.className = 'playlist-browser-row-label'; + label.textContent = `${index + 1}. ${item.displayLabel}`; + const meta = document.createElement('div'); + meta.className = 'playlist-browser-row-meta'; + meta.textContent = item.current || item.playing ? 'Playing now' : 'Queued'; + const submeta = document.createElement('div'); + submeta.className = 'playlist-browser-row-submeta'; + submeta.textContent = item.filename; + main.append(label, meta, submeta); + + const trailing = document.createElement('div'); + trailing.className = 'playlist-browser-row-actions'; + trailing.append( + createActionButton('Play', () => { + void playPlaylistItem(item.index); + }), + createActionButton('Up', () => { + void movePlaylistItem(item.index, -1); + }), + createActionButton('Down', () => { + void movePlaylistItem(item.index, 1); + }), + createActionButton('Remove', () => { + void removePlaylistItem(item.index); + }), + ); + row.append(main, trailing); + row.addEventListener('click', () => { + ctx.state.playlistBrowserActivePane = 'playlist'; + ctx.state.playlistBrowserSelectedPlaylistIndex = index; + render(); + }); + row.addEventListener('dblclick', () => { + ctx.state.playlistBrowserSelectedPlaylistIndex = index; + void playPlaylistItem(item.index); + }); + return row; + } + + function render(): void { + const snapshot = getSnapshot(); + if (!snapshot) { + ctx.dom.playlistBrowserDirectoryList.replaceChildren(); + ctx.dom.playlistBrowserPlaylistList.replaceChildren(); + return; + } + + ctx.dom.playlistBrowserTitle.textContent = snapshot.directoryPath ?? 'Playlist Browser'; + ctx.dom.playlistBrowserStatus.textContent = + ctx.state.playlistBrowserStatus || buildDefaultStatus(snapshot); + ctx.dom.playlistBrowserDirectoryList.replaceChildren( + ...snapshot.directoryItems.map((item, index) => renderDirectoryRow(item, index)), + ); + ctx.dom.playlistBrowserPlaylistList.replaceChildren( + ...snapshot.playlistItems.map((item, index) => renderPlaylistRow(item, index)), + ); + } + + function applySnapshot(snapshot: PlaylistBrowserSnapshot): void { + ctx.state.playlistBrowserSnapshot = snapshot; + syncSelection(snapshot); + render(); + } + + async function refreshSnapshot(): Promise { + const snapshot = await window.electronAPI.getPlaylistBrowserSnapshot(); + ctx.state.playlistBrowserStatus = ''; + applySnapshot(snapshot); + setStatus( + buildDefaultStatus(snapshot), + !snapshot.directoryAvailable && snapshot.directoryStatus.length > 0, + ); + } + + async function handleMutation( + action: Promise, + fallbackMessage: string, + ): Promise { + const result = await action; + if (!result.ok) { + setStatus(result.message, true); + return; + } + setStatus(result.message || fallbackMessage, false); + if (result.snapshot) { + applySnapshot(result.snapshot); + return; + } + await refreshSnapshot(); + } + + async function appendDirectoryItem(filePath: string): Promise { + await handleMutation(window.electronAPI.appendPlaylistBrowserFile(filePath), 'Queued file'); + } + + async function playPlaylistItem(index: number): Promise { + const result = await window.electronAPI.playPlaylistBrowserIndex(index); + if (!result.ok) { + setStatus(result.message, true); + return; + } + closePlaylistBrowserModal(); + } + + async function removePlaylistItem(index: number): Promise { + await handleMutation(window.electronAPI.removePlaylistBrowserIndex(index), 'Removed queue item'); + } + + async function movePlaylistItem(index: number, direction: 1 | -1): Promise { + await handleMutation( + window.electronAPI.movePlaylistBrowserIndex(index, direction), + 'Moved queue item', + ); + } + + async function openPlaylistBrowserModal(): Promise { + if (ctx.state.playlistBrowserModalOpen) { + await refreshSnapshot(); + return; + } + + ctx.state.playlistBrowserModalOpen = true; + ctx.state.playlistBrowserActivePane = 'playlist'; + options.syncSettingsModalSubtitleSuppression(); + ctx.dom.overlay.classList.add('interactive'); + ctx.dom.playlistBrowserModal.classList.remove('hidden'); + ctx.dom.playlistBrowserModal.setAttribute('aria-hidden', 'false'); + window.electronAPI.notifyOverlayModalOpened('playlist-browser'); + + try { + await refreshSnapshot(); + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error), true); + } + } + + function closePlaylistBrowserModal(): void { + if (!ctx.state.playlistBrowserModalOpen) return; + ctx.state.playlistBrowserModalOpen = false; + ctx.state.playlistBrowserSnapshot = null; + ctx.state.playlistBrowserStatus = ''; + ctx.dom.playlistBrowserModal.classList.add('hidden'); + ctx.dom.playlistBrowserModal.setAttribute('aria-hidden', 'true'); + window.electronAPI.notifyOverlayModalClosed('playlist-browser'); + options.syncSettingsModalSubtitleSuppression(); + if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { + ctx.dom.overlay.classList.remove('interactive'); + } + } + + function moveSelection(delta: number): void { + const snapshot = getSnapshot(); + if (!snapshot) return; + if (ctx.state.playlistBrowserActivePane === 'directory') { + ctx.state.playlistBrowserSelectedDirectoryIndex = clampIndex( + ctx.state.playlistBrowserSelectedDirectoryIndex + delta, + snapshot.directoryItems.length, + ); + } else { + ctx.state.playlistBrowserSelectedPlaylistIndex = clampIndex( + ctx.state.playlistBrowserSelectedPlaylistIndex + delta, + snapshot.playlistItems.length, + ); + } + render(); + } + + function jumpSelection(target: 'start' | 'end'): void { + const snapshot = getSnapshot(); + if (!snapshot) return; + const length = + ctx.state.playlistBrowserActivePane === 'directory' + ? snapshot.directoryItems.length + : snapshot.playlistItems.length; + const nextIndex = target === 'start' ? 0 : Math.max(0, length - 1); + if (ctx.state.playlistBrowserActivePane === 'directory') { + ctx.state.playlistBrowserSelectedDirectoryIndex = nextIndex; + } else { + ctx.state.playlistBrowserSelectedPlaylistIndex = nextIndex; + } + render(); + } + + function activateSelection(): void { + const snapshot = getSnapshot(); + if (!snapshot) return; + if (ctx.state.playlistBrowserActivePane === 'directory') { + const item = snapshot.directoryItems[ctx.state.playlistBrowserSelectedDirectoryIndex]; + if (item) { + void appendDirectoryItem(item.path); + } + return; + } + + const item = snapshot.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex]; + if (item) { + void playPlaylistItem(item.index); + } + } + + function handlePlaylistBrowserKeydown(event: KeyboardEvent): boolean { + if (!ctx.state.playlistBrowserModalOpen) return false; + + if (event.key === 'Escape') { + event.preventDefault(); + closePlaylistBrowserModal(); + return true; + } + if (event.key === 'Tab') { + event.preventDefault(); + ctx.state.playlistBrowserActivePane = + ctx.state.playlistBrowserActivePane === 'directory' ? 'playlist' : 'directory'; + render(); + return true; + } + if (event.key === 'Home') { + event.preventDefault(); + jumpSelection('start'); + return true; + } + if (event.key === 'End') { + event.preventDefault(); + jumpSelection('end'); + return true; + } + if (event.key === 'ArrowUp' && (event.ctrlKey || event.metaKey)) { + if (ctx.state.playlistBrowserActivePane === 'playlist') { + event.preventDefault(); + const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex]; + if (item) { + void movePlaylistItem(item.index, -1); + } + return true; + } + } + if (event.key === 'ArrowDown' && (event.ctrlKey || event.metaKey)) { + if (ctx.state.playlistBrowserActivePane === 'playlist') { + event.preventDefault(); + const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex]; + if (item) { + void movePlaylistItem(item.index, 1); + } + return true; + } + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + moveSelection(-1); + return true; + } + if (event.key === 'ArrowDown') { + event.preventDefault(); + moveSelection(1); + return true; + } + if (event.key === 'Enter') { + event.preventDefault(); + activateSelection(); + return true; + } + if (event.key === 'Delete' || event.key === 'Backspace') { + if (ctx.state.playlistBrowserActivePane === 'playlist') { + event.preventDefault(); + const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex]; + if (item) { + void removePlaylistItem(item.index); + } + return true; + } + } + + return false; + } + + function wireDomEvents(): void { + ctx.dom.playlistBrowserClose.addEventListener('click', () => { + closePlaylistBrowserModal(); + }); + } + + return { + openPlaylistBrowserModal, + closePlaylistBrowserModal, + handlePlaylistBrowserKeydown, + refreshSnapshot, + wireDomEvents, + }; +} diff --git a/src/renderer/modals/session-help.ts b/src/renderer/modals/session-help.ts index f5c8177..30a7982 100644 --- a/src/renderer/modals/session-help.ts +++ b/src/renderer/modals/session-help.ts @@ -130,6 +130,7 @@ function describeCommand(command: (string | number)[]): string { } if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls'; if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options'; + if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser'; if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle'; if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle'; if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { @@ -164,6 +165,7 @@ function sectionForCommand(command: (string | number)[]): string { if ( first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN || + first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN || first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX) ) { return 'Runtime settings'; diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 48a445a..4672e0c 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -33,6 +33,7 @@ import { createControllerDebugModal } from './modals/controller-debug.js'; import { createControllerSelectModal } from './modals/controller-select.js'; import { createJimakuModal } from './modals/jimaku.js'; import { createKikuModal } from './modals/kiku.js'; +import { createPlaylistBrowserModal } from './modals/playlist-browser.js'; import { createSessionHelpModal } from './modals/session-help.js'; import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js'; import { isControllerInteractionBlocked } from './controller-interaction-blocking.js'; @@ -71,7 +72,8 @@ function isAnySettingsModalOpen(): boolean { ctx.state.kikuModalOpen || ctx.state.jimakuModalOpen || ctx.state.youtubePickerModalOpen || - ctx.state.sessionHelpModalOpen + ctx.state.sessionHelpModalOpen || + ctx.state.playlistBrowserModalOpen ); } @@ -85,6 +87,7 @@ function isAnyModalOpen(): boolean { ctx.state.subsyncModalOpen || ctx.state.youtubePickerModalOpen || ctx.state.sessionHelpModalOpen || + ctx.state.playlistBrowserModalOpen || ctx.state.subtitleSidebarModalOpen ); } @@ -153,12 +156,17 @@ const youtubePickerModal = createYoutubeTrackPickerModal(ctx, { restorePointerInteractionState: mouseHandlers.restorePointerInteractionState, syncSettingsModalSubtitleSuppression, }); +const playlistBrowserModal = createPlaylistBrowserModal(ctx, { + modalStateReader: { isAnyModalOpen }, + syncSettingsModalSubtitleSuppression, +}); const keyboardHandlers = createKeyboardHandlers(ctx, { handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown, handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown, handleKikuKeydown: kikuModal.handleKikuKeydown, handleJimakuKeydown: jimakuModal.handleJimakuKeydown, handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown, + handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown, handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown, handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown, handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, @@ -209,6 +217,7 @@ function getActiveModal(): string | null { if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar'; if (ctx.state.jimakuModalOpen) return 'jimaku'; if (ctx.state.youtubePickerModalOpen) return 'youtube-track-picker'; + if (ctx.state.playlistBrowserModalOpen) return 'playlist-browser'; if (ctx.state.kikuModalOpen) return 'kiku'; if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options'; if (ctx.state.subsyncModalOpen) return 'subsync'; @@ -232,6 +241,9 @@ function dismissActiveUiAfterError(): void { if (ctx.state.youtubePickerModalOpen) { youtubePickerModal.closeYoutubePickerModal(); } + if (ctx.state.playlistBrowserModalOpen) { + playlistBrowserModal.closePlaylistBrowserModal(); + } if (ctx.state.runtimeOptionsModalOpen) { runtimeOptionsModal.closeRuntimeOptionsModal(); } @@ -439,6 +451,11 @@ function registerModalOpenHandlers(): void { youtubePickerModal.openYoutubePickerModal(payload); }); }); + window.electronAPI.onOpenPlaylistBrowser(() => { + runGuardedAsync('playlist-browser:open', async () => { + await playlistBrowserModal.openPlaylistBrowserModal(); + }); + }); window.electronAPI.onCancelYoutubeTrackPicker(() => { runGuarded('youtube:picker-cancel', () => { youtubePickerModal.closeYoutubePickerModal(); @@ -518,6 +535,11 @@ async function init(): Promise { runGuarded('subtitle-position:update', () => { positioning.applyStoredSubtitlePosition(position, 'media-change'); measurementReporter.schedule(); + if (ctx.state.playlistBrowserModalOpen) { + runGuardedAsync('playlist-browser:refresh-on-media-change', async () => { + await playlistBrowserModal.refreshSnapshot(); + }); + } }); }); @@ -572,6 +594,7 @@ async function init(): Promise { jimakuModal.wireDomEvents(); youtubePickerModal.wireDomEvents(); + playlistBrowserModal.wireDomEvents(); kikuModal.wireDomEvents(); runtimeOptionsModal.wireDomEvents(); subsyncModal.wireDomEvents(); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index c81f8e4..40ddff5 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -1,4 +1,5 @@ import type { + PlaylistBrowserSnapshot, ControllerButtonSnapshot, ControllerDeviceInfo, ResolvedControllerConfig, @@ -78,6 +79,12 @@ export type RendererState = { sessionHelpModalOpen: boolean; sessionHelpSelectedIndex: number; + playlistBrowserModalOpen: boolean; + playlistBrowserSnapshot: PlaylistBrowserSnapshot | null; + playlistBrowserStatus: string; + playlistBrowserActivePane: 'directory' | 'playlist'; + playlistBrowserSelectedDirectoryIndex: number; + playlistBrowserSelectedPlaylistIndex: number; subtitleSidebarCues: SubtitleCue[]; subtitleSidebarActiveCueIndex: number; subtitleSidebarToggleKey: string; @@ -175,6 +182,12 @@ export function createRendererState(): RendererState { sessionHelpModalOpen: false, sessionHelpSelectedIndex: 0, + playlistBrowserModalOpen: false, + playlistBrowserSnapshot: null, + playlistBrowserStatus: '', + playlistBrowserActivePane: 'playlist', + playlistBrowserSelectedDirectoryIndex: 0, + playlistBrowserSelectedPlaylistIndex: 0, subtitleSidebarCues: [], subtitleSidebarActiveCueIndex: -1, subtitleSidebarToggleKey: 'Backslash', diff --git a/src/renderer/utils/dom.ts b/src/renderer/utils/dom.ts index 002aad5..7ae0331 100644 --- a/src/renderer/utils/dom.ts +++ b/src/renderer/utils/dom.ts @@ -96,6 +96,13 @@ export type RendererDom = { sessionHelpStatus: HTMLDivElement; sessionHelpFilter: HTMLInputElement; sessionHelpContent: HTMLDivElement; + + playlistBrowserModal: HTMLDivElement; + playlistBrowserTitle: HTMLDivElement; + playlistBrowserStatus: HTMLDivElement; + playlistBrowserDirectoryList: HTMLUListElement; + playlistBrowserPlaylistList: HTMLUListElement; + playlistBrowserClose: HTMLButtonElement; }; function getRequiredElement(id: string): T { @@ -211,5 +218,12 @@ export function resolveRendererDom(): RendererDom { sessionHelpStatus: getRequiredElement('sessionHelpStatus'), sessionHelpFilter: getRequiredElement('sessionHelpFilter'), sessionHelpContent: getRequiredElement('sessionHelpContent'), + + playlistBrowserModal: getRequiredElement('playlistBrowserModal'), + playlistBrowserTitle: getRequiredElement('playlistBrowserTitle'), + playlistBrowserStatus: getRequiredElement('playlistBrowserStatus'), + playlistBrowserDirectoryList: getRequiredElement('playlistBrowserDirectoryList'), + playlistBrowserPlaylistList: getRequiredElement('playlistBrowserPlaylistList'), + playlistBrowserClose: getRequiredElement('playlistBrowserClose'), }; } diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 468b7d5..30615b4 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -6,6 +6,7 @@ export const OVERLAY_HOSTED_MODALS = [ 'subsync', 'jimaku', 'youtube-track-picker', + 'playlist-browser', 'kiku', 'controller-select', 'controller-debug', @@ -67,6 +68,11 @@ export const IPC_CHANNELS = { getAnilistQueueStatus: 'anilist:get-queue-status', retryAnilistNow: 'anilist:retry-now', appendClipboardVideoToQueue: 'clipboard:append-video-to-queue', + getPlaylistBrowserSnapshot: 'playlist-browser:get-snapshot', + appendPlaylistBrowserFile: 'playlist-browser:append-file', + playPlaylistBrowserIndex: 'playlist-browser:play-index', + removePlaylistBrowserIndex: 'playlist-browser:remove-index', + movePlaylistBrowserIndex: 'playlist-browser:move-index', jimakuGetMediaInfo: 'jimaku:get-media-info', jimakuSearchEntries: 'jimaku:search-entries', jimakuListFiles: 'jimaku:list-files', @@ -100,6 +106,7 @@ export const IPC_CHANNELS = { jimakuOpen: 'jimaku:open', youtubePickerOpen: 'youtube:picker-open', youtubePickerCancel: 'youtube:picker-cancel', + playlistBrowserOpen: 'playlist-browser:open', keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested', configHotReload: 'config:hot-reload', diff --git a/src/types/runtime.ts b/src/types/runtime.ts index bf55555..b9949f7 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -76,6 +76,40 @@ export interface SubsyncResult { message: string; } +export interface PlaylistBrowserDirectoryItem { + path: string; + basename: string; + episodeLabel?: string | null; + isCurrentFile: boolean; +} + +export interface PlaylistBrowserQueueItem { + index: number; + id: number | null; + filename: string; + title: string | null; + displayLabel: string; + current: boolean; + playing: boolean; + path: string | null; +} + +export interface PlaylistBrowserSnapshot { + directoryPath: string | null; + directoryAvailable: boolean; + directoryStatus: string; + directoryItems: PlaylistBrowserDirectoryItem[]; + playlistItems: PlaylistBrowserQueueItem[]; + playingIndex: number | null; + currentFilePath: string | null; +} + +export interface PlaylistBrowserMutationResult { + ok: boolean; + message: string; + snapshot?: PlaylistBrowserSnapshot; +} + export type ControllerButtonBinding = | 'none' | 'select' @@ -354,10 +388,19 @@ export interface ElectronAPI { onOpenRuntimeOptions: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void; onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; + onOpenPlaylistBrowser: (callback: () => void) => void; onCancelYoutubeTrackPicker: (callback: () => void) => void; onKeyboardModeToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void; appendClipboardVideoToQueue: () => Promise; + getPlaylistBrowserSnapshot: () => Promise; + appendPlaylistBrowserFile: (path: string) => Promise; + playPlaylistBrowserIndex: (index: number) => Promise; + removePlaylistBrowserIndex: (index: number) => Promise; + movePlaylistBrowserIndex: ( + index: number, + direction: 1 | -1, + ) => Promise; youtubePickerResolve: ( request: YoutubePickerResolveRequest, ) => Promise; @@ -367,6 +410,7 @@ export interface ElectronAPI { | 'subsync' | 'jimaku' | 'youtube-track-picker' + | 'playlist-browser' | 'kiku' | 'controller-select' | 'controller-debug' @@ -378,6 +422,7 @@ export interface ElectronAPI { | 'subsync' | 'jimaku' | 'youtube-track-picker' + | 'playlist-browser' | 'kiku' | 'controller-select' | 'controller-debug'