feat: add full descriptions in a scrollable popup

This commit is contained in:
2026-10-09 21:07:19 +02:00
parent 79a14dce1e
commit 3c848abeff
3 changed files with 228 additions and 8 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ description = "A terminal YouTube browser that streams videos to your chosen pla
[dependencies]
clap = { version = "4.6.7", features = ["derive"] }
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] }
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
+175 -5
View File
@@ -4,6 +4,9 @@
//! Without a CLI query, start in insert mode; otherwise search and start in normal mode.
//! Insert: Enter submits a changed query and returns to normal; Esc returns without searching.
//! Normal: j/k selects, Enter streams, and i returns to insert mode to edit the query.
//! Normal-mode d opens the full description; Up/Down scrolls lines and PgUp/PgDn scrolls pages.
//! The centered popup uses up to 70% width and 60% height, capped at 96 by 20 cells.
//! Esc closes the popup; j/k loads the selected video's description and i returns to editing.
//! The player must read media from standard input when passed `-`.
//! Audio and video are streamed through a pipe, never saved to a video file.
//! See `--help` for controls; replacing playback or quitting cancels its processes.
@@ -16,9 +19,10 @@ use clap::Parser;
use ratatui::{
DefaultTerminal, Frame,
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
layout::{Constraint, Layout},
layout::{Constraint, Layout, Rect},
style::{Color, Style, Stylize},
widgets::{Block, Paragraph, Row, Table, TableState, Wrap},
text::Line,
widgets::{Block, Clear, Paragraph, Row, Table, TableState, Wrap},
};
use youtube::{Job, Playback, Video};
@@ -26,7 +30,7 @@ use youtube::{Job, Playback, Video};
#[command(
version,
about = "Search YouTube and stream videos to your chosen player",
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH.\nThe player receives audio/video on standard input, with '-' as its last argument.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; q quits.\nCtrl-U clears in INSERT; Ctrl-C quits in either mode.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH.\nThe player receives audio/video on standard input, with '-' as its last argument.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; q quits.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nThe centered description window uses up to 70% width and 60% height, capped at 96 by 20 cells.\nWhile it is open, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nCtrl-U clears in INSERT; Ctrl-C quits in either mode.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
)]
struct Options {
/// Player executable (must support '-' for media on standard input).
@@ -54,6 +58,12 @@ struct App {
submitted_query: String,
videos: Vec<Video>,
selection: TableState,
description_open: bool,
description_job: Option<(usize, Job)>,
description_error: Option<String>,
description_scroll: u16,
description_max_scroll: u16,
description_viewport: Option<(u16, u16)>,
job: Option<Job>,
playback: Option<Playback>,
status: String,
@@ -74,6 +84,12 @@ impl App {
submitted_query: String::new(),
videos: Vec::new(),
selection: TableState::default(),
description_open: false,
description_job: None,
description_error: None,
description_scroll: 0,
description_max_scroll: 0,
description_viewport: None,
job: None,
playback: None,
status: "Type a query, then press Enter.".into(),
@@ -92,6 +108,8 @@ impl App {
match Job::search(query) {
Ok(job) => {
self.job = Some(job);
self.description_open = false;
self.description_job = None;
self.submitted_query = query.to_owned();
self.videos.clear();
self.selection = TableState::default();
@@ -101,6 +119,28 @@ impl App {
}
}
fn load_description(&mut self) {
self.description_job = None;
self.description_error = None;
self.description_scroll = 0;
self.description_viewport = None;
let Some(index) = self
.selection
.selected()
.filter(|&index| index < self.videos.len())
else {
return;
};
let video = &self.videos[index];
if video.description.is_some() {
return;
}
match Job::description(&video.id) {
Ok(job) => self.description_job = Some((index, job)),
Err(error) => self.description_error = Some(error.to_string()),
}
}
fn play(&mut self) {
if self.job.is_some() {
return;
@@ -139,6 +179,23 @@ impl App {
Err(error) => error,
};
}
if let Some(result) = self
.description_job
.as_mut()
.and_then(|(_, job)| job.poll())
{
let (index, _) = self
.description_job
.take()
.expect("completed description job exists");
if let Some(video) = self.videos.get_mut(index) {
match result.and_then(|output| youtube::full_description(&output, &video.id)) {
Ok(description) => video.description = Some(description),
Err(error) => self.description_error = Some(error),
}
}
self.description_viewport = None;
}
if let Some(result) = self.playback.as_mut().and_then(Playback::poll) {
self.playback = None;
self.status = match result {
@@ -152,6 +209,8 @@ impl App {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return true;
}
let selected_before = self.selection.selected();
let description_was_open = self.description_open;
if self.mode == Mode::Insert {
match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
@@ -177,7 +236,46 @@ impl App {
} else {
match key.code {
KeyCode::Char('q') => return true,
KeyCode::Char('i') => self.mode = Mode::Insert,
KeyCode::Char('i') => {
self.description_open = false;
self.mode = Mode::Insert;
}
KeyCode::Char('d') => {
self.description_open = !self.description_open
&& self
.selection
.selected()
.is_some_and(|index| index < self.videos.len());
}
KeyCode::Esc => self.description_open = false,
KeyCode::Down if self.description_open => {
self.description_scroll = self
.description_scroll
.saturating_add(1)
.min(self.description_max_scroll);
}
KeyCode::Up if self.description_open => {
self.description_scroll = self.description_scroll.saturating_sub(1);
}
KeyCode::PageDown if self.description_open => {
let page = self
.description_viewport
.map(|(_, height)| height)
.unwrap_or(1)
.max(1);
self.description_scroll = self
.description_scroll
.saturating_add(page)
.min(self.description_max_scroll);
}
KeyCode::PageUp if self.description_open => {
let page = self
.description_viewport
.map(|(_, height)| height)
.unwrap_or(1)
.max(1);
self.description_scroll = self.description_scroll.saturating_sub(page);
}
KeyCode::Enter => self.play(),
KeyCode::Down | KeyCode::Char('j') => self.selection.select_next(),
KeyCode::Up | KeyCode::Char('k') => self.selection.select_previous(),
@@ -194,6 +292,11 @@ impl App {
self.selection
.select(Some(index.min(self.videos.len() - 1)));
}
if !self.description_open {
self.description_job = None;
} else if !description_was_open || self.selection.selected() != selected_before {
self.load_description();
}
false
}
@@ -249,9 +352,74 @@ impl App {
Mode::Insert => {
"INSERT · Enter search → normal · Esc normal · Ctrl-U clear · Ctrl-C quit"
}
Mode::Normal => "NORMAL · j/k or ↑/↓ select · Enter stream · i insert · q quit",
Mode::Normal if self.description_open => {
"NORMAL · ↑/↓ PgUp/PgDn scroll · j/k video · d/Esc close · i insert · q quit"
}
Mode::Normal => {
"NORMAL · j/k select · Enter stream · i insert · d description · q quit"
}
};
frame.render_widget(Paragraph::new(help_text).dim(), help);
if self.description_open
&& let Some(video) = self
.selection
.selected()
.and_then(|index| self.videos.get(index))
{
let area = frame.area();
let width = area
.width
.saturating_sub(4)
.min((u32::from(area.width) * 70 / 100) as u16)
.min(96);
let height = area
.height
.saturating_sub(4)
.min((u32::from(area.height) * 60 / 100) as u16)
.min(20);
let popup = Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
);
frame.render_widget(Clear, popup);
let text = match video.description.as_deref() {
Some("") => "This video has no description.",
Some(description) => description,
None => self
.description_error
.as_deref()
.unwrap_or("Loading full description..."),
};
let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
let viewport = (width.saturating_sub(2), height.saturating_sub(2));
if self.description_viewport != Some(viewport) {
self.description_max_scroll = if viewport.0 == 0 || viewport.1 == 0 {
0
} else {
paragraph
.line_count(viewport.0)
.saturating_sub(viewport.1 as usize)
.min(u16::MAX as usize) as u16
};
self.description_viewport = Some(viewport);
}
self.description_scroll = self.description_scroll.min(self.description_max_scroll);
frame.render_widget(
paragraph.scroll((self.description_scroll, 0)).block(
Block::bordered()
.title(" Description · ↑/↓ PgUp/PgDn · d/Esc close ")
.title_bottom(Line::from(vec![
" ".into(),
video.title.as_str().into(),
" ".into(),
]))
.border_style(Style::new().fg(Color::Cyan)),
),
popup,
);
}
}
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
@@ -290,11 +458,13 @@ mod tests {
id: "jNQXAC9IVRw".into(),
title: "Me at the zoo".into(),
channel: None,
description: None,
},
Video {
id: "rQ_J9WH6CGk".into(),
title: "Rust course".into(),
channel: None,
description: None,
},
];
app.selection.select(Some(0));
+52 -2
View File
@@ -13,6 +13,9 @@ pub struct Video {
pub id: String,
pub title: String,
pub channel: Option<String>,
// Search descriptions are truncated; only full video metadata populates this cache.
#[serde(skip)]
pub description: Option<String>,
}
#[derive(Deserialize)]
@@ -42,6 +45,22 @@ pub fn search_results(output: &str) -> Result<Vec<Video>, String> {
Ok(videos)
}
pub fn full_description(output: &str, expected_id: &str) -> Result<String, String> {
#[derive(Deserialize)]
struct Details {
id: String,
description: Option<String>,
}
let details: Details =
serde_json::from_str(output).map_err(|error| format!("Invalid video metadata: {error}"))?;
if details.id != expected_id {
return Err("Video metadata did not match the requested video".into());
}
let mut description = details.description.unwrap_or_default();
description.retain(|c| !c.is_control() || c == '\n' || c == '\t');
Ok(description)
}
// Own the extractor so replacing a request or quitting also cancels its process.
pub struct Job {
child: Child,
@@ -50,9 +69,25 @@ pub struct Job {
impl Job {
pub fn search(query: &str) -> io::Result<Self> {
Self::spawn(&[
"--flat-playlist",
"--dump-single-json",
&format!("ytsearch20:{query}"),
])
}
pub fn description(id: &str) -> io::Result<Self> {
Self::spawn(&[
"--no-playlist",
"--skip-download",
"--dump-single-json",
&format!("https://www.youtube.com/watch?v={id}"),
])
}
fn spawn(args: &[&str]) -> io::Result<Self> {
let mut child = extractor()
.args(["--flat-playlist", "--dump-single-json"])
.arg(format!("ytsearch20:{query}"))
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -265,4 +300,19 @@ mod tests {
assert!(search_results(r#"{"entries":[{"id":"--bad","title":"Bad"}]}"#).is_err());
assert!(search_results("not JSON").is_err());
}
#[test]
fn full_descriptions_preserve_paragraphs_and_reject_mismatched_videos() {
let metadata = r#"{"id":"jNQXAC9IVRw","description":"First paragraph.\n\n日本語\tsecond paragraph.\u001b\nLast line."}"#;
assert_eq!(
full_description(metadata, "jNQXAC9IVRw").unwrap(),
"First paragraph.\n\n日本語\tsecond paragraph.\nLast line."
);
assert!(full_description(metadata, "rQ_J9WH6CGk").is_err());
assert!(full_description("not JSON", "jNQXAC9IVRw").is_err());
assert_eq!(
full_description(r#"{"id":"jNQXAC9IVRw","description":null}"#, "jNQXAC9IVRw").unwrap(),
""
);
}
}