From 18c6410f241960933754e300b338e480cb070319 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 25 Jul 2026 23:35:44 -0700 Subject: [PATCH] feat(launcher): add previous episode option to history entry menu - Extract buildHistoryEntryActions to build the series action menu, now including a previous-episode option alongside replay/next/browse/quit - Update docs and changelog entry to describe the new option - Add tests covering previous/replay/next ordering and omission when last watched file is missing --- changes/history-post-playback-menu.md | 1 + docs-site/launcher-script.md | 1 + launcher/commands/history-command.ts | 46 ++++++++++++++++++----- launcher/commands/history-session.test.ts | 37 +++++++++++++++++- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/changes/history-post-playback-menu.md b/changes/history-post-playback-menu.md index 72ae0841..8e65ebd2 100644 --- a/changes/history-post-playback-menu.md +++ b/changes/history-post-playback-menu.md @@ -2,3 +2,4 @@ type: added area: launcher - After a watch-history episode ends or mpv closes, the fzf or rofi launcher returns to that series with options to play the previous episode, rewatch, play the next episode, select another episode, or quit SubMiner. Previous and Next continue across season directories. +- The action menu shown right after picking a series from `subminer -H` now also offers the previous episode, matching the menu shown after playback. diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index 1ed941ee..a14b0ce5 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -73,6 +73,7 @@ subminer -R -H # rofi history browser The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu: +- **Previous episode**: plays the episode before the last watched one and continues into the previous season directory when the season starts - **Replay last watched**: replays the most recently watched episode - **Next episode**: plays the episode after the last watched one and continues into the next season directory when the season ends - **Browse episodes**: lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu appears first diff --git a/launcher/commands/history-command.ts b/launcher/commands/history-command.ts index 567c0b0b..594f370b 100644 --- a/launcher/commands/history-command.ts +++ b/launcher/commands/history-command.ts @@ -67,6 +67,34 @@ export function buildHistorySessionActions( return actions; } +export function buildHistoryEntryActions( + lastWatchedPath: string | null, + previousEpisodePath: string | null, + nextEpisodePath: string | null, +): HistorySessionMenuAction[] { + const actions: HistorySessionMenuAction[] = []; + if (previousEpisodePath) { + actions.push({ + kind: 'previous', + label: `Previous episode: ${path.basename(previousEpisodePath)}`, + }); + } + if (lastWatchedPath) { + actions.push({ + kind: 'replay', + label: `Replay last watched: ${path.basename(lastWatchedPath)}`, + }); + } + if (nextEpisodePath) { + actions.push({ kind: 'next', label: `Next episode: ${path.basename(nextEpisodePath)}` }); + } + actions.push( + { kind: 'browse', label: 'Browse episodes' }, + { kind: 'quit', label: 'Quit SubMiner' }, + ); + return actions; +} + interface HistoryPlaybackLoopDeps { play: (videoPath: string) => Promise; pickPostPlaybackAction: (input: { @@ -332,17 +360,14 @@ export async function runHistoryCommand( const lastPath = path.resolve(entry.lastWatched.sourcePath); const lastExists = fs.existsSync(lastPath); + const previousEpisode = findPreviousEpisode(lastPath); const nextEpisode = findNextEpisode(lastPath); - const actions: HistorySessionMenuAction[] = []; - if (lastExists) { - 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: 'browse', label: 'Browse episodes' }); - actions.push({ kind: 'quit', label: 'Quit SubMiner' }); + const actions = buildHistoryEntryActions( + lastExists ? lastPath : null, + previousEpisode, + nextEpisode, + ); const entryIcon = seriesIcons[seriesIdx] ?? null; const actionIdx = pickIndex( @@ -357,13 +382,14 @@ export async function runHistoryCommand( switch (actions[actionIdx]!.kind) { case 'replay': return { entry, videoPath: lastPath, themePath, entryIcon }; + case 'previous': + return previousEpisode ? { entry, videoPath: previousEpisode, themePath, entryIcon } : null; case 'next': 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; } diff --git a/launcher/commands/history-session.test.ts b/launcher/commands/history-session.test.ts index 9b52663c..43c72183 100644 --- a/launcher/commands/history-session.test.ts +++ b/launcher/commands/history-session.test.ts @@ -1,7 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import path from 'node:path'; -import { buildHistorySessionActions, runHistoryPlaybackLoop } from './history-command.js'; +import { + buildHistoryEntryActions, + buildHistorySessionActions, + runHistoryPlaybackLoop, +} from './history-command.js'; import type { HistorySeriesEntry } from '../history.js'; type HistoryLoop = ( @@ -186,6 +190,37 @@ test('history show menu omits previous and next when the just-played episode has ]); }); +test('history entry menu offers previous, replay, and next before playback starts', () => { + assert.equal( + typeof buildHistoryEntryActions, + 'function', + 'history entry actions not implemented', + ); + + assert.deepEqual( + buildHistoryEntryActions( + '/shows/test-show/episode-03.mkv', + '/shows/test-show/episode-02.mkv', + '/shows/test-show/episode-04.mkv', + ), + [ + { kind: 'previous', label: 'Previous episode: episode-02.mkv' }, + { kind: 'replay', label: 'Replay last watched: episode-03.mkv' }, + { kind: 'next', label: 'Next episode: episode-04.mkv' }, + { kind: 'browse', label: 'Browse episodes' }, + { kind: 'quit', label: 'Quit SubMiner' }, + ], + ); +}); + +test('history entry menu omits replay when the last watched file is gone', () => { + assert.deepEqual(buildHistoryEntryActions(null, null, '/shows/test-show/episode-04.mkv'), [ + { kind: 'next', label: 'Next episode: episode-04.mkv' }, + { kind: 'browse', label: 'Browse episodes' }, + { 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,