mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
Add playlist browser overlay modal (#37)
This commit is contained in:
144
src/renderer/modals/playlist-browser-renderer.ts
Normal file
144
src/renderer/modals/playlist-browser-renderer.ts
Normal 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;
|
||||
}
|
||||
659
src/renderer/modals/playlist-browser.test.ts
Normal file
659
src/renderer/modals/playlist-browser.test.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createMutationSnapshot(): PlaylistBrowserSnapshot {
|
||||
return {
|
||||
directoryPath: '/tmp/show',
|
||||
directoryAvailable: true,
|
||||
directoryStatus: '/tmp/show',
|
||||
currentFilePath: '/tmp/show/Show - S01E02.mkv',
|
||||
playingIndex: 0,
|
||||
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,
|
||||
},
|
||||
{
|
||||
path: '/tmp/show/Show - S01E03.mkv',
|
||||
basename: 'Show - S01E03.mkv',
|
||||
episodeLabel: 'S1E3',
|
||||
isCurrentFile: false,
|
||||
},
|
||||
],
|
||||
playlistItems: [
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
id: 3,
|
||||
filename: '/tmp/show/Show - S01E03.mkv',
|
||||
title: 'Episode 3',
|
||||
displayLabel: 'Episode 3',
|
||||
current: false,
|
||||
playing: false,
|
||||
path: '/tmp/show/Show - S01E03.mkv',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function restoreGlobalDescriptor<K extends keyof typeof globalThis>(
|
||||
key: K,
|
||||
descriptor: PropertyDescriptor | undefined,
|
||||
) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, key, descriptor);
|
||||
return;
|
||||
}
|
||||
Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
|
||||
function createPlaylistBrowserDomFixture() {
|
||||
return {
|
||||
overlay: {
|
||||
classList: createClassList(),
|
||||
focus: () => {},
|
||||
},
|
||||
playlistBrowserModal: createFakeElement(),
|
||||
playlistBrowserTitle: createFakeElement(),
|
||||
playlistBrowserStatus: createFakeElement(),
|
||||
playlistBrowserDirectoryList: createListStub(),
|
||||
playlistBrowserPlaylistList: createListStub(),
|
||||
playlistBrowserClose: createFakeElement(),
|
||||
};
|
||||
}
|
||||
|
||||
function createPlaylistBrowserElectronApi(overrides?: Partial<ElectronAPI>): ElectronAPI {
|
||||
return {
|
||||
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() }),
|
||||
...overrides,
|
||||
} as ElectronAPI;
|
||||
}
|
||||
|
||||
function setupPlaylistBrowserModalTest(options?: {
|
||||
electronApi?: Partial<ElectronAPI>;
|
||||
shouldToggleMouseIgnore?: boolean;
|
||||
}) {
|
||||
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const state = createRendererState();
|
||||
const dom = createPlaylistBrowserDomFixture();
|
||||
const ctx = {
|
||||
state,
|
||||
platform: {
|
||||
shouldToggleMouseIgnore: options?.shouldToggleMouseIgnore ?? false,
|
||||
},
|
||||
dom,
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: createPlaylistBrowserElectronApi(options?.electronApi),
|
||||
focus: () => {},
|
||||
} satisfies { electronAPI: ElectronAPI; focus: () => void },
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => createPlaylistRow(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
return {
|
||||
state,
|
||||
dom,
|
||||
createModal(overrides: Partial<Parameters<typeof createPlaylistBrowserModal>[1]> = {}) {
|
||||
return createPlaylistBrowserModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
...overrides,
|
||||
});
|
||||
},
|
||||
restore() {
|
||||
restoreGlobalDescriptor('window', previousWindowDescriptor);
|
||||
restoreGlobalDescriptor('document', previousDocumentDescriptor);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('playlist browser test cleanup must delete injected globals that were originally absent', () => {
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
|
||||
|
||||
const env = setupPlaylistBrowserModalTest();
|
||||
|
||||
try {
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), true);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
|
||||
assert.equal(typeof globalThis.window, 'undefined');
|
||||
assert.equal(typeof globalThis.document, 'undefined');
|
||||
});
|
||||
|
||||
test('playlist browser modal opens with playlist-focused current item selection', async () => {
|
||||
const notifications: string[] = [];
|
||||
const env = setupPlaylistBrowserModalTest({
|
||||
electronApi: {
|
||||
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
|
||||
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const modal = env.createModal();
|
||||
|
||||
await modal.openPlaylistBrowserModal();
|
||||
|
||||
assert.equal(env.state.playlistBrowserModalOpen, true);
|
||||
assert.equal(env.state.playlistBrowserActivePane, 'playlist');
|
||||
assert.equal(env.state.playlistBrowserSelectedPlaylistIndex, 1);
|
||||
assert.equal(env.state.playlistBrowserSelectedDirectoryIndex, 1);
|
||||
assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 2);
|
||||
assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 2);
|
||||
assert.equal(env.dom.playlistBrowserDirectoryList.children[0]?.children.length, 2);
|
||||
assert.equal(env.dom.playlistBrowserPlaylistList.children[0]?.children.length, 2);
|
||||
assert.deepEqual(notifications, ['open:playlist-browser']);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('playlist browser modal action buttons stop double-click propagation', async () => {
|
||||
const env = setupPlaylistBrowserModalTest();
|
||||
|
||||
try {
|
||||
const modal = env.createModal();
|
||||
|
||||
await modal.openPlaylistBrowserModal();
|
||||
|
||||
const row =
|
||||
env.dom.playlistBrowserDirectoryList.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 {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('playlist browser preserves prior selection across mutation snapshots', async () => {
|
||||
const env = setupPlaylistBrowserModalTest({
|
||||
electronApi: {
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
...createSnapshot(),
|
||||
directoryItems: [
|
||||
...createSnapshot().directoryItems,
|
||||
{
|
||||
path: '/tmp/show/Show - S01E03.mkv',
|
||||
basename: 'Show - S01E03.mkv',
|
||||
episodeLabel: 'S1E3',
|
||||
isCurrentFile: false,
|
||||
},
|
||||
],
|
||||
playlistItems: [
|
||||
...createSnapshot().playlistItems,
|
||||
{
|
||||
index: 2,
|
||||
id: 3,
|
||||
filename: '/tmp/show/Show - S01E03.mkv',
|
||||
title: 'Episode 3',
|
||||
displayLabel: 'Episode 3',
|
||||
current: false,
|
||||
playing: false,
|
||||
path: '/tmp/show/Show - S01E03.mkv',
|
||||
},
|
||||
],
|
||||
}),
|
||||
appendPlaylistBrowserFile: async () => ({
|
||||
ok: true,
|
||||
message: 'Queued file',
|
||||
snapshot: createMutationSnapshot(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const modal = env.createModal();
|
||||
|
||||
await modal.openPlaylistBrowserModal();
|
||||
env.state.playlistBrowserActivePane = 'directory';
|
||||
env.state.playlistBrowserSelectedDirectoryIndex = 2;
|
||||
env.state.playlistBrowserSelectedPlaylistIndex = 0;
|
||||
|
||||
await modal.handlePlaylistBrowserKeydown({
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
preventDefault: () => {},
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
} as never);
|
||||
|
||||
assert.equal(env.state.playlistBrowserSelectedDirectoryIndex, 2);
|
||||
assert.equal(env.state.playlistBrowserSelectedPlaylistIndex, 2);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('playlist browser modal keydown routes append, remove, reorder, tab switch, and play', async () => {
|
||||
const calls: Array<[string, unknown[]]> = [];
|
||||
const notifications: string[] = [];
|
||||
const env = setupPlaylistBrowserModalTest({
|
||||
electronApi: {
|
||||
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
|
||||
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
|
||||
appendPlaylistBrowserFile: async (filePath: string) => {
|
||||
calls.push(['append', [filePath]]);
|
||||
return { ok: true, message: 'append-ok', snapshot: createMutationSnapshot() };
|
||||
},
|
||||
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() };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const modal = env.createModal();
|
||||
|
||||
await modal.openPlaylistBrowserModal();
|
||||
|
||||
const preventDefault = () => {};
|
||||
env.state.playlistBrowserActivePane = 'directory';
|
||||
env.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(env.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(env.state.playlistBrowserModalOpen, false);
|
||||
assert.deepEqual(notifications, ['open:playlist-browser', 'close:playlist-browser']);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('playlist browser keeps modal open when playing selected queue item fails', async () => {
|
||||
const notifications: string[] = [];
|
||||
const env = setupPlaylistBrowserModalTest({
|
||||
electronApi: {
|
||||
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
|
||||
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: false, message: 'play failed' }),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const modal = env.createModal();
|
||||
|
||||
await modal.openPlaylistBrowserModal();
|
||||
assert.equal(env.state.playlistBrowserModalOpen, true);
|
||||
|
||||
await modal.handlePlaylistBrowserKeydown({
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
preventDefault: () => {},
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
} as never);
|
||||
|
||||
assert.equal(env.state.playlistBrowserModalOpen, true);
|
||||
assert.equal(env.dom.playlistBrowserStatus.textContent, 'play failed');
|
||||
assert.equal(env.dom.playlistBrowserStatus.classList.contains('error'), true);
|
||||
assert.deepEqual(notifications, ['open:playlist-browser']);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('playlist browser refresh failure clears stale rendered rows and reports the error', async () => {
|
||||
const notifications: string[] = [];
|
||||
let refreshShouldFail = false;
|
||||
const env = setupPlaylistBrowserModalTest({
|
||||
electronApi: {
|
||||
getPlaylistBrowserSnapshot: async () => {
|
||||
if (refreshShouldFail) {
|
||||
throw new Error('snapshot failed');
|
||||
}
|
||||
return createSnapshot();
|
||||
},
|
||||
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
|
||||
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const modal = env.createModal();
|
||||
|
||||
await modal.openPlaylistBrowserModal();
|
||||
assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 2);
|
||||
assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 2);
|
||||
|
||||
refreshShouldFail = true;
|
||||
await modal.refreshSnapshot();
|
||||
|
||||
assert.equal(env.state.playlistBrowserSnapshot, null);
|
||||
assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 0);
|
||||
assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 0);
|
||||
assert.equal(env.dom.playlistBrowserTitle.textContent, 'Playlist Browser');
|
||||
assert.equal(env.dom.playlistBrowserStatus.textContent, 'snapshot failed');
|
||||
assert.equal(env.dom.playlistBrowserStatus.classList.contains('error'), true);
|
||||
assert.deepEqual(notifications, ['open:playlist-browser']);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('playlist browser close clears rendered snapshot ui', async () => {
|
||||
const notifications: string[] = [];
|
||||
const env = setupPlaylistBrowserModalTest({
|
||||
electronApi: {
|
||||
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
|
||||
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const modal = env.createModal();
|
||||
|
||||
await modal.openPlaylistBrowserModal();
|
||||
assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 2);
|
||||
assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 2);
|
||||
|
||||
modal.closePlaylistBrowserModal();
|
||||
|
||||
assert.equal(env.state.playlistBrowserSnapshot, null);
|
||||
assert.equal(env.state.playlistBrowserStatus, '');
|
||||
assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 0);
|
||||
assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 0);
|
||||
assert.equal(env.dom.playlistBrowserTitle.textContent, 'Playlist Browser');
|
||||
assert.equal(env.dom.playlistBrowserStatus.textContent, '');
|
||||
assert.deepEqual(notifications, ['open:playlist-browser', 'close:playlist-browser']);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('playlist browser open is ignored while another modal is already open', async () => {
|
||||
const notifications: string[] = [];
|
||||
let snapshotCalls = 0;
|
||||
const env = setupPlaylistBrowserModalTest({
|
||||
electronApi: {
|
||||
getPlaylistBrowserSnapshot: async () => {
|
||||
snapshotCalls += 1;
|
||||
return createSnapshot();
|
||||
},
|
||||
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
|
||||
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const modal = env.createModal({
|
||||
modalStateReader: { isAnyModalOpen: () => true },
|
||||
});
|
||||
|
||||
await modal.openPlaylistBrowserModal();
|
||||
|
||||
assert.equal(env.state.playlistBrowserModalOpen, false);
|
||||
assert.equal(snapshotCalls, 0);
|
||||
assert.equal(env.dom.overlay.classList.contains('interactive'), false);
|
||||
assert.deepEqual(notifications, []);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
407
src/renderer/modals/playlist-browser.ts
Normal file
407
src/renderer/modals/playlist-browser.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
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 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`;
|
||||
}
|
||||
|
||||
function getDefaultDirectorySelectionIndex(snapshot: PlaylistBrowserSnapshot): number {
|
||||
const directoryIndex = snapshot.directoryItems.findIndex((item) => item.isCurrentFile);
|
||||
return clampIndex(directoryIndex >= 0 ? directoryIndex : 0, snapshot.directoryItems.length);
|
||||
}
|
||||
|
||||
function getDefaultPlaylistSelectionIndex(snapshot: PlaylistBrowserSnapshot): number {
|
||||
const playlistIndex =
|
||||
snapshot.playingIndex ?? snapshot.playlistItems.findIndex((item) => item.current || item.playing);
|
||||
return clampIndex(playlistIndex >= 0 ? playlistIndex : 0, snapshot.playlistItems.length);
|
||||
}
|
||||
|
||||
function resolvePreservedIndex<T>(
|
||||
previousIndex: number,
|
||||
previousItems: T[],
|
||||
nextItems: T[],
|
||||
matchIndex: (previousItem: T) => number,
|
||||
): number {
|
||||
if (nextItems.length <= 0) return 0;
|
||||
if (previousItems.length <= 0) return clampIndex(previousIndex, nextItems.length);
|
||||
|
||||
const normalizedPreviousIndex = clampIndex(previousIndex, previousItems.length);
|
||||
const previousItem = previousItems[normalizedPreviousIndex];
|
||||
const matchedIndex = previousItem ? matchIndex(previousItem) : -1;
|
||||
return clampIndex(matchedIndex >= 0 ? matchedIndex : normalizedPreviousIndex, nextItems.length);
|
||||
}
|
||||
|
||||
function resolveDirectorySelectionIndex(
|
||||
snapshot: PlaylistBrowserSnapshot,
|
||||
previousSnapshot: PlaylistBrowserSnapshot,
|
||||
previousIndex: number,
|
||||
): number {
|
||||
return resolvePreservedIndex(
|
||||
previousIndex,
|
||||
previousSnapshot.directoryItems,
|
||||
snapshot.directoryItems,
|
||||
(previousItem: PlaylistBrowserDirectoryItem) =>
|
||||
snapshot.directoryItems.findIndex((item) => item.path === previousItem.path),
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePlaylistSelectionIndex(
|
||||
snapshot: PlaylistBrowserSnapshot,
|
||||
previousSnapshot: PlaylistBrowserSnapshot,
|
||||
previousIndex: number,
|
||||
): number {
|
||||
return resolvePreservedIndex(
|
||||
previousIndex,
|
||||
previousSnapshot.playlistItems,
|
||||
snapshot.playlistItems,
|
||||
(previousItem: PlaylistBrowserQueueItem) => {
|
||||
if (previousItem.id !== null) {
|
||||
const byId = snapshot.playlistItems.findIndex((item) => item.id === previousItem.id);
|
||||
if (byId >= 0) return byId;
|
||||
}
|
||||
if (previousItem.path) {
|
||||
return snapshot.playlistItems.findIndex((item) => item.path === previousItem.path);
|
||||
}
|
||||
return -1;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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 resetSnapshotUi(): void {
|
||||
ctx.state.playlistBrowserSnapshot = null;
|
||||
ctx.state.playlistBrowserStatus = '';
|
||||
ctx.state.playlistBrowserSelectedDirectoryIndex = 0;
|
||||
ctx.state.playlistBrowserSelectedPlaylistIndex = 0;
|
||||
ctx.dom.playlistBrowserTitle.textContent = 'Playlist Browser';
|
||||
ctx.dom.playlistBrowserDirectoryList.replaceChildren();
|
||||
ctx.dom.playlistBrowserPlaylistList.replaceChildren();
|
||||
ctx.dom.playlistBrowserStatus.textContent = '';
|
||||
ctx.dom.playlistBrowserStatus.classList.remove('error');
|
||||
}
|
||||
|
||||
function syncSelection(
|
||||
snapshot: PlaylistBrowserSnapshot,
|
||||
previousSnapshot: PlaylistBrowserSnapshot | null,
|
||||
): void {
|
||||
if (!previousSnapshot) {
|
||||
ctx.state.playlistBrowserSelectedDirectoryIndex = getDefaultDirectorySelectionIndex(snapshot);
|
||||
ctx.state.playlistBrowserSelectedPlaylistIndex = getDefaultPlaylistSelectionIndex(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.playlistBrowserSelectedDirectoryIndex = resolveDirectorySelectionIndex(
|
||||
snapshot,
|
||||
previousSnapshot,
|
||||
ctx.state.playlistBrowserSelectedDirectoryIndex,
|
||||
);
|
||||
ctx.state.playlistBrowserSelectedPlaylistIndex = resolvePlaylistSelectionIndex(
|
||||
snapshot,
|
||||
previousSnapshot,
|
||||
ctx.state.playlistBrowserSelectedPlaylistIndex,
|
||||
);
|
||||
}
|
||||
|
||||
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) =>
|
||||
renderPlaylistBrowserDirectoryRow(ctx, item, index, {
|
||||
appendDirectoryItem,
|
||||
movePlaylistItem,
|
||||
playPlaylistItem,
|
||||
removePlaylistItem,
|
||||
render,
|
||||
}),
|
||||
),
|
||||
);
|
||||
ctx.dom.playlistBrowserPlaylistList.replaceChildren(
|
||||
...snapshot.playlistItems.map((item, index) =>
|
||||
renderPlaylistBrowserPlaylistRow(ctx, item, index, {
|
||||
appendDirectoryItem,
|
||||
movePlaylistItem,
|
||||
playPlaylistItem,
|
||||
removePlaylistItem,
|
||||
render,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function applySnapshot(snapshot: PlaylistBrowserSnapshot): void {
|
||||
const previousSnapshot = ctx.state.playlistBrowserSnapshot;
|
||||
ctx.state.playlistBrowserSnapshot = snapshot;
|
||||
syncSelection(snapshot, previousSnapshot);
|
||||
render();
|
||||
}
|
||||
|
||||
async function refreshSnapshot(): Promise<void> {
|
||||
try {
|
||||
const snapshot = await window.electronAPI.getPlaylistBrowserSnapshot();
|
||||
ctx.state.playlistBrowserStatus = '';
|
||||
applySnapshot(snapshot);
|
||||
setStatus(
|
||||
buildDefaultStatus(snapshot),
|
||||
!snapshot.directoryAvailable && snapshot.directoryStatus.length > 0,
|
||||
);
|
||||
} catch (error) {
|
||||
resetSnapshotUi();
|
||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (options.modalStateReader.isAnyModalOpen()) {
|
||||
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');
|
||||
await refreshSnapshot();
|
||||
}
|
||||
|
||||
function closePlaylistBrowserModal(): void {
|
||||
if (!ctx.state.playlistBrowserModalOpen) return;
|
||||
ctx.state.playlistBrowserModalOpen = false;
|
||||
resetSnapshotUi();
|
||||
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.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';
|
||||
|
||||
Reference in New Issue
Block a user