feat: preview video thumbnails in a floating window
This commit is contained in:
+136
-21
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user