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

@@ -93,6 +93,11 @@ export interface MainIpcRuntimeServiceDepsParams {
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex'];
removePlaylistBrowserIndex: IpcDepsRuntimeOptions['removePlaylistBrowserIndex'];
movePlaylistBrowserIndex: IpcDepsRuntimeOptions['movePlaylistBrowserIndex'];
getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker'];
}
@@ -193,6 +198,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
@@ -247,6 +253,11 @@ export function createMainIpcRuntimeServiceDeps(
getAnilistQueueStatus: params.getAnilistQueueStatus,
retryAnilistQueueNow: params.retryAnilistQueueNow,
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
playPlaylistBrowserIndex: params.playPlaylistBrowserIndex,
removePlaylistBrowserIndex: params.removePlaylistBrowserIndex,
movePlaylistBrowserIndex: params.movePlaylistBrowserIndex,
getImmersionTracker: params.getImmersionTracker,
};
}
@@ -358,6 +369,7 @@ export function createMpvCommandRuntimeServiceDeps(
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
openPlaylistBrowser: params.openPlaylistBrowser,
runtimeOptionsCycle: params.runtimeOptionsCycle,
showMpvOsd: params.showMpvOsd,
mpvReplaySubtitle: params.mpvReplaySubtitle,

View File

@@ -13,6 +13,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
replayCurrentSubtitle: () => void;
@@ -35,6 +36,7 @@ export function handleMpvCommandFromIpcRuntime(
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
openPlaylistBrowser: deps.openPlaylistBrowser,
runtimeOptionsCycle: deps.cycleRuntimeOption,
showMpvOsd: deps.showMpvOsd,
mpvReplaySubtitle: deps.replayCurrentSubtitle,

View File

@@ -11,6 +11,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: true }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
@@ -68,6 +69,20 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
getPlaylistBrowserSnapshot: async () =>
({
directoryPath: null,
directoryAvailable: false,
directoryStatus: '',
directoryItems: [],
playlistItems: [],
playingIndex: null,
currentFilePath: null,
}) as never,
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
},
ankiJimakuDeps: {

View File

@@ -14,6 +14,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},

View File

@@ -11,6 +11,7 @@ test('handle mpv command handler forwards command and built deps', () => {
triggerSubsyncFromConfig: () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},

View File

@@ -10,6 +10,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
openYoutubeTrackPicker: () => {
calls.push('youtube-picker');
},
openPlaylistBrowser: () => {
calls.push('playlist-browser');
},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: (text) => calls.push(`osd:${text}`),
replayCurrentSubtitle: () => calls.push('replay'),
@@ -26,6 +29,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
deps.triggerSubsyncFromConfig();
deps.openRuntimeOptionsPalette();
void deps.openYoutubeTrackPicker();
void deps.openPlaylistBrowser();
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
deps.showMpvOsd('hello');
deps.replayCurrentSubtitle();
@@ -39,6 +43,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
'subsync',
'palette',
'youtube-picker',
'playlist-browser',
'osd:hello',
'replay',
'next',

View File

@@ -7,6 +7,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),

View File

