mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-30 06:12:06 -07:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
326
src/main/runtime/playlist-browser-runtime.test.ts
Normal file
326
src/main/runtime/playlist-browser-runtime.test.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { PlaylistBrowserQueueItem } from '../../types';
|
||||
import {
|
||||
appendPlaylistBrowserFileRuntime,
|
||||
getPlaylistBrowserSnapshotRuntime,
|
||||
movePlaylistBrowserIndexRuntime,
|
||||
playPlaylistBrowserIndexRuntime,
|
||||
removePlaylistBrowserIndexRuntime,
|
||||
} from './playlist-browser-runtime';
|
||||
|
||||
type FakePlaylistEntry = {
|
||||
current?: boolean;
|
||||
playing?: boolean;
|
||||
filename: string;
|
||||
title?: string;
|
||||
id?: number;
|
||||
};
|
||||
|
||||
function createTempVideoDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-playlist-browser-'));
|
||||
}
|
||||
|
||||
function createFakeMpvClient(options: {
|
||||
currentVideoPath: string;
|
||||
playlist: FakePlaylistEntry[];
|
||||
connected?: boolean;
|
||||
}) {
|
||||
let playlist = options.playlist.map((item, index) => ({
|
||||
id: item.id ?? index + 1,
|
||||
current: item.current ?? false,
|
||||
playing: item.playing ?? item.current ?? false,
|
||||
filename: item.filename,
|
||||
title: item.title ?? null,
|
||||
}));
|
||||
const commands: Array<(string | number)[]> = [];
|
||||
|
||||
const syncFlags = (): void => {
|
||||
let playingIndex = playlist.findIndex((item) => item.current || item.playing);
|
||||
if (playingIndex < 0 && playlist.length > 0) {
|
||||
playingIndex = 0;
|
||||
}
|
||||
playlist = playlist.map((item, index) => ({
|
||||
...item,
|
||||
current: index === playingIndex,
|
||||
playing: index === playingIndex,
|
||||
}));
|
||||
};
|
||||
|
||||
syncFlags();
|
||||
|
||||
return {
|
||||
connected: options.connected ?? true,
|
||||
currentVideoPath: options.currentVideoPath,
|
||||
async requestProperty(name: string): Promise<unknown> {
|
||||
if (name === 'playlist') {
|
||||
return playlist;
|
||||
}
|
||||
if (name === 'playlist-playing-pos') {
|
||||
return playlist.findIndex((item) => item.current || item.playing);
|
||||
}
|
||||
if (name === 'path') {
|
||||
return this.currentVideoPath;
|
||||
}
|
||||
throw new Error(`Unexpected property: ${name}`);
|
||||
},
|
||||
send(payload: { command: unknown[] }): boolean {
|
||||
const command = payload.command as (string | number)[];
|
||||
commands.push(command);
|
||||
const [action, first, second] = command;
|
||||
if (action === 'loadfile' && typeof first === 'string' && second === 'append') {
|
||||
playlist.push({
|
||||
id: playlist.length + 1,
|
||||
filename: first,
|
||||
title: null,
|
||||
current: false,
|
||||
playing: false,
|
||||
});
|
||||
syncFlags();
|
||||
return true;
|
||||
}
|
||||
if (action === 'playlist-play-index' && typeof first === 'number' && playlist[first]) {
|
||||
playlist = playlist.map((item, index) => ({
|
||||
...item,
|
||||
current: index === first,
|
||||
playing: index === first,
|
||||
}));
|
||||
this.currentVideoPath = playlist[first]!.filename;
|
||||
return true;
|
||||
}
|
||||
if (action === 'playlist-remove' && typeof first === 'number' && playlist[first]) {
|
||||
const removingCurrent = playlist[first]!.current || playlist[first]!.playing;
|
||||
playlist.splice(first, 1);
|
||||
if (removingCurrent) {
|
||||
syncFlags();
|
||||
this.currentVideoPath =
|
||||
playlist.find((item) => item.current || item.playing)?.filename ?? this.currentVideoPath;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
action === 'playlist-move' &&
|
||||
typeof first === 'number' &&
|
||||
typeof second === 'number' &&
|
||||
playlist[first]
|
||||
) {
|
||||
const [moved] = playlist.splice(first, 1);
|
||||
playlist.splice(second, 0, moved!);
|
||||
syncFlags();
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
getCommands(): Array<(string | number)[]> {
|
||||
return commands;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('getPlaylistBrowserSnapshotRuntime lists sibling videos in best-effort episode order', async () => {
|
||||
const dir = createTempVideoDir();
|
||||
const episode2 = path.join(dir, 'Show - S01E02.mkv');
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
const special = path.join(dir, 'Show - Special.mp4');
|
||||
const ignored = path.join(dir, 'notes.txt');
|
||||
fs.writeFileSync(episode2, '');
|
||||
fs.writeFileSync(episode1, '');
|
||||
fs.writeFileSync(special, '');
|
||||
fs.writeFileSync(ignored, '');
|
||||
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode2,
|
||||
playlist: [
|
||||
{ filename: episode1, current: false, playing: false, title: 'Episode 1' },
|
||||
{ filename: episode2, current: true, playing: true, title: 'Episode 2' },
|
||||
],
|
||||
});
|
||||
|
||||
const snapshot = await getPlaylistBrowserSnapshotRuntime({
|
||||
getMpvClient: () => mpvClient,
|
||||
});
|
||||
|
||||
assert.equal(snapshot.directoryAvailable, true);
|
||||
assert.equal(snapshot.directoryPath, dir);
|
||||
assert.equal(snapshot.currentFilePath, episode2);
|
||||
assert.equal(snapshot.playingIndex, 1);
|
||||
assert.deepEqual(
|
||||
snapshot.directoryItems.map((item) => [item.basename, item.isCurrentFile]),
|
||||
[
|
||||
['Show - S01E01.mkv', false],
|
||||
['Show - S01E02.mkv', true],
|
||||
['Show - Special.mp4', false],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapshot.playlistItems.map((item) => ({
|
||||
index: item.index,
|
||||
displayLabel: item.displayLabel,
|
||||
current: item.current,
|
||||
})),
|
||||
[
|
||||
{ index: 0, displayLabel: 'Episode 1', current: false },
|
||||
{ index: 1, displayLabel: 'Episode 2', current: true },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('getPlaylistBrowserSnapshotRuntime degrades directory pane for remote media', async () => {
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: 'https://example.com/video.m3u8',
|
||||
playlist: [{ filename: 'https://example.com/video.m3u8', current: true }],
|
||||
});
|
||||
|
||||
const snapshot = await getPlaylistBrowserSnapshotRuntime({
|
||||
getMpvClient: () => mpvClient,
|
||||
});
|
||||
|
||||
assert.equal(snapshot.directoryAvailable, false);
|
||||
assert.equal(snapshot.directoryItems.length, 0);
|
||||
assert.match(snapshot.directoryStatus, /local filesystem/i);
|
||||
assert.equal(snapshot.playlistItems.length, 1);
|
||||
});
|
||||
|
||||
test('playlist-browser mutation runtimes mutate queue and return refreshed snapshots', async () => {
|
||||
const dir = createTempVideoDir();
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
const episode2 = path.join(dir, 'Show - S01E02.mkv');
|
||||
const episode3 = path.join(dir, 'Show - S01E03.mkv');
|
||||
fs.writeFileSync(episode1, '');
|
||||
fs.writeFileSync(episode2, '');
|
||||
fs.writeFileSync(episode3, '');
|
||||
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [
|
||||
{ filename: episode1, current: true, title: 'Episode 1' },
|
||||
{ filename: episode2, title: 'Episode 2' },
|
||||
],
|
||||
});
|
||||
|
||||
const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
|
||||
const deps = {
|
||||
getMpvClient: () => mpvClient,
|
||||
schedule: (callback: () => void, delayMs: number) => {
|
||||
scheduled.push({ callback, delayMs });
|
||||
},
|
||||
};
|
||||
|
||||
const appendResult = await appendPlaylistBrowserFileRuntime(deps, episode3);
|
||||
assert.equal(appendResult.ok, true);
|
||||
assert.deepEqual(mpvClient.getCommands().at(-1), ['loadfile', episode3, 'append']);
|
||||
assert.deepEqual(
|
||||
appendResult.snapshot?.playlistItems.map((item) => item.path),
|
||||
[episode1, episode2, episode3],
|
||||
);
|
||||
|
||||
const moveResult = await movePlaylistBrowserIndexRuntime(deps, 2, -1);
|
||||
assert.equal(moveResult.ok, true);
|
||||
assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-move', 2, 1]);
|
||||
assert.deepEqual(
|
||||
moveResult.snapshot?.playlistItems.map((item) => item.path),
|
||||
[episode1, episode3, episode2],
|
||||
);
|
||||
|
||||
const playResult = await playPlaylistBrowserIndexRuntime(deps, 1);
|
||||
assert.equal(playResult.ok, true);
|
||||
assert.deepEqual(mpvClient.getCommands().slice(-2), [
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 1],
|
||||
]);
|
||||
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
|
||||
scheduled[0]?.callback();
|
||||
assert.deepEqual(mpvClient.getCommands().slice(-2), [
|
||||
['set_property', 'sid', 'auto'],
|
||||
['set_property', 'secondary-sid', 'auto'],
|
||||
]);
|
||||
assert.equal(playResult.snapshot?.playingIndex, 1);
|
||||
|
||||
const removeResult = await removePlaylistBrowserIndexRuntime(deps, 2);
|
||||
assert.equal(removeResult.ok, true);
|
||||
assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-remove', 2]);
|
||||
assert.deepEqual(
|
||||
removeResult.snapshot?.playlistItems.map((item) => item.path),
|
||||
[episode1, episode3],
|
||||
);
|
||||
});
|
||||
|
||||
test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', async () => {
|
||||
const dir = createTempVideoDir();
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
const episode2 = path.join(dir, 'Show - S01E02.mkv');
|
||||
fs.writeFileSync(episode1, '');
|
||||
fs.writeFileSync(episode2, '');
|
||||
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [
|
||||
{ filename: episode1, current: true },
|
||||
{ filename: episode2 },
|
||||
],
|
||||
});
|
||||
|
||||
const deps = {
|
||||
getMpvClient: () => mpvClient,
|
||||
};
|
||||
|
||||
const moveUp = await movePlaylistBrowserIndexRuntime(deps, 0, -1);
|
||||
assert.deepEqual(moveUp, {
|
||||
ok: false,
|
||||
message: 'Playlist item is already at the top.',
|
||||
});
|
||||
|
||||
const moveDown = await movePlaylistBrowserIndexRuntime(deps, 1, 1);
|
||||
assert.deepEqual(moveDown, {
|
||||
ok: false,
|
||||
message: 'Playlist item is already at the bottom.',
|
||||
});
|
||||
});
|
||||
|
||||
test('getPlaylistBrowserSnapshotRuntime normalizes playlist labels from title then filename', async () => {
|
||||
const dir = createTempVideoDir();
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
fs.writeFileSync(episode1, '');
|
||||
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [{ filename: episode1, current: true, title: '' }],
|
||||
});
|
||||
|
||||
const snapshot = await getPlaylistBrowserSnapshotRuntime({
|
||||
getMpvClient: () => mpvClient,
|
||||
});
|
||||
|
||||
const item = snapshot.playlistItems[0] as PlaylistBrowserQueueItem;
|
||||
assert.equal(item.displayLabel, 'Show - S01E01.mkv');
|
||||
assert.equal(item.path, episode1);
|
||||
});
|
||||
|
||||
test('playPlaylistBrowserIndexRuntime skips local subtitle reset for remote playlist entries', async () => {
|
||||
const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: 'https://example.com/video-1.m3u8',
|
||||
playlist: [
|
||||
{ filename: 'https://example.com/video-1.m3u8', current: true, title: 'Episode 1' },
|
||||
{ filename: 'https://example.com/video-2.m3u8', title: 'Episode 2' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await playPlaylistBrowserIndexRuntime(
|
||||
{
|
||||
getMpvClient: () => mpvClient,
|
||||
schedule: (callback, delayMs) => {
|
||||
scheduled.push({ callback, delayMs });
|
||||
},
|
||||
},
|
||||
1,
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(mpvClient.getCommands().slice(-1), [['playlist-play-index', 1]]);
|
||||
assert.equal(scheduled.length, 0);
|
||||
});
|
||||
314
src/main/runtime/playlist-browser-runtime.ts
Normal file
314
src/main/runtime/playlist-browser-runtime.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
PlaylistBrowserDirectoryItem,
|
||||
PlaylistBrowserMutationResult,
|
||||
PlaylistBrowserQueueItem,
|
||||
PlaylistBrowserSnapshot,
|
||||
} from '../../types';
|
||||
import { isRemoteMediaPath } from '../../jimaku/utils';
|
||||
import { hasVideoExtension } from '../../shared/video-extensions';
|
||||
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
|
||||
|
||||
type PlaylistLike = {
|
||||
filename?: unknown;
|
||||
title?: unknown;
|
||||
id?: unknown;
|
||||
current?: unknown;
|
||||
playing?: unknown;
|
||||
};
|
||||
|
||||
type MpvPlaylistBrowserClientLike = {
|
||||
connected: boolean;
|
||||
currentVideoPath?: string | null;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
send: (payload: { command: unknown[]; request_id?: number }) => boolean;
|
||||
};
|
||||
|
||||
export type PlaylistBrowserRuntimeDeps = {
|
||||
getMpvClient: () => MpvPlaylistBrowserClientLike | null;
|
||||
schedule?: (callback: () => void, delayMs: number) => void;
|
||||
};
|
||||
|
||||
function trimToNull(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
async function readProperty(
|
||||
client: MpvPlaylistBrowserClientLike | null,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
if (!client?.requestProperty) return null;
|
||||
try {
|
||||
return await client.requestProperty(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCurrentFilePath(
|
||||
client: MpvPlaylistBrowserClientLike | null,
|
||||
): Promise<string | null> {
|
||||
const currentVideoPath = trimToNull(client?.currentVideoPath);
|
||||
if (currentVideoPath) return currentVideoPath;
|
||||
return trimToNull(await readProperty(client, 'path'));
|
||||
}
|
||||
|
||||
function resolveDirectorySnapshot(
|
||||
currentFilePath: string | null,
|
||||
): Pick<PlaylistBrowserSnapshot, 'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'> {
|
||||
if (!currentFilePath) {
|
||||
return {
|
||||
directoryAvailable: false,
|
||||
directoryItems: [],
|
||||
directoryPath: null,
|
||||
directoryStatus: 'Current media path is unavailable.',
|
||||
};
|
||||
}
|
||||
|
||||
if (isRemoteMediaPath(currentFilePath)) {
|
||||
return {
|
||||
directoryAvailable: false,
|
||||
directoryItems: [],
|
||||
directoryPath: null,
|
||||
directoryStatus: 'Directory browser requires a local filesystem video.',
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(currentFilePath);
|
||||
const directoryPath = path.dirname(resolvedPath);
|
||||
try {
|
||||
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
|
||||
const videoPaths = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => hasVideoExtension(path.extname(name)))
|
||||
.map((name) => path.join(directoryPath, name));
|
||||
|
||||
const directoryItems: PlaylistBrowserDirectoryItem[] = sortPlaylistBrowserDirectoryItems(
|
||||
videoPaths,
|
||||
).map((item) => ({
|
||||
...item,
|
||||
isCurrentFile: item.path === resolvedPath,
|
||||
}));
|
||||
|
||||
return {
|
||||
directoryAvailable: true,
|
||||
directoryItems,
|
||||
directoryPath,
|
||||
directoryStatus: directoryPath,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
directoryAvailable: false,
|
||||
directoryItems: [],
|
||||
directoryPath,
|
||||
directoryStatus: `Could not read parent directory: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlaylistItems(raw: unknown): PlaylistBrowserQueueItem[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((entry, index) => {
|
||||
const item = (entry ?? {}) as PlaylistLike;
|
||||
const filename = trimToNull(item.filename) ?? '';
|
||||
const title = trimToNull(item.title);
|
||||
const normalizedPath =
|
||||
filename && !isRemoteMediaPath(filename) ? path.resolve(filename) : trimToNull(filename);
|
||||
return {
|
||||
index,
|
||||
id: typeof item.id === 'number' ? item.id : null,
|
||||
filename,
|
||||
title,
|
||||
displayLabel:
|
||||
title ?? (path.basename(filename || '') || filename || `Playlist item ${index + 1}`),
|
||||
current: item.current === true,
|
||||
playing: item.playing === true,
|
||||
path: normalizedPath,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function ensureConnectedClient(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
): MpvPlaylistBrowserClientLike | { ok: false; message: string } {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'MPV is not connected.',
|
||||
};
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
async function getPlaylistItemsFromClient(
|
||||
client: MpvPlaylistBrowserClientLike | null,
|
||||
): Promise<PlaylistBrowserQueueItem[]> {
|
||||
return normalizePlaylistItems(await readProperty(client, 'playlist'));
|
||||
}
|
||||
|
||||
export async function getPlaylistBrowserSnapshotRuntime(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
): Promise<PlaylistBrowserSnapshot> {
|
||||
const client = deps.getMpvClient();
|
||||
const currentFilePath = await resolveCurrentFilePath(client);
|
||||
const [playlistItems, playingPosValue] = await Promise.all([
|
||||
getPlaylistItemsFromClient(client),
|
||||
readProperty(client, 'playlist-playing-pos'),
|
||||
]);
|
||||
const playingIndex =
|
||||
typeof playingPosValue === 'number' && Number.isInteger(playingPosValue)
|
||||
? playingPosValue
|
||||
: playlistItems.findIndex((item) => item.current || item.playing);
|
||||
|
||||
return {
|
||||
...resolveDirectorySnapshot(currentFilePath),
|
||||
playlistItems,
|
||||
playingIndex: playingIndex >= 0 ? playingIndex : null,
|
||||
currentFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
async function validatePlaylistIndex(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
index: number,
|
||||
): Promise<
|
||||
| { ok: false; message: string }
|
||||
| { ok: true; client: MpvPlaylistBrowserClientLike; playlistItems: PlaylistBrowserQueueItem[] }
|
||||
> {
|
||||
const client = ensureConnectedClient(deps);
|
||||
if ('ok' in client) {
|
||||
return client;
|
||||
}
|
||||
const playlistItems = await getPlaylistItemsFromClient(client);
|
||||
if (!Number.isInteger(index) || index < 0 || index >= playlistItems.length) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Playlist item not found.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
client,
|
||||
playlistItems,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildMutationResult(
|
||||
message: string,
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
): Promise<PlaylistBrowserMutationResult> {
|
||||
return {
|
||||
ok: true,
|
||||
message,
|
||||
snapshot: await getPlaylistBrowserSnapshotRuntime(deps),
|
||||
};
|
||||
}
|
||||
|
||||
function rearmLocalSubtitleSelection(client: MpvPlaylistBrowserClientLike): void {
|
||||
client.send({ command: ['set_property', 'sid', 'auto'] });
|
||||
client.send({ command: ['set_property', 'secondary-sid', 'auto'] });
|
||||
}
|
||||
|
||||
function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void {
|
||||
client.send({ command: ['set_property', 'sub-auto', 'fuzzy'] });
|
||||
}
|
||||
|
||||
function isLocalPlaylistItem(item: PlaylistBrowserQueueItem | null | undefined): item is PlaylistBrowserQueueItem {
|
||||
return Boolean(item?.path && !isRemoteMediaPath(item.path));
|
||||
}
|
||||
|
||||
function scheduleLocalSubtitleSelectionRearm(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
client: MpvPlaylistBrowserClientLike,
|
||||
): void {
|
||||
(deps.schedule ?? setTimeout)(() => {
|
||||
rearmLocalSubtitleSelection(client);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
export async function appendPlaylistBrowserFileRuntime(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
filePath: string,
|
||||
): Promise<PlaylistBrowserMutationResult> {
|
||||
const client = ensureConnectedClient(deps);
|
||||
if ('ok' in client) {
|
||||
return client;
|
||||
}
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Playlist browser file is not readable.',
|
||||
};
|
||||
}
|
||||
|
||||
client.send({ command: ['loadfile', resolvedPath, 'append'] });
|
||||
return buildMutationResult(`Queued ${path.basename(resolvedPath)}`, deps);
|
||||
}
|
||||
|
||||
export async function playPlaylistBrowserIndexRuntime(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
index: number,
|
||||
): Promise<PlaylistBrowserMutationResult> {
|
||||
const result = await validatePlaylistIndex(deps, index);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const targetItem = result.playlistItems[index] ?? null;
|
||||
if (isLocalPlaylistItem(targetItem)) {
|
||||
prepareLocalSubtitleAutoload(result.client);
|
||||
}
|
||||
result.client.send({ command: ['playlist-play-index', index] });
|
||||
if (isLocalPlaylistItem(targetItem)) {
|
||||
scheduleLocalSubtitleSelectionRearm(deps, result.client);
|
||||
}
|
||||
return buildMutationResult(`Playing playlist item ${index + 1}`, deps);
|
||||
}
|
||||
|
||||
export async function removePlaylistBrowserIndexRuntime(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
index: number,
|
||||
): Promise<PlaylistBrowserMutationResult> {
|
||||
const result = await validatePlaylistIndex(deps, index);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.client.send({ command: ['playlist-remove', index] });
|
||||
return buildMutationResult(`Removed playlist item ${index + 1}`, deps);
|
||||
}
|
||||
|
||||
export async function movePlaylistBrowserIndexRuntime(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
index: number,
|
||||
direction: -1 | 1,
|
||||
): Promise<PlaylistBrowserMutationResult> {
|
||||
const result = await validatePlaylistIndex(deps, index);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Playlist item is already at the top.',
|
||||
};
|
||||
}
|
||||
if (targetIndex >= result.playlistItems.length) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Playlist item is already at the bottom.',
|
||||
};
|
||||
}
|
||||
|
||||
result.client.send({ command: ['playlist-move', index, targetIndex] });
|
||||
return buildMutationResult(`Moved playlist item ${index + 1}`, deps);
|
||||
}
|
||||
50
src/main/runtime/playlist-browser-sort.test.ts
Normal file
50
src/main/runtime/playlist-browser-sort.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
|
||||
|
||||
test('sortPlaylistBrowserDirectoryItems prefers parsed season and episode order', () => {
|
||||
const root = '/library/show';
|
||||
const items = sortPlaylistBrowserDirectoryItems([
|
||||
path.join(root, 'Show - S01E10.mkv'),
|
||||
path.join(root, 'Show - S01E02.mkv'),
|
||||
path.join(root, 'Show - S01E01.mkv'),
|
||||
path.join(root, 'Show - Episode 7.mkv'),
|
||||
path.join(root, 'Show - 01x03.mkv'),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.basename),
|
||||
[
|
||||
'Show - S01E01.mkv',
|
||||
'Show - S01E02.mkv',
|
||||
'Show - 01x03.mkv',
|
||||
'Show - Episode 7.mkv',
|
||||
'Show - S01E10.mkv',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.episodeLabel),
|
||||
['S1E1', 'S1E2', 'S1E3', 'E7', 'S1E10'],
|
||||
);
|
||||
});
|
||||
|
||||
test('sortPlaylistBrowserDirectoryItems falls back to deterministic natural ordering', () => {
|
||||
const root = '/library/show';
|
||||
const items = sortPlaylistBrowserDirectoryItems([
|
||||
path.join(root, 'Show Part 10.mkv'),
|
||||
path.join(root, 'Show Part 2.mkv'),
|
||||
path.join(root, 'Show Part 1.mkv'),
|
||||
path.join(root, 'Show Special.mkv'),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.basename),
|
||||
['Show Part 1.mkv', 'Show Part 2.mkv', 'Show Part 10.mkv', 'Show Special.mkv'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.episodeLabel),
|
||||
[null, null, null, null],
|
||||
);
|
||||
});
|
||||
129
src/main/runtime/playlist-browser-sort.ts
Normal file
129
src/main/runtime/playlist-browser-sort.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import path from 'node:path';
|
||||
|
||||
type ParsedEpisodeKey = {
|
||||
season: number | null;
|
||||
episode: number;
|
||||
};
|
||||
|
||||
type SortToken = string | number;
|
||||
|
||||
export type PlaylistBrowserSortedDirectoryItem = {
|
||||
path: string;
|
||||
basename: string;
|
||||
episodeLabel: string | null;
|
||||
};
|
||||
|
||||
const COLLATOR = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
function parseEpisodeKey(basename: string): ParsedEpisodeKey | null {
|
||||
const name = basename.replace(/\.[^.]+$/, '');
|
||||
const seasonEpisode = name.match(/(?:^|[^a-z0-9])s(\d{1,2})\s*e(\d{1,3})(?:$|[^a-z0-9])/i);
|
||||
if (seasonEpisode) {
|
||||
return {
|
||||
season: Number(seasonEpisode[1]),
|
||||
episode: Number(seasonEpisode[2]),
|
||||
};
|
||||
}
|
||||
|
||||
const seasonByX = name.match(/(?:^|[^a-z0-9])(\d{1,2})x(\d{1,3})(?:$|[^a-z0-9])/i);
|
||||
if (seasonByX) {
|
||||
return {
|
||||
season: Number(seasonByX[1]),
|
||||
episode: Number(seasonByX[2]),
|
||||
};
|
||||
}
|
||||
|
||||
const namedEpisode = name.match(
|
||||
/(?:^|[^a-z0-9])(?:ep|episode|第)\s*(\d{1,3})(?:\s*(?:話|episode|ep))?(?:$|[^a-z0-9])/i,
|
||||
);
|
||||
if (namedEpisode) {
|
||||
return {
|
||||
season: null,
|
||||
episode: Number(namedEpisode[1]),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildEpisodeLabel(parsed: ParsedEpisodeKey | null): string | null {
|
||||
if (!parsed) return null;
|
||||
if (parsed.season !== null) {
|
||||
return `S${parsed.season}E${parsed.episode}`;
|
||||
}
|
||||
return `E${parsed.episode}`;
|
||||
}
|
||||
|
||||
function tokenizeNaturalSort(basename: string): SortToken[] {
|
||||
return basename
|
||||
.toLowerCase()
|
||||
.split(/(\d+)/)
|
||||
.filter((token) => token.length > 0)
|
||||
.map((token) => (/^\d+$/.test(token) ? Number(token) : token));
|
||||
}
|
||||
|
||||
function compareNaturalTokens(left: SortToken[], right: SortToken[]): number {
|
||||
const maxLength = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const a = left[index];
|
||||
const b = right[index];
|
||||
if (a === undefined) return -1;
|
||||
if (b === undefined) return 1;
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
if (a !== b) return a - b;
|
||||
continue;
|
||||
}
|
||||
const comparison = COLLATOR.compare(String(a), String(b));
|
||||
if (comparison !== 0) return comparison;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sortPlaylistBrowserDirectoryItems(
|
||||
paths: string[],
|
||||
): PlaylistBrowserSortedDirectoryItem[] {
|
||||
return paths
|
||||
.map((pathValue) => {
|
||||
const basename = path.basename(pathValue);
|
||||
const parsed = parseEpisodeKey(basename);
|
||||
return {
|
||||
path: pathValue,
|
||||
basename,
|
||||
parsed,
|
||||
episodeLabel: buildEpisodeLabel(parsed),
|
||||
naturalTokens: tokenizeNaturalSort(basename),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.parsed && right.parsed) {
|
||||
if (
|
||||
left.parsed.season !== null &&
|
||||
right.parsed.season !== null &&
|
||||
left.parsed.season !== right.parsed.season
|
||||
) {
|
||||
return left.parsed.season - right.parsed.season;
|
||||
}
|
||||
if (left.parsed.episode !== right.parsed.episode) {
|
||||
return left.parsed.episode - right.parsed.episode;
|
||||
}
|
||||
} else if (left.parsed && !right.parsed) {
|
||||
return -1;
|
||||
} else if (!left.parsed && right.parsed) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const naturalComparison = compareNaturalTokens(left.naturalTokens, right.naturalTokens);
|
||||
if (naturalComparison !== 0) {
|
||||
return naturalComparison;
|
||||
}
|
||||
return COLLATOR.compare(left.basename, right.basename);
|
||||
})
|
||||
.map(({ path: itemPath, basename, episodeLabel }) => ({
|
||||
path: itemPath,
|
||||
basename,
|
||||
episodeLabel,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user