feat: support multiple video selection
This commit is contained in:
+109
-15
@@ -93,6 +93,7 @@ struct DisplayRow {
|
||||
struct Screen {
|
||||
content: Content,
|
||||
selection: ListState,
|
||||
checked: BTreeSet<String>,
|
||||
description_scroll: u16,
|
||||
error: Option<String>,
|
||||
title: String,
|
||||
@@ -105,6 +106,7 @@ impl Screen {
|
||||
let mut screen = Self {
|
||||
content,
|
||||
selection: ListState::default(),
|
||||
checked: BTreeSet::new(),
|
||||
description_scroll: 0,
|
||||
error: None,
|
||||
title: String::new(),
|
||||
@@ -153,6 +155,42 @@ impl Screen {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn playable(&self) -> bool {
|
||||
matches!(
|
||||
self.content,
|
||||
Content::Series { .. } | Content::Collection { .. }
|
||||
)
|
||||
}
|
||||
fn toggle_checked(&mut self) {
|
||||
if self.playable()
|
||||
&& let Some(id) = self.selected_key().map(str::to_owned)
|
||||
{
|
||||
if !self.checked.remove(&id) {
|
||||
self.checked.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn playback_videos(&self) -> Vec<Video> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
fn selected_key(&self) -> Option<&str> {
|
||||
self.selection
|
||||
.selected()
|
||||
@@ -289,6 +327,14 @@ impl Screen {
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
if !self.checked.is_empty() {
|
||||
let visible: BTreeSet<_> = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter_map(|row| row.key.as_deref())
|
||||
.collect();
|
||||
self.checked.retain(|id| visible.contains(id.as_str()));
|
||||
}
|
||||
self.update_description();
|
||||
}
|
||||
}
|
||||
@@ -506,7 +552,9 @@ impl Browser {
|
||||
return;
|
||||
};
|
||||
let id = media.id.clone();
|
||||
match miruro_browser::slug(&media.title) {
|
||||
let slug = miruro_browser::slug(&media.title);
|
||||
self.screen_mut().checked.clear();
|
||||
match slug {
|
||||
Ok(slug) => {
|
||||
self.request(
|
||||
Request::Episodes {
|
||||
@@ -529,18 +577,25 @@ impl Browser {
|
||||
{
|
||||
self.push(Screen::new(Content::Series { media, data: None }));
|
||||
self.load_series();
|
||||
} else if let Some(video) = self.screen().selected_video() {
|
||||
} else {
|
||||
let videos = self.screen().playback_videos();
|
||||
if videos.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.invalidate();
|
||||
self.screen_mut().error = None;
|
||||
let result = self.state.progress_file(&video).and_then(|progress_file| {
|
||||
// Dropping the launcher cancels only its CLI child, not the native player.
|
||||
self.player = None;
|
||||
PlayerLaunch::start(&video.id, &self.options.player, &progress_file)
|
||||
});
|
||||
// Dropping the launcher cancels only its CLI child, not the native player.
|
||||
self.player = None;
|
||||
let result = PlayerLaunch::start(&videos, &self.options.player, &mut self.state);
|
||||
match result {
|
||||
Ok(player) => {
|
||||
self.player = Some(player);
|
||||
self.status = format!("Opening {} in Terebi…", safe(&video.title));
|
||||
self.screen_mut().checked.clear();
|
||||
self.status = if videos.len() == 1 {
|
||||
format!("Opening {} in Terebi…", safe(&videos[0].title))
|
||||
} else {
|
||||
format!("Opening {} episodes in Terebi…", videos.len())
|
||||
};
|
||||
}
|
||||
Err(error) => self.fail(error),
|
||||
}
|
||||
@@ -601,6 +656,7 @@ impl Browser {
|
||||
current.extend(incoming);
|
||||
}
|
||||
*data = Some(series.episodes);
|
||||
self.screen_mut().checked.clear();
|
||||
self.screen_mut().selection.select(next);
|
||||
self.screen_mut().clamp();
|
||||
self.screen_mut().rebuild_display();
|
||||
@@ -981,6 +1037,7 @@ impl Browser {
|
||||
}
|
||||
fn refresh(&mut self) {
|
||||
self.invalidate();
|
||||
self.screen_mut().checked.clear();
|
||||
match config::load(&self.config_path) {
|
||||
Ok(config) => {
|
||||
self.config = config;
|
||||
@@ -1153,6 +1210,8 @@ impl Browser {
|
||||
Some(Action::ViewNext) => self.history(true),
|
||||
Some(Action::ViewRefresh) => self.refresh(),
|
||||
Some(Action::VideoPlay) => self.open(),
|
||||
Some(Action::VideoToggle) => self.screen_mut().toggle_checked(),
|
||||
Some(Action::VideoClear) => self.screen_mut().checked.clear(),
|
||||
Some(
|
||||
action @ (Action::VideoNext
|
||||
| Action::VideoPrevious
|
||||
@@ -1265,8 +1324,10 @@ impl Browser {
|
||||
Content::Catalog { .. } | Content::Library { .. }
|
||||
) {
|
||||
"Open series"
|
||||
} else {
|
||||
} else if self.screen().checked.is_empty() {
|
||||
"Play episode in Terebi"
|
||||
} else {
|
||||
"Play checked episodes in Terebi"
|
||||
},
|
||||
),
|
||||
(Action::VideoNext, "Next title/episode"),
|
||||
@@ -1309,6 +1370,8 @@ impl Browser {
|
||||
Content::Series { .. } | Content::Collection { .. }
|
||||
) {
|
||||
bindings.extend([
|
||||
(Action::VideoToggle, "Toggle checked episode"),
|
||||
(Action::VideoClear, "Clear checked episodes"),
|
||||
(Action::WatchLaterSave, "Save episode to watch later"),
|
||||
(Action::SavedVideoSave, "Save episode"),
|
||||
(Action::WatchStatusWatching, "Mark episode as watching"),
|
||||
@@ -1366,12 +1429,20 @@ impl Browser {
|
||||
Content::Catalog { .. } | Content::Library { .. }
|
||||
) {
|
||||
"open"
|
||||
} else {
|
||||
} else if self.screen().checked.is_empty() {
|
||||
"play"
|
||||
} else {
|
||||
"play checked"
|
||||
},
|
||||
),
|
||||
(Action::CategoryBrowse, "categories"),
|
||||
];
|
||||
if self.screen().playable() {
|
||||
actions.extend([
|
||||
(Action::VideoToggle, "check"),
|
||||
(Action::VideoClear, "clear checks"),
|
||||
]);
|
||||
}
|
||||
if !matches!(self.screen().content, Content::Collection { .. }) {
|
||||
actions.push((Action::ChannelTags, "save show/tags"));
|
||||
}
|
||||
@@ -1420,6 +1491,9 @@ impl Browser {
|
||||
"INFO",
|
||||
&[
|
||||
(Action::InfoDescription, "description"),
|
||||
(Action::VideoPlay, "play"),
|
||||
(Action::VideoToggle, "check"),
|
||||
(Action::VideoClear, "clear checks"),
|
||||
(Action::InfoHide, "close"),
|
||||
(Action::Help, "help"),
|
||||
],
|
||||
@@ -1429,6 +1503,9 @@ impl Browser {
|
||||
"DESCRIPTION",
|
||||
&[
|
||||
(Action::InfoOverview, "info"),
|
||||
(Action::VideoPlay, "play"),
|
||||
(Action::VideoToggle, "check"),
|
||||
(Action::VideoClear, "clear checks"),
|
||||
(Action::InfoHide, "close"),
|
||||
(Action::Help, "help"),
|
||||
(Action::WatchStatusWatching, "mark watching"),
|
||||
@@ -1575,7 +1652,23 @@ impl Browser {
|
||||
" · "
|
||||
};
|
||||
ListItem::new(vec![
|
||||
Line::from(row.title.as_str()).style(style),
|
||||
Line::from(vec![
|
||||
ratatui::text::Span::raw(if self.screen().playable() {
|
||||
if row
|
||||
.key
|
||||
.as_ref()
|
||||
.is_some_and(|id| self.screen().checked.contains(id))
|
||||
{
|
||||
"[x] "
|
||||
} else {
|
||||
"[ ] "
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}),
|
||||
ratatui::text::Span::raw(row.title.as_str()),
|
||||
])
|
||||
.style(style),
|
||||
Line::from(vec![
|
||||
ratatui::text::Span::raw(row.details.as_str()),
|
||||
ratatui::text::Span::raw(separator),
|
||||
@@ -1627,12 +1720,13 @@ impl Browser {
|
||||
query,
|
||||
self.input.is_some() && self.help.is_none() && self.removal.is_none(),
|
||||
);
|
||||
let title = match self.screen().content {
|
||||
Content::Catalog { .. } | Content::Library { .. } => " Titles ",
|
||||
_ => " Episodes ",
|
||||
let title = if self.screen().playable() {
|
||||
format!(" Episodes · {} checked ", self.screen().checked.len())
|
||||
} else {
|
||||
" Titles ".into()
|
||||
};
|
||||
let block = Block::bordered().title(crate::loading_title(
|
||||
title,
|
||||
&title,
|
||||
"",
|
||||
self.intent.is_some().then(|| ui::spinner(elapsed)),
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user