@@ -0,0 +1,326 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import type { PlaylistBrowserQueueItem } from '../../types';
import {
appendPlaylistBrowserFileRuntime,
getPlaylistBrowserSnapshotRuntime,
movePlaylistBrowserIndexRuntime,
playPlaylistBrowserIndexRuntime,
removePlaylistBrowserIndexRuntime,
} from './playlist-browser-runtime';
type FakePlaylistEntry = {
current?: boolean;
playing?: boolean;
filename: string;
title?: string;
id?: number;
};
function createTempVideoDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-playlist-browser-'));
}
function createFakeMpvClient(options: {
currentVideoPath: string;
playlist: FakePlaylistEntry[];
connected?: boolean;
}) {
let playlist = options.playlist.map((item, index) => ({
id: item.id ?? index + 1,
current: item.current ?? false,
playing: item.playing ?? item.current ?? false,
filename: item.filename,
title: item.title ?? null,
}));
const commands: Array<(string | number)[]> = [];
const syncFlags = (): void => {
let playingIndex = playlist.findIndex((item) => item.current || item.playing);
if (playingIndex < 0 && playlist.length > 0) {
playingIndex = 0;
}
playlist = playlist.map((item, index) => ({
...item,
current: index === playingIndex,
playing: index === playingIndex,
}));
};
syncFlags();
return {
connected: options.connected ?? true,
currentVideoPath: options.currentVideoPath,
async requestProperty(name: string): Promise<unknown> {
if (name === 'playlist') {
return playlist;
}
if (name === 'playlist-playing-pos') {
return playlist.findIndex((item) => item.current || item.playing);
}
if (name === 'path') {
return this.currentVideoPath;
}
throw new Error(`Unexpected property: ${name}`);
},
send(payload: { command: unknown[] }): boolean {
const command = payload.command as (string | number)[];
commands.push(command);
const [action, first, second] = command;
if (action === 'loadfile' && typeof first === 'string' && second === 'append') {
playlist.push({
id: playlist.length + 1,
filename: first,
title: null,
current: false,
playing: false,
});
syncFlags();
return true;
}
if (action === 'playlist-play-index' && typeof first === 'number' && playlist[first]) {
playlist = playlist.map((item, index) => ({
...item,
current: index === first,
playing: index === first,
}));
this.currentVideoPath = playlist[first]!.filename;
return true;
}
if (action === 'playlist-remove' && typeof first === 'number' && playlist[first]) {
const removingCurrent = playlist[first]!.current || playlist[first]!.playing;
playlist.splice(first, 1);
if (removingCurrent) {
syncFlags();
this.currentVideoPath =
playlist.find((item) => item.current || item.playing)?.filename ?? this.currentVideoPath;
}
return true;
}
if (
action === 'playlist-move' &&
typeof first === 'number' &&
typeof second === 'number' &&
playlist[first]
) {
const [moved] = playlist.splice(first, 1);
playlist.splice(second, 0, moved!);
syncFlags();
return true;
}
return true;
},
getCommands(): Array<(string | number)[]> {
return commands;
},
};
}
test('getPlaylistBrowserSnapshotRuntime lists sibling videos in best-effort episode order', async () => {
const dir = createTempVideoDir();
const episode2 = path.join(dir, 'Show - S01E02.mkv');
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const special = path.join(dir, 'Show - Special.mp4');
const ignored = path.join(dir, 'notes.txt');
fs.writeFileSync(episode2, '');
fs.writeFileSync(episode1, '');
fs.writeFileSync(special, '');
fs.writeFileSync(ignored, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode2,
playlist: [
{ filename: episode1, current: false, playing: false, title: 'Episode 1' },
{ filename: episode2, current: true, playing: true, title: 'Episode 2' },
],
});
const snapshot = await getPlaylistBrowserSnapshotRuntime({
getMpvClient: () => mpvClient,
});
assert.equal(snapshot.directoryAvailable, true);
assert.equal(snapshot.directoryPath, dir);
assert.equal(snapshot.currentFilePath, episode2);
assert.equal(snapshot.playingIndex, 1);
assert.deepEqual(
snapshot.directoryItems.map((item) => [item.basename, item.isCurrentFile]),
[
['Show - S01E01.mkv', false],
['Show - S01E02.mkv', true],
['Show - Special.mp4', false],
],
);
assert.deepEqual(
snapshot.playlistItems.map((item) => ({
index: item.index,
displayLabel: item.displayLabel,
current: item.current,
})),
[
{ index: 0, displayLabel: 'Episode 1', current: false },
{ index: 1, displayLabel: 'Episode 2', current: true },
],
);
});
test('getPlaylistBrowserSnapshotRuntime degrades directory pane for remote media', async () => {
const mpvClient = createFakeMpvClient({
currentVideoPath: 'https://example.com/video.m3u8',
playlist: [{ filename: 'https://example.com/video.m3u8', current: true }],
});
const snapshot = await getPlaylistBrowserSnapshotRuntime({
getMpvClient: () => mpvClient,
});
assert.equal(snapshot.directoryAvailable, false);
assert.equal(snapshot.directoryItems.length, 0);
assert.match(snapshot.directoryStatus, /local filesystem/i);
assert.equal(snapshot.playlistItems.length, 1);
});
test('playlist-browser mutation runtimes mutate queue and return refreshed snapshots', async () => {
const dir = createTempVideoDir();
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
const episode3 = path.join(dir, 'Show - S01E03.mkv');
fs.writeFileSync(episode1, '');
fs.writeFileSync(episode2, '');
fs.writeFileSync(episode3, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [
{ filename: episode1, current: true, title: 'Episode 1' },
{ filename: episode2, title: 'Episode 2' },
],
});
const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
const deps = {
getMpvClient: () => mpvClient,
schedule: (callback: () => void, delayMs: number) => {
scheduled.push({ callback, delayMs });
},
};
const appendResult = await appendPlaylistBrowserFileRuntime(deps, episode3);
assert.equal(appendResult.ok, true);
assert.deepEqual(mpvClient.getCommands().at(-1), ['loadfile', episode3, 'append']);
assert.deepEqual(
appendResult.snapshot?.playlistItems.map((item) => item.path),
[episode1, episode2, episode3],
);
const moveResult = await movePlaylistBrowserIndexRuntime(deps, 2, -1);
assert.equal(moveResult.ok, true);
assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-move', 2, 1]);
assert.deepEqual(
moveResult.snapshot?.playlistItems.map((item) => item.path),
[episode1, episode3, episode2],
);
const playResult = await playPlaylistBrowserIndexRuntime(deps, 1);
assert.equal(playResult.ok, true);
assert.deepEqual(mpvClient.getCommands().slice(-2), [
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 1],
]);
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
scheduled[0]?.callback();
assert.deepEqual(mpvClient.getCommands().slice(-2), [
['set_property', 'sid', 'auto'],
['set_property', 'secondary-sid', 'auto'],
]);
assert.equal(playResult.snapshot?.playingIndex, 1);
const removeResult = await removePlaylistBrowserIndexRuntime(deps, 2);
assert.equal(removeResult.ok, true);
assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-remove', 2]);
assert.deepEqual(
removeResult.snapshot?.playlistItems.map((item) => item.path),
[episode1, episode3],
);
});
test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', async () => {
const dir = createTempVideoDir();
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
fs.writeFileSync(episode1, '');
fs.writeFileSync(episode2, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [
{ filename: episode1, current: true },
{ filename: episode2 },
],
});
const deps = {
getMpvClient: () => mpvClient,
};
const moveUp = await movePlaylistBrowserIndexRuntime(deps, 0, -1);
assert.deepEqual(moveUp, {
ok: false,
message: 'Playlist item is already at the top.',
});
const moveDown = await movePlaylistBrowserIndexRuntime(deps, 1, 1);
assert.deepEqual(moveDown, {
ok: false,
message: 'Playlist item is already at the bottom.',
});
});
test('getPlaylistBrowserSnapshotRuntime normalizes playlist labels from title then filename', async () => {
const dir = createTempVideoDir();
const episode1 = path.join(dir, 'Show - S01E01.mkv');
fs.writeFileSync(episode1, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [{ filename: episode1, current: true, title: '' }],
});
const snapshot = await getPlaylistBrowserSnapshotRuntime({
getMpvClient: () => mpvClient,
});
const item = snapshot.playlistItems[0] as PlaylistBrowserQueueItem;
assert.equal(item.displayLabel, 'Show - S01E01.mkv');
assert.equal(item.path, episode1);
});
test('playPlaylistBrowserIndexRuntime skips local subtitle reset for remote playlist entries', async () => {
const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
const mpvClient = createFakeMpvClient({
currentVideoPath: 'https://example.com/video-1.m3u8',
playlist: [
{ filename: 'https://example.com/video-1.m3u8', current: true, title: 'Episode 1' },
{ filename: 'https://example.com/video-2.m3u8', title: 'Episode 2' },
],
});
const result = await playPlaylistBrowserIndexRuntime(
{
getMpvClient: () => mpvClient,
schedule: (callback, delayMs) => {
scheduled.push({ callback, delayMs });
},
},
1,
);
assert.equal(result.ok, true);
assert.deepEqual(mpvClient.getCommands().slice(-1), [['playlist-play-index', 1]]);
assert.equal(scheduled.length, 0);
});

