diff --git a/Cargo.lock b/Cargo.lock index c49b1d1..aa62964 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" @@ -260,6 +266,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ + "base64 0.22.1", "bitflags", "crossterm_winapi", "derive_more", @@ -1445,6 +1452,7 @@ name = "tubular" version = "0.1.0" dependencies = [ "clap", + "crossterm", "image", "libc", "ratatui", @@ -1508,7 +1516,7 @@ version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ - "base64", + "base64 0.23.1", "log", "percent-encoding", "rustls", @@ -1524,7 +1532,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" dependencies = [ - "base64", + "base64 0.23.1", "http", "httparse", "log", diff --git a/Cargo.toml b/Cargo.toml index 8ed47b9..8b886bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ description = "A terminal YouTube and Miruro browser that opens selected videos [dependencies] clap = { version = "4.6.7", features = ["derive"] } +crossterm = { version = "0.29.0", default-features = false, features = ["osc52"] } image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] } ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } ratatui-image = { version = "11.1.0", default-features = false, features = ["crossterm"] } diff --git a/README.md b/README.md index 5c98818..490317a 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit | `j` / `k`, ↓ / ↑; `g` / `G`, Home / End | Select a title or episode | | Space / Esc | Check/uncheck the hovered episode / discard episode checks | | Enter | Open a series, or play checked episodes in displayed order (hovered episode when none are checked) | +| `y` | Copy the hovered episode's page URL (also in previews and offline collections; `video.copy_url`) | | `-` / `+` | Back / forward through cached screens, preserving query and selection | | `w` / `v` | Save the selected episode to watch later / saved episodes | | `s` | Edit the selected show's category tags; Enter saves the show, including with no tags | @@ -121,6 +122,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin | Enter | Play checked videos in displayed order, or the hovered video when none are checked | | `p` | Reopen the provider chooser | | `o` | Open the selected YouTube video in the default browser (also works in previews; configurable as `video.open`) | +| `y` | Copy the hovered video's page URL (also in previews and offline collections; `video.copy_url`) | | `g` / Home, `G` / End | Select the first / last loaded video | | `i` | Edit the current category/combined feed search; other views use general YouTube Search | | `/` | Go directly to general YouTube Search | @@ -154,6 +156,8 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin **Ctrl-C quits from any mode or popup.** +URL copying always uses the highlighted row, not the playback checks, and copies the same canonical page URL used for playback—not an expiring stream URL. It uses the terminal's OSC 52 clipboard support without external clipboard tools. Clipboard writes must be supported and enabled by your terminal and any multiplexer; Tubular can report sending the URL, but cannot confirm that the terminal accepted it. Text inputs and selectors keep `y` as ordinary input or ignore it. + The bottom bar shows essential actions for the current context. Press `?` in normal mode—including selectors and previews—for the full reference. Scroll help with `j` / `k`, arrows, Ctrl-J / Ctrl-K, or PageDown / PageUp; `?` or Esc closes it without changing the underlying view or dialog. In text fields, `?` remains ordinary input. The overview groups shortcut/action rows under bold section headers such as `VIEWS AND VIDEOS`, with an empty line beneath each header. Shortcuts and action descriptions remain normal weight; the rows reflect configured bindings rather than showing internal action paths. diff --git a/src/keys.rs b/src/keys.rs index aa0db25..47f3725 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -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)", diff --git a/src/main.rs b/src/main.rs index fc0b031..9012172 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(); diff --git a/src/miruro_ui.rs b/src/miruro_ui.rs index f7c0a51..ad40d9b 100644 --- a/src/miruro_ui.rs +++ b/src/miruro_ui.rs @@ -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"), diff --git a/src/ui.rs b/src/ui.rs index 57b2e40..bbac306 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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), diff --git a/src/youtube.rs b/src/youtube.rs index da3bdad..be564c2 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -863,6 +863,16 @@ fn failure(errors: &Receiver>, fallback: String) -> Option io::Result { + 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>> { if !valid_video_id(id) { return Err(io::Error::new( @@ -876,7 +886,7 @@ pub fn open_in_browser(id: &str) -> io::Result>> { "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::>>()?; let reports = state.progress_files(videos)?; diff --git a/tests/unit/youtube.rs b/tests/unit/youtube.rs index 43228e9..1ecedfb 100644 --- a/tests/unit/youtube.rs +++ b/tests/unit/youtube.rs @@ -532,6 +532,30 @@ mod subprocesses { assert_reaped(pid); } } +#[test] +fn video_urls_preserve_provider_identity_and_reject_invalid_keys() { + assert_eq!( + video_url("jNQXAC9IVRw").unwrap(), + "https://www.youtube.com/watch?v=jNQXAC9IVRw" + ); + assert_eq!( + video_url("miruro:opaque_ID-7:3").unwrap(), + "https://barelystarted.miruro.tv/watch/opaque_ID-7/episode?ep=3" + ); + for id in [ + "", + "--help", + "jNQXAC9IVRw&list=other", + "miruro:id:0", + "miruro:id:03", + "miruro:../id:3", + ] { + assert_eq!( + video_url(id).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + } +} #[test] fn browser_open_rejects_unsafe_video_ids_before_spawning() {