feat: copy selected video URL

This commit is contained in:
2026-10-22 15:38:59 +02:00
parent 01275e20b8
commit b12bba9125
9 changed files with 80 additions and 14 deletions
+2
View File
@@ -121,6 +121,7 @@ actions! {
ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"];
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"];
VideoCopyUrl, "video.copy_url", "Copy the selected video's URL to the terminal clipboard", VIEW, ["y"];
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"];
@@ -495,6 +496,7 @@ impl Bindings {
&[VideoOpen],
"Open the selected YouTube video in the default browser",
),
(&[VideoCopyUrl], "Copy the selected video's URL"),
(
&[WatchStatusWatching],
"Mark selected as watching (clear watched)",
+10
View File
@@ -2937,6 +2937,16 @@ impl App {
Some(Action::VideoToggle) => self.views[self.view_index].toggle_checked(),
Some(Action::VideoClear) => self.views[self.view_index].checked.clear(),
Some(Action::VideoOpen) => self.open_video_in_browser(),
Some(Action::VideoCopyUrl) => {
let view = &self.views[self.view_index];
if let Some(video) = view
.selection
.selected()
.and_then(|index| view.videos.get(index))
{
self.status = ui::copy_video_url(&video.id);
}
}
Some(Action::VideoNext) => {
self.views[self.view_index].prefetch_requested = true;
self.views[self.view_index].selection.select_next();
+6
View File
@@ -1212,6 +1212,11 @@ impl Browser {
Some(Action::VideoPlay) => self.open(),
Some(Action::VideoToggle) => self.screen_mut().toggle_checked(),
Some(Action::VideoClear) => self.screen_mut().checked.clear(),
Some(Action::VideoCopyUrl) if self.screen().playable() => {
if let Some(id) = self.screen().selected_key() {
self.status = ui::copy_video_url(id);
}
}
Some(
action @ (Action::VideoNext
| Action::VideoPrevious
@@ -1372,6 +1377,7 @@ impl Browser {
bindings.extend([
(Action::VideoToggle, "Toggle checked episode"),
(Action::VideoClear, "Clear checked episodes"),
(Action::VideoCopyUrl, "Copy selected episode URL"),
(Action::WatchLaterSave, "Save episode to watch later"),
(Action::SavedVideoSave, "Save episode"),
(Action::WatchStatusWatching, "Mark episode as watching"),
+10
View File
@@ -2,6 +2,7 @@ use std::time::Duration;
use ratatui::{
Frame,
crossterm::{clipboard::CopyToClipboard, execute},
layout::{Constraint, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
@@ -12,6 +13,15 @@ pub const ACCENT: Style = Style::new().fg(Color::Cyan);
pub const SELECTED: Style = Style::new().fg(Color::Black).bg(Color::Cyan);
pub const SELECTION_MARKER: &str = "> ";
pub fn copy_video_url(id: &str) -> String {
let result = crate::youtube::video_url(id)
.and_then(|url| execute!(std::io::stdout(), CopyToClipboard::to_clipboard_from(url)));
match result {
Ok(()) => "Video URL sent to terminal clipboard.".into(),
Err(error) => format!("Cannot copy video URL: {error}"),
}
}
pub fn browser_layout(area: Rect, header_height: u16) -> [Rect; 5] {
Layout::vertical([
Constraint::Length(header_height),
+13 -12
View File
@@ -863,6 +863,16 @@ fn failure(errors: &Receiver<io::Result<String>>, fallback: String) -> Option<Re
}
}
pub fn video_url(id: &str) -> io::Result<String> {
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))
}
}
pub fn open_in_browser(id: &str) -> io::Result<Receiver<Result<(), String>>> {
if !valid_video_id(id) {
return Err(io::Error::new(
@@ -876,7 +886,7 @@ pub fn open_in_browser(id: &str) -> io::Result<Receiver<Result<(), String>>> {
"xdg-open"
};
let child = isolated_command(OsStr::new(opener))
.arg(format!("https://www.youtube.com/watch?v={id}"))
.arg(video_url(id)?)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
@@ -926,19 +936,10 @@ impl PlayerLaunch {
"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))
}
};
let mut command = isolated_command(player);
command.arg("--new-instance");
if let [video] = videos {
let url = playback_url(&video.id)?;
let url = video_url(&video.id)?;
let report = state.progress_file(video)?;
crate::video_state::register_identity(&report, &video.id)?;
command
@@ -957,7 +958,7 @@ impl PlayerLaunch {
"Duplicate queue identity",
));
}
playback_url(&video.id)
video_url(&video.id)
})
.collect::<io::Result<Vec<_>>>()?;
let reports = state.progress_files(videos)?;