mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
Compare commits
2 Commits
fb212cd575
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
08c6807cb1
|
|||
|
18c6410f24
|
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Rofi menu prompts now keep a space between the prompt text and the input field instead of running into the search placeholder.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
collectVideos,
|
collectVideos,
|
||||||
findRofiTheme,
|
findRofiTheme,
|
||||||
formatPickerLaunchError,
|
formatPickerLaunchError,
|
||||||
|
formatRofiPrompt,
|
||||||
showFzfMenu,
|
showFzfMenu,
|
||||||
showRofiMenu,
|
showRofiMenu,
|
||||||
} from '../picker.js';
|
} from '../picker.js';
|
||||||
@@ -67,6 +68,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: {
|
||||||
@@ -171,7 +200,16 @@ function showRofiIndexMenu(
|
|||||||
themePath: string | null,
|
themePath: string | null,
|
||||||
icons: Array<string | null> = [],
|
icons: Array<string | null> = [],
|
||||||
): number {
|
): number {
|
||||||
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
|
const rofiArgs = [
|
||||||
|
'-dmenu',
|
||||||
|
'-i',
|
||||||
|
'-matching',
|
||||||
|
'fuzzy',
|
||||||
|
'-format',
|
||||||
|
'i',
|
||||||
|
'-p',
|
||||||
|
formatRofiPrompt(prompt),
|
||||||
|
];
|
||||||
const hasIcons = icons.some(Boolean);
|
const hasIcons = icons.some(Boolean);
|
||||||
if (hasIcons) rofiArgs.push('-show-icons');
|
if (hasIcons) rofiArgs.push('-show-icons');
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
@@ -332,17 +370,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 +392,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,
|
||||||
|
|||||||
+16
-1
@@ -3,7 +3,22 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { findRofiTheme } from './picker';
|
import { findRofiTheme, formatRofiPrompt } from './picker';
|
||||||
|
|
||||||
|
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
|
||||||
|
|
||||||
|
test('formatRofiPrompt appends a single trailing space', () => {
|
||||||
|
assert.equal(formatRofiPrompt('Select Video'), 'Select Video ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatRofiPrompt collapses existing trailing whitespace to one space', () => {
|
||||||
|
assert.equal(formatRofiPrompt('Watch History '), 'Watch History ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatRofiPrompt leaves an empty prompt empty', () => {
|
||||||
|
assert.equal(formatRofiPrompt(''), '');
|
||||||
|
assert.equal(formatRofiPrompt(' '), '');
|
||||||
|
});
|
||||||
|
|
||||||
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
|
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -17,13 +17,22 @@ export function escapeShellSingle(value: string): string {
|
|||||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rofi renders the prompt flush against the input field, so keep exactly one
|
||||||
|
* trailing space to separate them.
|
||||||
|
*/
|
||||||
|
export function formatRofiPrompt(prompt: string): string {
|
||||||
|
const trimmed = prompt.trimEnd();
|
||||||
|
return trimmed ? `${trimmed} ` : '';
|
||||||
|
}
|
||||||
|
|
||||||
export function showRofiFlatMenu(
|
export function showRofiFlatMenu(
|
||||||
items: string[],
|
items: string[],
|
||||||
prompt: string,
|
prompt: string,
|
||||||
initialQuery = '',
|
initialQuery = '',
|
||||||
themePath: string | null = null,
|
themePath: string | null = null,
|
||||||
): string {
|
): string {
|
||||||
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', prompt];
|
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', formatRofiPrompt(prompt)];
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
args.push('-theme', themePath);
|
args.push('-theme', themePath);
|
||||||
} else {
|
} else {
|
||||||
@@ -110,7 +119,7 @@ export async function promptOptionalJellyfinSearch(
|
|||||||
themePath: string | null = null,
|
themePath: string | null = null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (useRofi && commandExists('rofi')) {
|
if (useRofi && commandExists('rofi')) {
|
||||||
const rofiArgs = ['-dmenu', '-i', '-p', 'Jellyfin Search (optional)'];
|
const rofiArgs = ['-dmenu', '-i', '-p', formatRofiPrompt('Jellyfin Search (optional)')];
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
rofiArgs.push('-theme', themePath);
|
rofiArgs.push('-theme', themePath);
|
||||||
} else {
|
} else {
|
||||||
@@ -157,7 +166,7 @@ function showRofiIconMenu(
|
|||||||
themePath: string | null = null,
|
themePath: string | null = null,
|
||||||
): number {
|
): number {
|
||||||
if (entries.length === 0) return -1;
|
if (entries.length === 0) return -1;
|
||||||
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', prompt];
|
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', formatRofiPrompt(prompt)];
|
||||||
if (initialQuery) rofiArgs.push('-filter', initialQuery);
|
if (initialQuery) rofiArgs.push('-filter', initialQuery);
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
rofiArgs.push('-theme', themePath);
|
rofiArgs.push('-theme', themePath);
|
||||||
@@ -391,7 +400,7 @@ export function showRofiMenu(
|
|||||||
'-dmenu',
|
'-dmenu',
|
||||||
'-i',
|
'-i',
|
||||||
'-p',
|
'-p',
|
||||||
'Select Video ',
|
formatRofiPrompt('Select Video'),
|
||||||
'-show-icons',
|
'-show-icons',
|
||||||
'-theme-str',
|
'-theme-str',
|
||||||
'configuration { font: "Noto Sans CJK JP Regular 8";}',
|
'configuration { font: "Noto Sans CJK JP Regular 8";}',
|
||||||
|
|||||||
Reference in New Issue
Block a user