feat(launcher): add post-playback history menu with previous episode (#170)

This commit is contained in:
2026-07-22 23:05:53 -07:00
committed by GitHub
parent deae61b211
commit 20b50ec4e1
11 changed files with 543 additions and 22 deletions
+151 -9
View File
@@ -12,6 +12,7 @@ import {
} from '../picker.js';
import {
findNextEpisode,
findPreviousEpisode,
groupHistoryBySeries,
listSeasonDirs,
materializeCoverArt,
@@ -23,6 +24,139 @@ import {
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit';
export interface HistoryPlaybackSelection {
entry: HistorySeriesEntry;
videoPath: string;
themePath?: string | null;
entryIcon?: string | null;
}
interface HistorySessionMenuAction {
kind: HistorySessionAction;
label: string;
}
export function buildHistorySessionActions(
justPlayedPath: string,
previousEpisodePath: string | null,
nextEpisodePath: string | null,
): HistorySessionMenuAction[] {
const actions: HistorySessionMenuAction[] = [];
if (previousEpisodePath) {
actions.push({
kind: 'previous',
label: `Previous episode: ${path.basename(previousEpisodePath)}`,
});
}
actions.push({
kind: 'replay',
label: `Rewatch episode: ${path.basename(justPlayedPath)}`,
});
if (nextEpisodePath) {
actions.push({
kind: 'next',
label: `Play next episode: ${path.basename(nextEpisodePath)}`,
});
}
actions.push(
{ kind: 'browse', label: 'Select / browse episode' },
{ kind: 'quit', label: 'Quit SubMiner' },
);
return actions;
}
interface HistoryPlaybackLoopDeps {
play: (videoPath: string) => Promise<void>;
pickPostPlaybackAction: (input: {
entry: HistorySeriesEntry;
justPlayedPath: string;
previousEpisodePath: string | null;
nextEpisodePath: string | null;
}) => Promise<HistorySessionAction | null>;
findPreviousEpisode: (videoPath: string) => string | null;
findNextEpisode: (videoPath: string) => string | null;
browseEpisodes: (entry: HistorySeriesEntry) => Promise<string | null>;
}
export async function runHistoryPlaybackLoop(
initial: HistoryPlaybackSelection,
deps: HistoryPlaybackLoopDeps,
): Promise<void> {
let videoPath = initial.videoPath;
while (true) {
await deps.play(videoPath);
const previousEpisodePath = deps.findPreviousEpisode(videoPath);
const nextEpisodePath = deps.findNextEpisode(videoPath);
const action = await deps.pickPostPlaybackAction({
entry: initial.entry,
justPlayedPath: videoPath,
previousEpisodePath,
nextEpisodePath,
});
switch (action) {
case 'replay':
break;
case 'previous':
if (!previousEpisodePath) return;
videoPath = previousEpisodePath;
break;
case 'next':
if (!nextEpisodePath) return;
videoPath = nextEpisodePath;
break;
case 'browse': {
const browsedPath = await deps.browseEpisodes(initial.entry);
if (!browsedPath) return;
videoPath = browsedPath;
break;
}
case 'quit':
case null:
return;
}
}
}
export async function runHistorySession(
context: LauncherCommandContext,
play: (videoPath: string) => Promise<void>,
): Promise<boolean> {
const initial = await runHistoryCommand(context);
if (!initial) return false;
await runHistoryPlaybackLoop(initial, {
play,
findPreviousEpisode,
findNextEpisode,
browseEpisodes: async (entry) => browseEpisodes(entry, context, initial.themePath ?? null),
pickPostPlaybackAction: async ({
entry,
justPlayedPath,
previousEpisodePath,
nextEpisodePath,
}) => {
const actions = buildHistorySessionActions(
justPlayedPath,
previousEpisodePath,
nextEpisodePath,
);
const actionIdx = pickIndex(
actions.map((action) => action.label),
entry.displayName,
context.args.useRofi,
initial.themePath ?? null,
actions.map(() => initial.entryIcon ?? null),
);
return actionIdx < 0 ? null : actions[actionIdx]!.kind;
},
});
return true;
}
function checkPickerDependencies(args: Args): void {
if (args.useRofi) {
if (!commandExists('rofi')) fail('Missing dependency: rofi');
@@ -142,7 +276,7 @@ function browseEpisodes(
if (seasons.length > 1) {
const idx = pickIndex(
seasons.map((season) => season.name),
`${entry.displayName} Season`,
`${entry.displayName}: Season`,
args.useRofi,
themePath,
);
@@ -155,7 +289,9 @@ function browseEpisodes(
return pickEpisodeFromDir(dir, context);
}
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
export async function runHistoryCommand(
context: LauncherCommandContext,
): Promise<HistoryPlaybackSelection | null> {
const { args, scriptPath } = context;
checkPickerDependencies(args);
@@ -198,14 +334,15 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
const lastExists = fs.existsSync(lastPath);
const nextEpisode = findNextEpisode(lastPath);
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
const actions: HistorySessionMenuAction[] = [];
if (lastExists) {
actions.push({ kind: 'replay', label: `Replay last watched ${path.basename(lastPath)}` });
actions.push({ kind: 'replay', label: `Replay last watched: ${path.basename(lastPath)}` });
}
if (nextEpisode) {
actions.push({ kind: 'next', label: `Next episode ${path.basename(nextEpisode)}` });
actions.push({ kind: 'next', label: `Next episode: ${path.basename(nextEpisode)}` });
}
actions.push({ kind: 'browse', label: 'Browse episodes' });
actions.push({ kind: 'quit', label: 'Quit SubMiner' });
const entryIcon = seriesIcons[seriesIdx] ?? null;
const actionIdx = pickIndex(
@@ -219,10 +356,15 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
switch (actions[actionIdx]!.kind) {
case 'replay':
return lastPath;
return { entry, videoPath: lastPath, themePath, entryIcon };
case 'next':
return nextEpisode;
case 'browse':
return browseEpisodes(entry, context, themePath);
return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null;
case 'browse': {
const videoPath = browseEpisodes(entry, context, themePath);
return videoPath ? { entry, videoPath, themePath, entryIcon } : null;
}
case 'previous':
case 'quit':
return null;
}
}
+259
View File
@@ -0,0 +1,259 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { buildHistorySessionActions, runHistoryPlaybackLoop } from './history-command.js';
import type { HistorySeriesEntry } from '../history.js';
type HistoryLoop = (
initial: { entry: HistorySeriesEntry; videoPath: string },
deps: {
play: (videoPath: string) => Promise<void>;
pickPostPlaybackAction: (input: {
entry: HistorySeriesEntry;
justPlayedPath: string;
previousEpisodePath: string | null;
nextEpisodePath: string | null;
}) => Promise<'previous' | 'replay' | 'next' | 'browse' | 'quit' | null>;
findPreviousEpisode: (videoPath: string) => string | null;
findNextEpisode: (videoPath: string) => string | null;
browseEpisodes: (entry: HistorySeriesEntry) => Promise<string | null>;
},
) => Promise<void>;
const typedRunHistoryPlaybackLoop: HistoryLoop = runHistoryPlaybackLoop;
function makeEntry(lastWatchedPath: string): HistorySeriesEntry {
return {
seriesRoot: path.dirname(lastWatchedPath),
displayName: 'Test Show',
coverBlobHash: null,
lastWatched: {
videoId: 1,
sourcePath: lastWatchedPath,
parsedTitle: 'Test Show',
parsedSeason: 1,
parsedEpisode: 1,
animeTitle: 'Test Show',
lastWatchedMs: 1,
coverBlobHash: null,
},
};
}
test('history loop plays an initial selection, browsed selection, then quits', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/episode-01.mkv');
const played: string[] = [];
const menuEntries: HistorySeriesEntry[] = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-02.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ entry: menuEntry }) => {
menuEntries.push(menuEntry);
return menuCount++ === 0 ? 'browse' : 'quit';
},
findPreviousEpisode: () => null,
findNextEpisode: () => null,
browseEpisodes: async (browseEntry) => {
assert.equal(browseEntry, entry);
return '/shows/test-show/episode-04.mkv';
},
},
);
assert.deepEqual(played, ['/shows/test-show/episode-02.mkv', '/shows/test-show/episode-04.mkv']);
assert.deepEqual(menuEntries, [entry, entry]);
});
test('history replay uses the actual just-played path instead of the database row', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/stale-episode-01.mkv');
const played: string[] = [];
const menuPaths: string[] = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ justPlayedPath }) => {
menuPaths.push(justPlayedPath);
return menuCount++ === 0 ? 'replay' : 'quit';
},
findPreviousEpisode: () => null,
findNextEpisode: () => '/shows/test-show/episode-08.mkv',
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-07.mkv']);
assert.deepEqual(menuPaths, [
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-07.mkv',
]);
});
test('history next is computed from the actual just-played path', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/stale-episode-01.mkv');
const played: string[] = [];
const nextInputs: string[] = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ nextEpisodePath }) => {
if (menuCount++ === 0) {
assert.equal(nextEpisodePath, '/shows/test-show/episode-08.mkv');
return 'next';
}
assert.equal(nextEpisodePath, null);
return 'quit';
},
findPreviousEpisode: () => null,
findNextEpisode: (videoPath) => {
nextInputs.push(videoPath);
return videoPath.endsWith('episode-07.mkv') ? '/shows/test-show/episode-08.mkv' : null;
},
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-08.mkv']);
assert.deepEqual(nextInputs, [
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-08.mkv',
]);
});
test('history show menu offers previous, rewatch, next, browse, and quit in order', () => {
assert.equal(
typeof buildHistorySessionActions,
'function',
'history session actions are not implemented',
);
assert.deepEqual(
buildHistorySessionActions(
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-06.mkv',
'/shows/test-show/episode-08.mkv',
),
[
{ kind: 'previous', label: 'Previous episode: episode-06.mkv' },
{ kind: 'replay', label: 'Rewatch episode: episode-07.mkv' },
{ kind: 'next', label: 'Play next episode: episode-08.mkv' },
{ kind: 'browse', label: 'Select / browse episode' },
{ kind: 'quit', label: 'Quit SubMiner' },
],
);
});
test('history show menu omits previous and next when the just-played episode has neither', () => {
assert.equal(
typeof buildHistorySessionActions,
'function',
'history session actions are not implemented',
);
assert.deepEqual(buildHistorySessionActions('/shows/test-show/finale.mkv', null, null), [
{ kind: 'replay', label: 'Rewatch episode: finale.mkv' },
{ kind: 'browse', label: 'Select / browse episode' },
{ kind: 'quit', label: 'Quit SubMiner' },
]);
});
test('history playback loop selects previous based on the just-played path, then re-derives previous from the new current episode', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/stale-episode-09.mkv');
const played: string[] = [];
const previousInputs: string[] = [];
const previousSeenByMenu: Array<string | null> = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ previousEpisodePath }) => {
previousSeenByMenu.push(previousEpisodePath);
return menuCount++ === 0 ? 'previous' : 'quit';
},
findPreviousEpisode: (videoPath) => {
previousInputs.push(videoPath);
if (videoPath.endsWith('episode-07.mkv')) return '/shows/test-show/episode-06.mkv';
if (videoPath.endsWith('episode-06.mkv')) return '/shows/test-show/episode-05.mkv';
return null;
},
findNextEpisode: () => null,
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-06.mkv']);
assert.deepEqual(previousInputs, [
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-06.mkv',
]);
assert.deepEqual(previousSeenByMenu, [
'/shows/test-show/episode-06.mkv',
'/shows/test-show/episode-05.mkv',
]);
});
test('history playback loop stops advancing when previous is chosen with no prior episode', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/episode-01.mkv');
const played: string[] = [];
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-01.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ previousEpisodePath }) => {
assert.equal(previousEpisodePath, null);
return 'previous';
},
findPreviousEpisode: () => null,
findNextEpisode: () => null,
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-01.mkv']);
});
+15 -1
View File
@@ -5,7 +5,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { LauncherCommandContext } from './context.js';
import { runPlaybackCommandWithDeps } from './playback-command.js';
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
import { state } from '../mpv.js';
function createContext(): LauncherCommandContext {
@@ -103,6 +103,20 @@ function createContext(): LauncherCommandContext {
};
}
test('playback cleanup signal handlers are registered once across repeated sessions', () => {
assert.equal(typeof registerCleanup, 'function', 'cleanup registration is not exported');
const context = createContext();
const registeredSignals: NodeJS.Signals[] = [];
context.processAdapter.onSignal = (signal) => {
registeredSignals.push(signal);
};
registerCleanup(context);
registerCleanup(context);
assert.deepEqual(registeredSignals, ['SIGINT', 'SIGTERM']);
});
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
const calls: string[] = [];
const context = createContext();
+5 -1
View File
@@ -30,6 +30,7 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const SETUP_POLL_INTERVAL_MS = 500;
const cleanupRegisteredProcessAdapters = new WeakSet<LauncherCommandContext['processAdapter']>();
function getLauncherConfigDir(): string {
return getDefaultConfigDir({
@@ -92,8 +93,10 @@ async function chooseTarget(
return { target: selected, kind: 'file' };
}
function registerCleanup(context: LauncherCommandContext): void {
export function registerCleanup(context: LauncherCommandContext): void {
const { args, processAdapter } = context;
if (cleanupRegisteredProcessAdapters.has(processAdapter)) return;
processAdapter.onSignal('SIGINT', () => {
stopOverlay(args);
processAdapter.exit(130);
@@ -102,6 +105,7 @@ function registerCleanup(context: LauncherCommandContext): void {
stopOverlay(args);
processAdapter.exit(143);
});
cleanupRegisteredProcessAdapters.add(processAdapter);
}
async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise<void> {