mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-30 06:12:06 -07:00
Compare commits
3 Commits
main
...
episode-br
| Author | SHA1 | Date | |
|---|---|---|---|
|
6ae3888b53
|
|||
|
6e041bc68e
|
|||
|
8db89c2239
|
@@ -63,6 +63,12 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
|
|||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
### Integrations
|
### Integrations
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
---
|
---
|
||||||
id: TASK-255
|
id: TASK-255
|
||||||
title: Add overlay playlist browser modal for sibling video files and mpv queue
|
title: Add overlay playlist browser modal for sibling video files and mpv queue
|
||||||
status: To Do
|
status: In Progress
|
||||||
assignee: []
|
assignee:
|
||||||
|
- codex
|
||||||
created_date: '2026-03-30 05:46'
|
created_date: '2026-03-30 05:46'
|
||||||
|
updated_date: '2026-03-30 08:34'
|
||||||
labels:
|
labels:
|
||||||
- feature
|
- feature
|
||||||
- overlay
|
- 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.
|
- [ ] #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.
|
- [ ] #6 Feature coverage includes automated tests for ordering/playlist behavior and docs or shortcut/help updates for the new modal.
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
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.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
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`.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
5
changes/260-playlist-browser.md
Normal file
5
changes/260-playlist-browser.md
Normal file
@@ -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.
|
||||||
@@ -471,6 +471,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
|||||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary 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 |
|
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
|
||||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 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 }
|
{ "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:<id>[: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:<id>[:next|prev]` cycles a runtime option value.
|
||||||
|
|
||||||
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
| `Space` | Toggle mpv pause |
|
| `Space` | Toggle mpv pause |
|
||||||
| `J` | Cycle primary subtitle track |
|
| `J` | Cycle primary subtitle track |
|
||||||
| `Shift+J` | Cycle secondary subtitle track |
|
| `Shift+J` | Cycle secondary subtitle track |
|
||||||
|
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||||
| `ArrowRight` | Seek forward 5 seconds |
|
| `ArrowRight` | Seek forward 5 seconds |
|
||||||
| `ArrowLeft` | Seek backward 5 seconds |
|
| `ArrowLeft` | Seek backward 5 seconds |
|
||||||
| `ArrowUp` | Seek forward 60 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) |
|
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
|
| `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).
|
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
`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`.
|
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
|
### Drag-and-Drop
|
||||||
|
|||||||
@@ -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('KeyJ'), ['cycle', 'sid']);
|
||||||
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-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+KeyC'), ['__youtube-picker-open']);
|
||||||
|
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyP'), ['__playlist-browser-open']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('default keybindings include fullscreen on F', () => {
|
test('default keybindings include fullscreen on F', () => {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const SPECIAL_COMMANDS = {
|
|||||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
|
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
|
||||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
||||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||||
|
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||||
@@ -66,6 +67,7 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
|||||||
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
|
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
|
||||||
},
|
},
|
||||||
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
|
{ 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+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
|
||||||
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
|
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
|
||||||
{ key: 'KeyQ', command: ['quit'] },
|
{ key: 'KeyQ', command: ['quit'] },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
|||||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
|
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
|
||||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
||||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||||
|
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
||||||
},
|
},
|
||||||
triggerSubsyncFromConfig: () => {
|
triggerSubsyncFromConfig: () => {
|
||||||
calls.push('subsync');
|
calls.push('subsync');
|
||||||
@@ -26,6 +27,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
|||||||
openYoutubeTrackPicker: () => {
|
openYoutubeTrackPicker: () => {
|
||||||
calls.push('youtube-picker');
|
calls.push('youtube-picker');
|
||||||
},
|
},
|
||||||
|
openPlaylistBrowser: () => {
|
||||||
|
calls.push('playlist-browser');
|
||||||
|
},
|
||||||
runtimeOptionsCycle: () => ({ ok: true }),
|
runtimeOptionsCycle: () => ({ ok: true }),
|
||||||
showMpvOsd: (text) => {
|
showMpvOsd: (text) => {
|
||||||
osd.push(text);
|
osd.push(text);
|
||||||
@@ -110,6 +114,14 @@ test('handleMpvCommandFromIpc dispatches special youtube picker open command', (
|
|||||||
assert.deepEqual(osd, []);
|
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', () => {
|
test('handleMpvCommandFromIpc does not forward commands while disconnected', () => {
|
||||||
const { options, sentCommands, osd } = createOptions({
|
const { options, sentCommands, osd } = createOptions({
|
||||||
isMpvConnected: () => false,
|
isMpvConnected: () => false,
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ export interface HandleMpvCommandFromIpcOptions {
|
|||||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
|
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
|
||||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
|
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
|
||||||
YOUTUBE_PICKER_OPEN: string;
|
YOUTUBE_PICKER_OPEN: string;
|
||||||
|
PLAYLIST_BROWSER_OPEN: string;
|
||||||
};
|
};
|
||||||
triggerSubsyncFromConfig: () => void;
|
triggerSubsyncFromConfig: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||||
|
openPlaylistBrowser: () => void | Promise<void>;
|
||||||
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
mpvReplaySubtitle: () => void;
|
mpvReplaySubtitle: () => void;
|
||||||
@@ -97,6 +99,11 @@ export function handleMpvCommandFromIpc(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (first === options.specialCommands.PLAYLIST_BROWSER_OPEN) {
|
||||||
|
void options.openPlaylistBrowser();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
|
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
|
||||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START
|
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
import type { SubtitleSidebarSnapshot } from '../../types';
|
import type { PlaylistBrowserSnapshot, SubtitleSidebarSnapshot } from '../../types';
|
||||||
|
|
||||||
interface FakeIpcRegistrar {
|
interface FakeIpcRegistrar {
|
||||||
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
||||||
@@ -148,6 +148,19 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
|
|||||||
getAnilistQueueStatus: () => ({}),
|
getAnilistQueueStatus: () => ({}),
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
appendClipboardVideoToQueue: () => ({ 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' }),
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
immersionTracker: null,
|
immersionTracker: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -241,6 +254,19 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
return { ok: true, message: 'done' };
|
return { ok: true, message: 'done' };
|
||||||
},
|
},
|
||||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
|
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' }),
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,10 +282,83 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
message: 'done',
|
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.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
|
||||||
assert.equal(deps.getPlaybackPaused(), true);
|
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 () => {
|
test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const calls: Array<{ id: string; value: unknown }> = [];
|
const calls: Array<{ id: string; value: unknown }> = [];
|
||||||
@@ -311,6 +410,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
getAnilistQueueStatus: () => ({}),
|
getAnilistQueueStatus: () => ({}),
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
appendClipboardVideoToQueue: () => ({ 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' }),
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
},
|
},
|
||||||
registrar,
|
registrar,
|
||||||
@@ -618,6 +730,19 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
getAnilistQueueStatus: () => ({}),
|
getAnilistQueueStatus: () => ({}),
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
appendClipboardVideoToQueue: () => ({ 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' }),
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
},
|
},
|
||||||
registrar,
|
registrar,
|
||||||
@@ -685,6 +810,19 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
|||||||
getAnilistQueueStatus: () => ({}),
|
getAnilistQueueStatus: () => ({}),
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
appendClipboardVideoToQueue: () => ({ 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' }),
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
},
|
},
|
||||||
registrar,
|
registrar,
|
||||||
@@ -755,6 +893,19 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
|||||||
getAnilistQueueStatus: () => ({}),
|
getAnilistQueueStatus: () => ({}),
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
appendClipboardVideoToQueue: () => ({ 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' }),
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
},
|
},
|
||||||
registrar,
|
registrar,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import electron from 'electron';
|
|||||||
import type { IpcMainEvent } from 'electron';
|
import type { IpcMainEvent } from 'electron';
|
||||||
import type {
|
import type {
|
||||||
ControllerConfigUpdate,
|
ControllerConfigUpdate,
|
||||||
|
PlaylistBrowserMutationResult,
|
||||||
|
PlaylistBrowserSnapshot,
|
||||||
ControllerPreferenceUpdate,
|
ControllerPreferenceUpdate,
|
||||||
ResolvedControllerConfig,
|
ResolvedControllerConfig,
|
||||||
RuntimeOptionId,
|
RuntimeOptionId,
|
||||||
@@ -78,6 +80,14 @@ export interface IpcServiceDeps {
|
|||||||
getAnilistQueueStatus: () => unknown;
|
getAnilistQueueStatus: () => unknown;
|
||||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||||
|
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||||
|
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||||
|
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||||
|
removePlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||||
|
movePlaylistBrowserIndex: (
|
||||||
|
index: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
) => Promise<PlaylistBrowserMutationResult>;
|
||||||
immersionTracker?: {
|
immersionTracker?: {
|
||||||
recordYomitanLookup: () => void;
|
recordYomitanLookup: () => void;
|
||||||
getSessionSummaries: (limit?: number) => Promise<unknown>;
|
getSessionSummaries: (limit?: number) => Promise<unknown>;
|
||||||
@@ -183,6 +193,14 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
getAnilistQueueStatus: () => unknown;
|
getAnilistQueueStatus: () => unknown;
|
||||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||||
|
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||||
|
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||||
|
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||||
|
removePlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||||
|
movePlaylistBrowserIndex: (
|
||||||
|
index: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
) => Promise<PlaylistBrowserMutationResult>;
|
||||||
getImmersionTracker?: () => IpcServiceDeps['immersionTracker'];
|
getImmersionTracker?: () => IpcServiceDeps['immersionTracker'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +264,11 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
getAnilistQueueStatus: options.getAnilistQueueStatus,
|
getAnilistQueueStatus: options.getAnilistQueueStatus,
|
||||||
retryAnilistQueueNow: options.retryAnilistQueueNow,
|
retryAnilistQueueNow: options.retryAnilistQueueNow,
|
||||||
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
||||||
|
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
|
||||||
|
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
|
||||||
|
playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
|
||||||
|
removePlaylistBrowserIndex: options.removePlaylistBrowserIndex,
|
||||||
|
movePlaylistBrowserIndex: options.movePlaylistBrowserIndex,
|
||||||
get immersionTracker() {
|
get immersionTracker() {
|
||||||
return options.getImmersionTracker?.() ?? null;
|
return options.getImmersionTracker?.() ?? null;
|
||||||
},
|
},
|
||||||
@@ -510,6 +533,44 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
return deps.appendClipboardVideoToQueue();
|
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
|
// Stats request handlers
|
||||||
ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => {
|
ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => {
|
||||||
const tracker = deps.immersionTracker;
|
const tracker = deps.immersionTracker;
|
||||||
|
|||||||
35
src/main.ts
35
src/main.ts
@@ -427,6 +427,13 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
|||||||
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
||||||
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
||||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
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 { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
||||||
import {
|
import {
|
||||||
createFrequencyDictionaryRuntimeService,
|
createFrequencyDictionaryRuntimeService,
|
||||||
@@ -1929,6 +1936,19 @@ function openRuntimeOptionsPalette(): void {
|
|||||||
overlayVisibilityComposer.openRuntimeOptionsPalette();
|
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() {
|
function getResolvedConfig() {
|
||||||
return configService.getConfig();
|
return configService.getConfig();
|
||||||
}
|
}
|
||||||
@@ -4109,11 +4129,16 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
|
|||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showMpvOsd(text),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const playlistBrowserRuntimeDeps = {
|
||||||
|
getMpvClient: () => appState.mpvClient,
|
||||||
|
};
|
||||||
|
|
||||||
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||||
mpvCommandMainDeps: {
|
mpvCommandMainDeps: {
|
||||||
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
||||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||||
|
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||||
cycleRuntimeOption: (id, direction) => {
|
cycleRuntimeOption: (id, direction) => {
|
||||||
if (!appState.runtimeOptionsManager) {
|
if (!appState.runtimeOptionsManager) {
|
||||||
return { ok: false, error: 'Runtime options manager unavailable' };
|
return { ok: false, error: 'Runtime options manager unavailable' };
|
||||||
@@ -4290,6 +4315,16 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
||||||
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
|
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
|
||||||
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
|
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,
|
getImmersionTracker: () => appState.immersionTracker,
|
||||||
},
|
},
|
||||||
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({
|
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
|
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
|
||||||
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
|
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
|
||||||
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
||||||
|
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
|
||||||
|
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
|
||||||
|
playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex'];
|
||||||
|
removePlaylistBrowserIndex: IpcDepsRuntimeOptions['removePlaylistBrowserIndex'];
|
||||||
|
movePlaylistBrowserIndex: IpcDepsRuntimeOptions['movePlaylistBrowserIndex'];
|
||||||
getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker'];
|
getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +198,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
|||||||
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
|
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
|
||||||
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
|
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
|
||||||
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
||||||
|
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
|
||||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||||
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
||||||
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
||||||
@@ -247,6 +253,11 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
getAnilistQueueStatus: params.getAnilistQueueStatus,
|
getAnilistQueueStatus: params.getAnilistQueueStatus,
|
||||||
retryAnilistQueueNow: params.retryAnilistQueueNow,
|
retryAnilistQueueNow: params.retryAnilistQueueNow,
|
||||||
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
||||||
|
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
|
||||||
|
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
|
||||||
|
playPlaylistBrowserIndex: params.playPlaylistBrowserIndex,
|
||||||
|
removePlaylistBrowserIndex: params.removePlaylistBrowserIndex,
|
||||||
|
movePlaylistBrowserIndex: params.movePlaylistBrowserIndex,
|
||||||
getImmersionTracker: params.getImmersionTracker,
|
getImmersionTracker: params.getImmersionTracker,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -358,6 +369,7 @@ export function createMpvCommandRuntimeServiceDeps(
|
|||||||
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
|
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
|
||||||
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
|
||||||
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
|
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
|
||||||
|
openPlaylistBrowser: params.openPlaylistBrowser,
|
||||||
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
||||||
showMpvOsd: params.showMpvOsd,
|
showMpvOsd: params.showMpvOsd,
|
||||||
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
|
|||||||
triggerSubsyncFromConfig: () => void;
|
triggerSubsyncFromConfig: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||||
|
openPlaylistBrowser: () => void | Promise<void>;
|
||||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
replayCurrentSubtitle: () => void;
|
replayCurrentSubtitle: () => void;
|
||||||
@@ -35,6 +36,7 @@ export function handleMpvCommandFromIpcRuntime(
|
|||||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||||
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
|
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
|
||||||
|
openPlaylistBrowser: deps.openPlaylistBrowser,
|
||||||
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
||||||
showMpvOsd: deps.showMpvOsd,
|
showMpvOsd: deps.showMpvOsd,
|
||||||
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
|||||||
triggerSubsyncFromConfig: async () => {},
|
triggerSubsyncFromConfig: async () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
openYoutubeTrackPicker: () => {},
|
openYoutubeTrackPicker: () => {},
|
||||||
|
openPlaylistBrowser: () => {},
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
cycleRuntimeOption: () => ({ ok: true }),
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
replayCurrentSubtitle: () => {},
|
replayCurrentSubtitle: () => {},
|
||||||
@@ -68,6 +69,20 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
|||||||
getAnilistQueueStatus: () => ({}) as never,
|
getAnilistQueueStatus: () => ({}) as never,
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
appendClipboardVideoToQueue: () => ({ 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' }),
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
},
|
},
|
||||||
ankiJimakuDeps: {
|
ankiJimakuDeps: {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
|
|||||||
triggerSubsyncFromConfig: async () => {},
|
triggerSubsyncFromConfig: async () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
openYoutubeTrackPicker: () => {},
|
openYoutubeTrackPicker: () => {},
|
||||||
|
openPlaylistBrowser: () => {},
|
||||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
replayCurrentSubtitle: () => {},
|
replayCurrentSubtitle: () => {},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ test('handle mpv command handler forwards command and built deps', () => {
|
|||||||
triggerSubsyncFromConfig: () => {},
|
triggerSubsyncFromConfig: () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
openYoutubeTrackPicker: () => {},
|
openYoutubeTrackPicker: () => {},
|
||||||
|
openPlaylistBrowser: () => {},
|
||||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
replayCurrentSubtitle: () => {},
|
replayCurrentSubtitle: () => {},
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
|||||||
openYoutubeTrackPicker: () => {
|
openYoutubeTrackPicker: () => {
|
||||||
calls.push('youtube-picker');
|
calls.push('youtube-picker');
|
||||||
},
|
},
|
||||||
|
openPlaylistBrowser: () => {
|
||||||
|
calls.push('playlist-browser');
|
||||||
|
},
|
||||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||||
replayCurrentSubtitle: () => calls.push('replay'),
|
replayCurrentSubtitle: () => calls.push('replay'),
|
||||||
@@ -26,6 +29,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
|||||||
deps.triggerSubsyncFromConfig();
|
deps.triggerSubsyncFromConfig();
|
||||||
deps.openRuntimeOptionsPalette();
|
deps.openRuntimeOptionsPalette();
|
||||||
void deps.openYoutubeTrackPicker();
|
void deps.openYoutubeTrackPicker();
|
||||||
|
void deps.openPlaylistBrowser();
|
||||||
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
|
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
|
||||||
deps.showMpvOsd('hello');
|
deps.showMpvOsd('hello');
|
||||||
deps.replayCurrentSubtitle();
|
deps.replayCurrentSubtitle();
|
||||||
@@ -39,6 +43,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
|||||||
'subsync',
|
'subsync',
|
||||||
'palette',
|
'palette',
|
||||||
'youtube-picker',
|
'youtube-picker',
|
||||||
|
'playlist-browser',
|
||||||
'osd:hello',
|
'osd:hello',
|
||||||
'replay',
|
'replay',
|
||||||
'next',
|
'next',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
|||||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||||
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
|
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
|
||||||
|
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
|
||||||
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
||||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||||
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
||||||
|
|||||||
326
src/main/runtime/playlist-browser-runtime.test.ts
Normal file
326
src/main/runtime/playlist-browser-runtime.test.ts
Normal file
@@ -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<unknown> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
314
src/main/runtime/playlist-browser-runtime.ts
Normal file
314
src/main/runtime/playlist-browser-runtime.ts
Normal file
@@ -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<unknown>;
|
||||||
|
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<unknown> {
|
||||||
|
if (!client?.requestProperty) return null;
|
||||||
|
try {
|
||||||
|
return await client.requestProperty(name);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCurrentFilePath(
|
||||||
|
client: MpvPlaylistBrowserClientLike | null,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const currentVideoPath = trimToNull(client?.currentVideoPath);
|
||||||
|
if (currentVideoPath) return currentVideoPath;
|
||||||
|
return trimToNull(await readProperty(client, 'path'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDirectorySnapshot(
|
||||||
|
currentFilePath: string | null,
|
||||||
|
): Pick<PlaylistBrowserSnapshot, 'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'> {
|
||||||
|
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<PlaylistBrowserQueueItem[]> {
|
||||||
|
return normalizePlaylistItems(await readProperty(client, 'playlist'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlaylistBrowserSnapshotRuntime(
|
||||||
|
deps: PlaylistBrowserRuntimeDeps,
|
||||||
|
): Promise<PlaylistBrowserSnapshot> {
|
||||||
|
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<PlaylistBrowserMutationResult> {
|
||||||
|
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<PlaylistBrowserMutationResult> {
|
||||||
|
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<PlaylistBrowserMutationResult> {
|
||||||
|
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<PlaylistBrowserMutationResult> {
|
||||||
|
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<PlaylistBrowserMutationResult> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
50
src/main/runtime/playlist-browser-sort.test.ts
Normal file
50
src/main/runtime/playlist-browser-sort.test.ts
Normal file
@@ -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],
|
||||||
|
);
|
||||||
|
});
|
||||||
129
src/main/runtime/playlist-browser-sort.ts
Normal file
129
src/main/runtime/playlist-browser-sort.ts
Normal file
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -38,6 +38,8 @@ import type {
|
|||||||
SubsyncManualRunRequest,
|
SubsyncManualRunRequest,
|
||||||
SubsyncResult,
|
SubsyncResult,
|
||||||
ClipboardAppendResult,
|
ClipboardAppendResult,
|
||||||
|
PlaylistBrowserMutationResult,
|
||||||
|
PlaylistBrowserSnapshot,
|
||||||
KikuFieldGroupingRequestData,
|
KikuFieldGroupingRequestData,
|
||||||
KikuFieldGroupingChoice,
|
KikuFieldGroupingChoice,
|
||||||
KikuMergePreviewRequest,
|
KikuMergePreviewRequest,
|
||||||
@@ -126,6 +128,7 @@ const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<Youtube
|
|||||||
IPC_CHANNELS.event.youtubePickerOpen,
|
IPC_CHANNELS.event.youtubePickerOpen,
|
||||||
(payload) => payload as YoutubePickerOpenPayload,
|
(payload) => payload as YoutubePickerOpenPayload,
|
||||||
);
|
);
|
||||||
|
const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
|
||||||
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
||||||
IPC_CHANNELS.event.youtubePickerCancel,
|
IPC_CHANNELS.event.youtubePickerCancel,
|
||||||
);
|
);
|
||||||
@@ -322,11 +325,25 @@ const electronAPI: ElectronAPI = {
|
|||||||
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
||||||
onOpenJimaku: onOpenJimakuEvent,
|
onOpenJimaku: onOpenJimakuEvent,
|
||||||
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
||||||
|
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
||||||
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
|
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
|
||||||
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
||||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
||||||
|
getPlaylistBrowserSnapshot: (): Promise<PlaylistBrowserSnapshot> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaylistBrowserSnapshot),
|
||||||
|
appendPlaylistBrowserFile: (pathValue: string): Promise<PlaylistBrowserMutationResult> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.appendPlaylistBrowserFile, pathValue),
|
||||||
|
playPlaylistBrowserIndex: (index: number): Promise<PlaylistBrowserMutationResult> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.playPlaylistBrowserIndex, index),
|
||||||
|
removePlaylistBrowserIndex: (index: number): Promise<PlaylistBrowserMutationResult> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.removePlaylistBrowserIndex, index),
|
||||||
|
movePlaylistBrowserIndex: (
|
||||||
|
index: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
): Promise<PlaylistBrowserMutationResult> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.movePlaylistBrowserIndex, index, direction),
|
||||||
youtubePickerResolve: (
|
youtubePickerResolve: (
|
||||||
request: YoutubePickerResolveRequest,
|
request: YoutubePickerResolveRequest,
|
||||||
): Promise<YoutubePickerResolveResult> =>
|
): Promise<YoutubePickerResolveResult> =>
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ function createKeyboardHandlerHarness() {
|
|||||||
let controllerSelectOpenCount = 0;
|
let controllerSelectOpenCount = 0;
|
||||||
let controllerDebugOpenCount = 0;
|
let controllerDebugOpenCount = 0;
|
||||||
let controllerSelectKeydownCount = 0;
|
let controllerSelectKeydownCount = 0;
|
||||||
|
let playlistBrowserKeydownCount = 0;
|
||||||
|
|
||||||
const createWordNode = (left: number) => ({
|
const createWordNode = (left: number) => ({
|
||||||
classList: createClassList(),
|
classList: createClassList(),
|
||||||
@@ -333,6 +334,10 @@ function createKeyboardHandlerHarness() {
|
|||||||
},
|
},
|
||||||
handleControllerDebugKeydown: () => false,
|
handleControllerDebugKeydown: () => false,
|
||||||
handleYoutubePickerKeydown: () => false,
|
handleYoutubePickerKeydown: () => false,
|
||||||
|
handlePlaylistBrowserKeydown: () => {
|
||||||
|
playlistBrowserKeydownCount += 1;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
handleSessionHelpKeydown: () => false,
|
handleSessionHelpKeydown: () => false,
|
||||||
openSessionHelpModal: () => {},
|
openSessionHelpModal: () => {},
|
||||||
appendClipboardVideoToQueue: () => {},
|
appendClipboardVideoToQueue: () => {},
|
||||||
@@ -352,6 +357,7 @@ function createKeyboardHandlerHarness() {
|
|||||||
controllerSelectOpenCount: () => controllerSelectOpenCount,
|
controllerSelectOpenCount: () => controllerSelectOpenCount,
|
||||||
controllerDebugOpenCount: () => controllerDebugOpenCount,
|
controllerDebugOpenCount: () => controllerDebugOpenCount,
|
||||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||||
|
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
||||||
setWordCount: (count: number) => {
|
setWordCount: (count: number) => {
|
||||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
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 () => {
|
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
|
||||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function createKeyboardHandlers(
|
|||||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
||||||
|
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||||
@@ -841,6 +842,11 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (ctx.state.playlistBrowserModalOpen) {
|
||||||
|
if (options.handlePlaylistBrowserKeydown(e)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (ctx.state.controllerSelectModalOpen) {
|
if (ctx.state.controllerSelectModalOpen) {
|
||||||
options.handleControllerSelectKeydown(e);
|
options.handleControllerSelectKeydown(e);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -320,6 +320,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
|
||||||
|
<div class="modal-content playlist-browser-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">Playlist Browser</div>
|
||||||
|
<button id="playlistBrowserClose" class="modal-close" type="button">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body playlist-browser-body">
|
||||||
|
<div id="playlistBrowserTitle" class="playlist-browser-title"></div>
|
||||||
|
<div id="playlistBrowserStatus" class="playlist-browser-status"></div>
|
||||||
|
<div class="playlist-browser-grid">
|
||||||
|
<div class="playlist-browser-pane">
|
||||||
|
<div class="playlist-browser-pane-title">Directory</div>
|
||||||
|
<ul id="playlistBrowserDirectoryList" class="playlist-browser-list"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-browser-pane">
|
||||||
|
<div class="playlist-browser-pane-title">Playlist</div>
|
||||||
|
<ul id="playlistBrowserPlaylistList" class="playlist-browser-list"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-browser-footer">
|
||||||
|
<span>Tab switch pane</span>
|
||||||
|
<span>Enter activate</span>
|
||||||
|
<span>Delete remove</span>
|
||||||
|
<span>Ctrl/Cmd+Arrows reorder</span>
|
||||||
|
<span>Esc close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="renderer.js"></script>
|
<script type="module" src="renderer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
430
src/renderer/modals/playlist-browser.test.ts
Normal file
430
src/renderer/modals/playlist-browser.test.ts
Normal file
@@ -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<string, string>();
|
||||||
|
return {
|
||||||
|
textContent: '',
|
||||||
|
innerHTML: '',
|
||||||
|
children: [] as unknown[],
|
||||||
|
listeners: new Map<string, Array<(event?: unknown) => 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<string, string>,
|
||||||
|
textContent: '',
|
||||||
|
children: [] as unknown[],
|
||||||
|
listeners: new Map<string, Array<(event?: unknown) => 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<typeof createPlaylistRow>[],
|
||||||
|
appendChild(child: ReturnType<typeof createPlaylistRow>) {
|
||||||
|
this.children.push(child);
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
replaceChildren(...children: ReturnType<typeof createPlaylistRow>[]) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
419
src/renderer/modals/playlist-browser.ts
Normal file
419
src/renderer/modals/playlist-browser.ts
Normal file
@@ -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<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
|
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<void> {
|
||||||
|
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<PlaylistBrowserMutationResult>,
|
||||||
|
fallbackMessage: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await handleMutation(window.electronAPI.appendPlaylistBrowserFile(filePath), 'Queued file');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playPlaylistItem(index: number): Promise<void> {
|
||||||
|
const result = await window.electronAPI.playPlaylistBrowserIndex(index);
|
||||||
|
if (!result.ok) {
|
||||||
|
setStatus(result.message, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closePlaylistBrowserModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePlaylistItem(index: number): Promise<void> {
|
||||||
|
await handleMutation(window.electronAPI.removePlaylistBrowserIndex(index), 'Removed queue item');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function movePlaylistItem(index: number, direction: 1 | -1): Promise<void> {
|
||||||
|
await handleMutation(
|
||||||
|
window.electronAPI.movePlaylistBrowserIndex(index, direction),
|
||||||
|
'Moved queue item',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPlaylistBrowserModal(): Promise<void> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
|
||||||
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
|
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.REPLAY_SUBTITLE) return 'Replay current subtitle';
|
||||||
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
|
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
|
||||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||||
@@ -164,6 +165,7 @@ function sectionForCommand(command: (string | number)[]): string {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
|
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
|
||||||
|
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
|
||||||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
|
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
|
||||||
) {
|
) {
|
||||||
return 'Runtime settings';
|
return 'Runtime settings';
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { createControllerDebugModal } from './modals/controller-debug.js';
|
|||||||
import { createControllerSelectModal } from './modals/controller-select.js';
|
import { createControllerSelectModal } from './modals/controller-select.js';
|
||||||
import { createJimakuModal } from './modals/jimaku.js';
|
import { createJimakuModal } from './modals/jimaku.js';
|
||||||
import { createKikuModal } from './modals/kiku.js';
|
import { createKikuModal } from './modals/kiku.js';
|
||||||
|
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
||||||
import { createSessionHelpModal } from './modals/session-help.js';
|
import { createSessionHelpModal } from './modals/session-help.js';
|
||||||
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||||
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||||
@@ -71,7 +72,8 @@ function isAnySettingsModalOpen(): boolean {
|
|||||||
ctx.state.kikuModalOpen ||
|
ctx.state.kikuModalOpen ||
|
||||||
ctx.state.jimakuModalOpen ||
|
ctx.state.jimakuModalOpen ||
|
||||||
ctx.state.youtubePickerModalOpen ||
|
ctx.state.youtubePickerModalOpen ||
|
||||||
ctx.state.sessionHelpModalOpen
|
ctx.state.sessionHelpModalOpen ||
|
||||||
|
ctx.state.playlistBrowserModalOpen
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ function isAnyModalOpen(): boolean {
|
|||||||
ctx.state.subsyncModalOpen ||
|
ctx.state.subsyncModalOpen ||
|
||||||
ctx.state.youtubePickerModalOpen ||
|
ctx.state.youtubePickerModalOpen ||
|
||||||
ctx.state.sessionHelpModalOpen ||
|
ctx.state.sessionHelpModalOpen ||
|
||||||
|
ctx.state.playlistBrowserModalOpen ||
|
||||||
ctx.state.subtitleSidebarModalOpen
|
ctx.state.subtitleSidebarModalOpen
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -153,12 +156,17 @@ const youtubePickerModal = createYoutubeTrackPickerModal(ctx, {
|
|||||||
restorePointerInteractionState: mouseHandlers.restorePointerInteractionState,
|
restorePointerInteractionState: mouseHandlers.restorePointerInteractionState,
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
});
|
});
|
||||||
|
const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
|
||||||
|
modalStateReader: { isAnyModalOpen },
|
||||||
|
syncSettingsModalSubtitleSuppression,
|
||||||
|
});
|
||||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||||
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
||||||
|
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
|
||||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||||
@@ -209,6 +217,7 @@ function getActiveModal(): string | null {
|
|||||||
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
|
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
|
||||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
||||||
if (ctx.state.youtubePickerModalOpen) return 'youtube-track-picker';
|
if (ctx.state.youtubePickerModalOpen) return 'youtube-track-picker';
|
||||||
|
if (ctx.state.playlistBrowserModalOpen) return 'playlist-browser';
|
||||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||||
if (ctx.state.subsyncModalOpen) return 'subsync';
|
if (ctx.state.subsyncModalOpen) return 'subsync';
|
||||||
@@ -232,6 +241,9 @@ function dismissActiveUiAfterError(): void {
|
|||||||
if (ctx.state.youtubePickerModalOpen) {
|
if (ctx.state.youtubePickerModalOpen) {
|
||||||
youtubePickerModal.closeYoutubePickerModal();
|
youtubePickerModal.closeYoutubePickerModal();
|
||||||
}
|
}
|
||||||
|
if (ctx.state.playlistBrowserModalOpen) {
|
||||||
|
playlistBrowserModal.closePlaylistBrowserModal();
|
||||||
|
}
|
||||||
if (ctx.state.runtimeOptionsModalOpen) {
|
if (ctx.state.runtimeOptionsModalOpen) {
|
||||||
runtimeOptionsModal.closeRuntimeOptionsModal();
|
runtimeOptionsModal.closeRuntimeOptionsModal();
|
||||||
}
|
}
|
||||||
@@ -439,6 +451,11 @@ function registerModalOpenHandlers(): void {
|
|||||||
youtubePickerModal.openYoutubePickerModal(payload);
|
youtubePickerModal.openYoutubePickerModal(payload);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
window.electronAPI.onOpenPlaylistBrowser(() => {
|
||||||
|
runGuardedAsync('playlist-browser:open', async () => {
|
||||||
|
await playlistBrowserModal.openPlaylistBrowserModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
window.electronAPI.onCancelYoutubeTrackPicker(() => {
|
window.electronAPI.onCancelYoutubeTrackPicker(() => {
|
||||||
runGuarded('youtube:picker-cancel', () => {
|
runGuarded('youtube:picker-cancel', () => {
|
||||||
youtubePickerModal.closeYoutubePickerModal();
|
youtubePickerModal.closeYoutubePickerModal();
|
||||||
@@ -518,6 +535,11 @@ async function init(): Promise<void> {
|
|||||||
runGuarded('subtitle-position:update', () => {
|
runGuarded('subtitle-position:update', () => {
|
||||||
positioning.applyStoredSubtitlePosition(position, 'media-change');
|
positioning.applyStoredSubtitlePosition(position, 'media-change');
|
||||||
measurementReporter.schedule();
|
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<void> {
|
|||||||
|
|
||||||
jimakuModal.wireDomEvents();
|
jimakuModal.wireDomEvents();
|
||||||
youtubePickerModal.wireDomEvents();
|
youtubePickerModal.wireDomEvents();
|
||||||
|
playlistBrowserModal.wireDomEvents();
|
||||||
kikuModal.wireDomEvents();
|
kikuModal.wireDomEvents();
|
||||||
runtimeOptionsModal.wireDomEvents();
|
runtimeOptionsModal.wireDomEvents();
|
||||||
subsyncModal.wireDomEvents();
|
subsyncModal.wireDomEvents();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
PlaylistBrowserSnapshot,
|
||||||
ControllerButtonSnapshot,
|
ControllerButtonSnapshot,
|
||||||
ControllerDeviceInfo,
|
ControllerDeviceInfo,
|
||||||
ResolvedControllerConfig,
|
ResolvedControllerConfig,
|
||||||
@@ -78,6 +79,12 @@ export type RendererState = {
|
|||||||
|
|
||||||
sessionHelpModalOpen: boolean;
|
sessionHelpModalOpen: boolean;
|
||||||
sessionHelpSelectedIndex: number;
|
sessionHelpSelectedIndex: number;
|
||||||
|
playlistBrowserModalOpen: boolean;
|
||||||
|
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
|
||||||
|
playlistBrowserStatus: string;
|
||||||
|
playlistBrowserActivePane: 'directory' | 'playlist';
|
||||||
|
playlistBrowserSelectedDirectoryIndex: number;
|
||||||
|
playlistBrowserSelectedPlaylistIndex: number;
|
||||||
subtitleSidebarCues: SubtitleCue[];
|
subtitleSidebarCues: SubtitleCue[];
|
||||||
subtitleSidebarActiveCueIndex: number;
|
subtitleSidebarActiveCueIndex: number;
|
||||||
subtitleSidebarToggleKey: string;
|
subtitleSidebarToggleKey: string;
|
||||||
@@ -175,6 +182,12 @@ export function createRendererState(): RendererState {
|
|||||||
|
|
||||||
sessionHelpModalOpen: false,
|
sessionHelpModalOpen: false,
|
||||||
sessionHelpSelectedIndex: 0,
|
sessionHelpSelectedIndex: 0,
|
||||||
|
playlistBrowserModalOpen: false,
|
||||||
|
playlistBrowserSnapshot: null,
|
||||||
|
playlistBrowserStatus: '',
|
||||||
|
playlistBrowserActivePane: 'playlist',
|
||||||
|
playlistBrowserSelectedDirectoryIndex: 0,
|
||||||
|
playlistBrowserSelectedPlaylistIndex: 0,
|
||||||
subtitleSidebarCues: [],
|
subtitleSidebarCues: [],
|
||||||
subtitleSidebarActiveCueIndex: -1,
|
subtitleSidebarActiveCueIndex: -1,
|
||||||
subtitleSidebarToggleKey: 'Backslash',
|
subtitleSidebarToggleKey: 'Backslash',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,13 @@ export type RendererDom = {
|
|||||||
sessionHelpStatus: HTMLDivElement;
|
sessionHelpStatus: HTMLDivElement;
|
||||||
sessionHelpFilter: HTMLInputElement;
|
sessionHelpFilter: HTMLInputElement;
|
||||||
sessionHelpContent: HTMLDivElement;
|
sessionHelpContent: HTMLDivElement;
|
||||||
|
|
||||||
|
playlistBrowserModal: HTMLDivElement;
|
||||||
|
playlistBrowserTitle: HTMLDivElement;
|
||||||
|
playlistBrowserStatus: HTMLDivElement;
|
||||||
|
playlistBrowserDirectoryList: HTMLUListElement;
|
||||||
|
playlistBrowserPlaylistList: HTMLUListElement;
|
||||||
|
playlistBrowserClose: HTMLButtonElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
||||||
@@ -211,5 +218,12 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
sessionHelpStatus: getRequiredElement<HTMLDivElement>('sessionHelpStatus'),
|
sessionHelpStatus: getRequiredElement<HTMLDivElement>('sessionHelpStatus'),
|
||||||
sessionHelpFilter: getRequiredElement<HTMLInputElement>('sessionHelpFilter'),
|
sessionHelpFilter: getRequiredElement<HTMLInputElement>('sessionHelpFilter'),
|
||||||
sessionHelpContent: getRequiredElement<HTMLDivElement>('sessionHelpContent'),
|
sessionHelpContent: getRequiredElement<HTMLDivElement>('sessionHelpContent'),
|
||||||
|
|
||||||
|
playlistBrowserModal: getRequiredElement<HTMLDivElement>('playlistBrowserModal'),
|
||||||
|
playlistBrowserTitle: getRequiredElement<HTMLDivElement>('playlistBrowserTitle'),
|
||||||
|
playlistBrowserStatus: getRequiredElement<HTMLDivElement>('playlistBrowserStatus'),
|
||||||
|
playlistBrowserDirectoryList: getRequiredElement<HTMLUListElement>('playlistBrowserDirectoryList'),
|
||||||
|
playlistBrowserPlaylistList: getRequiredElement<HTMLUListElement>('playlistBrowserPlaylistList'),
|
||||||
|
playlistBrowserClose: getRequiredElement<HTMLButtonElement>('playlistBrowserClose'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
|||||||
'subsync',
|
'subsync',
|
||||||
'jimaku',
|
'jimaku',
|
||||||
'youtube-track-picker',
|
'youtube-track-picker',
|
||||||
|
'playlist-browser',
|
||||||
'kiku',
|
'kiku',
|
||||||
'controller-select',
|
'controller-select',
|
||||||
'controller-debug',
|
'controller-debug',
|
||||||
@@ -67,6 +68,11 @@ export const IPC_CHANNELS = {
|
|||||||
getAnilistQueueStatus: 'anilist:get-queue-status',
|
getAnilistQueueStatus: 'anilist:get-queue-status',
|
||||||
retryAnilistNow: 'anilist:retry-now',
|
retryAnilistNow: 'anilist:retry-now',
|
||||||
appendClipboardVideoToQueue: 'clipboard:append-video-to-queue',
|
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',
|
jimakuGetMediaInfo: 'jimaku:get-media-info',
|
||||||
jimakuSearchEntries: 'jimaku:search-entries',
|
jimakuSearchEntries: 'jimaku:search-entries',
|
||||||
jimakuListFiles: 'jimaku:list-files',
|
jimakuListFiles: 'jimaku:list-files',
|
||||||
@@ -100,6 +106,7 @@ export const IPC_CHANNELS = {
|
|||||||
jimakuOpen: 'jimaku:open',
|
jimakuOpen: 'jimaku:open',
|
||||||
youtubePickerOpen: 'youtube:picker-open',
|
youtubePickerOpen: 'youtube:picker-open',
|
||||||
youtubePickerCancel: 'youtube:picker-cancel',
|
youtubePickerCancel: 'youtube:picker-cancel',
|
||||||
|
playlistBrowserOpen: 'playlist-browser:open',
|
||||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||||
configHotReload: 'config:hot-reload',
|
configHotReload: 'config:hot-reload',
|
||||||
|
|||||||
@@ -76,6 +76,40 @@ export interface SubsyncResult {
|
|||||||
message: string;
|
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 =
|
export type ControllerButtonBinding =
|
||||||
| 'none'
|
| 'none'
|
||||||
| 'select'
|
| 'select'
|
||||||
@@ -354,10 +388,19 @@ export interface ElectronAPI {
|
|||||||
onOpenRuntimeOptions: (callback: () => void) => void;
|
onOpenRuntimeOptions: (callback: () => void) => void;
|
||||||
onOpenJimaku: (callback: () => void) => void;
|
onOpenJimaku: (callback: () => void) => void;
|
||||||
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
||||||
|
onOpenPlaylistBrowser: (callback: () => void) => void;
|
||||||
onCancelYoutubeTrackPicker: (callback: () => void) => void;
|
onCancelYoutubeTrackPicker: (callback: () => void) => void;
|
||||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||||
|
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||||
|
appendPlaylistBrowserFile: (path: string) => Promise<PlaylistBrowserMutationResult>;
|
||||||
|
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||||
|
removePlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||||
|
movePlaylistBrowserIndex: (
|
||||||
|
index: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
) => Promise<PlaylistBrowserMutationResult>;
|
||||||
youtubePickerResolve: (
|
youtubePickerResolve: (
|
||||||
request: YoutubePickerResolveRequest,
|
request: YoutubePickerResolveRequest,
|
||||||
) => Promise<YoutubePickerResolveResult>;
|
) => Promise<YoutubePickerResolveResult>;
|
||||||
@@ -367,6 +410,7 @@ export interface ElectronAPI {
|
|||||||
| 'subsync'
|
| 'subsync'
|
||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'youtube-track-picker'
|
| 'youtube-track-picker'
|
||||||
|
| 'playlist-browser'
|
||||||
| 'kiku'
|
| 'kiku'
|
||||||
| 'controller-select'
|
| 'controller-select'
|
||||||
| 'controller-debug'
|
| 'controller-debug'
|
||||||
@@ -378,6 +422,7 @@ export interface ElectronAPI {
|
|||||||
| 'subsync'
|
| 'subsync'
|
||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'youtube-track-picker'
|
| 'youtube-track-picker'
|
||||||
|
| 'playlist-browser'
|
||||||
| 'kiku'
|
| 'kiku'
|
||||||
| 'controller-select'
|
| 'controller-select'
|
||||||
| 'controller-debug'
|
| 'controller-debug'
|
||||||
|
|||||||
Reference in New Issue
Block a user