View File

@@ -0,0 +1,314 @@
import fs from 'node:fs';
import path from 'node:path';
import type {
PlaylistBrowserDirectoryItem,
PlaylistBrowserMutationResult,
PlaylistBrowserQueueItem,
PlaylistBrowserSnapshot,
} from '../../types';
import { isRemoteMediaPath } from '../../jimaku/utils';
import { hasVideoExtension } from '../../shared/video-extensions';
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
type PlaylistLike = {
filename?: unknown;
title?: unknown;
id?: unknown;
current?: unknown;
playing?: unknown;
};
type MpvPlaylistBrowserClientLike = {
connected: boolean;
currentVideoPath?: string | null;
requestProperty?: (name: string) => Promise<unknown>;
send: (payload: { command: unknown[]; request_id?: number }) => boolean;
};
export type PlaylistBrowserRuntimeDeps = {
getMpvClient: () => MpvPlaylistBrowserClientLike | null;
schedule?: (callback: () => void, delayMs: number) => void;
};
function trimToNull(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
async function readProperty(
client: MpvPlaylistBrowserClientLike | null,
name: string,
): Promise<unknown> {
if (!client?.requestProperty) return null;
try {
return await client.requestProperty(name);
} catch {
return null;
}
}
async function resolveCurrentFilePath(
client: MpvPlaylistBrowserClientLike | null,
): Promise<string | null> {
const currentVideoPath = trimToNull(client?.currentVideoPath);
if (currentVideoPath) return currentVideoPath;
return trimToNull(await readProperty(client, 'path'));
}
function resolveDirectorySnapshot(
currentFilePath: string | null,
): Pick<PlaylistBrowserSnapshot, 'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'> {
if (!currentFilePath) {
return {
directoryAvailable: false,
directoryItems: [],
directoryPath: null,
directoryStatus: 'Current media path is unavailable.',
};
}
if (isRemoteMediaPath(currentFilePath)) {
return {
directoryAvailable: false,
directoryItems: [],
directoryPath: null,
directoryStatus: 'Directory browser requires a local filesystem video.',
};
}
const resolvedPath = path.resolve(currentFilePath);
const directoryPath = path.dirname(resolvedPath);
try {
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
const videoPaths = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((name) => hasVideoExtension(path.extname(name)))
.map((name) => path.join(directoryPath, name));
const directoryItems: PlaylistBrowserDirectoryItem[] = sortPlaylistBrowserDirectoryItems(
videoPaths,
).map((item) => ({
...item,
isCurrentFile: item.path === resolvedPath,
}));
return {
directoryAvailable: true,
directoryItems,
directoryPath,
directoryStatus: directoryPath,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
directoryAvailable: false,
directoryItems: [],
directoryPath,
directoryStatus: `Could not read parent directory: ${message}`,
};
}
}
function normalizePlaylistItems(raw: unknown): PlaylistBrowserQueueItem[] {
if (!Array.isArray(raw)) return [];
return raw.map((entry, index) => {
const item = (entry ?? {}) as PlaylistLike;
const filename = trimToNull(item.filename) ?? '';
const title = trimToNull(item.title);
const normalizedPath =
filename && !isRemoteMediaPath(filename) ? path.resolve(filename) : trimToNull(filename);
return {
index,
id: typeof item.id === 'number' ? item.id : null,
filename,
title,
displayLabel:
title ?? (path.basename(filename || '') || filename || `Playlist item ${index + 1}`),
current: item.current === true,
playing: item.playing === true,
path: normalizedPath,
};
});
}
function ensureConnectedClient(
deps: PlaylistBrowserRuntimeDeps,
): MpvPlaylistBrowserClientLike | { ok: false; message: string } {
const client = deps.getMpvClient();
if (!client?.connected) {
return {
ok: false,
message: 'MPV is not connected.',
};
}
return client;
}
async function getPlaylistItemsFromClient(
client: MpvPlaylistBrowserClientLike | null,
): Promise<PlaylistBrowserQueueItem[]> {
return normalizePlaylistItems(await readProperty(client, 'playlist'));
}
export async function getPlaylistBrowserSnapshotRuntime(
deps: PlaylistBrowserRuntimeDeps,
): Promise<PlaylistBrowserSnapshot> {
const client = deps.getMpvClient();
const currentFilePath = await resolveCurrentFilePath(client);
const [playlistItems, playingPosValue] = await Promise.all([
getPlaylistItemsFromClient(client),
readProperty(client, 'playlist-playing-pos'),
]);
const playingIndex =
typeof playingPosValue === 'number' && Number.isInteger(playingPosValue)
? playingPosValue
: playlistItems.findIndex((item) => item.current || item.playing);
return {
...resolveDirectorySnapshot(currentFilePath),
playlistItems,
playingIndex: playingIndex >= 0 ? playingIndex : null,
currentFilePath,
};
}
async function validatePlaylistIndex(
deps: PlaylistBrowserRuntimeDeps,
index: number,
): Promise<
| { ok: false; message: string }
| { ok: true; client: MpvPlaylistBrowserClientLike; playlistItems: PlaylistBrowserQueueItem[] }
> {
const client = ensureConnectedClient(deps);
if ('ok' in client) {
return client;
}
const playlistItems = await getPlaylistItemsFromClient(client);
if (!Number.isInteger(index) || index < 0 || index >= playlistItems.length) {
return {
ok: false,
message: 'Playlist item not found.',
};
}
return {
ok: true,
client,
playlistItems,
};
}
async function buildMutationResult(
message: string,
deps: PlaylistBrowserRuntimeDeps,
): Promise<PlaylistBrowserMutationResult> {
return {
ok: true,
message,
snapshot: await getPlaylistBrowserSnapshotRuntime(deps),
};
}
function rearmLocalSubtitleSelection(client: MpvPlaylistBrowserClientLike): void {
client.send({ command: ['set_property', 'sid', 'auto'] });
client.send({ command: ['set_property', 'secondary-sid', 'auto'] });
}
function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void {
client.send({ command: ['set_property', 'sub-auto', 'fuzzy'] });
}
function isLocalPlaylistItem(item: PlaylistBrowserQueueItem | null | undefined): item is PlaylistBrowserQueueItem {
return Boolean(item?.path && !isRemoteMediaPath(item.path));
}
function scheduleLocalSubtitleSelectionRearm(
deps: PlaylistBrowserRuntimeDeps,
client: MpvPlaylistBrowserClientLike,
): void {
(deps.schedule ?? setTimeout)(() => {
rearmLocalSubtitleSelection(client);
}, 400);
}
export async function appendPlaylistBrowserFileRuntime(
deps: PlaylistBrowserRuntimeDeps,
filePath: string,
): Promise<PlaylistBrowserMutationResult> {
const client = ensureConnectedClient(deps);
if ('ok' in client) {
return client;
}
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
return {
ok: false,
message: 'Playlist browser file is not readable.',
};
}
client.send({ command: ['loadfile', resolvedPath, 'append'] });
return buildMutationResult(`Queued ${path.basename(resolvedPath)}`, deps);
}
export async function playPlaylistBrowserIndexRuntime(
deps: PlaylistBrowserRuntimeDeps,
index: number,
): Promise<PlaylistBrowserMutationResult> {
const result = await validatePlaylistIndex(deps, index);
if (!result.ok) {
return result;
}
const targetItem = result.playlistItems[index] ?? null;
if (isLocalPlaylistItem(targetItem)) {
prepareLocalSubtitleAutoload(result.client);
}
result.client.send({ command: ['playlist-play-index', index] });
if (isLocalPlaylistItem(targetItem)) {
scheduleLocalSubtitleSelectionRearm(deps, result.client);
}
return buildMutationResult(`Playing playlist item ${index + 1}`, deps);
}
export async function removePlaylistBrowserIndexRuntime(
deps: PlaylistBrowserRuntimeDeps,
index: number,
): Promise<PlaylistBrowserMutationResult> {
const result = await validatePlaylistIndex(deps, index);
if (!result.ok) {
return result;
}
result.client.send({ command: ['playlist-remove', index] });
return buildMutationResult(`Removed playlist item ${index + 1}`, deps);
}
export async function movePlaylistBrowserIndexRuntime(
deps: PlaylistBrowserRuntimeDeps,
index: number,
direction: -1 | 1,
): Promise<PlaylistBrowserMutationResult> {
const result = await validatePlaylistIndex(deps, index);
if (!result.ok) {
return result;
}
const targetIndex = index + direction;
if (targetIndex < 0) {
return {
ok: false,
message: 'Playlist item is already at the top.',
};
}
if (targetIndex >= result.playlistItems.length) {
return {
ok: false,
message: 'Playlist item is already at the bottom.',
};
}
result.client.send({ command: ['playlist-move', index, targetIndex] });
return buildMutationResult(`Moved playlist item ${index + 1}`, deps);
}

