feat: support multiple video selection

This commit is contained in:
2026-10-22 14:01:47 +02:00
parent acfb3be1e7
commit 2d30120072
12 changed files with 883 additions and 169 deletions
+109 -15
View File
@@ -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)),
));