feat: add full descriptions in a scrollable popup
This commit is contained in:
+175
-5
@@ -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
@@ -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(),
|
||||
""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user