feat: preview video thumbnails in a floating window

This commit is contained in:
2026-10-09 22:08:34 +02:00
parent 656474df5b
commit 689f14a634
5 changed files with 1152 additions and 36 deletions
+136 -21
View File
@@ -7,6 +7,9 @@
//! 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.
//! Normal-mode t toggles a thumbnail in the same-sized popup; j/k follows selection, Esc closes.
//! Images use native terminal graphics when supported, otherwise colored half-block characters.
//! curl fetches JPEGs lazily into memory; decoding and image resizing/encoding run off-thread.
//! Channel categories live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
//! Under `[categories]`, write `Rust = ["@RustVideos"]`; channel URLs and UC IDs also work.
//! Normal-mode c opens the category picker; n there prompts for a new name, Enter saves, Esc cancels.
@@ -17,6 +20,7 @@
//! See `--help` for controls; replacing playback or quitting cancels its processes.
mod config;
mod thumbnail;
mod youtube;
use std::{ffi::OsString, io, path::PathBuf, time::Duration};
@@ -36,7 +40,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.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\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; c saves a channel; q quits.\nTab/Shift-Tab switch Search/category views; r reloads config and refreshes the current view.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nRust = [\"@RustVideos\"]\nChannel entries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\nFeeds combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nCATEGORY PICKER: j/k or arrows choose; Enter saves; n prompts for a new category; Esc cancels.\nNEW CATEGORY: type a name; Enter creates it and saves the channel; Esc returns to the picker; Ctrl-U clears.\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 any mode or popup.\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; thumbnails also use curl.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\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; t toggles thumbnail; c saves a channel; q quits.\nTab/Shift-Tab switch Search/category views; r reloads config and refreshes the current view.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nRust = [\"@RustVideos\"]\nChannel entries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\nFeeds combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nCATEGORY PICKER: j/k or arrows choose; Enter saves; n prompts for a new category; Esc cancels.\nNEW CATEGORY: type a name; Enter creates it and saves the channel; Esc returns to the picker; Ctrl-U clears.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, 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.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a category name edits text.\nCtrl-U clears in INSERT; Ctrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
)]
struct Options {
/// Player executable (IINA or a player supporting '-' for standard input).
@@ -95,6 +99,8 @@ struct App {
views: Vec<View>,
view_index: usize,
category_dialog: Option<CategoryDialog>,
thumbnail_open: bool,
thumbnail: thumbnail::Thumbnail,
description_open: bool,
description_job: Option<(usize, Job)>,
description_error: Option<String>,
@@ -126,6 +132,8 @@ impl App {
views,
view_index: 0,
category_dialog: None,
thumbnail_open: false,
thumbnail: thumbnail::Thumbnail::new(),
description_open: false,
description_job: None,
description_error: None,
@@ -157,6 +165,7 @@ impl App {
self.job = None;
self.view_index = index;
}
self.close_thumbnail();
self.description_open = false;
self.description_job = None;
self.description_viewport = None;
@@ -177,6 +186,7 @@ impl App {
self.job = None;
self.description_open = false;
self.description_job = None;
self.close_thumbnail();
let view = &mut self.views[self.view_index];
view.videos.clear();
view.selection = TableState::default();
@@ -237,9 +247,11 @@ impl App {
self.job = None;
self.description_open = false;
self.description_job = None;
self.close_thumbnail();
}
fn refresh(&mut self) {
self.close_thumbnail();
match config::load(&self.config_path) {
Ok(config) => {
self.apply_config(config);
@@ -250,6 +262,7 @@ impl App {
}
fn open_category_picker(&mut self) {
self.close_thumbnail();
let view = &self.views[self.view_index];
let Some(video) = view
.selection
@@ -368,6 +381,13 @@ impl App {
self.category_dialog = Some(dialog);
}
fn close_thumbnail(&mut self) {
if self.thumbnail_open {
self.thumbnail_open = false;
self.thumbnail.cancel();
}
}
fn load_description(&mut self) {
self.description_job = None;
self.description_error = None;
@@ -483,6 +503,7 @@ impl App {
}
let selected_before = self.views[self.view_index].selection.selected();
let description_was_open = self.description_open;
let thumbnail_was_open = self.thumbnail_open;
if self.mode == Mode::Insert {
match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
@@ -510,16 +531,32 @@ impl App {
KeyCode::Char('q') => return true,
KeyCode::Char('i') => {
self.description_open = false;
self.close_thumbnail();
self.mode = Mode::Insert;
}
KeyCode::Char('d') => {
self.close_thumbnail();
self.description_open = !self.description_open
&& self.views[self.view_index]
.selection
.selected()
.is_some_and(|index| index < self.views[self.view_index].videos.len());
}
KeyCode::Esc => self.description_open = false,
KeyCode::Char('t') => {
if self.thumbnail_open {
self.close_thumbnail();
} else {
self.thumbnail_open = self.views[self.view_index]
.selection
.selected()
.is_some_and(|index| index < self.views[self.view_index].videos.len());
}
self.description_open = false;
}
KeyCode::Esc => {
self.description_open = false;
self.close_thumbnail();
}
KeyCode::Down if self.description_open => {
self.description_scroll = self
.description_scroll
@@ -586,6 +623,16 @@ impl App {
{
self.load_description();
}
if self.thumbnail_open
&& (!thumbnail_was_open
|| self.views[self.view_index].selection.selected() != selected_before)
&& let Some(video) = self.views[self.view_index]
.selection
.selected()
.and_then(|index| self.views[self.view_index].videos.get(index))
{
self.thumbnail.select(&video.id);
}
false
}
@@ -656,8 +703,11 @@ impl App {
Mode::Normal if self.description_open => {
"NORMAL · ↑/↓ PgUp/PgDn scroll · j/k video · d/Esc close · i insert · q quit"
}
Mode::Normal if self.thumbnail_open => {
"NORMAL · j/k video · t/Esc close · d description · i insert · q quit"
}
Mode::Normal => {
"NORMAL · j/k select · Enter play · i search · d info · c save · Tab views · r refresh · q quit"
"NORMAL · j/k · Enter play · i search · d info · t image · c save · Tab views · r refresh · q quit"
}
};
frame.render_widget(Paragraph::new(help_text).dim(), help);
@@ -667,23 +717,7 @@ impl App {
.selected()
.and_then(|index| self.views[self.view_index].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,
);
let popup = preview_area(frame.area());
frame.render_widget(Clear, popup);
let text = match video.description.as_deref() {
Some("") => "This video has no description.",
@@ -694,7 +728,10 @@ impl App {
.unwrap_or("Loading full description..."),
};
let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
let viewport = (width.saturating_sub(2), height.saturating_sub(2));
let viewport = (
popup.width.saturating_sub(2),
popup.height.saturating_sub(2),
);
if self.description_viewport != Some(viewport) {
self.description_max_scroll = if viewport.0 == 0 || viewport.1 == 0 {
0
@@ -721,6 +758,26 @@ impl App {
popup,
);
}
if self.thumbnail_open
&& let Some(video) = self.views[self.view_index]
.selection
.selected()
.and_then(|index| self.views[self.view_index].videos.get(index))
{
let popup = preview_area(frame.area());
frame.render_widget(Clear, popup);
let block = Block::bordered()
.title(" Thumbnail · t/Esc close ")
.title_bottom(Line::from(vec![
" ".into(),
video.title.as_str().into(),
" ".into(),
]))
.border_style(Style::new().fg(Color::Cyan));
let inner = block.inner(popup);
frame.render_widget(block, popup);
self.thumbnail.draw(frame, inner);
}
self.draw_category_dialog(frame);
}
@@ -804,6 +861,7 @@ impl App {
}
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
self.thumbnail.query_terminal();
if !self.query.trim().is_empty() {
self.search();
}
@@ -821,6 +879,25 @@ impl App {
}
}
fn preview_area(area: Rect) -> Rect {
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);
Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
)
}
fn main() -> io::Result<()> {
let options = Options::parse();
let config_path = config::config_path()?;
@@ -960,4 +1037,42 @@ mod tests {
assert!(!app.views[app.view_index].loaded);
assert!(app.job.is_none());
}
#[test]
fn thumbnails_are_normal_mode_only_and_other_popups_dismiss_them() {
let mut app = app(&["tubular"]);
app.key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE));
assert_eq!(app.query, "t");
assert!(!app.thumbnail_open);
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE));
assert!(!app.thumbnail_open);
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Me at the zoo","channel_id":"UC4QobU6STFB0P71PMvOGN5A"}]}"#,
).unwrap();
app.views[0].videos[0].description = Some("A cached full description.".into());
app.views[0].selection.select(Some(0));
app.views[0].loaded = true;
app.thumbnail_open = true;
app.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert!(app.description_open);
assert!(!app.thumbnail_open);
assert!(app.description_job.is_none());
app.thumbnail_open = true;
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!app.description_open);
assert!(!app.thumbnail_open);
assert_eq!(app.mode, Mode::Normal);
app.thumbnail_open = true;
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert!(app.category_dialog.is_some());
assert!(!app.thumbnail_open);
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.thumbnail_open = true;
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Insert);
assert!(!app.thumbnail_open);
}
}