feat: add modal YouTube browsing and configurable streaming

This commit is contained in:
2026-10-09 21:03:34 +02:00
parent 7e5bcc9087
commit 79a14dce1e
4 changed files with 1569 additions and 2 deletions
+333 -2
View File
@@ -1,3 +1,334 @@
fn main() {
println!("Hello, world!");
//! A Ratatui YouTube browser backed by yt-dlp and FFmpeg, with no API key.
//!
//! Run `cargo run -- --player mpv --player-arg=--volume=50 "rust programming"`.
//! 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.
//! 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.
mod youtube;
use std::{ffi::OsString, io, time::Duration};
use clap::Parser;
use ratatui::{
DefaultTerminal, Frame,
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
layout::{Constraint, Layout},
style::{Color, Style, Stylize},
widgets::{Block, Paragraph, Row, Table, TableState, Wrap},
};
use youtube::{Job, Playback, Video};
#[derive(Parser)]
#[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\""
)]
struct Options {
/// Player executable (must support '-' for media on standard input).
#[arg(long, default_value = "mpv")]
player: OsString,
/// Argument passed to the player before '-'; repeat for multiple arguments.
#[arg(long, allow_hyphen_values = true)]
player_arg: Vec<OsString>,
/// Optional initial YouTube search.
query: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
enum Mode {
Insert,
Normal,
}
struct App {
options: Options,
query: String,
mode: Mode,
submitted_query: String,
videos: Vec<Video>,
selection: TableState,
job: Option<Job>,
playback: Option<Playback>,
status: String,
}
impl App {
fn new(options: Options) -> Self {
let query = options.query.clone().unwrap_or_default();
let mode = if query.trim().is_empty() {
Mode::Insert
} else {
Mode::Normal
};
Self {
options,
query,
mode,
submitted_query: String::new(),
videos: Vec::new(),
selection: TableState::default(),
job: None,
playback: None,
status: "Type a query, then press Enter.".into(),
}
}
fn search(&mut self) {
let query = self.query.trim();
if query.is_empty() {
self.status = "Enter a search first.".into();
return;
}
if query == self.submitted_query {
return;
}
match Job::search(query) {
Ok(job) => {
self.job = Some(job);
self.submitted_query = query.to_owned();
self.videos.clear();
self.selection = TableState::default();
self.status = format!("Searching for {query}...");
}
Err(error) => self.status = error.to_string(),
}
}
fn play(&mut self) {
if self.job.is_some() {
return;
}
let Some(video) = self
.selection
.selected()
.and_then(|index| self.videos.get(index))
else {
return;
};
match Playback::start(&video.id, &self.options.player, &self.options.player_arg) {
Ok(playback) => {
self.status = format!(
"Streaming {} in {}...",
video.title,
self.options.player.to_string_lossy()
);
self.playback = Some(playback);
}
Err(error) => self.status = error.to_string(),
}
}
fn update(&mut self) {
if let Some(result) = self.job.as_mut().and_then(Job::poll) {
self.job = None;
self.status = match result.and_then(|output| youtube::search_results(&output)) {
Ok(videos) => {
self.videos = videos;
self.selection = TableState::default();
self.selection
.select((!self.videos.is_empty()).then_some(0));
format!("{} results. Select a video to stream.", self.videos.len())
}
Err(error) => error,
};
}
if let Some(result) = self.playback.as_mut().and_then(Playback::poll) {
self.playback = None;
self.status = match result {
Ok(()) => "Playback finished.".into(),
Err(error) => error,
};
}
}
fn key(&mut self, key: KeyEvent) -> bool {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return true;
}
if self.mode == Mode::Insert {
match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Enter => {
self.search();
self.mode = Mode::Normal;
}
KeyCode::Backspace => {
self.query.pop();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.query.clear();
}
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.query.push(c);
}
_ => {}
}
} else {
match key.code {
KeyCode::Char('q') => return true,
KeyCode::Char('i') => self.mode = Mode::Insert,
KeyCode::Enter => self.play(),
KeyCode::Down | KeyCode::Char('j') => self.selection.select_next(),
KeyCode::Up | KeyCode::Char('k') => self.selection.select_previous(),
KeyCode::Home | KeyCode::Char('g') => self.selection.select_first(),
KeyCode::End | KeyCode::Char('G') => self.selection.select_last(),
_ => {}
}
}
if let Some(index) = self
.selection
.selected()
.filter(|_| !self.videos.is_empty())
{
self.selection
.select(Some(index.min(self.videos.len() - 1)));
}
false
}
fn draw(&mut self, frame: &mut Frame) {
let [search, results, status, help] = Layout::vertical([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.areas(frame.area());
let search_style = if self.mode == Mode::Insert {
Style::new().fg(Color::Cyan)
} else {
Style::new()
};
// End-only editing; scroll long queries without counting UTF-8 bytes as columns.
let query = ratatui::text::Line::raw(self.query.as_str());
let scroll = query
.width()
.saturating_sub(search.width.saturating_sub(3) as usize);
frame.render_widget(
Paragraph::new(query)
.scroll((0, scroll.min(u16::MAX as usize) as u16))
.block(
Block::bordered()
.title(" tubular / YouTube search ")
.border_style(search_style),
),
search,
);
let table = Table::new(
self.videos.iter().map(|video| {
Row::new([
video.title.as_str(),
video.channel.as_deref().unwrap_or("Unknown"),
])
}),
[Constraint::Percentage(75), Constraint::Percentage(25)],
)
.header(Row::new(["Video", "Channel"]).bold())
.block(Block::bordered().title(" Results "))
.row_highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan))
.highlight_symbol("> ");
frame.render_stateful_widget(table, results, &mut self.selection);
frame.render_widget(
Paragraph::new(self.status.as_str())
.wrap(Wrap { trim: true })
.block(Block::bordered().title(" Status ")),
status,
);
let help_text = match self.mode {
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",
};
frame.render_widget(Paragraph::new(help_text).dim(), help);
}
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
if !self.query.trim().is_empty() {
self.search();
}
loop {
self.update();
terminal.draw(|frame| self.draw(frame))?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
&& key.kind != KeyEventKind::Release
&& self.key(key)
{
return Ok(());
}
}
}
}
fn main() -> io::Result<()> {
let app = App::new(Options::parse());
ratatui::run(|terminal| app.run(terminal))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modal_keys_edit_or_navigate_without_submitting_on_escape() {
let mut app = App::new(Options::parse_from(["tubular"]));
assert_eq!(app.mode, Mode::Insert);
app.videos = vec![
Video {
id: "jNQXAC9IVRw".into(),
title: "Me at the zoo".into(),
channel: None,
},
Video {
id: "rQ_J9WH6CGk".into(),
title: "Rust course".into(),
channel: None,
},
];
app.selection.select(Some(0));
for c in ['j', 'k', 'q'] {
assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
}
app.update();
assert_eq!(app.query, "jkq");
assert_eq!(app.selection.selected(), Some(0));
assert!(app.job.is_none());
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Normal);
assert!(app.job.is_none());
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(app.selection.selected(), Some(1));
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(app.selection.selected(), Some(0));
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Insert);
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Normal);
assert!(app.query.is_empty());
assert!(app.job.is_none());
assert_eq!(
App::new(Options::parse_from(["tubular", "rust"])).mode,
Mode::Normal
);
assert_eq!(
App::new(Options::parse_from(["tubular", " "])).mode,
Mode::Insert
);
}
}