refactor: split playlist browser wiring

This commit is contained in:
2026-03-30 18:43:41 -07:00
parent 90772f994c
commit c8e42b3973
10 changed files with 455 additions and 170 deletions

View File

@@ -5,7 +5,7 @@ status: In Progress
assignee:
- codex
created_date: '2026-03-30 05:46'
updated_date: '2026-03-30 09:22'
updated_date: '2026-03-31 01:42'
labels:
- feature
- overlay
@@ -64,4 +64,10 @@ Repo gate blockers outside this feature: `bun run test:fast` hits existing Bun `
2026-03-30: Pulled unresolved CodeRabbit review threads for PR #37. Actionable set is three items: unreadable-file stat error handling in playlist-browser runtime, stale playlist-browser DOM after failed refresh/close, and missing modal-ownership guard before opening the playlist-browser overlay. Proceeding test-first for each.
2026-03-30: Addressed current CodeRabbit follow-up findings for PR #37. Fixed playlist-browser unreadable-file stat handling, stale playlist-browser DOM reset on refresh failure/close, modal-ownership guard before opening the playlist-browser overlay, async rejection surfacing for PLAYLIST_BROWSER_OPEN IPC commands, overlay bootstrap before playlist-browser open dispatch, texthooker option normalization in the mpv plugin, and superseded local subtitle-rearm suppression. Added targeted regressions plus new playlist-browser-open helper coverage. Verification: `bun test src/main/runtime/playlist-browser-runtime.test.ts src/main/runtime/playlist-browser-open.test.ts src/core/services/ipc-command.test.ts src/renderer/modals/playlist-browser.test.ts`, `lua scripts/test-plugin-start-gate.lua`, `bun run typecheck`, `bun run build`.
Addressed CodeRabbit follow-ups on the playlist browser PR: clamped stale playingIndex values, failed mutation paths when MPV rejects send(), added temp-dir cleanup in runtime tests, and blocked action-button dblclick bubbling in the renderer. Verification: `bun run typecheck`, `bun run build`, `bun test src/main/runtime/playlist-browser-runtime.test.ts src/renderer/modals/playlist-browser.test.ts`.
Additional follow-up: moved playlist-browser keydown handling ahead of keyboard-driven lookup controls so KeyH/ArrowLeft/ArrowRight and related chords are routed to the modal first. Verification refreshed with `bun test src/main/runtime/playlist-browser-runtime.test.ts src/renderer/modals/playlist-browser.test.ts src/renderer/handlers/keyboard.test.ts`, `bun run typecheck`, and `bun run build`.
Split playlist-browser UI row rendering into `src/renderer/modals/playlist-browser-renderer.ts` and left `src/renderer/modals/playlist-browser.ts` as the controller/wiring layer. Moved playlist-browser IPC/runtime wiring into `src/main/runtime/playlist-browser-ipc.ts` and collapsed the `src/main.ts` registration block to use that helper. Verification after refactor: `bun run typecheck`, `bun run build`, `bun test src/main/runtime/playlist-browser-runtime.test.ts src/renderer/modals/playlist-browser.test.ts src/renderer/handlers/keyboard.test.ts`.
<!-- SECTION:NOTES:END -->

View File

@@ -428,13 +428,7 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import {
appendPlaylistBrowserFileRuntime,
getPlaylistBrowserSnapshotRuntime,
movePlaylistBrowserIndexRuntime,
playPlaylistBrowserIndexRuntime,
removePlaylistBrowserIndexRuntime,
} from './main/runtime/playlist-browser-runtime';
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import {
createFrequencyDictionaryRuntimeService,
@@ -4134,9 +4128,7 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
showMpvOsd: (text) => showMpvOsd(text),
});
const playlistBrowserRuntimeDeps = {
getMpvClient: () => appState.mpvClient,
};
const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient);
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mpvCommandMainDeps: {
@@ -4320,16 +4312,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
getPlaylistBrowserSnapshot: () =>
getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
appendPlaylistBrowserFile: (filePath) =>
appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath),
playPlaylistBrowserIndex: (index) =>
playPlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index),
removePlaylistBrowserIndex: (index) =>
removePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index),
movePlaylistBrowserIndex: (index, direction) =>
movePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index, direction),
...playlistBrowserMainDeps,
getImmersionTracker: () => appState.immersionTracker,
},
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({

View File

@@ -0,0 +1,46 @@
import type { RegisterIpcRuntimeServicesParams } from '../ipc-runtime';
import {
appendPlaylistBrowserFileRuntime,
getPlaylistBrowserSnapshotRuntime,
movePlaylistBrowserIndexRuntime,
playPlaylistBrowserIndexRuntime,
removePlaylistBrowserIndexRuntime,
type PlaylistBrowserRuntimeDeps,
} from './playlist-browser-runtime';
type PlaylistBrowserMainDeps = Pick<
RegisterIpcRuntimeServicesParams['mainDeps'],
| 'getPlaylistBrowserSnapshot'
| 'appendPlaylistBrowserFile'
| 'playPlaylistBrowserIndex'
| 'removePlaylistBrowserIndex'
| 'movePlaylistBrowserIndex'
>;
export type PlaylistBrowserIpcRuntime = {
playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps;
playlistBrowserMainDeps: PlaylistBrowserMainDeps;
};
export function createPlaylistBrowserIpcRuntime(
getMpvClient: PlaylistBrowserRuntimeDeps['getMpvClient'],
): PlaylistBrowserIpcRuntime {
const playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps = {
getMpvClient,
};
return {
playlistBrowserRuntimeDeps,
playlistBrowserMainDeps: {
getPlaylistBrowserSnapshot: () => getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
appendPlaylistBrowserFile: (filePath: string) =>
appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath),
playPlaylistBrowserIndex: (index: number) =>
playPlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index),
removePlaylistBrowserIndex: (index: number) =>
removePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index),
movePlaylistBrowserIndex: (index: number, direction: 1 | -1) =>
movePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index, direction),
},
};
}

View File

@@ -2,7 +2,7 @@ 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 test, { type TestContext } from 'node:test';
import type { PlaylistBrowserQueueItem } from '../../types';
import {
@@ -21,8 +21,12 @@ type FakePlaylistEntry = {
id?: number;
};
function createTempVideoDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-playlist-browser-'));
function createTempVideoDir(t: TestContext): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-playlist-browser-'));
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
return dir;
}
function createFakeMpvClient(options: {
@@ -121,8 +125,8 @@ function createFakeMpvClient(options: {
};
}
test('getPlaylistBrowserSnapshotRuntime lists sibling videos in best-effort episode order', async () => {
const dir = createTempVideoDir();
test('getPlaylistBrowserSnapshotRuntime lists sibling videos in best-effort episode order', async (t) => {
const dir = createTempVideoDir(t);
const episode2 = path.join(dir, 'Show - S01E02.mkv');
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const special = path.join(dir, 'Show - Special.mp4');
@@ -169,6 +173,35 @@ test('getPlaylistBrowserSnapshotRuntime lists sibling videos in best-effort epis
);
});
test('getPlaylistBrowserSnapshotRuntime clamps stale playing index to the playlist bounds', async (t) => {
const dir = createTempVideoDir(t);
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, playing: true, title: 'Episode 1' },
{ filename: episode2, title: 'Episode 2' },
],
});
const requestProperty = mpvClient.requestProperty.bind(mpvClient);
mpvClient.requestProperty = async (name: string): Promise<unknown> => {
if (name === 'playlist-playing-pos') {
return 99;
}
return requestProperty(name);
};
const snapshot = await getPlaylistBrowserSnapshotRuntime({
getMpvClient: () => mpvClient,
});
assert.equal(snapshot.playingIndex, 1);
});
test('getPlaylistBrowserSnapshotRuntime degrades directory pane for remote media', async () => {
const mpvClient = createFakeMpvClient({
currentVideoPath: 'https://example.com/video.m3u8',
@@ -185,8 +218,8 @@ test('getPlaylistBrowserSnapshotRuntime degrades directory pane for remote media
assert.equal(snapshot.playlistItems.length, 1);
});
test('playlist-browser mutation runtimes mutate queue and return refreshed snapshots', async () => {
const dir = createTempVideoDir();
test('playlist-browser mutation runtimes mutate queue and return refreshed snapshots', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
const episode3 = path.join(dir, 'Show - S01E03.mkv');
@@ -249,8 +282,52 @@ test('playlist-browser mutation runtimes mutate queue and return refreshed snaps
);
});
test('appendPlaylistBrowserFileRuntime returns an error result when statSync throws', async () => {
const dir = createTempVideoDir();
test('playlist-browser mutation runtimes report MPV send rejection', async (t) => {
const dir = createTempVideoDir(t);
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' },
{ filename: episode3, title: 'Episode 3' },
],
});
const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
mpvClient.send = () => false;
const deps = {
getMpvClient: () => mpvClient,
schedule: (callback: () => void, delayMs: number) => {
scheduled.push({ callback, delayMs });
},
};
const appendResult = await appendPlaylistBrowserFileRuntime(deps, episode3);
assert.equal(appendResult.ok, false);
assert.equal(appendResult.snapshot, undefined);
const playResult = await playPlaylistBrowserIndexRuntime(deps, 1);
assert.equal(playResult.ok, false);
assert.equal(playResult.snapshot, undefined);
assert.deepEqual(scheduled, []);
const removeResult = await removePlaylistBrowserIndexRuntime(deps, 1);
assert.equal(removeResult.ok, false);
assert.equal(removeResult.snapshot, undefined);
const moveResult = await movePlaylistBrowserIndexRuntime(deps, 1, 1);
assert.equal(moveResult.ok, false);
assert.equal(moveResult.snapshot, undefined);
});
test('appendPlaylistBrowserFileRuntime returns an error result when statSync throws', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
fs.writeFileSync(episode1, '');
@@ -284,8 +361,8 @@ test('appendPlaylistBrowserFileRuntime returns an error result when statSync thr
}
});
test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', async () => {
const dir = createTempVideoDir();
test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
fs.writeFileSync(episode1, '');
@@ -316,8 +393,8 @@ test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', as
});
});
test('getPlaylistBrowserSnapshotRuntime normalizes playlist labels from title then filename', async () => {
const dir = createTempVideoDir();
test('getPlaylistBrowserSnapshotRuntime normalizes playlist labels from title then filename', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
fs.writeFileSync(episode1, '');
@@ -360,8 +437,8 @@ test('playPlaylistBrowserIndexRuntime skips local subtitle reset for remote play
assert.equal(scheduled.length, 0);
});
test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm callbacks', async () => {
const dir = createTempVideoDir();
test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm callbacks', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
const episode3 = path.join(dir, 'Show - S01E03.mkv');

View File

@@ -148,12 +148,33 @@ function ensureConnectedClient(
return client;
}
function buildRejectedCommandResult(): PlaylistBrowserMutationResult {
return {
ok: false,
message: 'Could not send command to MPV.',
};
}
async function getPlaylistItemsFromClient(
client: MpvPlaylistBrowserClientLike | null,
): Promise<PlaylistBrowserQueueItem[]> {
return normalizePlaylistItems(await readProperty(client, 'playlist'));
}
function resolvePlayingIndex(
playlistItems: PlaylistBrowserQueueItem[],
playingPosValue: unknown,
): number | null {
if (playlistItems.length === 0) {
return null;
}
if (typeof playingPosValue === 'number' && Number.isInteger(playingPosValue)) {
return Math.min(Math.max(playingPosValue, 0), playlistItems.length - 1);
}
const playingIndex = playlistItems.findIndex((item) => item.current || item.playing);
return playingIndex >= 0 ? playingIndex : null;
}
export async function getPlaylistBrowserSnapshotRuntime(
deps: PlaylistBrowserRuntimeDeps,
): Promise<PlaylistBrowserSnapshot> {
@@ -163,15 +184,11 @@ export async function getPlaylistBrowserSnapshotRuntime(
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,
playingIndex: resolvePlayingIndex(playlistItems, playingPosValue),
currentFilePath,
};
}
@@ -270,7 +287,9 @@ export async function appendPlaylistBrowserFileRuntime(
};
}
client.send({ command: ['loadfile', resolvedPath, 'append'] });
if (!client.send({ command: ['loadfile', resolvedPath, 'append'] })) {
return buildRejectedCommandResult();
}
return buildMutationResult(`Queued ${path.basename(resolvedPath)}`, deps);
}
@@ -287,7 +306,9 @@ export async function playPlaylistBrowserIndexRuntime(
if (isLocalPlaylistItem(targetItem)) {
prepareLocalSubtitleAutoload(result.client);
}
result.client.send({ command: ['playlist-play-index', index] });
if (!result.client.send({ command: ['playlist-play-index', index] })) {
return buildRejectedCommandResult();
}
if (isLocalPlaylistItem(targetItem)) {
scheduleLocalSubtitleSelectionRearm(deps, result.client, path.resolve(targetItem.path));
}
@@ -303,7 +324,9 @@ export async function removePlaylistBrowserIndexRuntime(
return result;
}
result.client.send({ command: ['playlist-remove', index] });
if (!result.client.send({ command: ['playlist-remove', index] })) {
return buildRejectedCommandResult();
}
return buildMutationResult(`Removed playlist item ${index + 1}`, deps);
}
@@ -331,6 +354,8 @@ export async function movePlaylistBrowserIndexRuntime(
};
}
result.client.send({ command: ['playlist-move', index, targetIndex] });
if (!result.client.send({ command: ['playlist-move', index, targetIndex] })) {
return buildRejectedCommandResult();
}
return buildMutationResult(`Moved playlist item ${index + 1}`, deps);
}

View File

@@ -653,6 +653,25 @@ test('keyboard mode: playlist browser modal handles arrow keys before yomitan po
}
});
test('keyboard mode: playlist browser modal handles h before lookup controls', async () => {
const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } =
createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.playlistBrowserModalOpen = true;
ctx.state.keyboardSelectedWordIndex = 2;
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
assert.equal(playlistBrowserKeydownCount(), 1);
assert.equal(ctx.state.keyboardSelectedWordIndex, 2);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();

View File

@@ -816,6 +816,12 @@ export function createKeyboardHandlers(
return;
}
if (ctx.state.playlistBrowserModalOpen) {
if (options.handlePlaylistBrowserKeydown(e)) {
return;
}
}
if (handleKeyboardDrivenModeLookupControls(e)) {
e.preventDefault();
return;
@@ -842,11 +848,6 @@ export function createKeyboardHandlers(
return;
}
}
if (ctx.state.playlistBrowserModalOpen) {
if (options.handlePlaylistBrowserKeydown(e)) {
return;
}
}
if (ctx.state.controllerSelectModalOpen) {
options.handleControllerSelectKeydown(e);
return;

View File

@@ -0,0 +1,144 @@
import type {
PlaylistBrowserDirectoryItem,
PlaylistBrowserQueueItem,
} from '../../types';
import type { RendererContext } from '../context';
type PlaylistBrowserRowRenderActions = {
appendDirectoryItem: (filePath: string) => void;
movePlaylistItem: (index: number, direction: 1 | -1) => void;
playPlaylistItem: (index: number) => void;
removePlaylistItem: (index: number) => void;
render: () => void;
};
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();
});
button.addEventListener('dblclick', (event) => {
event.preventDefault?.();
event.stopPropagation();
});
return button;
}
export function renderPlaylistBrowserDirectoryRow(
ctx: RendererContext,
item: PlaylistBrowserDirectoryItem,
index: number,
actions: PlaylistBrowserRowRenderActions,
): 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 actions.appendDirectoryItem(item.path);
}),
);
row.append(main, trailing);
row.addEventListener('click', () => {
ctx.state.playlistBrowserActivePane = 'directory';
ctx.state.playlistBrowserSelectedDirectoryIndex = index;
actions.render();
});
row.addEventListener('dblclick', () => {
ctx.state.playlistBrowserSelectedDirectoryIndex = index;
void actions.appendDirectoryItem(item.path);
});
return row;
}
export function renderPlaylistBrowserPlaylistRow(
ctx: RendererContext,
item: PlaylistBrowserQueueItem,
index: number,
actions: PlaylistBrowserRowRenderActions,
): 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 actions.playPlaylistItem(item.index);
}),
createActionButton('Up', () => {
void actions.movePlaylistItem(item.index, -1);
}),
createActionButton('Down', () => {
void actions.movePlaylistItem(item.index, 1);
}),
createActionButton('Remove', () => {
void actions.removePlaylistItem(item.index);
}),
);
row.append(main, trailing);
row.addEventListener('click', () => {
ctx.state.playlistBrowserActivePane = 'playlist';
ctx.state.playlistBrowserSelectedPlaylistIndex = index;
actions.render();
});
row.addEventListener('dblclick', () => {
ctx.state.playlistBrowserSelectedPlaylistIndex = index;
void actions.playPlaylistItem(item.index);
});
return row;
}

View File

@@ -216,6 +216,88 @@ test('playlist browser modal opens with playlist-focused current item selection'
}
});
test('playlist browser modal action buttons stop double-click propagation', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getPlaylistBrowserSnapshot: async () => createSnapshot(),
notifyOverlayModalOpened: () => {},
notifyOverlayModalClosed: () => {},
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();
const row = directoryList.children[0] as ReturnType<typeof createPlaylistRow> | undefined;
const trailing = row?.children?.[1] as ReturnType<typeof createPlaylistRow> | undefined;
const button =
trailing?.children?.at(-1) as
| { listeners?: Map<string, Array<(event?: unknown) => void>> }
| undefined;
const dblclickHandler = button?.listeners?.get('dblclick')?.[0];
assert.equal(typeof dblclickHandler, 'function');
let stopped = false;
dblclickHandler?.({
stopPropagation: () => {
stopped = true;
},
});
assert.equal(stopped, true);
} 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;

View File

@@ -1,28 +1,19 @@
import type {
PlaylistBrowserDirectoryItem,
PlaylistBrowserMutationResult,
PlaylistBrowserQueueItem,
PlaylistBrowserSnapshot,
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
import {
renderPlaylistBrowserDirectoryRow,
renderPlaylistBrowserPlaylistRow,
} from './playlist-browser-renderer.js';
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;
@@ -75,111 +66,6 @@ export function createPlaylistBrowserModal(
);
}
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) {
@@ -192,10 +78,26 @@ export function createPlaylistBrowserModal(
ctx.dom.playlistBrowserStatus.textContent =
ctx.state.playlistBrowserStatus || buildDefaultStatus(snapshot);
ctx.dom.playlistBrowserDirectoryList.replaceChildren(
...snapshot.directoryItems.map((item, index) => renderDirectoryRow(item, index)),
...snapshot.directoryItems.map((item, index) =>
renderPlaylistBrowserDirectoryRow(ctx, item, index, {
appendDirectoryItem,
movePlaylistItem,
playPlaylistItem,
removePlaylistItem,
render,
}),
),
);
ctx.dom.playlistBrowserPlaylistList.replaceChildren(
...snapshot.playlistItems.map((item, index) => renderPlaylistRow(item, index)),
...snapshot.playlistItems.map((item, index) =>
renderPlaylistBrowserPlaylistRow(ctx, item, index, {
appendDirectoryItem,
movePlaylistItem,
playPlaylistItem,
removePlaylistItem,
render,
}),
),
);
}