feat: open selected YouTube video in browser
This commit is contained in:
@@ -97,6 +97,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `j` / `k`, ↓ / ↑ | Select the next / previous video |
|
| `j` / `k`, ↓ / ↑ | Select the next / previous video |
|
||||||
| Enter | Launch the selected video in Terebi |
|
| Enter | Launch the selected video in Terebi |
|
||||||
|
| `o` | Open the selected YouTube video in the default browser (also works in previews; configurable as `video.open`) |
|
||||||
| `g` / Home, `G` / End | Select the first / last loaded video |
|
| `g` / Home, `G` / End | Select the first / last loaded video |
|
||||||
| `i` | Edit the current category/combined feed search; other views use general YouTube Search |
|
| `i` | Edit the current category/combined feed search; other views use general YouTube Search |
|
||||||
| `/` | Go directly to general YouTube Search |
|
| `/` | Go directly to general YouTube Search |
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ actions! {
|
|||||||
ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"];
|
ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"];
|
||||||
ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"];
|
ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"];
|
||||||
VideoPlay, "video.play", "Play the selected video", VIEW, ["enter"];
|
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"];
|
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"];
|
VideoPrevious, "video.select.previous", "Select the previous video", VIEW, ["k", "up", "ctrl+k"];
|
||||||
VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"];
|
VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"];
|
||||||
@@ -479,6 +480,10 @@ impl Bindings {
|
|||||||
&[
|
&[
|
||||||
(&[VideoNext, VideoPrevious], "Select next / previous video"),
|
(&[VideoNext, VideoPrevious], "Select next / previous video"),
|
||||||
(&[VideoPlay], "Play the selected video in Terebi"),
|
(&[VideoPlay], "Play the selected video in Terebi"),
|
||||||
|
(
|
||||||
|
&[VideoOpen],
|
||||||
|
"Open the selected YouTube video in the default browser",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
&[WatchStatusWatching],
|
&[WatchStatusWatching],
|
||||||
"Mark selected as watching (clear watched)",
|
"Mark selected as watching (clear watched)",
|
||||||
|
|||||||
+32
@@ -948,6 +948,7 @@ struct App {
|
|||||||
description_viewport: Option<(u16, u16)>,
|
description_viewport: Option<(u16, u16)>,
|
||||||
job: Option<Job>,
|
job: Option<Job>,
|
||||||
player_launch: Option<PlayerLaunch>,
|
player_launch: Option<PlayerLaunch>,
|
||||||
|
browser_launch: Option<std::sync::mpsc::Receiver<Result<(), String>>>,
|
||||||
status: String,
|
status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,6 +1024,7 @@ impl App {
|
|||||||
description_viewport: None,
|
description_viewport: None,
|
||||||
job: None,
|
job: None,
|
||||||
player_launch: None,
|
player_launch: None,
|
||||||
|
browser_launch: None,
|
||||||
status: "Enter a query to search YouTube.".into(),
|
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>) {
|
fn finish_description(&mut self, id: String, result: Result<String, String>) {
|
||||||
match result.and_then(|output| youtube::full_description(&output, &id)) {
|
match result.and_then(|output| youtube::full_description(&output, &id)) {
|
||||||
Ok(description) => self.remember_description(id, description),
|
Ok(description) => self.remember_description(id, description),
|
||||||
@@ -2601,6 +2621,17 @@ impl App {
|
|||||||
Err(error) => error,
|
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() {
|
match self.video_state.poll_progress() {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
self.rebuild_collections();
|
self.rebuild_collections();
|
||||||
@@ -2869,6 +2900,7 @@ impl App {
|
|||||||
self.browse_category_collection(CollectionKind::Saved)
|
self.browse_category_collection(CollectionKind::Saved)
|
||||||
}
|
}
|
||||||
Some(Action::VideoPlay) => self.play(),
|
Some(Action::VideoPlay) => self.play(),
|
||||||
|
Some(Action::VideoOpen) => self.open_video_in_browser(),
|
||||||
Some(Action::VideoNext) => {
|
Some(Action::VideoNext) => {
|
||||||
self.views[self.view_index].prefetch_requested = true;
|
self.views[self.view_index].prefetch_requested = true;
|
||||||
self.views[self.view_index].selection.select_next();
|
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 {
|
pub struct PlayerLaunch {
|
||||||
player: Child,
|
player: Child,
|
||||||
player_errors: Receiver<io::Result<String>>,
|
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]
|
#[test]
|
||||||
fn publication_dates_prefer_exact_metadata_and_handle_utc_boundaries_and_invalid_dates() {
|
fn publication_dates_prefer_exact_metadata_and_handle_utc_boundaries_and_invalid_dates() {
|
||||||
for feed in [false, true] {
|
for feed in [false, true] {
|
||||||
|
|||||||
Reference in New Issue
Block a user