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
+12 -2
View File
@@ -119,12 +119,14 @@ actions! {
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"];
ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"];
VideoPlay, "video.play", "Play the selected video", VIEW, ["enter"];
VideoPlay, "video.play", "Play checked videos in displayed order, or the selected video", VIEW, ["enter"];
VideoOpen, "video.open", "Open the selected YouTube video in the default browser", VIEW, ["o"];
VideoNext, "video.select.next", "Select the next video", VIEW, ["j", "down", "ctrl+j"];
VideoPrevious, "video.select.previous", "Select the previous video", VIEW, ["k", "up", "ctrl+k"];
VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"];
VideoLast, "video.select.last", "Select the last video", VIEW, ["G", "end"];
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"];
InfoOverview, "video.info.overview", "Show the video overview", 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"];
@@ -480,7 +482,15 @@ impl Bindings {
"VIEWS AND VIDEOS",
&[
(&[VideoNext, VideoPrevious], "Select next / previous video"),
(&[VideoPlay], "Play the selected video in Terebi"),
(
&[VideoPlay],
"Play checked videos in displayed order, or the selected video",
),
(
&[VideoToggle],
"Check / uncheck the selected video for playback",
),
(&[VideoClear], "Clear playback checks in the current view"),
(
&[VideoOpen],
"Open the selected YouTube video in the default browser",
+121 -54
View File
File diff suppressed because one or more lines are too long
+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)),
));
+22 -1
View File
@@ -346,6 +346,23 @@ impl State {
Ok(())
}
pub fn progress_files(&mut self, videos: &[Video]) -> io::Result<Vec<PathBuf>> {
if videos.len() > MAX_STREAMS.saturating_sub(self.streams.len()) {
return Err(invalid(
"Playback session limit reached; remove inactive playback-sessions directories and restart",
));
}
// Validate the entire batch before creating any retained sessions.
let mut snapshots = videos.to_vec();
for video in &mut snapshots {
validate_snapshot(video, false)?;
}
snapshots
.iter()
.map(|video| self.create_progress_file(video))
.collect()
}
pub fn progress_file(&mut self, video: &Video) -> io::Result<PathBuf> {
if self.streams.len() >= MAX_STREAMS {
return Err(invalid(
@@ -354,6 +371,10 @@ impl State {
}
let mut video = video.clone();
validate_snapshot(&mut video, false)?;
self.create_progress_file(&video)
}
fn create_progress_file(&mut self, video: &Video) -> io::Result<PathBuf> {
fs::create_dir_all(self.sessions.parent().unwrap_or(Path::new(".")))?;
private_directory(&self.sessions, true)?;
let directory = loop {
@@ -369,7 +390,7 @@ impl State {
}
};
let (mut snapshot, _) = StoredFile::open(directory.join("video.toml"))?;
snapshot.save(video_document(std::iter::once(&video)))?;
snapshot.save(video_document(std::iter::once(video)))?;
let path = directory.join("progress.jsonl");
self.streams.push(ProgressStream::new(path.clone()));
Ok(path)
+82 -14
View File
@@ -1,8 +1,9 @@
use std::{
collections::VecDeque,
collections::{BTreeSet, VecDeque},
ffi::OsStr,
fs::{self, OpenOptions},
io::{self, Read},
path::Path,
path::PathBuf,
process::{Child, Command, Stdio},
sync::mpsc::{self, Receiver, TryRecvError},
thread,
@@ -914,20 +915,87 @@ pub struct PlayerLaunch {
}
impl PlayerLaunch {
pub fn start(id: &str, player: &OsStr, progress_file: &Path) -> io::Result<Self> {
let url = if valid_video_id(id) {
format!("https://www.youtube.com/watch?v={id}")
} else {
tubular::miruro::Identity::from_key(id)
.and_then(|identity| identity.watch_url())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?
pub fn start(
videos: &[Video],
player: &OsStr,
state: &mut crate::video_state::State,
) -> io::Result<Self> {
if videos.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"No videos selected",
));
}
let playback_url = |id: &str| {
if valid_video_id(id) {
Ok(format!("https://www.youtube.com/watch?v={id}"))
} else {
tubular::miruro::Identity::from_key(id)
.and_then(|identity| identity.watch_url())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))
}
};
crate::video_state::register_identity(progress_file, id)?;
let mut command = isolated_command(player);
command
.args(["--new-instance", "--url", &url])
.arg("--progress-file")
.arg(progress_file);
command.arg("--new-instance");
if let [video] = videos {
let url = playback_url(&video.id)?;
let report = state.progress_file(video)?;
crate::video_state::register_identity(&report, &video.id)?;
command
.args(["--url", &url])
.arg("--progress-file")
.arg(report);
return Self::launch(command);
}
let mut ids = BTreeSet::new();
let urls = videos
.iter()
.map(|video| {
if !ids.insert(video.id.as_str()) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Duplicate queue identity",
));
}
playback_url(&video.id)
})
.collect::<io::Result<Vec<_>>>()?;
let reports = state.progress_files(videos)?;
#[derive(Serialize)]
struct QueueItem {
url: String,
progress_file: PathBuf,
}
#[derive(Serialize)]
struct Queue {
version: u8,
items: Vec<QueueItem>,
}
let items = videos
.iter()
.zip(urls)
.zip(reports)
.map(|((video, url), report)| {
crate::video_state::register_identity(&report, &video.id)?;
let parent = fs::canonicalize(report.parent().expect("session directory"))?;
Ok(QueueItem {
url,
progress_file: parent.join("progress.jsonl"),
})
})
.collect::<io::Result<Vec<_>>>()?;
let manifest = items[0].progress_file.with_file_name("queue.json");
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options.open(&manifest)?;
serde_json::to_writer(&file, &Queue { version: 1, items })?;
file.sync_all()?;
command.arg("--queue-file").arg(manifest);
Self::launch(command)
}