feat: open selected YouTube video in browser
This commit is contained in:
@@ -119,6 +119,7 @@ actions! {
|
||||
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"];
|
||||
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"];
|
||||
@@ -479,6 +480,10 @@ impl Bindings {
|
||||
&[
|
||||
(&[VideoNext, VideoPrevious], "Select next / previous video"),
|
||||
(&[VideoPlay], "Play the selected video in Terebi"),
|
||||
(
|
||||
&[VideoOpen],
|
||||
"Open the selected YouTube video in the default browser",
|
||||
),
|
||||
(
|
||||
&[WatchStatusWatching],
|
||||
"Mark selected as watching (clear watched)",
|
||||
|
||||
+32
@@ -948,6 +948,7 @@ struct App {
|
||||
description_viewport: Option<(u16, u16)>,
|
||||
job: Option<Job>,
|
||||
player_launch: Option<PlayerLaunch>,
|
||||
browser_launch: Option<std::sync::mpsc::Receiver<Result<(), String>>>,
|
||||
status: String,
|
||||
}
|
||||
|
||||
@@ -1023,6 +1024,7 @@ impl App {
|
||||
description_viewport: None,
|
||||
job: None,
|
||||
player_launch: None,
|
||||
browser_launch: None,
|
||||
status: "Enter a query to search YouTube.".into(),
|
||||
}
|
||||
}
|
||||
@@ -2553,6 +2555,24 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_video_in_browser(&mut self) {
|
||||
let view = &self.views[self.view_index];
|
||||
let Some(video) = view
|
||||
.selection
|
||||
.selected()
|
||||
.and_then(|index| view.videos.get(index))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match youtube::open_in_browser(&video.id) {
|
||||
Ok(launch) => {
|
||||
self.browser_launch = Some(launch);
|
||||
self.status = format!("Opening {} in the default browser...", video.title);
|
||||
}
|
||||
Err(error) => self.status = error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_description(&mut self, id: String, result: Result<String, String>) {
|
||||
match result.and_then(|output| youtube::full_description(&output, &id)) {
|
||||
Ok(description) => self.remember_description(id, description),
|
||||
@@ -2601,6 +2621,17 @@ impl App {
|
||||
Err(error) => error,
|
||||
};
|
||||
}
|
||||
if let Some(result) = self
|
||||
.browser_launch
|
||||
.as_ref()
|
||||
.and_then(|launch| launch.try_recv().ok())
|
||||
{
|
||||
self.browser_launch = None;
|
||||
self.status = match result {
|
||||
Ok(()) => "Video handed to the default browser.".into(),
|
||||
Err(error) => error,
|
||||
};
|
||||
}
|
||||
match self.video_state.poll_progress() {
|
||||
Ok(true) => {
|
||||
self.rebuild_collections();
|
||||
@@ -2869,6 +2900,7 @@ impl App {
|
||||
self.browse_category_collection(CollectionKind::Saved)
|
||||
}
|
||||
Some(Action::VideoPlay) => self.play(),
|
||||
Some(Action::VideoOpen) => self.open_video_in_browser(),
|
||||
Some(Action::VideoNext) => {
|
||||
self.views[self.view_index].prefetch_requested = true;
|
||||
self.views[self.view_index].selection.select_next();
|
||||
|
||||
@@ -805,6 +805,52 @@ fn failure(errors: &Receiver<io::Result<String>>, fallback: String) -> Option<Re
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_in_browser(id: &str) -> io::Result<Receiver<Result<(), String>>> {
|
||||
if !valid_video_id(id) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Invalid YouTube video ID",
|
||||
));
|
||||
}
|
||||
let opener = if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
} else {
|
||||
"xdg-open"
|
||||
};
|
||||
let child = isolated_command(OsStr::new(opener))
|
||||
.arg(format!("https://www.youtube.com/watch?v={id}"))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|error| {
|
||||
io::Error::new(
|
||||
error.kind(),
|
||||
format!("Cannot start browser opener: {error}"),
|
||||
)
|
||||
})?;
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let result = child
|
||||
.wait_with_output()
|
||||
.map_err(|error| format!("Cannot wait for browser opener: {error}"))
|
||||
.and_then(|output| {
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!(
|
||||
"Browser opener exited with {}: {}",
|
||||
output.status,
|
||||
error.trim()
|
||||
))
|
||||
}
|
||||
});
|
||||
let _ = sender.send(result);
|
||||
});
|
||||
Ok(receiver)
|
||||
}
|
||||
|
||||
pub struct PlayerLaunch {
|
||||
player: Child,
|
||||
player_errors: Receiver<io::Result<String>>,
|
||||
@@ -1465,6 +1511,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_open_rejects_unsafe_video_ids_before_spawning() {
|
||||
for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] {
|
||||
assert!(matches!(
|
||||
open_in_browser(id),
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publication_dates_prefer_exact_metadata_and_handle_utc_boundaries_and_invalid_dates() {
|
||||
for feed in [false, true] {
|
||||
|
||||
Reference in New Issue
Block a user