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'
|
--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`) |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
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.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
@@ -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)?;
|
||||||
|
|||||||
+30
-23
@@ -193,9 +193,10 @@ 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 =
|
||||||
.parse::<toml_edit::DocumentMut>()
|
"[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n[keys.video]\nenqueue = 'f22'\n"
|
||||||
.unwrap();
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.unwrap();
|
||||||
app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap();
|
app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap();
|
||||||
app.key_hints = KeyHints::new(&app.config.keys);
|
app.key_hints = KeyHints::new(&app.config.keys);
|
||||||
for context in [
|
for context in [
|
||||||
@@ -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,27 +239,33 @@ 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() {
|
||||||
let mut app = app(&["tubular"]);
|
for modifiers in [
|
||||||
app.views[0].videos = youtube::search_results(
|
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"}]}"#,
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#,
|
||||||
).unwrap().videos;
|
).unwrap().videos;
|
||||||
app.views[0].selection.select(Some(1));
|
app.views[0].selection.select(Some(1));
|
||||||
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
|
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
|
||||||
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]
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+51
-35
@@ -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,33 +736,39 @@ 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() {
|
||||||
let directory = Directory::new();
|
for modifiers in [
|
||||||
let mut browser = directory.browser(Some("anime"));
|
KeyModifiers::NONE,
|
||||||
catalog_results(&mut browser);
|
KeyModifiers::SUPER,
|
||||||
enter_series(&mut browser);
|
KeyModifiers::CONTROL,
|
||||||
let first = browser.screen().selected_video().unwrap();
|
] {
|
||||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
let directory = Directory::new();
|
||||||
browser.select(Action::VideoNext);
|
let mut browser = directory.browser(Some("anime"));
|
||||||
let hovered = browser.screen().selected_video().unwrap();
|
catalog_results(&mut browser);
|
||||||
browser.save(CollectionKind::Saved);
|
enter_series(&mut browser);
|
||||||
browser.set_watch_status(WatchStatus::Watching);
|
let first = browser.screen().selected_video().unwrap();
|
||||||
assert!(!browser.state.contains(CollectionKind::Saved, &first.id));
|
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||||
assert!(!browser.state.contains(CollectionKind::Watching, &first.id));
|
browser.select(Action::VideoNext);
|
||||||
assert!(browser.state.contains(CollectionKind::Saved, &hovered.id));
|
let hovered = browser.screen().selected_video().unwrap();
|
||||||
assert!(
|
browser.save(CollectionKind::Saved);
|
||||||
browser
|
browser.set_watch_status(WatchStatus::Watching);
|
||||||
.state
|
assert!(!browser.state.contains(CollectionKind::Saved, &first.id));
|
||||||
.contains(CollectionKind::Watching, &hovered.id)
|
assert!(!browser.state.contains(CollectionKind::Watching, &first.id));
|
||||||
);
|
assert!(browser.state.contains(CollectionKind::Saved, &hovered.id));
|
||||||
browser.open();
|
assert!(
|
||||||
assert!(browser.screen().error.is_some());
|
browser
|
||||||
assert_eq!(browser.screen().checked, BTreeSet::from([first.id.clone()]));
|
.state
|
||||||
assert_eq!(browser.screen().playback_videos()[0].id, first.id);
|
.contains(CollectionKind::Watching, &hovered.id)
|
||||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
);
|
||||||
browser.options.player = "/usr/bin/true".into();
|
browser.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||||
browser.open();
|
assert!(browser.screen().error.is_some());
|
||||||
assert!(browser.screen().error.is_none());
|
assert_eq!(browser.screen().checked, BTreeSet::from([first.id.clone()]));
|
||||||
assert!(browser.player.is_some());
|
assert_eq!(browser.screen().playback_videos()[0].id, first.id);
|
||||||
assert!(browser.screen().checked.is_empty());
|
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||||
assert_eq!(browser.screen().playback_videos()[0].id, hovered.id);
|
browser.options.player = "/usr/bin/true".into();
|
||||||
|
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();
|
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
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user