View File

@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
test('sortPlaylistBrowserDirectoryItems prefers parsed season and episode order', () => {
const root = '/library/show';
const items = sortPlaylistBrowserDirectoryItems([
path.join(root, 'Show - S01E10.mkv'),
path.join(root, 'Show - S01E02.mkv'),
path.join(root, 'Show - S01E01.mkv'),
path.join(root, 'Show - Episode 7.mkv'),
path.join(root, 'Show - 01x03.mkv'),
]);
assert.deepEqual(
items.map((item) => item.basename),
[
'Show - S01E01.mkv',
'Show - S01E02.mkv',
'Show - 01x03.mkv',
'Show - Episode 7.mkv',
'Show - S01E10.mkv',
],
);
assert.deepEqual(
items.map((item) => item.episodeLabel),
['S1E1', 'S1E2', 'S1E3', 'E7', 'S1E10'],
);
});
test('sortPlaylistBrowserDirectoryItems falls back to deterministic natural ordering', () => {
const root = '/library/show';
const items = sortPlaylistBrowserDirectoryItems([
path.join(root, 'Show Part 10.mkv'),
path.join(root, 'Show Part 2.mkv'),
path.join(root, 'Show Part 1.mkv'),
path.join(root, 'Show Special.mkv'),
]);
assert.deepEqual(
items.map((item) => item.basename),
['Show Part 1.mkv', 'Show Part 2.mkv', 'Show Part 10.mkv', 'Show Special.mkv'],
);
assert.deepEqual(
items.map((item) => item.episodeLabel),
[null, null, null, null],
);
});

