use anyhow::{anyhow, Context, Result};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers},
execute,
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
Terminal,
};
use serde::Deserialize;
use std::{
cmp,
io::{self, Read},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const ASCII_CHARS: &[u8] = b"@%#*+=-:. ";
const MAX_RENDER_WIDTH: u32 = 120;
const MAX_RENDER_HEIGHT: u32 = 40;
const AUDIO_START_DELAY_MS: u64 = 120;
#[derive(Debug, Clone)]
struct VideoItem {
id: String,
title: String,
channel: String,
duration: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
SearchInput,
Results,
}
struct App {
input: String,
results: Vec<VideoItem>,
selected: usize,
status: String,
mode: Mode,
list_state: ListState,
}
impl App {
fn new() -> Self {
Self {
input: String::new(),
results: Vec::new(),
selected: 0,
status: "Type to search, Enter to run. q to quit.".to_string(),
mode: Mode::SearchInput,
list_state: ListState::default(),
}
}
fn selected_item(&self) -> Option<VideoItem> {
self.results.get(self.selected).cloned()
}
}
#[derive(Debug, Deserialize)]
struct FfprobeStream {
width: Option<u32>,
height: Option<u32>,
avg_frame_rate: Option<String>,
duration: Option<String>,
}
#[derive(Debug, Deserialize)]
struct FfprobeResult {
streams: Vec<FfprobeStream>,
format: Option<FfprobeFormat>,
}
#[derive(Debug, Deserialize)]
struct FfprobeFormat {
duration: Option<String>,
}
#[derive(Debug, Clone, Copy)]
struct VideoProbe {
width: u32,
height: u32,
fps: f32,
duration: Option<f32>,
}
fn main() -> Result<()> {
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
terminal::enable_raw_mode()?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
let res = run_app(&mut terminal, &mut app);
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(err) = res {
eprintln!("Error: {:#}", err);
}
Ok(())
}
fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> Result<()> {
loop {
terminal.draw(|f| ui(f, app))?;
if event::poll(Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
if handle_key(terminal, app, key)? {
break;
}
}
}
}
Ok(())
}
fn handle_key(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
key: KeyEvent,
) -> Result<bool> {
match app.mode {
Mode::SearchInput => match key.code {
KeyCode::Char('q') => return Ok(true),
KeyCode::Enter => {
let query = app.input.trim().to_string();
if query.is_empty() {
app.status = "Enter a search query.".to_string();
return Ok(false);
}
app.status = "Searching...".to_string();
terminal.draw(|f| ui(f, app))?;
match yt_search(&query, 12) {
Ok(results) => {
app.results = results;
app.selected = 0;
app.list_state.select(Some(0));
app.mode = Mode::Results;
app.status = "Up/Down to move, Enter to play, Tab/s to search, q to quit."
.to_string();
}
Err(err) => {
app.status = format!("Search failed: {err}");
app.results.clear();
app.selected = 0;
app.list_state.select(None);
}
}
}
KeyCode::Backspace => {
app.input.pop();
}
KeyCode::Down | KeyCode::Tab => {
if !app.results.is_empty() {
app.mode = Mode::Results;
app.list_state.select(Some(app.selected));
app.status =
"Up/Down to move, Enter to play, Tab/s to search, q to quit."
.to_string();
}
}
KeyCode::Char(c) => {
if !key.modifiers.contains(KeyModifiers::CONTROL) {
app.input.push(c);
}
}
_ => {}
},
Mode::Results => match key.code {
KeyCode::Char('q') => return Ok(true),
KeyCode::Char('s') | KeyCode::Tab => {
app.mode = Mode::SearchInput;
app.status = "Type to search, Enter to run. q to quit.".to_string();
}
KeyCode::Up => {
if !app.results.is_empty() {
app.selected = app.selected.saturating_sub(1);
app.list_state.select(Some(app.selected));
}
}
KeyCode::Down => {
if !app.results.is_empty() {
app.selected = cmp::min(app.selected + 1, app.results.len() - 1);
app.list_state.select(Some(app.selected));
}
}
KeyCode::Enter => {
if let Some(item) = app.selected_item() {
app.status = format!("Downloading: {}", item.title);
terminal.draw(|f| ui(f, app))?;
if let Err(err) = play_video(terminal, &item) {
app.status = format!("Playback failed: {err}");
} else {
app.status = "Playback finished. Enter to play again, s to search.".to_string();
}
}
}
_ => {}
},
}
Ok(false)
}
fn ui(f: &mut ratatui::Frame, app: &mut App) {
let size = f.size();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(5), Constraint::Length(2)].as_ref())
.split(size);
let input = Paragraph::new(app.input.as_str())
.block(Block::default().title("Search").borders(Borders::ALL))
.style(match app.mode {
Mode::SearchInput => Style::default().fg(Color::Yellow),
Mode::Results => Style::default(),
});
f.render_widget(input, chunks[0]);
if app.results.is_empty() {
app.list_state.select(None);
} else if app.list_state.selected().is_none() {
app.list_state.select(Some(app.selected));
}
let list = build_results_list(&app.results, chunks[1]);
f.render_stateful_widget(list, chunks[1], &mut app.list_state);
let status = Paragraph::new(app.status.as_str())
.block(Block::default().borders(Borders::TOP))
.wrap(Wrap { trim: true });
f.render_widget(status, chunks[2]);
if app.mode == Mode::SearchInput {
let cursor_x = chunks[0].x + 1 + app.input.len() as u16;
let cursor_y = chunks[0].y + 1;
f.set_cursor(cursor_x, cursor_y);
}
}
const HIGHLIGHT_SYMBOL: &str = "> ";
fn build_results_list(results: &[VideoItem], area: Rect) -> List<'static> {
let width = area
.width
.saturating_sub(2 + HIGHLIGHT_SYMBOL.len() as u16) as usize;
let items: Vec<ListItem> = results
.iter()
.map(|item| {
let mut line = format!("{} {}", item.title, item.channel);
if let Some(dur) = item.duration {
line.push_str(&format!(" [{}]", format_duration(dur)));
}
let line = truncate_to_width(&line, width);
ListItem::new(Line::from(Span::raw(line)))
})
.collect();
List::new(items)
.block(Block::default().title("Results").borders(Borders::ALL))
.highlight_style(Style::default().fg(Color::Black).bg(Color::LightYellow))
.highlight_symbol(HIGHLIGHT_SYMBOL)
}
fn truncate_to_width(input: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
if UnicodeWidthStr::width(input) <= width {
return input.to_string();
}
let mut out = String::new();
let mut used = 0;
let limit = width.saturating_sub(1);
for ch in input.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + w > limit {
break;
}
out.push(ch);
used += w;
}
out.push('…');
out
}
fn yt_search(query: &str, limit: usize) -> Result<Vec<VideoItem>> {
let search_arg = format!("ytsearch{}:{}", limit, query);
let output = Command::new("yt-dlp")
.args([
"--dump-json",
"--skip-download",
"--flat-playlist",
"--quiet",
"--no-warnings",
"--no-progress",
&search_arg,
])
.output()
.context("failed to run yt-dlp for search")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = stderr.trim();
let stdout = stdout.trim();
let err_text = if !stderr.is_empty() {
stderr
} else if !stdout.is_empty() {
stdout
} else {
"<empty>"
};
return Err(anyhow!(
"yt-dlp error (exit {}): {}",
output.status.code().unwrap_or(-1),
err_text
));
}
let mut items = Vec::new();
let mut bad_lines: Vec<String> = Vec::new();
for raw in String::from_utf8_lossy(&output.stdout).lines() {
let line = raw.trim();
if line.is_empty() {
continue;
}
match parse_search_line(line) {
Some(item) => items.push(item),
None => {
if bad_lines.len() < 3 {
bad_lines.push(line.to_string());
}
}
}
}
if items.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !bad_lines.is_empty() {
return Err(anyhow!(
"invalid yt-dlp json. examples: {:?}. stderr: {}",
bad_lines,
stderr.trim()
));
}
return Err(anyhow!("no results. stderr: {}", stderr.trim()));
}
Ok(items)
}
fn parse_search_line(line: &str) -> Option<VideoItem> {
let value: serde_json::Value = serde_json::from_str(line).ok()?;
let id = value.get("id")?.as_str()?.to_string();
let title = value
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("(no title)")
.to_string();
let channel = value
.get("channel")
.and_then(|v| v.as_str())
.or_else(|| value.get("uploader").and_then(|v| v.as_str()))
.unwrap_or("(unknown)")
.to_string();
let duration = value
.get("duration")
.and_then(|v| v.as_f64())
.map(|v| v.round() as u64);
Some(VideoItem {
id,
title,
channel,
duration,
})
}
fn format_duration(total: u64) -> String {
let hours = total / 3600;
let minutes = (total % 3600) / 60;
let seconds = total % 60;
if hours > 0 {
format!("{}:{:02}:{:02}", hours, minutes, seconds)
} else {
format!("{}:{:02}", minutes, seconds)
}
}
fn play_video(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, item: &VideoItem) -> Result<()> {
let video_path = download_video(&item.id)?;
let probe = probe_video(&video_path).unwrap_or(VideoProbe {
width: 1280,
height: 720,
fps: 30.0,
duration: None,
});
let size = terminal.size()?;
let (target_w, target_h) = calculate_dimensions(
probe.width,
probe.height,
size.width as u32,
size.height.saturating_sub(2) as u32,
);
let mut offset_secs: f32 = 0.0;
let (mut ffmpeg, mut audio) = spawn_pipeline(&video_path, target_w, target_h, offset_secs)?;
let frame_size = (target_w * target_h * 3) as usize;
let mut buffer = vec![0u8; frame_size];
let frame_duration = Duration::from_secs_f32(1.0 / probe.fps.max(1.0));
let frame_duration_secs = frame_duration.as_secs_f32();
let mut play_start = Instant::now() + Duration::from_millis(AUDIO_START_DELAY_MS);
let mut frame_index: u64 = 0;
loop {
let mut seek_delta: Option<f32> = None;
if event::poll(Duration::from_millis(1))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Left => seek_delta = Some(-5.0),
KeyCode::Right => seek_delta = Some(5.0),
KeyCode::PageUp => seek_delta = Some(-30.0),
KeyCode::PageDown => seek_delta = Some(30.0),
_ => {}
}
}
}
if let Some(delta) = seek_delta {
let max_offset = probe.duration.unwrap_or(f32::INFINITY);
let new_offset = (offset_secs + delta).clamp(0.0, max_offset.max(0.0));
if (new_offset - offset_secs).abs() > f32::EPSILON {
offset_secs = new_offset;
kill_child(&mut ffmpeg);
if let Some(child) = audio.as_mut() {
kill_child(child);
}
let (new_ffmpeg, new_audio) =
spawn_pipeline(&video_path, target_w, target_h, offset_secs)?;
ffmpeg = new_ffmpeg;
audio = new_audio;
frame_index = 0;
play_start = Instant::now() + Duration::from_millis(AUDIO_START_DELAY_MS);
}
continue;
}
let now = Instant::now();
if now < play_start {
std::thread::sleep(play_start - now);
}
let elapsed = Instant::now().saturating_duration_since(play_start);
let target_frame = (elapsed.as_secs_f32() / frame_duration_secs).floor() as u64;
if target_frame > frame_index {
let frames_to_skip = target_frame - frame_index;
for _ in 0..frames_to_skip {
if ffmpeg.stdout.as_mut().unwrap().read_exact(&mut buffer).is_err() {
return Ok(());
}
}
frame_index = target_frame;
} else {
let next_time = play_start
+ Duration::from_secs_f32(frame_duration_secs * frame_index as f32);
let now = Instant::now();
if now < next_time {
std::thread::sleep(next_time - now);
}
}
if let Err(_) = ffmpeg.stdout.as_mut().unwrap().read_exact(&mut buffer) {
break;
}
let frame_lines = ascii_frame_color(&buffer, target_w as usize, target_h as usize);
let current_secs = offset_secs + frame_index as f32 * frame_duration_secs;
terminal.draw(|f| draw_player(f, item, &frame_lines, current_secs, probe.duration))?;
frame_index = frame_index.saturating_add(1);
}
kill_child(&mut ffmpeg);
if let Some(child) = audio.as_mut() {
kill_child(child);
}
Ok(())
}
fn draw_player(
f: &mut ratatui::Frame,
item: &VideoItem,
frame_lines: &[Line<'static>],
current_secs: f32,
total_secs: Option<f32>,
) {
let size = f.size();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(2)].as_ref())
.split(size);
let frame = Paragraph::new(Text::from(frame_lines.to_vec()))
.block(Block::default().borders(Borders::ALL));
f.render_widget(frame, chunks[0]);
let status_text = build_status_text(item, current_secs, total_secs, chunks[1].width);
let status = Paragraph::new(status_text)
.block(Block::default().borders(Borders::TOP))
.wrap(Wrap { trim: true });
f.render_widget(status, chunks[1]);
}
fn ascii_frame_color(pixels: &[u8], width: usize, height: usize) -> Vec<Line<'static>> {
let mut lines = Vec::with_capacity(height);
for y in 0..height {
let row_start = y * width * 3;
let mut spans = Vec::with_capacity(width);
for x in 0..width {
let idx = row_start + x * 3;
let r = pixels[idx];
let g = pixels[idx + 1];
let b = pixels[idx + 2];
let gray =
(0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32).round() as u8;
let ascii_idx = gray as usize * (ASCII_CHARS.len() - 1) / 255;
let ch = ASCII_CHARS[ascii_idx] as char;
spans.push(Span::styled(
ch.to_string(),
Style::default().fg(Color::Rgb(r, g, b)),
));
}
lines.push(Line::from(spans));
}
lines
}
fn calculate_dimensions(video_w: u32, video_h: u32, term_w: u32, term_h: u32) -> (u32, u32) {
if term_w == 0 || term_h == 0 {
return (1, 1);
}
let char_aspect = 0.5_f32;
let video_aspect = video_w as f32 / video_h as f32;
let term_aspect = (term_w as f32 * char_aspect) / term_h as f32;
let (mut new_w, mut new_h) = if video_aspect > term_aspect {
let w = term_w;
let h = ((term_w as f32) / (video_aspect * 2.0)).max(1.0) as u32;
(w, h.max(1))
} else {
let h = term_h;
let w = ((term_h as f32) * video_aspect * 2.0).max(1.0) as u32;
(w.max(1), h)
};
let max_w = MAX_RENDER_WIDTH.min(term_w).max(1);
let max_h = MAX_RENDER_HEIGHT.min(term_h).max(1);
if new_w > max_w {
let ratio = max_w as f32 / new_w as f32;
new_w = max_w;
new_h = ((new_h as f32) * ratio).max(1.0) as u32;
}
if new_h > max_h {
let ratio = max_h as f32 / new_h as f32;
new_h = max_h;
new_w = ((new_w as f32) * ratio).max(1.0) as u32;
}
(new_w.max(1), new_h.max(1))
}
fn build_status_text(
item: &VideoItem,
current_secs: f32,
total_secs: Option<f32>,
width: u16,
) -> Text<'static> {
let width = width.saturating_sub(2) as usize;
let time_text = if let Some(total) = total_secs {
format!("{} / {}", format_time(current_secs), format_time(total))
} else {
format_time(current_secs)
};
let mut line1 = time_text.clone();
if let Some(total) = total_secs {
let ratio = if total > 0.0 {
(current_secs / total).clamp(0.0, 1.0)
} else {
0.0
};
let bar_width = width.saturating_sub(time_text.len() + 2);
if bar_width >= 8 {
let filled = ((bar_width as f32) * ratio).round() as usize;
let empty = bar_width.saturating_sub(filled);
let bar = format!("[{}{}]", "#".repeat(filled), "-".repeat(empty));
line1 = format!("{bar} {time_text}");
}
}
let line2_raw =
format!("Playing: {} — {} (q stop, ←/→ 5s, PgUp/PgDn 30s)", item.title, item.channel);
let line2 = truncate_to_width(&line2_raw, width);
Text::from(vec![Line::from(line1), Line::from(line2)])
}
fn format_time(secs: f32) -> String {
let total = secs.max(0.0).round() as u64;
let hours = total / 3600;
let minutes = (total % 3600) / 60;
let seconds = total % 60;
if hours > 0 {
format!("{}:{:02}:{:02}", hours, minutes, seconds)
} else {
format!("{}:{:02}", minutes, seconds)
}
}
fn download_video(id: &str) -> Result<String> {
let dir = "/tmp/youtube_cli";
std::fs::create_dir_all(dir).context("failed to create download dir")?;
let output_path = format!("{dir}/{id}.mp4");
if std::path::Path::new(&output_path).exists() {
return Ok(output_path);
}
let url = format!("https://www.youtube.com/watch?v={id}");
let output = Command::new("yt-dlp")
.args([
"--no-playlist",
"-f",
"bestvideo+bestaudio/best",
"--merge-output-format",
"mp4",
"--quiet",
"--no-warnings",
"-o",
&output_path,
&url,
])
.output()
.context("failed to run yt-dlp for download")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("yt-dlp download failed: {stderr}"));
}
Ok(output_path)
}
fn spawn_audio(path: &str, offset_secs: f32) -> Result<Child> {
let offset = format!("{:.2}", offset_secs.max(0.0));
let child = Command::new("ffplay")
.args([
"-nodisp",
"-autoexit",
"-loglevel",
"quiet",
"-ss",
&offset,
path,
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("failed to spawn ffplay")?;
Ok(child)
}
fn spawn_ffmpeg(path: &str, width: u32, height: u32, offset_secs: f32) -> Result<Child> {
let scale = format!("scale={width}:{height}");
let offset = format!("{:.2}", offset_secs.max(0.0));
let child = Command::new("ffmpeg")
.args([
"-loglevel",
"error",
"-ss",
&offset,
"-i",
path,
"-vf",
&scale,
"-pix_fmt",
"rgb24",
"-f",
"rawvideo",
"-",
])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("failed to spawn ffmpeg")?;
Ok(child)
}
fn spawn_pipeline(
path: &str,
width: u32,
height: u32,
offset_secs: f32,
) -> Result<(Child, Option<Child>)> {
let ffmpeg = spawn_ffmpeg(path, width, height, offset_secs)?;
let audio = spawn_audio(path, offset_secs).ok();
Ok((ffmpeg, audio))
}
fn kill_child(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
fn probe_video(path: &str) -> Result<VideoProbe> {
let output = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,avg_frame_rate,duration",
"-show_entries",
"format=duration",
"-of",
"json",
path,
])
.output()
.context("failed to run ffprobe")?;
if !output.status.success() {
return Err(anyhow!("ffprobe failed"));
}
let parsed: FfprobeResult = serde_json::from_slice(&output.stdout)?;
let stream = parsed
.streams
.first()
.ok_or_else(|| anyhow!("no video stream"))?;
let width = stream.width.unwrap_or(1280);
let height = stream.height.unwrap_or(720);
let fps = stream
.avg_frame_rate
.as_deref()
.and_then(parse_fraction)
.unwrap_or(30.0);
let duration = stream
.duration
.as_deref()
.and_then(|d| d.parse::<f32>().ok())
.or_else(|| {
parsed
.format
.as_ref()
.and_then(|fmt| fmt.duration.as_deref())
.and_then(|d| d.parse::<f32>().ok())
});
Ok(VideoProbe {
width,
height,
fps,
duration,
})
}
fn parse_fraction(text: &str) -> Option<f32> {
let mut parts = text.split('/');
let num: f32 = parts.next()?.parse().ok()?;
let den: f32 = parts.next()?.parse().ok()?;
if den == 0.0 {
None
} else {
Some(num / den)
}
}
#[cfg(test)]
mod tests {
use super::parse_search_line;
#[test]
fn parse_search_line_basic() {
let line = r#"{"id":"abc123","title":"Hello","channel":"World","duration":120}"#;
let item = parse_search_line(line).expect("parsed");
assert_eq!(item.id, "abc123");
assert_eq!(item.title, "Hello");
assert_eq!(item.channel, "World");
assert_eq!(item.duration, Some(120));
}
#[test]
fn parse_search_line_url_type() {
let line = r#"{"_type":"url","id":"5C_HPTJg5ek","url":"https://www.youtube.com/watch?v=5C_HPTJg5ek","title":"T","uploader":"Chan"}"#;
let item = parse_search_line(line).expect("parsed");
assert_eq!(item.id, "5C_HPTJg5ek");
assert_eq!(item.title, "T");
assert_eq!(item.channel, "Chan");
assert_eq!(item.duration, None);
}
#[test]
fn parse_search_line_missing_id() {
let line = r#"{"title":"No id"}"#;
assert!(parse_search_line(line).is_none());
}
}