feat: save and categorize Miruro shows
This commit is contained in:
+746
-15
@@ -1,4 +1,5 @@
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
io,
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
@@ -77,6 +78,10 @@ enum Content {
|
||||
filter: WatchFilter,
|
||||
videos: Vec<Video>,
|
||||
},
|
||||
Library {
|
||||
categories: BTreeSet<String>,
|
||||
shows: Vec<crate::video_state::SavedShow>,
|
||||
},
|
||||
}
|
||||
struct DisplayRow {
|
||||
key: Option<String>,
|
||||
@@ -115,6 +120,7 @@ impl Screen {
|
||||
Content::Catalog { media, .. } => media.len(),
|
||||
Content::Series { data, .. } => data.as_ref().map_or(0, Vec::len),
|
||||
Content::Collection { videos, .. } => videos.len(),
|
||||
Content::Library { shows, .. } => shows.len(),
|
||||
}
|
||||
}
|
||||
fn clamp(&mut self) {
|
||||
@@ -124,6 +130,17 @@ impl Screen {
|
||||
Some(self.selection.selected().unwrap_or(0).min(self.len() - 1))
|
||||
});
|
||||
}
|
||||
fn selected_media(&self) -> Option<Media> {
|
||||
let index = self.selection.selected();
|
||||
match &self.content {
|
||||
Content::Catalog { media, .. } => index.and_then(|i| media.get(i)).cloned(),
|
||||
Content::Series { media, .. } => Some(media.clone()),
|
||||
Content::Library { shows, .. } => index
|
||||
.and_then(|i| shows.get(i))
|
||||
.map(|show| show.media.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn selected_video(&self) -> Option<Video> {
|
||||
let index = self.selection.selected()?;
|
||||
match &self.content {
|
||||
@@ -166,7 +183,7 @@ impl Screen {
|
||||
self.rows = media
|
||||
.iter()
|
||||
.map(|media| DisplayRow {
|
||||
key: None,
|
||||
key: Some(media.id.clone()),
|
||||
title: safe(&media.title),
|
||||
details: media_summary(media),
|
||||
metadata: media_details(media),
|
||||
@@ -251,6 +268,25 @@ impl Screen {
|
||||
DisplayRow { key: Some(video.id.clone()), title, details: video.publication_date.as_deref().map(safe).unwrap_or_default(), metadata }
|
||||
}).collect();
|
||||
}
|
||||
Content::Library { categories, shows } => {
|
||||
self.title = format!(
|
||||
"Miruro · Saved shows · {}",
|
||||
if categories.is_empty() {
|
||||
"All saved shows".into()
|
||||
} else {
|
||||
categories.iter().cloned().collect::<Vec<_>>().join(" + ")
|
||||
}
|
||||
);
|
||||
self.rows = shows
|
||||
.iter()
|
||||
.map(|show| DisplayRow {
|
||||
key: Some(show.media.id.clone()),
|
||||
title: safe(&show.media.title),
|
||||
details: media_summary(&show.media),
|
||||
metadata: media_details(&show.media),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
self.update_description();
|
||||
}
|
||||
@@ -268,6 +304,7 @@ struct Browser {
|
||||
description_focus: bool,
|
||||
help: Option<u16>,
|
||||
removal: Option<(CollectionKind, Vec<String>)>,
|
||||
modal: Option<LibraryModal>,
|
||||
status: String,
|
||||
worker: Option<Worker>,
|
||||
generation: u64,
|
||||
@@ -281,6 +318,66 @@ enum Intent {
|
||||
Source(String),
|
||||
}
|
||||
|
||||
enum LibraryModal {
|
||||
Tags {
|
||||
media: Media,
|
||||
checked: BTreeSet<String>,
|
||||
names: Vec<String>,
|
||||
picker: Picker,
|
||||
name: Option<String>,
|
||||
error: Option<String>,
|
||||
},
|
||||
Categories {
|
||||
names: Vec<String>,
|
||||
checked: BTreeSet<String>,
|
||||
picker: Picker,
|
||||
},
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Picker {
|
||||
query: String,
|
||||
editing: bool,
|
||||
matches: Vec<usize>,
|
||||
selection: ListState,
|
||||
}
|
||||
impl Picker {
|
||||
fn refilter(&mut self, names: &[String]) {
|
||||
let query = self.query.to_lowercase();
|
||||
self.matches = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, name)| name.to_lowercase().contains(&query))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
self.selection.select(if self.matches.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
self.selection
|
||||
.selected()
|
||||
.unwrap_or(0)
|
||||
.min(self.matches.len() - 1),
|
||||
)
|
||||
});
|
||||
}
|
||||
fn selected<'a>(&self, names: &'a [String]) -> Option<&'a String> {
|
||||
self.selection
|
||||
.selected()
|
||||
.and_then(|i| self.matches.get(i))
|
||||
.and_then(|i| names.get(*i))
|
||||
}
|
||||
fn step(&mut self, down: bool) {
|
||||
if !self.matches.is_empty() {
|
||||
let i = self.selection.selected().unwrap_or(0);
|
||||
self.selection.select(Some(if down {
|
||||
(i + 1).min(self.matches.len() - 1)
|
||||
} else {
|
||||
i.saturating_sub(1)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Browser {
|
||||
fn new(options: Options, config_path: PathBuf, config: config::Config, state: State) -> Self {
|
||||
let mut query = String::new();
|
||||
@@ -300,6 +397,7 @@ impl Browser {
|
||||
description_focus: false,
|
||||
help: None,
|
||||
removal: None,
|
||||
modal: None,
|
||||
status: String::new(),
|
||||
worker: None,
|
||||
generation: 0,
|
||||
@@ -419,16 +517,9 @@ impl Browser {
|
||||
}
|
||||
}
|
||||
fn open(&mut self) {
|
||||
if let Content::Catalog { media, .. } = &self.screen().content {
|
||||
let Some(media) = self
|
||||
.screen()
|
||||
.selection
|
||||
.selected()
|
||||
.and_then(|index| media.get(index))
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(media) = self.screen().selected_media()
|
||||
&& !matches!(self.screen().content, Content::Series { .. })
|
||||
{
|
||||
self.push(Screen::new(Content::Series { media, data: None }));
|
||||
self.load_series();
|
||||
} else if let Some(video) = self.screen().selected_video() {
|
||||
@@ -572,6 +663,226 @@ impl Browser {
|
||||
});
|
||||
self.push(screen);
|
||||
}
|
||||
fn categories(&self) -> BTreeSet<String> {
|
||||
self.state
|
||||
.shows()
|
||||
.iter()
|
||||
.flat_map(|show| show.tags.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
fn tags(&mut self) {
|
||||
let Some(media) = self.screen().selected_media() else {
|
||||
return;
|
||||
};
|
||||
let checked = self
|
||||
.state
|
||||
.shows()
|
||||
.iter()
|
||||
.find(|show| show.media.id == media.id)
|
||||
.map(|show| show.tags.clone())
|
||||
.unwrap_or_default();
|
||||
let names: Vec<_> = self.categories().into_iter().collect();
|
||||
let mut picker = Picker::default();
|
||||
picker.refilter(&names);
|
||||
self.description_focus = false;
|
||||
self.modal = Some(LibraryModal::Tags {
|
||||
media,
|
||||
checked,
|
||||
names,
|
||||
picker,
|
||||
name: None,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
fn category_picker(&mut self) {
|
||||
let mut names = vec!["All saved shows".to_owned()];
|
||||
names.extend(self.categories());
|
||||
let mut picker = Picker::default();
|
||||
picker.refilter(&names);
|
||||
self.description_focus = false;
|
||||
self.modal = Some(LibraryModal::Categories {
|
||||
names,
|
||||
checked: BTreeSet::new(),
|
||||
picker,
|
||||
});
|
||||
}
|
||||
fn library(&mut self, categories: BTreeSet<String>) {
|
||||
let shows = library_shows(&self.state, &categories);
|
||||
self.push(Screen::new(Content::Library { categories, shows }));
|
||||
}
|
||||
fn rebuild_library(&mut self) {
|
||||
for screen in &mut self.screens {
|
||||
if let Content::Library { categories, shows } = &mut screen.content {
|
||||
let selected = screen
|
||||
.selection
|
||||
.selected()
|
||||
.and_then(|i| shows.get(i))
|
||||
.map(|show| show.media.id.clone());
|
||||
let replacement = library_shows(&self.state, categories);
|
||||
let next = selected
|
||||
.and_then(|id| replacement.iter().position(|show| show.media.id == id))
|
||||
.or(screen.selection.selected());
|
||||
*shows = replacement;
|
||||
screen.selection.select(next);
|
||||
screen.clamp();
|
||||
screen.rebuild_display();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn modal_key(&mut self, key: KeyEvent, action: Option<Action>) {
|
||||
if matches!(key.code, KeyCode::Char(ch) if ch.is_control()) {
|
||||
return;
|
||||
}
|
||||
let mut modal = self.modal.take().expect("modal active");
|
||||
if let LibraryModal::Tags {
|
||||
name: Some(name),
|
||||
checked,
|
||||
names,
|
||||
picker,
|
||||
error,
|
||||
..
|
||||
} = &mut modal
|
||||
{
|
||||
match action {
|
||||
Some(Action::InputCancel) => {
|
||||
if let LibraryModal::Tags { name, error, .. } = &mut modal {
|
||||
*name = None;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
Some(Action::InputSubmit) => {
|
||||
let value = name.trim().to_owned();
|
||||
if value.is_empty() || value.chars().any(char::is_control) {
|
||||
*error = Some("Enter a nonempty tag without control characters.".into());
|
||||
} else if names.contains(&value) {
|
||||
*error = Some("That tag already exists.".into());
|
||||
} else {
|
||||
checked.insert(value.clone());
|
||||
names.push(value.clone());
|
||||
names.sort();
|
||||
picker.query.clear();
|
||||
picker.refilter(names);
|
||||
picker
|
||||
.selection
|
||||
.select(names.iter().position(|name| name == &value));
|
||||
if let LibraryModal::Tags { name, error, .. } = &mut modal {
|
||||
*name = None;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
crate::edit_input(name, key, action);
|
||||
}
|
||||
}
|
||||
self.modal = Some(modal);
|
||||
return;
|
||||
}
|
||||
let is_tags = matches!(modal, LibraryModal::Tags { .. });
|
||||
let (names, picker) = match &mut modal {
|
||||
LibraryModal::Tags { names, picker, .. }
|
||||
| LibraryModal::Categories { names, picker, .. } => (names, picker),
|
||||
};
|
||||
let action = if picker.editing {
|
||||
match action {
|
||||
Some(Action::InputCancel) => {
|
||||
picker.editing = false;
|
||||
None
|
||||
}
|
||||
Some(Action::InputSubmit) if is_tags => {
|
||||
picker.editing = false;
|
||||
None
|
||||
}
|
||||
Some(Action::InputSubmit) => Some(Action::SelectorOpen),
|
||||
Some(Action::TagInputNext | Action::SelectorInputNext) => {
|
||||
picker.step(true);
|
||||
None
|
||||
}
|
||||
Some(Action::TagInputPrevious | Action::SelectorInputPrevious) => {
|
||||
picker.step(false);
|
||||
None
|
||||
}
|
||||
_ => {
|
||||
crate::edit_input(&mut picker.query, key, action);
|
||||
picker.refilter(names);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
action
|
||||
};
|
||||
match action {
|
||||
Some(Action::TagCancel | Action::SelectorClose) => return,
|
||||
Some(Action::TagEdit | Action::SelectorEdit) => picker.editing = true,
|
||||
Some(Action::TagNext | Action::SelectorNext) => picker.step(true),
|
||||
Some(Action::TagPrevious | Action::SelectorPrevious) => picker.step(false),
|
||||
Some(Action::TagNew) => {
|
||||
if let LibraryModal::Tags { name, error, .. } = &mut modal {
|
||||
*name = Some(String::new());
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
Some(Action::TagToggle | Action::SelectorToggle) => {
|
||||
if let Some(selected) = picker.selected(names).cloned() {
|
||||
let all = !is_tags
|
||||
&& picker.matches.get(picker.selection.selected().unwrap_or(0)) == Some(&0);
|
||||
let checked = match &mut modal {
|
||||
LibraryModal::Tags { checked, .. }
|
||||
| LibraryModal::Categories { checked, .. } => checked,
|
||||
};
|
||||
if all {
|
||||
checked.clear();
|
||||
} else if !checked.remove(&selected) {
|
||||
checked.insert(selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Action::TagSave) => {
|
||||
if let LibraryModal::Tags {
|
||||
media,
|
||||
checked,
|
||||
error,
|
||||
..
|
||||
} = &mut modal
|
||||
{
|
||||
match self.state.save_show(media, checked) {
|
||||
Ok(()) => {
|
||||
self.rebuild_library();
|
||||
self.status = "Saved show and category tags.".into();
|
||||
return;
|
||||
}
|
||||
Err(failure) => *error = Some(safe(&failure.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Action::SelectorOpen) => {
|
||||
if let LibraryModal::Categories {
|
||||
names,
|
||||
checked,
|
||||
picker,
|
||||
} = &modal
|
||||
{
|
||||
if !checked.is_empty() {
|
||||
self.library(checked.clone());
|
||||
return;
|
||||
}
|
||||
if let Some(selected) = picker.selected(names) {
|
||||
let mut categories = checked.clone();
|
||||
if picker.matches.get(picker.selection.selected().unwrap_or(0)) == Some(&0)
|
||||
{
|
||||
categories.clear();
|
||||
} else {
|
||||
categories.insert(selected.clone());
|
||||
}
|
||||
self.library(categories);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.modal = Some(modal);
|
||||
}
|
||||
fn rebuild_collections(&mut self) {
|
||||
for screen in &mut self.screens {
|
||||
let Content::Collection {
|
||||
@@ -633,6 +944,18 @@ impl Browser {
|
||||
}
|
||||
}
|
||||
fn remove(&mut self, bulk: bool) {
|
||||
if matches!(self.screen().content, Content::Library { .. }) {
|
||||
if !bulk && let Some(media) = self.screen().selected_media() {
|
||||
match self.state.remove_show(&media.id) {
|
||||
Ok(()) => {
|
||||
self.rebuild_library();
|
||||
self.status = "Removed saved show; episode state unchanged.".into();
|
||||
}
|
||||
Err(error) => self.fail(error),
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Content::Collection { kind, videos, .. } = &self.screen().content else {
|
||||
return;
|
||||
};
|
||||
@@ -686,6 +1009,7 @@ impl Browser {
|
||||
match self.screen().content {
|
||||
Content::Catalog { .. } => self.search(),
|
||||
Content::Series { .. } => self.load_series(),
|
||||
Content::Library { .. } => self.rebuild_library(),
|
||||
Content::Collection { .. } => {
|
||||
self.rebuild_collections();
|
||||
self.status = "Collection metadata refreshed.".into();
|
||||
@@ -695,9 +1019,30 @@ impl Browser {
|
||||
fn context(&self) -> Context {
|
||||
if self.help.is_some() {
|
||||
Context::Help
|
||||
} else if let Some(modal) = &self.modal {
|
||||
match modal {
|
||||
LibraryModal::Tags { name: Some(_), .. } => Context::TagInput,
|
||||
LibraryModal::Tags { picker, .. } => {
|
||||
if picker.editing {
|
||||
Context::TagFilter
|
||||
} else {
|
||||
Context::TagList
|
||||
}
|
||||
}
|
||||
LibraryModal::Categories { picker, .. } => {
|
||||
if picker.editing {
|
||||
Context::SelectorInput
|
||||
} else {
|
||||
Context::SelectorNormal
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if self.input.is_some() {
|
||||
Context::SearchInput
|
||||
} else if matches!(self.screen().content, Content::Collection { .. }) {
|
||||
} else if matches!(
|
||||
self.screen().content,
|
||||
Content::Collection { .. } | Content::Library { .. }
|
||||
) {
|
||||
if self.description_focus {
|
||||
Context::CollectionDescription
|
||||
} else {
|
||||
@@ -729,6 +1074,24 @@ impl Browser {
|
||||
screen.update_description();
|
||||
}
|
||||
fn paste(&mut self, text: &str) {
|
||||
if self.help.is_none()
|
||||
&& let Some(modal) = &mut self.modal
|
||||
{
|
||||
match modal {
|
||||
LibraryModal::Tags {
|
||||
name: Some(name), ..
|
||||
} => crate::paste_input(name, text),
|
||||
LibraryModal::Tags { names, picker, .. }
|
||||
| LibraryModal::Categories { names, picker, .. }
|
||||
if picker.editing =>
|
||||
{
|
||||
crate::paste_input(&mut picker.query, text);
|
||||
picker.refilter(names);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.removal.is_none()
|
||||
&& self.help.is_none()
|
||||
&& let Some(input) = &mut self.input
|
||||
@@ -746,6 +1109,14 @@ impl Browser {
|
||||
self.invalidate();
|
||||
return true;
|
||||
}
|
||||
if self.modal.is_some() && self.help.is_none() {
|
||||
if action == Some(Action::Help) {
|
||||
self.help = Some(0);
|
||||
} else {
|
||||
self.modal_key(key, action);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if self.removal.is_some() {
|
||||
match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Enter if key.modifiers.is_empty() => {
|
||||
@@ -791,6 +1162,8 @@ impl Browser {
|
||||
return false;
|
||||
}
|
||||
match action {
|
||||
Some(Action::ChannelTags) => self.tags(),
|
||||
Some(Action::CategoryBrowse) => self.category_picker(),
|
||||
Some(Action::Help) => self.help = Some(0),
|
||||
Some(Action::SearchEdit | Action::ViewSearch) => self.edit_search(),
|
||||
Some(Action::ViewPrevious) => self.history(false),
|
||||
@@ -849,6 +1222,47 @@ impl Browser {
|
||||
false
|
||||
}
|
||||
fn bindings(&self) -> Vec<(Action, &'static str)> {
|
||||
if let Some(modal) = &self.modal {
|
||||
return match modal {
|
||||
LibraryModal::Tags { name: Some(_), .. } => vec![
|
||||
(Action::InputSubmit, "Stage checked tag"),
|
||||
(Action::InputCancel, "Cancel new tag"),
|
||||
(Action::DeleteWord, "Delete word"),
|
||||
(Action::DeleteLine, "Clear name"),
|
||||
],
|
||||
LibraryModal::Tags { picker, .. } if picker.editing => vec![
|
||||
(Action::InputSubmit, "Return to tags"),
|
||||
(Action::InputCancel, "Return to tags"),
|
||||
(Action::TagInputNext, "Next match"),
|
||||
(Action::TagInputPrevious, "Previous match"),
|
||||
(Action::DeleteLine, "Clear filter"),
|
||||
],
|
||||
LibraryModal::Tags { .. } => vec![
|
||||
(Action::TagNext, "Next tag"),
|
||||
(Action::TagPrevious, "Previous tag"),
|
||||
(Action::TagEdit, "Filter tags"),
|
||||
(Action::TagToggle, "Toggle tag"),
|
||||
(Action::TagNew, "New checked tag"),
|
||||
(Action::TagSave, "Save show and tags"),
|
||||
(Action::TagCancel, "Cancel without saving"),
|
||||
],
|
||||
LibraryModal::Categories { picker, .. } if picker.editing => vec![
|
||||
(Action::InputSubmit, "Browse selected categories"),
|
||||
(Action::InputCancel, "Return to selector"),
|
||||
(Action::SelectorInputNext, "Next match"),
|
||||
(Action::SelectorInputPrevious, "Previous match"),
|
||||
(Action::DeleteLine, "Clear filter"),
|
||||
],
|
||||
LibraryModal::Categories { .. } => vec![
|
||||
(Action::SelectorNext, "Next category"),
|
||||
(Action::SelectorPrevious, "Previous category"),
|
||||
(Action::SelectorEdit, "Filter categories"),
|
||||
(Action::SelectorToggle, "Toggle combined category"),
|
||||
(Action::SelectorOpen, "Browse selected categories"),
|
||||
(Action::SelectorClose, "Close"),
|
||||
],
|
||||
};
|
||||
}
|
||||
if self.input.is_some() {
|
||||
return vec![
|
||||
(Action::InputSubmit, "Search catalog"),
|
||||
@@ -864,7 +1278,10 @@ impl Browser {
|
||||
(Action::ViewSearch, "Search catalog"),
|
||||
(
|
||||
Action::VideoPlay,
|
||||
if matches!(self.screen().content, Content::Catalog { .. }) {
|
||||
if matches!(
|
||||
self.screen().content,
|
||||
Content::Catalog { .. } | Content::Library { .. }
|
||||
) {
|
||||
"Open series"
|
||||
} else {
|
||||
"Play episode (fresh source)"
|
||||
@@ -885,12 +1302,25 @@ impl Browser {
|
||||
(Action::Quit, "Exit"),
|
||||
(Action::Interrupt, "Exit immediately"),
|
||||
];
|
||||
bindings.push((Action::CategoryBrowse, "Browse saved shows by category"));
|
||||
if !matches!(self.screen().content, Content::Collection { .. }) {
|
||||
bindings.push((Action::ChannelTags, "Save show / edit category tags"));
|
||||
}
|
||||
if matches!(self.screen().content, Content::Library { .. }) {
|
||||
bindings.push((
|
||||
Action::CollectionRemoveSelected,
|
||||
"Remove selected saved show only",
|
||||
));
|
||||
}
|
||||
if self.config.keys.label(Action::InfoDescription)
|
||||
!= self.config.keys.label(Action::InfoOverview)
|
||||
{
|
||||
bindings.push((Action::InfoDescription, "Show/hide metadata"));
|
||||
}
|
||||
if !matches!(self.screen().content, Content::Catalog { .. }) {
|
||||
if matches!(
|
||||
self.screen().content,
|
||||
Content::Series { .. } | Content::Collection { .. }
|
||||
) {
|
||||
bindings.extend([
|
||||
(Action::WatchLaterSave, "Save episode to watch later"),
|
||||
(Action::SavedVideoSave, "Save episode"),
|
||||
@@ -922,6 +1352,52 @@ impl Browser {
|
||||
bindings
|
||||
}
|
||||
fn hints(&self) -> String {
|
||||
if self.modal.is_some() && self.help.is_none() {
|
||||
return self
|
||||
.bindings()
|
||||
.into_iter()
|
||||
.map(|(action, label)| format!("{} {label}", self.config.keys.label(action)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
}
|
||||
if self.modal.is_none()
|
||||
&& self.help.is_none()
|
||||
&& self.input.is_none()
|
||||
&& self.removal.is_none()
|
||||
&& !self.description_focus
|
||||
{
|
||||
let mut actions = vec![
|
||||
(Action::Help, "help"),
|
||||
(Action::SearchEdit, "search"),
|
||||
(
|
||||
Action::VideoPlay,
|
||||
if matches!(
|
||||
self.screen().content,
|
||||
Content::Catalog { .. } | Content::Library { .. }
|
||||
) {
|
||||
"open"
|
||||
} else {
|
||||
"play"
|
||||
},
|
||||
),
|
||||
(Action::CategoryBrowse, "categories"),
|
||||
];
|
||||
if !matches!(self.screen().content, Content::Collection { .. }) {
|
||||
actions.push((Action::ChannelTags, "save show/tags"));
|
||||
}
|
||||
if matches!(
|
||||
self.screen().content,
|
||||
Content::Library { .. } | Content::Collection { .. }
|
||||
) {
|
||||
actions.push((Action::CollectionRemoveSelected, "remove"));
|
||||
}
|
||||
actions.extend([(Action::ViewPrevious, "back"), (Action::Quit, "quit")]);
|
||||
return actions
|
||||
.into_iter()
|
||||
.map(|(action, label)| format!("{} {label}", self.config.keys.label(action)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
}
|
||||
if self.removal.is_some() {
|
||||
return format!(
|
||||
"y/Enter confirms · n/Esc cancels · {} exits",
|
||||
@@ -1052,6 +1528,16 @@ impl Browser {
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let show = if matches!(
|
||||
self.screen().content,
|
||||
Content::Catalog { .. } | Content::Library { .. }
|
||||
) {
|
||||
row.key
|
||||
.as_deref()
|
||||
.and_then(|id| self.state.shows().iter().find(|show| show.media.id == id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let style = row
|
||||
.key
|
||||
.as_deref()
|
||||
@@ -1062,6 +1548,25 @@ impl Browser {
|
||||
.as_deref()
|
||||
.map(|id| self.markers(id))
|
||||
.unwrap_or_default();
|
||||
let markers = show
|
||||
.map(|show| {
|
||||
format!(
|
||||
"saved show{}",
|
||||
if show.tags.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
" · {}",
|
||||
show.tags
|
||||
.iter()
|
||||
.map(|tag| safe(tag))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ")
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap_or(markers);
|
||||
let separator = if row.details.is_empty() || markers.is_empty() {
|
||||
""
|
||||
} else {
|
||||
@@ -1121,7 +1626,7 @@ impl Browser {
|
||||
self.input.is_some() && self.help.is_none() && self.removal.is_none(),
|
||||
);
|
||||
let title = match self.screen().content {
|
||||
Content::Catalog { .. } => " Titles ",
|
||||
Content::Catalog { .. } | Content::Library { .. } => " Titles ",
|
||||
_ => " Episodes ",
|
||||
};
|
||||
let block = Block::bordered().title(crate::loading_title(
|
||||
@@ -1144,6 +1649,7 @@ impl Browser {
|
||||
Content::Catalog { .. } => "No catalog titles matched. Edit the query to search again.".into(),
|
||||
Content::Series { .. } => "No episodes were returned for this series. Planned counts do not create episodes.".into(),
|
||||
Content::Collection { .. } => "No Miruro episodes in this collection/filter. Save episodes from a series; YouTube entries are not shown here.".into(),
|
||||
Content::Library { .. } => "No saved shows in these categories. Save a catalog title with the show tags action, then confirm.".into(),
|
||||
}
|
||||
};
|
||||
frame.render_widget(
|
||||
@@ -1237,9 +1743,106 @@ impl Browser {
|
||||
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
|
||||
frame.render_widget(block, popup);
|
||||
}
|
||||
if self.help.is_none()
|
||||
&& let Some(modal) = &mut self.modal
|
||||
{
|
||||
let (title, names, checked, picker, name, error) = match modal {
|
||||
LibraryModal::Tags {
|
||||
media,
|
||||
names,
|
||||
checked,
|
||||
picker,
|
||||
name,
|
||||
error,
|
||||
} => (
|
||||
format!(" Save show · {} ", safe(&media.title)),
|
||||
names,
|
||||
checked,
|
||||
picker,
|
||||
name.as_deref(),
|
||||
error.as_deref(),
|
||||
),
|
||||
LibraryModal::Categories {
|
||||
names,
|
||||
checked,
|
||||
picker,
|
||||
} => (
|
||||
" Saved-show categories ".into(),
|
||||
names,
|
||||
checked,
|
||||
picker,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
};
|
||||
frame.render_widget(Clear, popup);
|
||||
let block = Block::bordered().title(title).border_style(ui::ACCENT);
|
||||
let inner = block.inner(popup);
|
||||
frame.render_widget(block, popup);
|
||||
let [input, list, message] = ratatui::layout::Layout::vertical([
|
||||
ratatui::layout::Constraint::Length(3),
|
||||
ratatui::layout::Constraint::Min(0),
|
||||
ratatui::layout::Constraint::Length(2),
|
||||
])
|
||||
.areas(inner);
|
||||
ui::search(
|
||||
frame,
|
||||
input,
|
||||
if name.is_some() {
|
||||
" New checked tag "
|
||||
} else {
|
||||
" Filter "
|
||||
},
|
||||
name.unwrap_or(&picker.query),
|
||||
name.is_some() || picker.editing,
|
||||
);
|
||||
let items: Vec<_> = picker
|
||||
.matches
|
||||
.iter()
|
||||
.map(|i| {
|
||||
ListItem::new(format!(
|
||||
"[{}] {}",
|
||||
if checked.contains(&names[*i]) {
|
||||
"x"
|
||||
} else {
|
||||
" "
|
||||
},
|
||||
safe(&names[*i])
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
frame.render_stateful_widget(
|
||||
List::new(items)
|
||||
.highlight_symbol(ui::SELECTION_MARKER)
|
||||
.highlight_style(ui::SELECTED),
|
||||
list,
|
||||
&mut picker.selection,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
error.unwrap_or("Category tags may overlap; see configured controls below."),
|
||||
)
|
||||
.wrap(Wrap { trim: true }),
|
||||
message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn library_shows(
|
||||
state: &State,
|
||||
categories: &BTreeSet<String>,
|
||||
) -> Vec<crate::video_state::SavedShow> {
|
||||
let mut shows: Vec<_> = state
|
||||
.shows()
|
||||
.iter()
|
||||
.filter(|show| categories.is_empty() || !show.tags.is_disjoint(categories))
|
||||
.cloned()
|
||||
.collect();
|
||||
shows.sort_by_cached_key(|show| show.media.title.to_lowercase());
|
||||
shows
|
||||
}
|
||||
|
||||
fn collection_videos(state: &State, kind: CollectionKind, filter: WatchFilter) -> Vec<Video> {
|
||||
state
|
||||
.videos(kind)
|
||||
@@ -1899,6 +2502,134 @@ mod tests {
|
||||
assert!(browser.screen().error.is_some()); // Native launch errors remain visible.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_modal_keys_and_inputs_do_not_leak_to_main_actions() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
let overrides: toml_edit::DocumentMut =
|
||||
"[keys]\nchannel.tags = 'f2'\ntags.new = 'f3'\ntags.save = 'f4'\ninput.submit = 'f5'\n"
|
||||
.parse()
|
||||
.unwrap();
|
||||
browser.config.keys = crate::keys::Bindings::from_toml(overrides.get("keys")).unwrap();
|
||||
browser.key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::TagList);
|
||||
browser.key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::TagInput);
|
||||
browser.paste("Safe");
|
||||
browser.key(KeyEvent::new(KeyCode::Char('\u{7}'), KeyModifiers::NONE));
|
||||
browser.key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::TagList);
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(browser.state.shows().is_empty());
|
||||
assert_eq!(browser.index, 0);
|
||||
browser.key(KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.state.shows()[0].tags,
|
||||
BTreeSet::from(["Safe".into()])
|
||||
);
|
||||
assert!(browser.modal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_show_categories_survive_hover_and_filter_changes() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(None);
|
||||
for (id, tag) in [("first", "Drama"), ("second", "Favorites")] {
|
||||
browser
|
||||
.state
|
||||
.save_show(&media(id), &[tag.into()].into())
|
||||
.unwrap();
|
||||
}
|
||||
for filter in [None, Some("no matching category")] {
|
||||
browser.category_picker();
|
||||
for code in [KeyCode::Char('j'), KeyCode::Char(' '), KeyCode::Char('k')] {
|
||||
browser.key(KeyEvent::new(code, KeyModifiers::NONE));
|
||||
}
|
||||
if let Some(filter) = filter {
|
||||
browser.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
browser.paste(filter);
|
||||
}
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let Content::Library { shows, .. } = &browser.screen().content else {
|
||||
panic!("Checked categories must open even without a hovered match");
|
||||
};
|
||||
assert_eq!(
|
||||
shows
|
||||
.iter()
|
||||
.map(|show| show.media.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["first"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_shows_stage_cancel_reopen_and_remove_without_touching_episodes() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
let key = |browser: &mut Browser, code| {
|
||||
browser.key(KeyEvent::new(code, KeyModifiers::NONE));
|
||||
};
|
||||
key(&mut browser, KeyCode::Char('s'));
|
||||
key(&mut browser, KeyCode::Char('n'));
|
||||
browser.paste("Drama");
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
key(&mut browser, KeyCode::Esc);
|
||||
assert!(browser.state.shows().is_empty());
|
||||
key(&mut browser, KeyCode::Char('s'));
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
assert!(browser.state.shows()[0].tags.is_empty());
|
||||
enter_series(&mut browser);
|
||||
browser.select(Action::VideoNext);
|
||||
browser.save(CollectionKind::Saved);
|
||||
let episode = browser.screen().selected_video().unwrap().id;
|
||||
key(&mut browser, KeyCode::Char('s'));
|
||||
for tag in ["Drama", "Favorites"] {
|
||||
key(&mut browser, KeyCode::Char('n'));
|
||||
browser.paste(tag);
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
}
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
assert_eq!(browser.state.shows()[0].media.id, "first");
|
||||
browser
|
||||
.state
|
||||
.save_show(&media("second"), &BTreeSet::from(["Drama".into()]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
library_shows(
|
||||
&browser.state,
|
||||
&BTreeSet::from(["Drama".into(), "Favorites".into()])
|
||||
)
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
browser.state = State::open(&browser.config_path).unwrap();
|
||||
key(&mut browser, KeyCode::Char('c'));
|
||||
key(&mut browser, KeyCode::Char('i'));
|
||||
browser.paste("Favorites");
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
browser.open();
|
||||
assert!(
|
||||
matches!(&browser.pending, Some(Request::Episodes { media_id, .. }) if media_id == "first")
|
||||
);
|
||||
browser.history(false);
|
||||
browser.library(BTreeSet::new());
|
||||
key(&mut browser, KeyCode::Char('x'));
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
browser.history(false);
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
assert!(browser.state.contains(CollectionKind::Saved, &episode));
|
||||
assert_eq!(
|
||||
State::open(&browser.config_path).unwrap().shows()[0]
|
||||
.media
|
||||
.id,
|
||||
"second"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_errors_and_query_controls_do_not_create_results_or_commands() {
|
||||
let directory = Directory::new();
|
||||
|
||||
Reference in New Issue
Block a user