feat: queue videos in running player

This commit is contained in:
2026-10-22 16:02:39 +02:00
parent b12bba9125
commit 585907f876
10 changed files with 193 additions and 73 deletions
+5 -1
View File
@@ -57,7 +57,7 @@ terebi --new-instance --url 'https://www.youtube.com/watch?v=VIDEO_ID' \
--progress-file '/private/session/progress.jsonl' --progress-file '/private/session/progress.jsonl'
``` ```
Each submitted selection opens one independent player instance without replacing existing playback. Tubular creates a private reporting session per video and consumes Terebi's progress records independently of the launcher. `--player` overrides the Terebi CLI executable; it is not a generic mpv/IINA adapter. Tubular reports launcher failures, while later streaming/playback errors appear in Terebi. Quitting Tubular leaves launched players running. Plain Enter opens one independent player instance without replacing existing playback. Cmd/Ctrl+Enter appends to a running player's queue instead. Tubular creates a private reporting session per video and consumes Terebi's progress records independently of the launcher. `--player` overrides the Terebi CLI executable; it is not a generic mpv/IINA adapter. Tubular reports launcher failures, while later streaming/playback errors appear in Terebi. Quitting Tubular leaves launched players running.
### Multiple video selection ### Multiple video selection
@@ -71,6 +71,8 @@ Checks belong to each cached view/screen and follow stable video identities thro
Two or more checked videos launch `terebi --new-instance --queue-file '/private/session/queue.json'`. The private version-1 manifest contains ordered canonical episode/video page URLs and one independent `progress_file` per item. Terebi reuses its queue navigation and resolves each item when selected, not in advance; no expiring playback sources are stored. Each item's watching/watched state and resume identity remain independent, including after Tubular restarts. Single-video launches retain the command above. See the [queue contract](docs/terebi-url-playback-handoff.md#ordered-stream-queue-handoff). Two or more checked videos launch `terebi --new-instance --queue-file '/private/session/queue.json'`. The private version-1 manifest contains ordered canonical episode/video page URLs and one independent `progress_file` per item. Terebi reuses its queue navigation and resolves each item when selected, not in advance; no expiring playback sources are stored. Each item's watching/watched state and resume identity remain independent, including after Tubular restarts. Single-video launches retain the command above. See the [queue contract](docs/terebi-url-playback-handoff.md#ordered-stream-queue-handoff).
**Cmd+Return or Ctrl+Return** (`video.enqueue`) sends the same checked rows, or the hovered row when none are checked, to a running Terebi queue without replacing its current playback. Both bindings are available on every platform; the terminal must transmit the modified Return, or you can rebind the action. Enqueue always uses `terebi --enqueue --queue-file '/private/session/queue.json'`, even for one video, preserving independent per-item progress. If Terebi is not running, it starts with that queue. Plain Return still opens a new instance. These shortcuts do nothing on Miruro catalog titles or saved shows and do not submit text inputs.
### Miruro browser ### Miruro browser
Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, with a separate saved-show library organized by overlapping category tags, without YouTube's Search/Feed tabs or channel controls. Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, with a separate saved-show library organized by overlapping category tags, without YouTube's Search/Feed tabs or channel controls.
@@ -84,6 +86,7 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit
| `j` / `k`, ↓ / ↑; `g` / `G`, Home / End | Select a title or episode | | `j` / `k`, ↓ / ↑; `g` / `G`, Home / End | Select a title or episode |
| Space / Esc | Check/uncheck the hovered episode / discard episode checks | | Space / Esc | Check/uncheck the hovered episode / discard episode checks |
| Enter | Open a series, or play checked episodes in displayed order (hovered episode when none are checked) | | Enter | Open a series, or play checked episodes in displayed order (hovered episode when none are checked) |
| Cmd+Return / Ctrl+Return | Append checked episodes, or the hovered episode, to a running Terebi queue (`video.enqueue`) |
| `y` | Copy the hovered episode's page URL (also in previews and offline collections; `video.copy_url`) | | `y` | Copy the hovered episode's page URL (also in previews and offline collections; `video.copy_url`) |
| `-` / `+` | Back / forward through cached screens, preserving query and selection | | `-` / `+` | Back / forward through cached screens, preserving query and selection |
| `w` / `v` | Save the selected episode to watch later / saved episodes | | `w` / `v` | Save the selected episode to watch later / saved episodes |
@@ -120,6 +123,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
| `j` / `k`, ↓ / ↑ | Select the next / previous video | | `j` / `k`, ↓ / ↑ | Select the next / previous video |
| Space / Esc | Check/uncheck the hovered video / discard checks | | Space / Esc | Check/uncheck the hovered video / discard checks |
| Enter | Play checked videos in displayed order, or the hovered video when none are checked | | Enter | Play checked videos in displayed order, or the hovered video when none are checked |
| Cmd+Return / Ctrl+Return | Append checked videos, or the hovered video, to a running Terebi queue (`video.enqueue`) |
| `p` | Reopen the provider chooser | | `p` | Reopen the provider chooser |
| `o` | Open the selected YouTube video in the default browser (also works in previews; configurable as `video.open`) | | `o` | Open the selected YouTube video in the default browser (also works in previews; configurable as `video.open`) |
| `y` | Copy the hovered video's page URL (also in previews and offline collections; `video.copy_url`) | | `y` | Copy the hovered video's page URL (also in previews and offline collections; `video.copy_url`) |
+22 -2
View File
@@ -11,7 +11,7 @@ Terebi already accepts native Miruro episode URLs, resolves sources and supports
## URL and canonical-route handling ## URL and canonical-route handling
Tubular uses `PlayerLaunch::start(&videos, player, &mut state)` for both providers; the launcher allocates and binds private reporting sessions through `State`. For Miruro, `Identity::from_key(id)?.watch_url()?` validates the stable identity and constructs: Tubular uses `PlayerLaunch::start(&videos, player, &mut state, enqueue)` for both providers; the launcher allocates and binds private reporting sessions through `State`. Plain Enter passes `false`, retaining a separate player; `video.enqueue` passes `true`. For Miruro, `Identity::from_key(id)?.watch_url()?` validates the stable identity and constructs:
```sh ```sh
terebi --new-instance \ terebi --new-instance \
@@ -98,7 +98,7 @@ The version-1 manifest is:
} }
``` ```
The manifest contains 1–4,096 entries. Tubular uses it only for batches of two or more; a single selected item retains `--url` and `--progress-file`. `--queue-file` requires `--new-instance` and an absolute manifest path, and cannot be combined with another media argument or global `--progress-file`. Terebi validates the entire manifest before launch and again at GUI startup: supported page URLs, distinct stable identities, distinct absolute new report destinations, and plain owner-only report directories. POSIX-canonical report paths deduplicate macOS `/var` and `/private/var` aliases. Invalid batches fail rather than silently playing only their first entry. The manifest contains 1–4,096 entries. Plain Enter uses it for batches of two or more; a single item retains `--new-instance --url` and `--progress-file`. Enqueue uses a manifest even for one item. `--queue-file` requires either `--new-instance` or `--enqueue`, an absolute manifest path, and no other media argument or global `--progress-file`; those two launch modes cannot be combined. Terebi validates the entire manifest before delivery and again in the receiving application: supported page URLs, distinct stable identities, distinct absolute new report destinations, and plain owner-only report directories. POSIX-canonical report paths deduplicate macOS `/var` and `/private/var` aliases. Invalid batches fail rather than silently playing only their first entry.
Tubular validates all identities, snapshots and available session capacity before allocating a batch. It creates one existing-format snapshot/reporting session and identity marker per item; it never creates Terebi's report files. The manifest is `0600` inside the first retained `0700` session directory and contains no source URLs or transport credentials. Retention and the shared 4,096-session cap are unchanged; inactive queue entries count toward the cap too. Tubular validates all identities, snapshots and available session capacity before allocating a batch. It creates one existing-format snapshot/reporting session and identity marker per item; it never creates Terebi's report files. The manifest is `0600` inside the first retained `0700` session directory and contains no source URLs or transport credentials. Retention and the shared 4,096-session cap are unchanged; inactive queue entries count toward the cap too.
@@ -112,3 +112,23 @@ Each selected entry reports only to its own registered destination. Switching dr
- Both actual terminal interfaces were exercised with offline saved rows and a capture launcher: checking in reverse order produced a private two-item manifest in displayed order; successful submission cleared checks and the next Enter used single-video URL playback. This fixture did not run Terebi. - Both actual terminal interfaces were exercised with offline saved rows and a capture launcher: checking in reverse order produced a private two-item manifest in displayed order; successful submission cleared checks and the next Enter used single-video URL playback. This fixture did not run Terebi.
- Terebi: `make check-cli check-queue check-reporting check-resolved all` passed. Headless native playback exercised mixed YouTube/Miruro queues, stale-result cancellation, manual navigation, natural-end advancement, fresh resolution/resume on revisits and isolated per-item reports. - Terebi: `make check-cli check-queue check-reporting check-resolved all` passed. Headless native playback exercised mixed YouTube/Miruro queues, stale-result cancellation, manual navigation, natural-end advancement, fresh resolution/resume on revisits and isolated per-item reports.
- The packaged GUI smoke did not complete: native state snapshots stalled. Temporary tracing and probe files were removed. Further UI testing was stopped at the user's request and remains user-owned; no complete GUI smoke or visual-layout pass is claimed. The TODO remains open for that confirmation. - The packaged GUI smoke did not complete: native state snapshots stalled. Temporary tracing and probe files were removed. Further UI testing was stopped at the user's request and remains user-owned; no complete GUI smoke or visual-layout pass is claimed. The TODO remains open for that confirmation.
## Append to a running player
```sh
terebi --enqueue --queue-file '/private/session/queue.json'
```
`video.enqueue` defaults to both Super/Cmd+Return and Ctrl+Return. It shares plain Enter's checked-display-order selection and hovered-row fallback in video/episode lists, previews and offline collections. Miruro catalog/show rows and text inputs do not enqueue. Plain Enter remains a new-instance launch.
The CLI delivers a manifest reference through an explicit-app native URL-open event, so later submissions reach an already running player rather than relying on startup arguments. The receiver appends in supplied order without restarting current playback, changing paused/playing intent, cancelling current resolution or resolving appended items early. An empty or newly launched player starts the first queued item. OS-level delivery to a running packaged app still needs user confirmation; headless receiver checks are not LaunchServices proof.
The complete request is rejected on an identity already in the current queue, a retained report identity, a retained report path, or the original single-video report identity. Removing a row does not release its registration. Registrations are atomic; failed batches do not reserve their valid prefix. The original single-video report remains independently navigable, and unregistered identities cannot leak into it once queue reporting is active. Errors after OS handoff appear in Terebi; Tubular's checks retain the existing clear-on-successful-launcher-spawn policy.
### Enqueue verification
- Tubular: `cargo fmt --all`, `cargo test --locked` (**233 passed**), `cargo build --locked` and built `--help`; Rust launcher diagnostics were clean.
- Actual YouTube and Miruro terminal interfaces: reverse-checked rows produced display-ordered enqueue manifests, cleared checks fell back to the hovered row, Ctrl/Super+Return and an F12 override worked, and plain Return still used `--new-instance --url`. Six handoffs used private `0600` manifests in `0700` session directories with distinct report destinations. The capture launcher did not run Terebi.
- Terebi: `check-cli`, `check-queue`, `check-reporting` and `make all` passed. A regression check first failed on rebinding the original single-video identity and passed after binding it independently of appended reports.
- Headless native enqueue smoke with local video-only HLS passed: repeated delivery while paused/playing, unchanged current selection/revision, lazy mixed-provider append, natural-end advance, navigation/revisits, atomic rejection/rollback, isolated original/appended reports and empty-player startup.
- Full `make check-resolved` stopped in the pre-existing mixed-queue natural-end scenario at `Could not start Core Audio (560227702)` before reaching enqueue. No full A/V-suite pass is claimed. No packaged-GUI/LaunchServices smoke or visual-layout confirmation was performed; the app was built, not installed. Temporary terminal/server fixtures were removed.
+5
View File
@@ -120,6 +120,7 @@ actions! {
ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"]; ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"];
ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"]; ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"];
VideoPlay, "video.play", "Play checked videos in displayed order, or the selected video", VIEW, ["enter"]; VideoPlay, "video.play", "Play checked videos in displayed order, or the selected video", VIEW, ["enter"];
VideoEnqueue, "video.enqueue", "Append checked videos in displayed order, or the selected video, to Terebi's queue", VIEW, ["super+enter", "ctrl+enter"];
VideoOpen, "video.open", "Open the selected YouTube video in the default browser", VIEW, ["o"]; VideoOpen, "video.open", "Open the selected YouTube video in the default browser", VIEW, ["o"];
VideoCopyUrl, "video.copy_url", "Copy the selected video's URL to the terminal clipboard", VIEW, ["y"]; VideoCopyUrl, "video.copy_url", "Copy the selected video's URL to the terminal clipboard", VIEW, ["y"];
VideoNext, "video.select.next", "Select the next video", VIEW, ["j", "down", "ctrl+j"]; VideoNext, "video.select.next", "Select the next video", VIEW, ["j", "down", "ctrl+j"];
@@ -487,6 +488,10 @@ impl Bindings {
&[VideoPlay], &[VideoPlay],
"Play checked videos in displayed order, or the selected video", "Play checked videos in displayed order, or the selected video",
), ),
(
&[VideoEnqueue],
"Append checked videos, or the selected video, to Terebi's queue",
),
( (
&[VideoToggle], &[VideoToggle],
"Check / uncheck the selected video for playback", "Check / uncheck the selected video for playback",
+17 -5
View File
File diff suppressed because one or more lines are too long
+19 -3
View File
@@ -578,6 +578,11 @@ impl Browser {
self.push(Screen::new(Content::Series { media, data: None })); self.push(Screen::new(Content::Series { media, data: None }));
self.load_series(); self.load_series();
} else { } else {
self.play(false);
}
}
fn play(&mut self, enqueue: bool) {
if self.screen().playable() {
let videos = self.screen().playback_videos(); let videos = self.screen().playback_videos();
if videos.is_empty() { if videos.is_empty() {
return; return;
@@ -586,15 +591,16 @@ impl Browser {
self.screen_mut().error = None; self.screen_mut().error = None;
// Dropping the launcher cancels only its CLI child, not the native player. // Dropping the launcher cancels only its CLI child, not the native player.
self.player = None; self.player = None;
let result = PlayerLaunch::start(&videos, &self.options.player, &mut self.state); let result =
PlayerLaunch::start(&videos, &self.options.player, &mut self.state, enqueue);
match result { match result {
Ok(player) => { Ok(player) => {
self.player = Some(player); self.player = Some(player);
self.screen_mut().checked.clear(); self.screen_mut().checked.clear();
self.status = if videos.len() == 1 { self.status = if videos.len() == 1 {
format!("Opening {} in Terebi…", safe(&videos[0].title)) format!("Handing {} to Terebi…", safe(&videos[0].title))
} else { } else {
format!("Opening {} episodes in Terebi…", videos.len()) format!("Handing {} episodes to Terebi…", videos.len())
}; };
} }
Err(error) => self.fail(error), Err(error) => self.fail(error),
@@ -1210,6 +1216,7 @@ impl Browser {
Some(Action::ViewNext) => self.history(true), Some(Action::ViewNext) => self.history(true),
Some(Action::ViewRefresh) => self.refresh(), Some(Action::ViewRefresh) => self.refresh(),
Some(Action::VideoPlay) => self.open(), Some(Action::VideoPlay) => self.open(),
Some(Action::VideoEnqueue) => self.play(true),
Some(Action::VideoToggle) => self.screen_mut().toggle_checked(), Some(Action::VideoToggle) => self.screen_mut().toggle_checked(),
Some(Action::VideoClear) => self.screen_mut().checked.clear(), Some(Action::VideoClear) => self.screen_mut().checked.clear(),
Some(Action::VideoCopyUrl) if self.screen().playable() => { Some(Action::VideoCopyUrl) if self.screen().playable() => {
@@ -1375,6 +1382,10 @@ impl Browser {
Content::Series { .. } | Content::Collection { .. } Content::Series { .. } | Content::Collection { .. }
) { ) {
bindings.extend([ bindings.extend([
(
Action::VideoEnqueue,
"Append checked or selected episodes to Terebi's queue",
),
(Action::VideoToggle, "Toggle checked episode"), (Action::VideoToggle, "Toggle checked episode"),
(Action::VideoClear, "Clear checked episodes"), (Action::VideoClear, "Clear checked episodes"),
(Action::VideoCopyUrl, "Copy selected episode URL"), (Action::VideoCopyUrl, "Copy selected episode URL"),
@@ -1445,6 +1456,7 @@ impl Browser {
]; ];
if self.screen().playable() { if self.screen().playable() {
actions.extend([ actions.extend([
(Action::VideoEnqueue, "enqueue"),
(Action::VideoToggle, "check"), (Action::VideoToggle, "check"),
(Action::VideoClear, "clear checks"), (Action::VideoClear, "clear checks"),
]); ]);
@@ -1498,6 +1510,7 @@ impl Browser {
&[ &[
(Action::InfoDescription, "description"), (Action::InfoDescription, "description"),
(Action::VideoPlay, "play"), (Action::VideoPlay, "play"),
(Action::VideoEnqueue, "enqueue"),
(Action::VideoToggle, "check"), (Action::VideoToggle, "check"),
(Action::VideoClear, "clear checks"), (Action::VideoClear, "clear checks"),
(Action::InfoHide, "close"), (Action::InfoHide, "close"),
@@ -1510,6 +1523,7 @@ impl Browser {
&[ &[
(Action::InfoOverview, "info"), (Action::InfoOverview, "info"),
(Action::VideoPlay, "play"), (Action::VideoPlay, "play"),
(Action::VideoEnqueue, "enqueue"),
(Action::VideoToggle, "check"), (Action::VideoToggle, "check"),
(Action::VideoClear, "clear checks"), (Action::VideoClear, "clear checks"),
(Action::InfoHide, "close"), (Action::InfoHide, "close"),
@@ -1528,6 +1542,7 @@ impl Browser {
(Action::Help, "help"), (Action::Help, "help"),
(Action::ProviderChoose, "provider"), (Action::ProviderChoose, "provider"),
(Action::VideoPlay, "play"), (Action::VideoPlay, "play"),
(Action::VideoEnqueue, "enqueue"),
(Action::InfoOverview, "info"), (Action::InfoOverview, "info"),
(Action::WatchStatusWatching, "mark watching"), (Action::WatchStatusWatching, "mark watching"),
(Action::WatchStatusWatched, "mark watched"), (Action::WatchStatusWatched, "mark watched"),
@@ -1553,6 +1568,7 @@ impl Browser {
"play" "play"
}, },
), ),
(Action::VideoEnqueue, "enqueue"),
(Action::InfoOverview, "info"), (Action::InfoOverview, "info"),
(Action::WatchStatusWatching, "mark watching"), (Action::WatchStatusWatching, "mark watching"),
(Action::WatchStatusWatched, "mark watched"), (Action::WatchStatusWatched, "mark watched"),
+9 -2
View File
@@ -929,6 +929,7 @@ impl PlayerLaunch {
videos: &[Video], videos: &[Video],
player: &OsStr, player: &OsStr,
state: &mut crate::video_state::State, state: &mut crate::video_state::State,
enqueue: bool,
) -> io::Result<Self> { ) -> io::Result<Self> {
if videos.is_empty() { if videos.is_empty() {
return Err(io::Error::new( return Err(io::Error::new(
@@ -937,8 +938,14 @@ impl PlayerLaunch {
)); ));
} }
let mut command = isolated_command(player); let mut command = isolated_command(player);
command.arg("--new-instance"); command.arg(if enqueue {
if let [video] = videos { "--enqueue"
} else {
"--new-instance"
});
if let [video] = videos
&& !enqueue
{
let url = video_url(&video.id)?; let url = video_url(&video.id)?;
let report = state.progress_file(video)?; let report = state.progress_file(video)?;
crate::video_state::register_identity(&report, &video.id)?; crate::video_state::register_identity(&report, &video.id)?;
+12 -5
View File
@@ -193,7 +193,8 @@ fn playback_checks_are_local_to_views_and_previews_and_pruned_with_collections()
#[test] #[test]
fn playback_checks_render_independently_and_live_hints_use_configured_keys() { fn playback_checks_render_independently_and_live_hints_use_configured_keys() {
let mut app = app(&["tubular"]); let mut app = app(&["tubular"]);
let document = "[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n" let document =
"[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n[keys.video]\nenqueue = 'f22'\n"
.parse::<toml_edit::DocumentMut>() .parse::<toml_edit::DocumentMut>()
.unwrap(); .unwrap();
app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap(); app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap();
@@ -206,7 +207,7 @@ fn playback_checks_render_independently_and_live_hints_use_configured_keys() {
Context::CollectionInfo, Context::CollectionInfo,
Context::CollectionDescription, Context::CollectionDescription,
] { ] {
for key in ["F20", "F21"] { for key in ["F20", "F21", "F22"] {
assert!(app.key_hints.footer(context).contains(key)); assert!(app.key_hints.footer(context).contains(key));
assert!(app.key_hints.preview.iter().all(|hint| hint.contains(key))); assert!(app.key_hints.preview.iter().all(|hint| hint.contains(key)));
assert!(app.config.keys.help_text().to_string().contains(key)); assert!(app.config.keys.help_text().to_string().contains(key));
@@ -238,6 +239,11 @@ fn playback_checks_render_independently_and_live_hints_use_configured_keys() {
#[test] #[test]
fn playback_checks_survive_spawn_failure_clear_after_spawn_and_fall_back_to_hover() { fn playback_checks_survive_spawn_failure_clear_after_spawn_and_fall_back_to_hover() {
for modifiers in [
KeyModifiers::NONE,
KeyModifiers::SUPER,
KeyModifiers::CONTROL,
] {
let mut app = app(&["tubular"]); let mut app = app(&["tubular"]);
app.views[0].videos = youtube::search_results( app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#, r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#,
@@ -247,19 +253,20 @@ fn playback_checks_survive_spawn_failure_clear_after_spawn_and_fall_back_to_hove
app.views[0].toggle_checked(); app.views[0].toggle_checked();
app.views[0].selection.select(Some(0)); app.views[0].selection.select(Some(0));
app.options.player = "/nonexistent/tubular-test-player".into(); app.options.player = "/nonexistent/tubular-test-player".into();
app.play(); app.key(KeyEvent::new(KeyCode::Enter, modifiers));
assert!(app.player_launch.is_none()); assert!(app.player_launch.is_none());
assert!(app.views[0].checked.contains("rQ_J9WH6CGk")); assert!(app.views[0].checked.contains("rQ_J9WH6CGk"));
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk"); assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
app.options.player = "/usr/bin/true".into(); app.options.player = "/usr/bin/true".into();
app.play(); app.key(KeyEvent::new(KeyCode::Enter, modifiers));
assert!(app.player_launch.is_some()); assert!(app.player_launch.is_some());
assert!(app.views[0].checked.is_empty()); assert!(app.views[0].checked.is_empty());
assert_eq!(app.views[0].playback_videos()[0].id, "jNQXAC9IVRw"); assert_eq!(app.views[0].playback_videos()[0].id, "jNQXAC9IVRw");
app.player_launch = None; app.player_launch = None;
app.play(); app.key(KeyEvent::new(KeyCode::Enter, modifiers));
assert!(app.player_launch.is_some()); assert!(app.player_launch.is_some());
} }
}
#[test] #[test]
fn title_viewport_tracks_selected_two_line_rows_and_resize_without_changing_identity() { fn title_viewport_tracks_selected_two_line_rows_and_resize_without_changing_identity() {
+33
View File
@@ -60,6 +60,39 @@ fn playback_checks_are_configurable_in_views_without_stealing_modal_space() {
); );
} }
#[test]
fn enqueue_is_configurable_and_only_active_in_video_views() {
let defaults = Bindings::default();
let overrides = configured("[keys.video]\nenqueue = 'f22'\n").unwrap();
for &context in ALL {
let expected = VIEW.contains(&context).then_some(Action::VideoEnqueue);
for modifiers in [KeyModifiers::SUPER, KeyModifiers::CONTROL] {
assert_eq!(
press(&defaults, context, KeyCode::Enter, modifiers),
expected
);
assert_ne!(
press(&overrides, context, KeyCode::Enter, modifiers),
Some(Action::VideoEnqueue),
);
}
assert_eq!(
press(&overrides, context, KeyCode::F(22), KeyModifiers::NONE),
expected,
);
}
assert_eq!(
press(
&defaults,
Context::Normal,
KeyCode::Enter,
KeyModifiers::NONE
),
Some(Action::VideoPlay),
);
assert!(configured("[keys.video]\nenqueue = 'enter'\n").is_err());
}
#[test] #[test]
fn provider_choice_is_configurable_and_normal_only() { fn provider_choice_is_configurable_and_normal_only() {
let defaults = Bindings::default(); let defaults = Bindings::default();
+24 -8
View File
@@ -647,20 +647,30 @@ fn catalog_and_saved_show_rows_cannot_be_checked() {
let mut browser = directory.browser(Some("anime")); let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser); catalog_results(&mut browser);
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let generation = browser.generation;
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert_eq!(browser.generation, generation);
assert!(matches!(browser.screen().content, Content::Catalog { .. }));
assert!(browser.player.is_none());
assert!(browser.screen().checked.is_empty()); assert!(browser.screen().checked.is_empty());
assert!(browser.screen().playback_videos().is_empty()); assert!(browser.screen().playback_videos().is_empty());
assert!( assert!(!browser.bindings().iter().any(|(action, _)| {
!browser matches!(
.bindings() action,
.iter() Action::VideoToggle | Action::VideoClear | Action::VideoEnqueue
.any(|(action, _)| { matches!(action, Action::VideoToggle | Action::VideoClear) }) )
); }));
browser browser
.state .state
.save_show(&media("first"), &BTreeSet::new()) .save_show(&media("first"), &BTreeSet::new())
.unwrap(); .unwrap();
browser.library(BTreeSet::new()); browser.library(BTreeSet::new());
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let generation = browser.generation;
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SUPER));
assert_eq!(browser.generation, generation);
assert!(matches!(browser.screen().content, Content::Library { .. }));
assert!(browser.player.is_none());
assert!(browser.screen().checked.is_empty()); assert!(browser.screen().checked.is_empty());
assert!(browser.screen().playback_videos().is_empty()); assert!(browser.screen().playback_videos().is_empty());
enter_series(&mut browser); enter_series(&mut browser);
@@ -726,6 +736,11 @@ fn collection_checks_prune_hidden_and_removed_episodes_without_resurrection() {
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() { fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() {
for modifiers in [
KeyModifiers::NONE,
KeyModifiers::SUPER,
KeyModifiers::CONTROL,
] {
let directory = Directory::new(); let directory = Directory::new();
let mut browser = directory.browser(Some("anime")); let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser); catalog_results(&mut browser);
@@ -744,15 +759,16 @@ fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() {
.state .state
.contains(CollectionKind::Watching, &hovered.id) .contains(CollectionKind::Watching, &hovered.id)
); );
browser.open(); browser.key(KeyEvent::new(KeyCode::Enter, modifiers));
assert!(browser.screen().error.is_some()); assert!(browser.screen().error.is_some());
assert_eq!(browser.screen().checked, BTreeSet::from([first.id.clone()])); assert_eq!(browser.screen().checked, BTreeSet::from([first.id.clone()]));
assert_eq!(browser.screen().playback_videos()[0].id, first.id); assert_eq!(browser.screen().playback_videos()[0].id, first.id);
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
browser.options.player = "/usr/bin/true".into(); browser.options.player = "/usr/bin/true".into();
browser.open(); browser.key(KeyEvent::new(KeyCode::Enter, modifiers));
assert!(browser.screen().error.is_none()); assert!(browser.screen().error.is_none());
assert!(browser.player.is_some()); assert!(browser.player.is_some());
assert!(browser.screen().checked.is_empty()); assert!(browser.screen().checked.is_empty());
assert_eq!(browser.screen().playback_videos()[0].id, hovered.id); assert_eq!(browser.screen().playback_videos()[0].id, hovered.id);
} }
}
+2 -2
View File
@@ -1417,13 +1417,13 @@ fn player_launch_rejects_invalid_and_duplicate_batch_identities_before_creating_
let mut invalid_video = video(); let mut invalid_video = video();
invalid_video.id = id.into(); invalid_video.id = id.into();
assert!(matches!( assert!(matches!(
PlayerLaunch::start(&[video(), invalid_video], OsStr::new("terebi"), &mut state), PlayerLaunch::start(&[video(), invalid_video], OsStr::new("terebi"), &mut state, false),
Err(error) if error.kind() == io::ErrorKind::InvalidInput Err(error) if error.kind() == io::ErrorKind::InvalidInput
)); ));
} }
for videos in [vec![], vec![video(), video()]] { for videos in [vec![], vec![video(), video()]] {
assert!(matches!( assert!(matches!(
PlayerLaunch::start(&videos, OsStr::new("terebi"), &mut state), PlayerLaunch::start(&videos, OsStr::new("terebi"), &mut state, false),
Err(error) if error.kind() == io::ErrorKind::InvalidInput Err(error) if error.kind() == io::ErrorKind::InvalidInput
)); ));
} }