View File

@@ -0,0 +1,129 @@
import path from 'node:path';
type ParsedEpisodeKey = {
season: number | null;
episode: number;
};
type SortToken = string | number;
export type PlaylistBrowserSortedDirectoryItem = {
path: string;
basename: string;
episodeLabel: string | null;
};
const COLLATOR = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
function parseEpisodeKey(basename: string): ParsedEpisodeKey | null {
const name = basename.replace(/\.[^.]+$/, '');
const seasonEpisode = name.match(/(?:^|[^a-z0-9])s(\d{1,2})\s*e(\d{1,3})(?:$|[^a-z0-9])/i);
if (seasonEpisode) {
return {
season: Number(seasonEpisode[1]),
episode: Number(seasonEpisode[2]),
};
}
const seasonByX = name.match(/(?:^|[^a-z0-9])(\d{1,2})x(\d{1,3})(?:$|[^a-z0-9])/i);
if (seasonByX) {
return {
season: Number(seasonByX[1]),
episode: Number(seasonByX[2]),
};
}
const namedEpisode = name.match(
/(?:^|[^a-z0-9])(?:ep|episode|第)\s*(\d{1,3})(?:\s*(?:話|episode|ep))?(?:$|[^a-z0-9])/i,
);
if (namedEpisode) {
return {
season: null,
episode: Number(namedEpisode[1]),
};
}
return null;
}
function buildEpisodeLabel(parsed: ParsedEpisodeKey | null): string | null {
if (!parsed) return null;
if (parsed.season !== null) {
return `S${parsed.season}E${parsed.episode}`;
}
return `E${parsed.episode}`;
}
function tokenizeNaturalSort(basename: string): SortToken[] {
return basename
.toLowerCase()
.split(/(\d+)/)
.filter((token) => token.length > 0)
.map((token) => (/^\d+$/.test(token) ? Number(token) : token));
}
function compareNaturalTokens(left: SortToken[], right: SortToken[]): number {
const maxLength = Math.max(left.length, right.length);
for (let index = 0; index < maxLength; index += 1) {
const a = left[index];
const b = right[index];
if (a === undefined) return -1;
if (b === undefined) return 1;
if (typeof a === 'number' && typeof b === 'number') {
if (a !== b) return a - b;
continue;
}
const comparison = COLLATOR.compare(String(a), String(b));
if (comparison !== 0) return comparison;
}
return 0;
}
export function sortPlaylistBrowserDirectoryItems(
paths: string[],
): PlaylistBrowserSortedDirectoryItem[] {
return paths
.map((pathValue) => {
const basename = path.basename(pathValue);
const parsed = parseEpisodeKey(basename);
return {
path: pathValue,
basename,
parsed,
episodeLabel: buildEpisodeLabel(parsed),
naturalTokens: tokenizeNaturalSort(basename),
};
})
.sort((left, right) => {
if (left.parsed && right.parsed) {
if (
left.parsed.season !== null &&
right.parsed.season !== null &&
left.parsed.season !== right.parsed.season
) {
return left.parsed.season - right.parsed.season;
}
if (left.parsed.episode !== right.parsed.episode) {
return left.parsed.episode - right.parsed.episode;
}
} else if (left.parsed && !right.parsed) {
return -1;
} else if (!left.parsed && right.parsed) {
return 1;
}
const naturalComparison = compareNaturalTokens(left.naturalTokens, right.naturalTokens);
if (naturalComparison !== 0) {
return naturalComparison;
}
return COLLATOR.compare(left.basename, right.basename);
})
.map(({ path: itemPath, basename, episodeLabel }) => ({
path: itemPath,
basename,
episodeLabel,
}));
}