feat: add watch-later views and Terebi watched-state tracking

This commit is contained in:
2026-10-11 20:08:52 +02:00
parent af8032f12f
commit cf4941c879
5 changed files with 1556 additions and 39 deletions
+99
View File
@@ -105,6 +105,10 @@ actions! {
VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"];
ChannelTags, "channel.tags", "Edit the selected channel's category tags", VIEW, ["s"];
CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"];
WatchLaterSave, "video.watch_later.save", "Save the selected video to watch later", VIEW, ["w"];
WatchLaterBrowse, "view.watch_later.browse", "Browse this category's watch later, or the combined collection elsewhere", &[Context::Normal], ["W"];
WatchLaterFilter, "view.watch_later.filter", "Cycle watch-later filter: all, unwatched, watched", &[Context::Normal], ["f"];
WatchLaterRemove, "view.watch_later.remove", "Confirm removal of the current filtered watch-later subset", &[Context::Normal], ["D"];
SelectorNext, "selector.select.next", "Select the next category or channel", &[Context::SelectorNormal], ["j", "down"];
SelectorPrevious, "selector.select.previous", "Select the previous category or channel", &[Context::SelectorNormal], ["k", "up"];
SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"];
@@ -458,6 +462,21 @@ impl Bindings {
(&[Quit, Interrupt], "Quit"),
],
),
(
"WATCH LATER",
&[
(&[WatchLaterSave], "Save the selected video"),
(
&[WatchLaterBrowse],
"Browse category watch later; combined elsewhere",
),
(&[WatchLaterFilter], "Cycle all / unwatched / watched"),
(
&[WatchLaterRemove],
"Confirm removal of filtered subset (combined: all matches)",
),
],
),
(
"FULL DESCRIPTION",
&[
@@ -948,6 +967,86 @@ mod tests {
);
}
#[test]
fn watch_later_overrides_disable_and_respect_contexts() {
let defaults = Bindings::default();
for (key, action) in [
('w', Action::WatchLaterBrowse),
('d', Action::WatchLaterRemove),
] {
assert_eq!(
press(
&defaults,
Context::Normal,
KeyCode::Char(key),
KeyModifiers::SHIFT
),
Some(action),
);
}
assert_eq!(
press(
&defaults,
Context::Info,
KeyCode::Char('w'),
KeyModifiers::NONE
),
Some(Action::WatchLaterSave),
);
assert_eq!(
press(
&defaults,
Context::Info,
KeyCode::Char('d'),
KeyModifiers::NONE
),
Some(Action::InfoDescription),
);
let bindings = configured(
"[keys.video.watch_later]\nsave = 'alt+w'\n[keys.view.watch_later]\nbrowse = 'shift+x'\nfilter = 'ctrl+f'\nremove = []\n",
)
.unwrap();
for context in ALL {
assert_eq!(
press(&bindings, *context, KeyCode::Char('w'), KeyModifiers::ALT),
VIEW.contains(context).then_some(Action::WatchLaterSave),
);
for (code, modifiers, action) in [
(
KeyCode::Char('x'),
KeyModifiers::SHIFT,
Action::WatchLaterBrowse,
),
(
KeyCode::Char('f'),
KeyModifiers::CONTROL,
Action::WatchLaterFilter,
),
] {
assert_eq!(
press(&bindings, *context, code, modifiers),
(*context == Context::Normal).then_some(action),
);
}
for key in ['w', 'W', 'f', 'D'] {
assert_eq!(
press(&bindings, *context, KeyCode::Char(key), KeyModifiers::NONE),
None,
);
}
}
let help = bindings.help_text().to_string();
assert!(help.contains("Alt+w") && help.contains("X") && help.contains("Ctrl+f"));
assert!(help.contains("unbound") && help.contains("WATCH LATER"));
let collision = configured("[keys]\nview.watch_later.browse = 'w'")
.unwrap_err()
.to_string();
assert!(
collision.contains("view.watch_later.browse")
&& collision.contains("video.watch_later.save")
);
}
#[test]
fn invalid_entries_and_collisions_have_actionable_errors() {
for (source, path) in [