feat: open selected YouTube video in browser

This commit is contained in:
2026-10-17 16:54:23 +02:00
parent f8334716af
commit ba04cbe158
4 changed files with 94 additions and 0 deletions
+1
View File
@@ -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 |
+5
View File
@@ -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
View File
@@ -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();
+56
View File
@@ -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] {