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 @@
+