feat: discard playback selection with Escape
This commit is contained in:
@@ -61,10 +61,12 @@ Each submitted selection opens one independent player instance without replacing
|
|||||||
|
|
||||||
### Multiple video selection
|
### Multiple video selection
|
||||||
|
|
||||||
In video and episode lists, **Space** checks/unchecks the hovered row; **Ctrl-Space** clears 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 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.
|
||||||
|
|
||||||
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.
|
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 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.
|
||||||
|
|
||||||
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).
|
||||||
@@ -80,7 +82,7 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit
|
|||||||
| `i` / `/` | Edit the catalog query; Enter searches, Esc cancels |
|
| `i` / `/` | Edit the catalog query; Enter searches, Esc cancels |
|
||||||
| `p` | Reopen the provider chooser in normal mode |
|
| `p` | Reopen the provider chooser in normal mode |
|
||||||
| `j` / `k`, ↓ / ↑; `g` / `G`, Home / End | Select a title or episode |
|
| `j` / `k`, ↓ / ↑; `g` / `G`, Home / End | Select a title or episode |
|
||||||
| Space / Ctrl-Space | Check/uncheck the hovered episode / clear 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) |
|
||||||
| `-` / `+` | 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 |
|
||||||
@@ -115,7 +117,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
|
|||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `j` / `k`, ↓ / ↑ | Select the next / previous video |
|
| `j` / `k`, ↓ / ↑ | Select the next / previous video |
|
||||||
| Space / Ctrl-Space | Check/uncheck the hovered video / clear 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 |
|
||||||
| `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`) |
|
||||||
@@ -124,7 +126,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
|
|||||||
| `/` | Go directly to general YouTube Search |
|
| `/` | Go directly to general YouTube Search |
|
||||||
| `F` (Shift-F) | Go directly to the subscription Feed |
|
| `F` (Shift-F) | Go directly to the subscription Feed |
|
||||||
| `d` | Open video info; press again for the full description, again to return to info |
|
| `d` | Open video info; press again for the full description, again to return to info |
|
||||||
| Esc | Close the preview; otherwise return to the previous view |
|
| Esc | Close a preview first; otherwise discard multi-selection without navigating |
|
||||||
| `-` | Return to the previous view |
|
| `-` | Return to the previous view |
|
||||||
| `+` | Go forward after navigating back |
|
| `+` | Go forward after navigating back |
|
||||||
| `c` | Browse saved categories |
|
| `c` | Browse saved categories |
|
||||||
@@ -361,7 +363,7 @@ This prints TOML to stdout and exits without reading or writing config, starting
|
|||||||
video.info.overview = "I"
|
video.info.overview = "I"
|
||||||
video.info.description = "d"
|
video.info.description = "d"
|
||||||
video.info.hide = ["escape", "backspace"]
|
video.info.hide = ["escape", "backspace"]
|
||||||
view.history.previous = ["-", "escape"]
|
view.history.previous = "-"
|
||||||
view.history.next = "+"
|
view.history.next = "+"
|
||||||
video.play = ["enter", "ctrl+p"]
|
video.play = ["enter", "ctrl+p"]
|
||||||
```
|
```
|
||||||
|
|||||||
+3
-3
@@ -114,7 +114,7 @@ actions! {
|
|||||||
SearchEdit, "search.edit", "Edit general or current-category search", VIEW, ["i"];
|
SearchEdit, "search.edit", "Edit general or current-category search", VIEW, ["i"];
|
||||||
ViewSearch, "view.search", "Go to general YouTube Search", VIEW, ["/"];
|
ViewSearch, "view.search", "Go to general YouTube Search", VIEW, ["/"];
|
||||||
ViewFeed, "view.feed", "Go to the combined subscription Feed", VIEW, ["F"];
|
ViewFeed, "view.feed", "Go to the combined subscription Feed", VIEW, ["F"];
|
||||||
ViewPrevious, "view.history.previous", "Go back in view history", VIEW, ["-", "escape"];
|
ViewPrevious, "view.history.previous", "Go back in view history", VIEW, ["-"];
|
||||||
ViewNext, "view.history.next", "Go forward in view history", VIEW, ["+"];
|
ViewNext, "view.history.next", "Go forward in view history", VIEW, ["+"];
|
||||||
ViewTabNext, "view.tab.next", "Select the next view tab", VIEW, ["tab", "ctrl+tab"];
|
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"];
|
ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"];
|
||||||
@@ -126,7 +126,7 @@ actions! {
|
|||||||
VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"];
|
VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"];
|
||||||
VideoLast, "video.select.last", "Select the last video", VIEW, ["G", "end"];
|
VideoLast, "video.select.last", "Select the last video", VIEW, ["G", "end"];
|
||||||
VideoToggle, "video.select.toggle", "Toggle the selected video's playback check", VIEW, ["space"];
|
VideoToggle, "video.select.toggle", "Toggle the selected video's playback check", VIEW, ["space"];
|
||||||
VideoClear, "video.select.clear", "Clear playback checks in the current view", VIEW, ["ctrl+space"];
|
VideoClear, "video.select.clear", "Discard playback checks in the current view", VIEW, ["escape", "ctrl+space"];
|
||||||
InfoOverview, "video.info.overview", "Show the video overview", VIEW, ["d"];
|
InfoOverview, "video.info.overview", "Show the video overview", VIEW, ["d"];
|
||||||
InfoDescription, "video.info.description", "Show the video description", VIEW, ["d"];
|
InfoDescription, "video.info.description", "Show the video description", VIEW, ["d"];
|
||||||
InfoHide, "video.info.hide", "Close the video preview", &[Context::Info, Context::Description, Context::CollectionInfo, Context::CollectionDescription], ["escape"];
|
InfoHide, "video.info.hide", "Close the video preview", &[Context::Info, Context::Description, Context::CollectionInfo, Context::CollectionDescription], ["escape"];
|
||||||
@@ -782,7 +782,7 @@ fn overlap_winner(context: Context, first: Action, second: Action) -> Option<Act
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if pair(InfoHide, ViewPrevious)
|
if (pair(InfoHide, ViewPrevious) || pair(InfoHide, VideoClear))
|
||||||
&& matches!(
|
&& matches!(
|
||||||
context,
|
context,
|
||||||
Context::Info
|
Context::Info
|
||||||
|
|||||||
+4
-7
File diff suppressed because one or more lines are too long
+6
-2
@@ -1573,7 +1573,7 @@ impl Browser {
|
|||||||
fn markers(&self, id: &str) -> String {
|
fn markers(&self, id: &str) -> String {
|
||||||
let mut markers = String::new();
|
let mut markers = String::new();
|
||||||
for (present, label) in [
|
for (present, label) in [
|
||||||
(self.state.is_watched(id), "✓ watched"),
|
(self.state.is_watched(id), "watched"),
|
||||||
(
|
(
|
||||||
self.state.contains(CollectionKind::WatchLater, id),
|
self.state.contains(CollectionKind::WatchLater, id),
|
||||||
"watch later",
|
"watch later",
|
||||||
@@ -1660,7 +1660,11 @@ impl Browser {
|
|||||||
.is_some_and(|id| self.screen().checked.contains(id))
|
.is_some_and(|id| self.screen().checked.contains(id))
|
||||||
{
|
{
|
||||||
"* "
|
"* "
|
||||||
} else if row.key.as_deref().is_some_and(|id| self.state.is_watched(id)) {
|
} else if row
|
||||||
|
.key
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|id| self.state.is_watched(id))
|
||||||
|
{
|
||||||
"✓ "
|
"✓ "
|
||||||
} else {
|
} else {
|
||||||
" "
|
" "
|
||||||
|
|||||||
+20
-4
@@ -3634,7 +3634,7 @@ fn forward_navigation_skips_unsubscribed_views() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn escape_and_minus_restore_view_history_without_consuming_modal_escape() {
|
fn minus_restores_view_history_without_consuming_modal_escape() {
|
||||||
let mut app = app(&["tubular"]);
|
let mut app = app(&["tubular"]);
|
||||||
app.apply_config(config::Config {
|
app.apply_config(config::Config {
|
||||||
categories: [("Category".into(), Vec::new())].into(),
|
categories: [("Category".into(), Vec::new())].into(),
|
||||||
@@ -3675,7 +3675,7 @@ fn escape_and_minus_restore_view_history_without_consuming_modal_escape() {
|
|||||||
app.views[app.view_index].source,
|
app.views[app.view_index].source,
|
||||||
ViewSource::Channel("beta".into())
|
ViewSource::Channel("beta".into())
|
||||||
);
|
);
|
||||||
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.views[app.view_index].source,
|
app.views[app.view_index].source,
|
||||||
ViewSource::Channel("alpha".into())
|
ViewSource::Channel("alpha".into())
|
||||||
@@ -3691,7 +3691,7 @@ fn escape_and_minus_restore_view_history_without_consuming_modal_escape() {
|
|||||||
app.views[app.view_index].source,
|
app.views[app.view_index].source,
|
||||||
ViewSource::Category("Category".into())
|
ViewSource::Category("Category".into())
|
||||||
);
|
);
|
||||||
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
||||||
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
||||||
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
||||||
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
||||||
@@ -3727,7 +3727,7 @@ fn back_history_uses_source_identity_after_config_reorders_or_removes_views() {
|
|||||||
channels: [("@Alpha".into(), "alpha".into())].into(),
|
channels: [("@Alpha".into(), "alpha".into())].into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
||||||
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
||||||
assert!(app.job.is_none());
|
assert!(app.job.is_none());
|
||||||
}
|
}
|
||||||
@@ -6059,3 +6059,19 @@ fn search_without_a_handle_can_tag_a_registered_channel() {
|
|||||||
);
|
);
|
||||||
std::fs::remove_file(path).unwrap();
|
std::fs::remove_file(path).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_discards_checks_without_consuming_view_history() {
|
||||||
|
let mut app = app(&["tubular"]);
|
||||||
|
app.views[0].loaded = true;
|
||||||
|
app.views[1].loaded = true;
|
||||||
|
app.switch_view(1);
|
||||||
|
app.views[1].checked.insert("jNQXAC9IVRw".into());
|
||||||
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||||
|
assert!(app.views[1].checked.is_empty());
|
||||||
|
assert_eq!(app.view_index, 1);
|
||||||
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||||
|
assert_eq!(app.view_index, 1);
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
||||||
|
assert_eq!(app.view_index, 0);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user