fix: preserve video selection order for playback
This commit is contained in:
@@ -63,17 +63,17 @@ Plain Enter opens one independent player instance without replacing existing pla
|
||||
|
||||
### Multiple video selection
|
||||
|
||||
In video and episode lists, **Space** checks/unchecks the hovered row; **Esc** discards checks. **Enter** plays checked rows in their current displayed order, regardless of the order they were checked. With no checks, Enter plays the hovered row. These controls also work in collection views and metadata previews; Miruro catalog titles and saved shows cannot be checked.
|
||||
In video and episode lists, **Space** checks/unchecks the hovered row; **Esc** discards checks. **Enter** plays checked rows in the order they were checked, regardless of their current displayed order. Unchecking and rechecking a row moves it to the end. With no checks, Enter plays the hovered row. These controls also work in collection views and metadata previews; Miruro catalog titles and saved shows cannot be checked.
|
||||
|
||||
Selection and watched state share one marker slot: `*` for checked rows, otherwise `✓` for watched rows, otherwise blank. Checking a row only overrides its displayed marker, not its stored watch state. Category and tag selectors keep their checkboxes.
|
||||
|
||||
In normal browsing, Esc discards checks even when none are selected; `-` remains back navigation. Popups and text inputs keep their existing Esc close/cancel behavior. Ctrl-Space remains an alternative way to clear checks.
|
||||
|
||||
Checks belong to each cached view/screen and follow stable video identities through pagination and reordering. Refresh/replacement queries clear them; collection filtering/removal prunes checks for hidden/removed rows. Successful launcher creation clears checks, while immediate launch failures retain them. Saving, removing and marking watch status still affect the hovered video, not every checked row. Configure `video.select.toggle` and `video.select.clear`; terminals must transmit Ctrl-Space, or bind another chord.
|
||||
Checks belong to each cached view/screen and preserve check order by stable video identity through pagination and reordering. Refresh/replacement queries clear them; collection filtering/removal prunes checks for hidden/removed rows. Successful launcher creation clears checks, while immediate launch failures retain them. Saving, removing and marking watch status still affect the hovered video, not every checked row. Configure `video.select.toggle` and `video.select.clear`; terminals must transmit Ctrl-Space, or bind another chord.
|
||||
|
||||
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).
|
||||
|
||||
**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. 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.
|
||||
**Ctrl+Return** (`video.enqueue`) sends the same checked rows in check order, or the hovered row when none are checked, to a running Terebi queue without replacing its current playback. 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.
|
||||
|
||||
Tubular requests the Kitty keyboard protocol's escape-code disambiguation for its entire terminal session and restores the previous mode on exit. Supporting terminals can then distinguish Ctrl+Return from plain Return. A terminal or multiplexer that does not support/forward this protocol may still send identical bytes for both; Tubular cannot distinguish them. In that case, configure another key, for example `[keys.video]` with `enqueue = "alt+e"`.
|
||||
|
||||
@@ -89,7 +89,7 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit
|
||||
| `p` | Reopen the provider chooser in normal mode |
|
||||
| `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) |
|
||||
| Enter | Open a series, or play checked episodes in check order (hovered episode when none are checked) |
|
||||
| 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 |
|
||||
@@ -126,7 +126,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 |
|
||||
| Enter | Play checked videos in check order, or the hovered video when none are checked |
|
||||
| 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`) |
|
||||
|
||||
+4
-4
@@ -119,8 +119,8 @@ actions! {
|
||||
ViewTabNext, "view.tab.next", "Select the next view tab", VIEW, ["tab", "ctrl+tab"];
|
||||
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, ["ctrl+enter"];
|
||||
VideoPlay, "video.play", "Play checked videos in check order, or the selected video", VIEW, ["enter"];
|
||||
VideoEnqueue, "video.enqueue", "Append checked videos in check order, or the selected video, to Terebi's queue", VIEW, ["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"];
|
||||
@@ -486,11 +486,11 @@ impl Bindings {
|
||||
(&[VideoNext, VideoPrevious], "Select next / previous video"),
|
||||
(
|
||||
&[VideoPlay],
|
||||
"Play checked videos in displayed order, or the selected video",
|
||||
"Play checked videos in check order, or the selected video",
|
||||
),
|
||||
(
|
||||
&[VideoEnqueue],
|
||||
"Append checked videos, or the selected video, to Terebi's queue",
|
||||
"Append checked videos in check order, or the selected video, to Terebi's queue",
|
||||
),
|
||||
(
|
||||
&[VideoToggle],
|
||||
|
||||
+12
-8
File diff suppressed because one or more lines are too long
+26
-21
@@ -93,7 +93,7 @@ struct DisplayRow {
|
||||
struct Screen {
|
||||
content: Content,
|
||||
selection: ListState,
|
||||
checked: BTreeSet<String>,
|
||||
checked: Vec<String>,
|
||||
description_scroll: u16,
|
||||
error: Option<String>,
|
||||
title: String,
|
||||
@@ -106,7 +106,7 @@ impl Screen {
|
||||
let mut screen = Self {
|
||||
content,
|
||||
selection: ListState::default(),
|
||||
checked: BTreeSet::new(),
|
||||
checked: Vec::new(),
|
||||
description_scroll: 0,
|
||||
error: None,
|
||||
title: String::new(),
|
||||
@@ -163,10 +163,13 @@ impl Screen {
|
||||
}
|
||||
fn toggle_checked(&mut self) {
|
||||
if self.playable()
|
||||
&& let Some(id) = self.selected_key().map(str::to_owned)
|
||||
&& let Some(id) = self.selected_key()
|
||||
{
|
||||
if !self.checked.remove(&id) {
|
||||
self.checked.insert(id);
|
||||
if let Some(index) = self.checked.iter().position(|checked| checked == id) {
|
||||
self.checked.remove(index);
|
||||
} else {
|
||||
let id = id.to_owned();
|
||||
self.checked.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,22 +177,24 @@ impl Screen {
|
||||
if self.checked.is_empty() {
|
||||
return self.selected_video().into_iter().collect();
|
||||
}
|
||||
match &self.content {
|
||||
Content::Series {
|
||||
media,
|
||||
data: Some(data),
|
||||
} => data
|
||||
.iter()
|
||||
.filter(|episode| self.checked.contains(&episode.identity.key()))
|
||||
.map(|episode| episode.snapshot(media))
|
||||
.collect(),
|
||||
Content::Collection { videos, .. } => videos
|
||||
.iter()
|
||||
.filter(|video| self.checked.contains(&video.id))
|
||||
.cloned()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
// ponytail: scan rows per check; index IDs if very large lists need it.
|
||||
self.checked
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let index = self
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| row.key.as_ref() == Some(id))?;
|
||||
match &self.content {
|
||||
Content::Series {
|
||||
media,
|
||||
data: Some(data),
|
||||
} => data.get(index).map(|episode| episode.snapshot(media)),
|
||||
Content::Collection { videos, .. } => videos.get(index).cloned(),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn selected_key(&self) -> Option<&str> {
|
||||
self.selection
|
||||
|
||||
+14
-4
@@ -68,7 +68,7 @@ fn app(args: &[&str]) -> App {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_checks_follow_ids_across_feed_sort_and_clear_on_replacement() {
|
||||
fn playback_checks_preserve_check_order_across_feed_sort_and_clear_on_replacement() {
|
||||
let mut app = app(&["tubular"]);
|
||||
app.view_index = 1;
|
||||
app.feed_channels = vec!["https://www.youtube.com/@fixture".into()];
|
||||
@@ -87,7 +87,7 @@ fn playback_checks_follow_ids_across_feed_sort_and_clear_on_replacement() {
|
||||
.iter()
|
||||
.map(|video| video.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["rQ_J9WH6CGk", "jNQXAC9IVRw"],
|
||||
["jNQXAC9IVRw", "rQ_J9WH6CGk"],
|
||||
);
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
@@ -96,6 +96,16 @@ fn playback_checks_follow_ids_across_feed_sort_and_clear_on_replacement() {
|
||||
);
|
||||
let view = &mut app.views[1];
|
||||
assert_eq!(view.selection.selected(), Some(1));
|
||||
assert_eq!(
|
||||
view.playback_videos()
|
||||
.iter()
|
||||
.map(|video| video.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["jNQXAC9IVRw", "rQ_J9WH6CGk"],
|
||||
);
|
||||
view.selection.select(Some(2));
|
||||
view.toggle_checked();
|
||||
view.toggle_checked();
|
||||
assert_eq!(
|
||||
view.playback_videos()
|
||||
.iter()
|
||||
@@ -251,7 +261,7 @@ fn playback_checks_survive_spawn_failure_clear_after_spawn_and_fall_back_to_hove
|
||||
app.options.player = "/nonexistent/tubular-test-player".into();
|
||||
app.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(app.player_launch.is_none());
|
||||
assert!(app.views[0].checked.contains("rQ_J9WH6CGk"));
|
||||
assert!(app.views[0].checked.iter().any(|id| id == "rQ_J9WH6CGk"));
|
||||
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
|
||||
app.options.player = "/usr/bin/true".into();
|
||||
app.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
@@ -6069,7 +6079,7 @@ fn escape_discards_checks_without_consuming_view_history() {
|
||||
app.views[0].loaded = true;
|
||||
app.views[1].loaded = true;
|
||||
app.switch_view(1);
|
||||
app.views[1].checked.insert("jNQXAC9IVRw".into());
|
||||
app.views[1].checked.push("jNQXAC9IVRw".into());
|
||||
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(app.views[1].checked.is_empty());
|
||||
assert_eq!(app.view_index, 1);
|
||||
|
||||
+14
-18
@@ -576,7 +576,7 @@ fn empty_errors_and_query_controls_do_not_create_results_or_commands() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn episode_checks_follow_returned_order_and_cached_screen_history() {
|
||||
fn episode_checks_preserve_check_order_and_cached_screen_history() {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
|
||||
let directory = Directory::new();
|
||||
@@ -595,7 +595,7 @@ fn episode_checks_follow_returned_order_and_cached_screen_history() {
|
||||
.map(|video| video.id)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]);
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
assert!(browser.hints().contains("play checked"));
|
||||
assert!(browser.hints().contains("clear checks"));
|
||||
let mut terminal = Terminal::new(TestBackend::new(120, 32)).unwrap();
|
||||
@@ -610,6 +610,11 @@ fn episode_checks_follow_returned_order_and_cached_screen_history() {
|
||||
.map(|cell| cell.symbol())
|
||||
.collect();
|
||||
assert!(text.contains("2 checked"));
|
||||
browser.select(Action::VideoLast);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(ids(&browser), ["miruro:first:3"]);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]);
|
||||
|
||||
if let Content::Series {
|
||||
data: Some(data), ..
|
||||
@@ -618,13 +623,13 @@ fn episode_checks_follow_returned_order_and_cached_screen_history() {
|
||||
data.reverse();
|
||||
}
|
||||
browser.screen_mut().rebuild_display();
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]);
|
||||
browser.history(false);
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
browser.history(true);
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]);
|
||||
browser.screen_mut().selection.select(None);
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]);
|
||||
browser.select(Action::VideoLast);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
@@ -676,20 +681,14 @@ fn catalog_and_saved_show_rows_cannot_be_checked() {
|
||||
enter_series(&mut browser);
|
||||
assert_eq!(browser.screen().len(), 2);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.screen().checked,
|
||||
BTreeSet::from(["miruro:first:3".into()])
|
||||
);
|
||||
assert_eq!(browser.screen().checked, ["miruro:first:3"]);
|
||||
browser.edit_search();
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(browser.input.as_deref(), Some(" "));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
browser.input = None;
|
||||
browser.history(false);
|
||||
assert_eq!(
|
||||
browser.screen().checked,
|
||||
BTreeSet::from(["miruro:first:3".into()])
|
||||
);
|
||||
assert_eq!(browser.screen().checked, ["miruro:first:3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -715,10 +714,7 @@ fn collection_checks_prune_hidden_and_removed_episodes_without_resurrection() {
|
||||
browser.rebuild_collections();
|
||||
assert_eq!(browser.screen().checked.len(), 2);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.screen().checked,
|
||||
BTreeSet::from([second.id.clone()])
|
||||
);
|
||||
assert_eq!(browser.screen().checked, [second.id.clone()]);
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, second.id);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
@@ -757,7 +753,7 @@ fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() {
|
||||
);
|
||||
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().checked, [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();
|
||||
|
||||
Reference in New Issue
Block a user