feat: support multiple video selection
This commit is contained in:
+12
-2
@@ -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
File diff suppressed because one or more lines are too long
+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)),
|
||||
));
|
||||
|
||||
+22
-1
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user