feat: add playlist browser overlay modal

- Add overlay modal for browsing sibling video files and live mpv queue
- Add IPC commands for playlist operations (add, remove, move, play)
- Add playlist-browser-runtime and playlist-browser-sort modules
- Add keyboard handler and preload bindings for playlist browser
- Add default Ctrl+Alt+P keybinding to open the modal
- Add HTML structure, renderer wiring, and state for the modal
- Add changelog fragment and docs updates
This commit is contained in:
2026-03-30 01:50:38 -07:00
parent 6e041bc68e
commit 6ae3888b53
36 changed files with 2213 additions and 6 deletions

View 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 });
}
});

View 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,
};
}

View File

@@ -130,6 +130,7 @@ function describeCommand(command: (string | number)[]): string {
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
@@ -164,6 +165,7 @@ function sectionForCommand(command: (string | number)[]): string {
if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) {
return 'Runtime settings';