mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
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
This commit is contained in:
@@ -2,3 +2,4 @@ type: added
|
|||||||
area: launcher
|
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.
|
- 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.
|
||||||
|
|||||||
@@ -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:
|
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
|
- **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
|
- **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
|
- **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
|
||||||
|
|||||||
@@ -67,6 +67,34 @@ export function buildHistorySessionActions(
|
|||||||
return actions;
|
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 {
|
interface HistoryPlaybackLoopDeps {
|
||||||
play: (videoPath: string) => Promise<void>;
|
play: (videoPath: string) => Promise<void>;
|
||||||
pickPostPlaybackAction: (input: {
|
pickPostPlaybackAction: (input: {
|
||||||
@@ -332,17 +360,14 @@ export async function runHistoryCommand(
|
|||||||
|
|
||||||
const lastPath = path.resolve(entry.lastWatched.sourcePath);
|
const lastPath = path.resolve(entry.lastWatched.sourcePath);
|
||||||
const lastExists = fs.existsSync(lastPath);
|
const lastExists = fs.existsSync(lastPath);
|
||||||
|
const previousEpisode = findPreviousEpisode(lastPath);
|
||||||
const nextEpisode = findNextEpisode(lastPath);
|
const nextEpisode = findNextEpisode(lastPath);
|
||||||
|
|
||||||
const actions: HistorySessionMenuAction[] = [];
|
const actions = buildHistoryEntryActions(
|
||||||
if (lastExists) {
|
lastExists ? lastPath : null,
|
||||||
actions.push({ kind: 'replay', label: `Replay last watched: ${path.basename(lastPath)}` });
|
previousEpisode,
|
||||||
}
|
nextEpisode,
|
||||||
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 entryIcon = seriesIcons[seriesIdx] ?? null;
|
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
||||||
const actionIdx = pickIndex(
|
const actionIdx = pickIndex(
|
||||||
@@ -357,13 +382,14 @@ export async function runHistoryCommand(
|
|||||||
switch (actions[actionIdx]!.kind) {
|
switch (actions[actionIdx]!.kind) {
|
||||||
case 'replay':
|
case 'replay':
|
||||||
return { entry, videoPath: lastPath, themePath, entryIcon };
|
return { entry, videoPath: lastPath, themePath, entryIcon };
|
||||||
|
case 'previous':
|
||||||
|
return previousEpisode ? { entry, videoPath: previousEpisode, themePath, entryIcon } : null;
|
||||||
case 'next':
|
case 'next':
|
||||||
return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null;
|
return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null;
|
||||||
case 'browse': {
|
case 'browse': {
|
||||||
const videoPath = browseEpisodes(entry, context, themePath);
|
const videoPath = browseEpisodes(entry, context, themePath);
|
||||||
return videoPath ? { entry, videoPath, themePath, entryIcon } : null;
|
return videoPath ? { entry, videoPath, themePath, entryIcon } : null;
|
||||||
}
|
}
|
||||||
case 'previous':
|
|
||||||
case 'quit':
|
case 'quit':
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import path from 'node:path';
|
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';
|
import type { HistorySeriesEntry } from '../history.js';
|
||||||
|
|
||||||
type HistoryLoop = (
|
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 () => {
|
test('history playback loop selects previous based on the just-played path, then re-derives previous from the new current episode', async () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
typeof runHistoryPlaybackLoop,
|
typeof runHistoryPlaybackLoop,
|
||||||
|
|||||||
Reference in New Issue
Block a user