feat: queue videos in running player
This commit is contained in:
@@ -57,7 +57,7 @@ terebi --new-instance --url 'https://www.youtube.com/watch?v=VIDEO_ID' \
|
||||
--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
|
||||
|
||||
@@ -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).
|
||||
|
||||
**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
|
||||
|
||||
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 |
|
||||
| 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) |
|
||||
| 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`) |
|
||||
| `-` / `+` | Back / forward through cached screens, preserving query and selection |
|
||||
| `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 |
|
||||
| Space / Esc | Check/uncheck the hovered video / discard checks |
|
||||
| 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 |
|
||||
| `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`) |
|
||||
|
||||
@@ -11,7 +11,7 @@ Terebi already accepts native Miruro episode URLs, resolves sources and supports
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -120,6 +120,7 @@ actions! {
|
||||
ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"];
|
||||
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"];
|
||||
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"];
|
||||
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"];
|
||||
@@ -487,6 +488,10 @@ impl Bindings {
|
||||
&[VideoPlay],
|
||||
"Play checked videos in displayed order, or the selected video",
|
||||
),
|
||||
(
|
||||
&[VideoEnqueue],
|
||||
"Append checked videos, or the selected video, to Terebi's queue",
|
||||
),
|
||||
(
|
||||
&[VideoToggle],
|
||||
"Check / uncheck the selected video for playback",
|
||||
|
||||
+17
-5
File diff suppressed because one or more lines are too long
+19
-3
@@ -578,6 +578,11 @@ impl Browser {
|
||||
self.push(Screen::new(Content::Series { media, data: None }));
|
||||
self.load_series();
|
||||
} else {
|
||||
self.play(false);
|
||||
}
|
||||
}
|
||||
fn play(&mut self, enqueue: bool) {
|
||||
if self.screen().playable() {
|
||||
let videos = self.screen().playback_videos();
|
||||
if videos.is_empty() {
|
||||
return;
|
||||
@@ -586,15 +591,16 @@ impl Browser {
|
||||
self.screen_mut().error = None;
|
||||
// Dropping the launcher cancels only its CLI child, not the native player.
|
||||
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 {
|
||||
Ok(player) => {
|
||||
self.player = Some(player);
|
||||
self.screen_mut().checked.clear();
|
||||
self.status = if videos.len() == 1 {
|
||||
format!("Opening {} in Terebi…", safe(&videos[0].title))
|
||||
format!("Handing {} to Terebi…", safe(&videos[0].title))
|
||||
} else {
|
||||
format!("Opening {} episodes in Terebi…", videos.len())
|
||||
format!("Handing {} episodes to Terebi…", videos.len())
|
||||
};
|
||||
}
|
||||
Err(error) => self.fail(error),
|
||||
@@ -1210,6 +1216,7 @@ impl Browser {
|
||||
Some(Action::ViewNext) => self.history(true),
|
||||
Some(Action::ViewRefresh) => self.refresh(),
|
||||
Some(Action::VideoPlay) => self.open(),
|
||||
Some(Action::VideoEnqueue) => self.play(true),
|
||||
Some(Action::VideoToggle) => self.screen_mut().toggle_checked(),
|
||||
Some(Action::VideoClear) => self.screen_mut().checked.clear(),
|
||||
Some(Action::VideoCopyUrl) if self.screen().playable() => {
|
||||
@@ -1375,6 +1382,10 @@ impl Browser {
|
||||
Content::Series { .. } | Content::Collection { .. }
|
||||
) {
|
||||
bindings.extend([
|
||||
(
|
||||
Action::VideoEnqueue,
|
||||
"Append checked or selected episodes to Terebi's queue",
|
||||
),
|
||||
(Action::VideoToggle, "Toggle checked episode"),
|
||||
(Action::VideoClear, "Clear checked episodes"),
|
||||
(Action::VideoCopyUrl, "Copy selected episode URL"),
|
||||
@@ -1445,6 +1456,7 @@ impl Browser {
|
||||
];
|
||||
if self.screen().playable() {
|
||||
actions.extend([
|
||||
(Action::VideoEnqueue, "enqueue"),
|
||||
(Action::VideoToggle, "check"),
|
||||
(Action::VideoClear, "clear checks"),
|
||||
]);
|
||||
@@ -1498,6 +1510,7 @@ impl Browser {
|
||||
&[
|
||||
(Action::InfoDescription, "description"),
|
||||
(Action::VideoPlay, "play"),
|
||||
(Action::VideoEnqueue, "enqueue"),
|
||||
(Action::VideoToggle, "check"),
|
||||
(Action::VideoClear, "clear checks"),
|
||||
(Action::InfoHide, "close"),
|
||||
@@ -1510,6 +1523,7 @@ impl Browser {
|
||||
&[
|
||||
(Action::InfoOverview, "info"),
|
||||
(Action::VideoPlay, "play"),
|
||||
(Action::VideoEnqueue, "enqueue"),
|
||||
(Action::VideoToggle, "check"),
|
||||
(Action::VideoClear, "clear checks"),
|
||||
(Action::InfoHide, "close"),
|
||||
@@ -1528,6 +1542,7 @@ impl Browser {
|
||||
(Action::Help, "help"),
|
||||
(Action::ProviderChoose, "provider"),
|
||||
(Action::VideoPlay, "play"),
|
||||
(Action::VideoEnqueue, "enqueue"),
|
||||
(Action::InfoOverview, "info"),
|
||||
(Action::WatchStatusWatching, "mark watching"),
|
||||
(Action::WatchStatusWatched, "mark watched"),
|
||||
@@ -1553,6 +1568,7 @@ impl Browser {
|
||||
"play"
|
||||
},
|
||||
),
|
||||
(Action::VideoEnqueue, "enqueue"),
|
||||
(Action::InfoOverview, "info"),
|
||||
(Action::WatchStatusWatching, "mark watching"),
|
||||
(Action::WatchStatusWatched, "mark watched"),
|
||||
|
||||
+9
-2
@@ -929,6 +929,7 @@ impl PlayerLaunch {
|
||||
videos: &[Video],
|
||||
player: &OsStr,
|
||||
state: &mut crate::video_state::State,
|
||||
enqueue: bool,
|
||||
) -> io::Result<Self> {
|
||||
if videos.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
@@ -937,8 +938,14 @@ impl PlayerLaunch {
|
||||
));
|
||||
}
|
||||
let mut command = isolated_command(player);
|
||||
command.arg("--new-instance");
|
||||
if let [video] = videos {
|
||||
command.arg(if enqueue {
|
||||
"--enqueue"
|
||||
} else {
|
||||
"--new-instance"
|
||||
});
|
||||
if let [video] = videos
|
||||
&& !enqueue
|
||||
{
|
||||
let url = video_url(&video.id)?;
|
||||
let report = state.progress_file(video)?;
|
||||
crate::video_state::register_identity(&report, &video.id)?;
|
||||
|
||||
+12
-5
@@ -193,7 +193,8 @@ fn playback_checks_are_local_to_views_and_previews_and_pruned_with_collections()
|
||||
#[test]
|
||||
fn playback_checks_render_independently_and_live_hints_use_configured_keys() {
|
||||
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>()
|
||||
.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::CollectionDescription,
|
||||
] {
|
||||
for key in ["F20", "F21"] {
|
||||
for key in ["F20", "F21", "F22"] {
|
||||
assert!(app.key_hints.footer(context).contains(key));
|
||||
assert!(app.key_hints.preview.iter().all(|hint| hint.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]
|
||||
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"]);
|
||||
app.views[0].videos = youtube::search_results(
|
||||
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].selection.select(Some(0));
|
||||
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.views[0].checked.contains("rQ_J9WH6CGk"));
|
||||
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
|
||||
app.options.player = "/usr/bin/true".into();
|
||||
app.play();
|
||||
app.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(app.player_launch.is_some());
|
||||
assert!(app.views[0].checked.is_empty());
|
||||
assert_eq!(app.views[0].playback_videos()[0].id, "jNQXAC9IVRw");
|
||||
app.player_launch = None;
|
||||
app.play();
|
||||
app.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(app.player_launch.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_viewport_tracks_selected_two_line_rows_and_resize_without_changing_identity() {
|
||||
|
||||
@@ -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]
|
||||
fn provider_choice_is_configurable_and_normal_only() {
|
||||
let defaults = Bindings::default();
|
||||
|
||||
+24
-8
@@ -647,20 +647,30 @@ fn catalog_and_saved_show_rows_cannot_be_checked() {
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
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().playback_videos().is_empty());
|
||||
assert!(
|
||||
!browser
|
||||
.bindings()
|
||||
.iter()
|
||||
.any(|(action, _)| { matches!(action, Action::VideoToggle | Action::VideoClear) })
|
||||
);
|
||||
assert!(!browser.bindings().iter().any(|(action, _)| {
|
||||
matches!(
|
||||
action,
|
||||
Action::VideoToggle | Action::VideoClear | Action::VideoEnqueue
|
||||
)
|
||||
}));
|
||||
browser
|
||||
.state
|
||||
.save_show(&media("first"), &BTreeSet::new())
|
||||
.unwrap();
|
||||
browser.library(BTreeSet::new());
|
||||
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().playback_videos().is_empty());
|
||||
enter_series(&mut browser);
|
||||
@@ -726,6 +736,11 @@ fn collection_checks_prune_hidden_and_removed_episodes_without_resurrection() {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
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 mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
@@ -744,15 +759,16 @@ fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() {
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &hovered.id)
|
||||
);
|
||||
browser.open();
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(browser.screen().error.is_some());
|
||||
assert_eq!(browser.screen().checked, BTreeSet::from([first.id.clone()]));
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, first.id);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.options.player = "/usr/bin/true".into();
|
||||
browser.open();
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(browser.screen().error.is_none());
|
||||
assert!(browser.player.is_some());
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, hovered.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1417,13 +1417,13 @@ fn player_launch_rejects_invalid_and_duplicate_batch_identities_before_creating_
|
||||
let mut invalid_video = video();
|
||||
invalid_video.id = id.into();
|
||||
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
|
||||
));
|
||||
}
|
||||
for videos in [vec![], vec![video(), video()]] {
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user