feat: show Miruro series artwork in descriptions
This commit is contained in:
Generated
+17
@@ -491,6 +491,7 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"byteorder-lite",
|
"byteorder-lite",
|
||||||
|
"image-webp",
|
||||||
"moxcms",
|
"moxcms",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png",
|
"png",
|
||||||
@@ -498,6 +499,16 @@ dependencies = [
|
|||||||
"zune-jpeg",
|
"zune-jpeg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-webp"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder-lite",
|
||||||
|
"quick-error",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.2"
|
version = "2.14.2"
|
||||||
@@ -855,6 +866,12 @@ dependencies = [
|
|||||||
"wide",
|
"wide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ description = "A terminal YouTube and Miruro browser that opens selected videos
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.6.7", features = ["derive"] }
|
clap = { version = "4.6.7", features = ["derive"] }
|
||||||
image = { version = "0.25.10", default-features = false, features = ["jpeg"] }
|
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||||
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] }
|
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] }
|
||||||
ratatui-image = { version = "11.1.0", default-features = false, features = ["crossterm"] }
|
ratatui-image = { version = "11.1.0", default-features = false, features = ["crossterm"] }
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ A modal terminal YouTube and Miruro browser built with Rust and [Ratatui](https:
|
|||||||
|
|
||||||
- A recent stable Rust toolchain, including Cargo, to build and install.
|
- A recent stable Rust toolchain, including Cargo, to build and install.
|
||||||
- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for YouTube searches, feeds, and full descriptions; Miruro uses the native API client.
|
- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for YouTube searches, feeds, and full descriptions; Miruro uses the native API client.
|
||||||
- `curl` on `PATH` for thumbnails.
|
- `curl` on `PATH` for YouTube thumbnails and Miruro cover art.
|
||||||
- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. YouTube requires `--new-instance --url … --progress-file PATH`; Miruro additionally requires `--source-file PATH` and version-2 episode progress support.
|
- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. YouTube requires `--new-instance --url … --progress-file PATH`; Miruro additionally requires `--source-file PATH` and version-2 episode progress support.
|
||||||
|
|
||||||
The default playback setup requires **macOS 15+**, since Terebi is a native macOS player. Install Terebi separately and follow its dependency/setup instructions; installing Tubular does not install the player.
|
The default playback setup requires **macOS 15+**, since Terebi is a native macOS player. Install Terebi separately and follow its dependency/setup instructions; installing Tubular does not install the player.
|
||||||
|
|
||||||
A terminal with native image support can display thumbnails using its graphics protocol. Other terminals fall back to colored half-block characters.
|
A terminal with native image support can display thumbnails and cover art using its graphics protocol. Other terminals fall back to colored half-block characters.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ Each selection opens an independent player instance without replacing existing p
|
|||||||
|
|
||||||
Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, with a separate saved-show library organized by overlapping category tags, without YouTube's Search/Feed tabs or channel controls.
|
Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, with a separate saved-show library organized by overlapping category tags, without YouTube's Search/Feed tabs or channel controls.
|
||||||
|
|
||||||
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; press `d` for the selected title's synopsis and other returned facts. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first in the metadata popup, above the series synopsis. Listed counts do not guarantee that a supported Sun source is available.
|
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; press `d` for cover art, a bold title, dimmed metadata and a synopsis excerpt, using the same info layout as YouTube. Press `d` again for the full scrollable synopsis and returned facts; another `d` returns to info, and Esc closes either mode. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first, above the series synopsis and beside the series cover art in info mode. Listed counts do not guarantee that a supported Sun source is available.
|
||||||
|
|
||||||
| Key | Miruro action |
|
| Key | Miruro action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -73,7 +73,7 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit
|
|||||||
| `W` / `V` / `C` | Open Miruro's offline watch-later / saved / currently-watching episodes |
|
| `W` / `V` / `C` | Open Miruro's offline watch-later / saved / currently-watching episodes |
|
||||||
| `f` | Cycle All / Unwatched / Watched in watch later |
|
| `f` | Cycle All / Unwatched / Watched in watch later |
|
||||||
| `x` / `X` | Remove the selected / confirm removal of visible episodes from the active collection; `x` also removes a saved show from the library |
|
| `x` / `X` | Remove the selected / confirm removal of visible episodes from the active collection; `x` also removes a saved show from the library |
|
||||||
| `d` | Show/hide the synopsis/metadata popup; ↓ / ↑, Ctrl-j / Ctrl-k and page keys scroll; `j` / `k` change selection |
|
| `d` | Open info with cover art → full synopsis/metadata → info; ↓ / ↑, Ctrl-j / Ctrl-k and page keys scroll the full description; `j` / `k` change selection |
|
||||||
| Esc | Cancel input or close help/metadata and return to the episode/title list |
|
| Esc | Cancel input or close help/metadata and return to the episode/title list |
|
||||||
| `r` | Refresh the active screen and reload configured keys |
|
| `r` | Refresh the active screen and reload configured keys |
|
||||||
| `?` / `q` | Keyboard help / quit |
|
| `?` / `q` | Keyboard help / quit |
|
||||||
@@ -84,6 +84,8 @@ Space checks categories for combined browsing; Enter opens their deduplicated un
|
|||||||
|
|
||||||
Only episodes can have their watch status edited. Reopening a saved episode resolves new URLs; identity is the opaque Miruro catalog ID plus its returned episode number. Each interface shows only its provider's collections, while playback, watched state and storage remain shared. Episode markers show saved/watch-later/currently-watching membership and watched status; Terebi owns resume positions.
|
Only episodes can have their watch status edited. Reopening a saved episode resolves new URLs; identity is the opaque Miruro catalog ID plus its returned episode number. Each interface shows only its provider's collections, while playback, watched state and storage remain shared. Episode markers show saved/watch-later/currently-watching membership and watched status; Terebi owns resume positions.
|
||||||
|
|
||||||
|
Catalog results, series screens and saved-show lists use the returned `cover_url`; missing artwork leaves a text-only preview, and image failures do not hide metadata. Offline episode collections retain their stored text-only details. JPEG, PNG and WebP covers load, decode and resize off-thread, with native terminal graphics or proportional half-block rendering. The session image cache retains eight inactive images plus the current selection; a successful `r` refresh clears it.
|
||||||
|
|
||||||
Stream and external VTT subtitle headers are supplied explicitly; Terebi selects the source's declared default subtitle. Sources requiring media redirects, cross-origin HLS, proxies, iframe playback, or provider-specific playlist rewriting fail explicitly. No provider/language fallback or automatic expired-source refresh is performed.
|
Stream and external VTT subtitle headers are supplied explicitly; Terebi selects the source's declared default subtitle. Sources requiring media redirects, cross-origin HLS, proxies, iframe playback, or provider-specific playlist rewriting fail explicitly. No provider/language fallback or automatic expired-source refresh is performed.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+8
-63
@@ -3086,23 +3086,6 @@ impl App {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("Loading full description..."),
|
.unwrap_or("Loading full description..."),
|
||||||
};
|
};
|
||||||
let mut details = if mode == PreviewMode::Description {
|
|
||||||
inner.inner(ratatui::layout::Margin::new(1, 0))
|
|
||||||
} else {
|
|
||||||
let [_, image, _, mut details] = Layout::horizontal([
|
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Fill(3),
|
|
||||||
Constraint::Length(2),
|
|
||||||
Constraint::Fill(2),
|
|
||||||
])
|
|
||||||
.areas(inner);
|
|
||||||
let image_width = self.thumbnail.draw(frame, image);
|
|
||||||
let reclaimed = image.width - image_width;
|
|
||||||
details.x -= reclaimed;
|
|
||||||
details.width += reclaimed;
|
|
||||||
details.width = details.width.saturating_sub(2);
|
|
||||||
details
|
|
||||||
};
|
|
||||||
let metadata = [
|
let metadata = [
|
||||||
Line::raw(video.title.as_str()).bold(),
|
Line::raw(video.title.as_str()).bold(),
|
||||||
Line::default(),
|
Line::default(),
|
||||||
@@ -3118,6 +3101,7 @@ impl App {
|
|||||||
.dim(),
|
.dim(),
|
||||||
];
|
];
|
||||||
if mode == PreviewMode::Description {
|
if mode == PreviewMode::Description {
|
||||||
|
let details = inner.inner(ratatui::layout::Margin::new(1, 0));
|
||||||
let mut content = Text::from(text);
|
let mut content = Text::from(text);
|
||||||
content.lines.splice(
|
content.lines.splice(
|
||||||
0..0,
|
0..0,
|
||||||
@@ -3142,52 +3126,13 @@ impl App {
|
|||||||
self.description_scroll = self.description_scroll.min(self.description_max_scroll);
|
self.description_scroll = self.description_scroll.min(self.description_max_scroll);
|
||||||
frame.render_widget(paragraph.scroll((self.description_scroll, 0)), details);
|
frame.render_widget(paragraph.scroll((self.description_scroll, 0)), details);
|
||||||
} else {
|
} else {
|
||||||
let metadata = Paragraph::new(Vec::from(metadata)).wrap(Wrap { trim: true });
|
ui::info_preview(
|
||||||
let height = if details.is_empty() {
|
frame,
|
||||||
0
|
inner,
|
||||||
} else {
|
Some(&mut self.thumbnail),
|
||||||
metadata
|
Vec::from(metadata),
|
||||||
.line_count(details.width)
|
text,
|
||||||
.min(details.height as usize) as u16
|
);
|
||||||
};
|
|
||||||
let mut excerpt = details;
|
|
||||||
let gap = (excerpt.height - height).min(1);
|
|
||||||
excerpt.y += height + gap;
|
|
||||||
excerpt.height -= height + gap;
|
|
||||||
details.height = height;
|
|
||||||
frame.render_widget(metadata, details);
|
|
||||||
let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
|
|
||||||
let overflow = cached.is_some_and(|text| !text.is_empty())
|
|
||||||
&& !excerpt.is_empty()
|
|
||||||
&& paragraph.line_count(excerpt.width) > usize::from(excerpt.height);
|
|
||||||
frame.render_widget(paragraph, excerpt);
|
|
||||||
if overflow {
|
|
||||||
let buffer = frame.buffer_mut();
|
|
||||||
let y = excerpt.bottom() - 1;
|
|
||||||
let end = (excerpt.x..excerpt.right())
|
|
||||||
.rev()
|
|
||||||
.find_map(|x| {
|
|
||||||
let symbol = buffer
|
|
||||||
.cell((x, y))
|
|
||||||
.expect("excerpt is inside frame")
|
|
||||||
.symbol();
|
|
||||||
(!symbol.trim().is_empty())
|
|
||||||
.then(|| x.saturating_add(Span::raw(symbol).width() as u16))
|
|
||||||
})
|
|
||||||
.unwrap_or(excerpt.x);
|
|
||||||
let mut x = end.min(excerpt.right() - 1);
|
|
||||||
// Do not leave half of a wide character beneath the ellipsis.
|
|
||||||
if x > excerpt.x
|
|
||||||
&& Span::raw(buffer.cell((x - 1, y)).unwrap().symbol()).width() > 1
|
|
||||||
{
|
|
||||||
x -= 1;
|
|
||||||
}
|
|
||||||
buffer
|
|
||||||
.cell_mut((x, y))
|
|
||||||
.unwrap()
|
|
||||||
.set_symbol("…")
|
|
||||||
.set_style(Style::new());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
block.title(loading_title(
|
block.title(loading_title(
|
||||||
|
|||||||
+155
-61
@@ -24,9 +24,10 @@ use serde_json::Value;
|
|||||||
use tubular::miruro::Identity;
|
use tubular::miruro::Identity;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Options, config,
|
Options, PreviewMode, config,
|
||||||
keys::{Action, Context},
|
keys::{Action, Context},
|
||||||
miruro_browser::{self, Episode, Media, Request, Response, Worker},
|
miruro_browser::{self, Episode, Media, Request, Response, Worker},
|
||||||
|
thumbnail::Thumbnail,
|
||||||
ui,
|
ui,
|
||||||
video_state::{CollectionKind, State, WatchStatus},
|
video_state::{CollectionKind, State, WatchStatus},
|
||||||
youtube::{PlayerLaunch, Video},
|
youtube::{PlayerLaunch, Video},
|
||||||
@@ -130,14 +131,14 @@ impl Screen {
|
|||||||
Some(self.selection.selected().unwrap_or(0).min(self.len() - 1))
|
Some(self.selection.selected().unwrap_or(0).min(self.len() - 1))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
fn selected_media(&self) -> Option<Media> {
|
fn selected_media(&self) -> Option<&Media> {
|
||||||
let index = self.selection.selected();
|
let index = self.selection.selected();
|
||||||
match &self.content {
|
match &self.content {
|
||||||
Content::Catalog { media, .. } => index.and_then(|i| media.get(i)).cloned(),
|
Content::Catalog { media, .. } => index.and_then(|i| media.get(i)),
|
||||||
Content::Series { media, .. } => Some(media.clone()),
|
Content::Series { media, .. } => Some(media),
|
||||||
Content::Library { shows, .. } => index
|
Content::Library { shows, .. } => {
|
||||||
.and_then(|i| shows.get(i))
|
index.and_then(|i| shows.get(i)).map(|show| &show.media)
|
||||||
.map(|show| show.media.clone()),
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,7 +302,8 @@ struct Browser {
|
|||||||
screens: Vec<Screen>,
|
screens: Vec<Screen>,
|
||||||
index: usize,
|
index: usize,
|
||||||
input: Option<String>,
|
input: Option<String>,
|
||||||
description_focus: bool,
|
preview: Option<PreviewMode>,
|
||||||
|
thumbnail: Thumbnail,
|
||||||
help: Option<u16>,
|
help: Option<u16>,
|
||||||
removal: Option<(CollectionKind, Vec<String>)>,
|
removal: Option<(CollectionKind, Vec<String>)>,
|
||||||
modal: Option<LibraryModal>,
|
modal: Option<LibraryModal>,
|
||||||
@@ -394,7 +396,8 @@ impl Browser {
|
|||||||
})],
|
})],
|
||||||
index: 0,
|
index: 0,
|
||||||
input: None,
|
input: None,
|
||||||
description_focus: false,
|
preview: None,
|
||||||
|
thumbnail: Thumbnail::new(),
|
||||||
help: None,
|
help: None,
|
||||||
removal: None,
|
removal: None,
|
||||||
modal: None,
|
modal: None,
|
||||||
@@ -433,7 +436,7 @@ impl Browser {
|
|||||||
self.screens.truncate(self.index + 1);
|
self.screens.truncate(self.index + 1);
|
||||||
self.screens.push(screen);
|
self.screens.push(screen);
|
||||||
self.index += 1;
|
self.index += 1;
|
||||||
self.description_focus = false;
|
self.preview = None;
|
||||||
self.status.clear();
|
self.status.clear();
|
||||||
}
|
}
|
||||||
fn history(&mut self, forward: bool) {
|
fn history(&mut self, forward: bool) {
|
||||||
@@ -447,7 +450,7 @@ impl Browser {
|
|||||||
}
|
}
|
||||||
self.invalidate();
|
self.invalidate();
|
||||||
self.index = index;
|
self.index = index;
|
||||||
self.description_focus = false;
|
self.preview = None;
|
||||||
self.status.clear();
|
self.status.clear();
|
||||||
self.rebuild_collections();
|
self.rebuild_collections();
|
||||||
if matches!(&self.screen().content, Content::Series { data: None, .. }) {
|
if matches!(&self.screen().content, Content::Series { data: None, .. }) {
|
||||||
@@ -469,7 +472,7 @@ impl Browser {
|
|||||||
if let Content::Catalog { query, .. } = &self.screen().content {
|
if let Content::Catalog { query, .. } = &self.screen().content {
|
||||||
self.input = Some(query.clone());
|
self.input = Some(query.clone());
|
||||||
}
|
}
|
||||||
self.description_focus = false;
|
self.preview = None;
|
||||||
}
|
}
|
||||||
fn search(&mut self) {
|
fn search(&mut self) {
|
||||||
let Content::Catalog { query, .. } = &self.screen().content else {
|
let Content::Catalog { query, .. } = &self.screen().content else {
|
||||||
@@ -517,7 +520,7 @@ impl Browser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn open(&mut self) {
|
fn open(&mut self) {
|
||||||
if let Some(media) = self.screen().selected_media()
|
if let Some(media) = self.screen().selected_media().cloned()
|
||||||
&& !matches!(self.screen().content, Content::Series { .. })
|
&& !matches!(self.screen().content, Content::Series { .. })
|
||||||
{
|
{
|
||||||
self.push(Screen::new(Content::Series { media, data: None }));
|
self.push(Screen::new(Content::Series { media, data: None }));
|
||||||
@@ -671,7 +674,7 @@ impl Browser {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
fn tags(&mut self) {
|
fn tags(&mut self) {
|
||||||
let Some(media) = self.screen().selected_media() else {
|
let Some(media) = self.screen().selected_media().cloned() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let checked = self
|
let checked = self
|
||||||
@@ -684,7 +687,7 @@ impl Browser {
|
|||||||
let names: Vec<_> = self.categories().into_iter().collect();
|
let names: Vec<_> = self.categories().into_iter().collect();
|
||||||
let mut picker = Picker::default();
|
let mut picker = Picker::default();
|
||||||
picker.refilter(&names);
|
picker.refilter(&names);
|
||||||
self.description_focus = false;
|
self.preview = None;
|
||||||
self.modal = Some(LibraryModal::Tags {
|
self.modal = Some(LibraryModal::Tags {
|
||||||
media,
|
media,
|
||||||
checked,
|
checked,
|
||||||
@@ -699,7 +702,7 @@ impl Browser {
|
|||||||
names.extend(self.categories());
|
names.extend(self.categories());
|
||||||
let mut picker = Picker::default();
|
let mut picker = Picker::default();
|
||||||
picker.refilter(&names);
|
picker.refilter(&names);
|
||||||
self.description_focus = false;
|
self.preview = None;
|
||||||
self.modal = Some(LibraryModal::Categories {
|
self.modal = Some(LibraryModal::Categories {
|
||||||
names,
|
names,
|
||||||
checked: BTreeSet::new(),
|
checked: BTreeSet::new(),
|
||||||
@@ -946,7 +949,8 @@ impl Browser {
|
|||||||
fn remove(&mut self, bulk: bool) {
|
fn remove(&mut self, bulk: bool) {
|
||||||
if matches!(self.screen().content, Content::Library { .. }) {
|
if matches!(self.screen().content, Content::Library { .. }) {
|
||||||
if !bulk && let Some(media) = self.screen().selected_media() {
|
if !bulk && let Some(media) = self.screen().selected_media() {
|
||||||
match self.state.remove_show(&media.id) {
|
let id = media.id.clone();
|
||||||
|
match self.state.remove_show(&id) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.rebuild_library();
|
self.rebuild_library();
|
||||||
self.status = "Removed saved show; episode state unchanged.".into();
|
self.status = "Removed saved show; episode state unchanged.".into();
|
||||||
@@ -974,7 +978,7 @@ impl Browser {
|
|||||||
self.invalidate();
|
self.invalidate();
|
||||||
if bulk {
|
if bulk {
|
||||||
self.removal = Some((kind, ids));
|
self.removal = Some((kind, ids));
|
||||||
self.description_focus = false;
|
self.preview = None;
|
||||||
} else {
|
} else {
|
||||||
self.remove_ids(kind, ids);
|
self.remove_ids(kind, ids);
|
||||||
}
|
}
|
||||||
@@ -1000,7 +1004,10 @@ impl Browser {
|
|||||||
fn refresh(&mut self) {
|
fn refresh(&mut self) {
|
||||||
self.invalidate();
|
self.invalidate();
|
||||||
match config::load(&self.config_path) {
|
match config::load(&self.config_path) {
|
||||||
Ok(config) => self.config = config,
|
Ok(config) => {
|
||||||
|
self.config = config;
|
||||||
|
self.thumbnail.clear_cache();
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.fail(error);
|
self.fail(error);
|
||||||
return;
|
return;
|
||||||
@@ -1043,15 +1050,17 @@ impl Browser {
|
|||||||
self.screen().content,
|
self.screen().content,
|
||||||
Content::Collection { .. } | Content::Library { .. }
|
Content::Collection { .. } | Content::Library { .. }
|
||||||
) {
|
) {
|
||||||
if self.description_focus {
|
match self.preview {
|
||||||
Context::CollectionDescription
|
Some(PreviewMode::Info) => Context::CollectionInfo,
|
||||||
} else {
|
Some(PreviewMode::Description) => Context::CollectionDescription,
|
||||||
Context::CollectionNormal
|
None => Context::CollectionNormal,
|
||||||
}
|
}
|
||||||
} else if self.description_focus {
|
|
||||||
Context::Description
|
|
||||||
} else {
|
} else {
|
||||||
Context::Normal
|
match self.preview {
|
||||||
|
Some(PreviewMode::Info) => Context::Info,
|
||||||
|
Some(PreviewMode::Description) => Context::Description,
|
||||||
|
None => Context::Normal,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn select(&mut self, action: Action) {
|
fn select(&mut self, action: Action) {
|
||||||
@@ -1176,10 +1185,9 @@ impl Browser {
|
|||||||
| Action::VideoFirst
|
| Action::VideoFirst
|
||||||
| Action::VideoLast),
|
| Action::VideoLast),
|
||||||
) => self.select(action),
|
) => self.select(action),
|
||||||
Some(Action::InfoOverview | Action::InfoDescription) => {
|
Some(Action::InfoOverview) => self.preview = Some(PreviewMode::Info),
|
||||||
self.description_focus = !self.description_focus
|
Some(Action::InfoDescription) => self.preview = Some(PreviewMode::Description),
|
||||||
}
|
Some(Action::InfoHide) => self.preview = None,
|
||||||
Some(Action::InfoHide) => self.description_focus = false,
|
|
||||||
Some(Action::DescriptionNext) => {
|
Some(Action::DescriptionNext) => {
|
||||||
self.screen_mut().description_scroll =
|
self.screen_mut().description_scroll =
|
||||||
self.screen().description_scroll.saturating_add(1)
|
self.screen().description_scroll.saturating_add(1)
|
||||||
@@ -1297,7 +1305,10 @@ impl Browser {
|
|||||||
(Action::WatchLaterBrowse, "Watch later episodes"),
|
(Action::WatchLaterBrowse, "Watch later episodes"),
|
||||||
(Action::SavedVideoBrowse, "Saved episodes"),
|
(Action::SavedVideoBrowse, "Saved episodes"),
|
||||||
(Action::WatchingBrowse, "Currently watching episodes"),
|
(Action::WatchingBrowse, "Currently watching episodes"),
|
||||||
(Action::InfoOverview, "Show/hide metadata"),
|
(
|
||||||
|
Action::InfoOverview,
|
||||||
|
"Show series/episode info with cover art",
|
||||||
|
),
|
||||||
(Action::Help, "Keyboard help"),
|
(Action::Help, "Keyboard help"),
|
||||||
(Action::Quit, "Exit"),
|
(Action::Quit, "Exit"),
|
||||||
(Action::Interrupt, "Exit immediately"),
|
(Action::Interrupt, "Exit immediately"),
|
||||||
@@ -1312,10 +1323,11 @@ impl Browser {
|
|||||||
"Remove selected saved show only",
|
"Remove selected saved show only",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if self.config.keys.label(Action::InfoDescription)
|
if self.preview.is_some()
|
||||||
!= self.config.keys.label(Action::InfoOverview)
|
|| self.config.keys.label(Action::InfoDescription)
|
||||||
|
!= self.config.keys.label(Action::InfoOverview)
|
||||||
{
|
{
|
||||||
bindings.push((Action::InfoDescription, "Show/hide metadata"));
|
bindings.push((Action::InfoDescription, "Show full synopsis/metadata"));
|
||||||
}
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
self.screen().content,
|
self.screen().content,
|
||||||
@@ -1340,9 +1352,11 @@ impl Browser {
|
|||||||
bindings.push((Action::WatchLaterFilter, "Cycle All / Unwatched / Watched"));
|
bindings.push((Action::WatchLaterFilter, "Cycle All / Unwatched / Watched"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.description_focus {
|
if self.preview.is_some() {
|
||||||
|
bindings.push((Action::InfoHide, "Close preview"));
|
||||||
|
}
|
||||||
|
if self.preview == Some(PreviewMode::Description) {
|
||||||
bindings.extend([
|
bindings.extend([
|
||||||
(Action::InfoHide, "Close metadata"),
|
|
||||||
(Action::DescriptionNext, "Scroll metadata down"),
|
(Action::DescriptionNext, "Scroll metadata down"),
|
||||||
(Action::DescriptionPrevious, "Scroll metadata up"),
|
(Action::DescriptionPrevious, "Scroll metadata up"),
|
||||||
(Action::DescriptionPageNext, "Metadata page down"),
|
(Action::DescriptionPageNext, "Metadata page down"),
|
||||||
@@ -1364,11 +1378,12 @@ impl Browser {
|
|||||||
&& self.help.is_none()
|
&& self.help.is_none()
|
||||||
&& self.input.is_none()
|
&& self.input.is_none()
|
||||||
&& self.removal.is_none()
|
&& self.removal.is_none()
|
||||||
&& !self.description_focus
|
&& self.preview.is_none()
|
||||||
{
|
{
|
||||||
let mut actions = vec![
|
let mut actions = vec![
|
||||||
(Action::Help, "help"),
|
(Action::Help, "help"),
|
||||||
(Action::SearchEdit, "search"),
|
(Action::SearchEdit, "search"),
|
||||||
|
(Action::InfoOverview, "info"),
|
||||||
(
|
(
|
||||||
Action::VideoPlay,
|
Action::VideoPlay,
|
||||||
if matches!(
|
if matches!(
|
||||||
@@ -1425,10 +1440,20 @@ impl Browser {
|
|||||||
(Action::DeleteWord, "delete word"),
|
(Action::DeleteWord, "delete word"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
} else if self.description_focus {
|
} else if self.preview == Some(PreviewMode::Info) {
|
||||||
|
(
|
||||||
|
"INFO",
|
||||||
|
&[
|
||||||
|
(Action::InfoDescription, "description"),
|
||||||
|
(Action::InfoHide, "close"),
|
||||||
|
(Action::Help, "help"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
} else if self.preview == Some(PreviewMode::Description) {
|
||||||
(
|
(
|
||||||
"DESCRIPTION",
|
"DESCRIPTION",
|
||||||
&[
|
&[
|
||||||
|
(Action::InfoOverview, "info"),
|
||||||
(Action::InfoHide, "close"),
|
(Action::InfoHide, "close"),
|
||||||
(Action::Help, "help"),
|
(Action::Help, "help"),
|
||||||
(Action::WatchStatusWatching, "mark watching"),
|
(Action::WatchStatusWatching, "mark watching"),
|
||||||
@@ -1676,29 +1701,92 @@ impl Browser {
|
|||||||
);
|
);
|
||||||
frame.render_widget(Paragraph::new(self.hints()).dim(), hints);
|
frame.render_widget(Paragraph::new(self.hints()).dim(), hints);
|
||||||
let popup = crate::preview_area(area);
|
let popup = crate::preview_area(area);
|
||||||
if self.description_focus {
|
if let Some(mode) = self.preview {
|
||||||
let block = Block::bordered()
|
let cover = self.screens[self.index]
|
||||||
.title(" Description ")
|
.selected_media()
|
||||||
.border_style(ui::ACCENT);
|
.and_then(|media| media.metadata.get("cover_url"))
|
||||||
let inner = block.inner(popup).inner(Margin::new(1, 0));
|
.and_then(Value::as_str)
|
||||||
let mut metadata = ratatui::text::Text::from(self.screen().description.as_str());
|
.filter(|url| !url.trim().is_empty());
|
||||||
if let Some(key) = self.screen().selected_key() {
|
if mode == PreviewMode::Info {
|
||||||
metadata.lines.push(Line::from(self.markers(key)));
|
if let Some(url) = cover {
|
||||||
|
self.thumbnail.select_url(url);
|
||||||
|
} else {
|
||||||
|
self.thumbnail.cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let paragraph = Paragraph::new(metadata).wrap(Wrap { trim: true });
|
|
||||||
let max_scroll = if inner.is_empty() {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
paragraph
|
|
||||||
.line_count(inner.width)
|
|
||||||
.saturating_sub(usize::from(inner.height))
|
|
||||||
.min(u16::MAX as usize) as u16
|
|
||||||
};
|
|
||||||
let scroll = self.screen().description_scroll.min(max_scroll);
|
|
||||||
frame.render_widget(Clear, popup);
|
frame.render_widget(Clear, popup);
|
||||||
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
|
let block = Block::bordered()
|
||||||
|
.title(crate::loading_title(
|
||||||
|
if mode == PreviewMode::Info {
|
||||||
|
" Info "
|
||||||
|
} else {
|
||||||
|
" Description "
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
(mode == PreviewMode::Info && self.thumbnail.is_loading())
|
||||||
|
.then(|| ui::spinner(elapsed)),
|
||||||
|
))
|
||||||
|
.border_style(ui::ACCENT);
|
||||||
|
let inner = block.inner(popup);
|
||||||
|
let screen = &self.screens[self.index];
|
||||||
|
let row = screen.selection.selected().and_then(|i| screen.rows.get(i));
|
||||||
|
let mut metadata = Vec::new();
|
||||||
|
let mut text = screen.description.as_str();
|
||||||
|
if let Some(row) = row {
|
||||||
|
metadata.extend([
|
||||||
|
Line::from(row.title.as_str()).bold(),
|
||||||
|
Line::default(),
|
||||||
|
Line::from(row.details.as_str()).dim(),
|
||||||
|
]);
|
||||||
|
text = text
|
||||||
|
.strip_prefix(&row.title)
|
||||||
|
.unwrap_or(text)
|
||||||
|
.trim_start_matches('\n');
|
||||||
|
text = text
|
||||||
|
.strip_prefix(&row.details)
|
||||||
|
.unwrap_or(text)
|
||||||
|
.trim_start_matches('\n');
|
||||||
|
}
|
||||||
|
if let Some(key) = screen.selected_key() {
|
||||||
|
let markers = self.markers(key);
|
||||||
|
if !markers.is_empty() {
|
||||||
|
metadata.push(Line::from(markers).dim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == PreviewMode::Info {
|
||||||
|
ui::info_preview(
|
||||||
|
frame,
|
||||||
|
inner,
|
||||||
|
cover.map(|_| &mut self.thumbnail),
|
||||||
|
metadata,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let inner = inner.inner(Margin::new(1, 0));
|
||||||
|
let mut content = ratatui::text::Text::from(text);
|
||||||
|
content.lines.splice(
|
||||||
|
0..0,
|
||||||
|
[Line::default()]
|
||||||
|
.into_iter()
|
||||||
|
.chain(metadata)
|
||||||
|
.chain([Line::default()]),
|
||||||
|
);
|
||||||
|
let paragraph = Paragraph::new(content).wrap(Wrap { trim: false });
|
||||||
|
let max_scroll = if inner.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
paragraph
|
||||||
|
.line_count(inner.width)
|
||||||
|
.saturating_sub(usize::from(inner.height))
|
||||||
|
.min(u16::MAX as usize) as u16
|
||||||
|
};
|
||||||
|
let scroll = screen.description_scroll.min(max_scroll);
|
||||||
|
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
|
||||||
|
self.screen_mut().description_scroll = scroll;
|
||||||
|
}
|
||||||
frame.render_widget(block, popup);
|
frame.render_widget(block, popup);
|
||||||
self.screen_mut().description_scroll = scroll;
|
} else {
|
||||||
|
self.thumbnail.cancel();
|
||||||
}
|
}
|
||||||
if let Some((kind, ids)) = &self.removal {
|
if let Some((kind, ids)) = &self.removal {
|
||||||
frame.render_widget(Clear, popup);
|
frame.render_widget(Clear, popup);
|
||||||
@@ -2007,6 +2095,7 @@ pub fn run(
|
|||||||
SetCursorStyle::SteadyBlock
|
SetCursorStyle::SteadyBlock
|
||||||
)?;
|
)?;
|
||||||
let mut browser = Browser::new(options, config_path, config, state);
|
let mut browser = Browser::new(options, config_path, config, state);
|
||||||
|
browser.thumbnail.query_terminal();
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
browser.update();
|
browser.update();
|
||||||
@@ -2140,6 +2229,9 @@ mod tests {
|
|||||||
for (width, height) in [(120, 40), (60, 24), (24, 12)] {
|
for (width, height) in [(120, 40), (60, 24), (24, 12)] {
|
||||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||||
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||||
|
assert_eq!(browser.context(), Context::Info);
|
||||||
|
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||||
|
assert_eq!(browser.context(), Context::Description);
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -2164,8 +2256,10 @@ mod tests {
|
|||||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(popup_text(&terminal).contains("TAIL"));
|
assert!(popup_text(&terminal).contains("TAIL"));
|
||||||
|
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||||
|
assert_eq!(browser.context(), Context::Info);
|
||||||
browser.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
browser.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||||
assert!(!browser.description_focus);
|
assert!(browser.preview.is_none());
|
||||||
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
|
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
|
||||||
browser.screen_mut().description_scroll = 0;
|
browser.screen_mut().description_scroll = 0;
|
||||||
}
|
}
|
||||||
@@ -2290,7 +2384,7 @@ mod tests {
|
|||||||
.save(CollectionKind::Watching, &youtube)
|
.save(CollectionKind::Watching, &youtube)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
browser.save(CollectionKind::WatchLater);
|
browser.save(CollectionKind::WatchLater);
|
||||||
browser.description_focus = true;
|
browser.preview = Some(PreviewMode::Info);
|
||||||
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
|
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
|
||||||
assert!(
|
assert!(
|
||||||
browser
|
browser
|
||||||
@@ -2298,7 +2392,7 @@ mod tests {
|
|||||||
.contains(CollectionKind::Watching, &episode.id)
|
.contains(CollectionKind::Watching, &episode.id)
|
||||||
);
|
);
|
||||||
assert!(!browser.state.is_watched(&episode.id));
|
assert!(!browser.state.is_watched(&episode.id));
|
||||||
browser.description_focus = false;
|
browser.preview = None;
|
||||||
browser.collection(CollectionKind::Watching);
|
browser.collection(CollectionKind::Watching);
|
||||||
browser.open();
|
browser.open();
|
||||||
assert!(browser.pending.is_some());
|
assert!(browser.pending.is_some());
|
||||||
|
|||||||
+128
-15
@@ -4,7 +4,7 @@ use std::{
|
|||||||
thread,
|
thread,
|
||||||
};
|
};
|
||||||
|
|
||||||
use image::{ImageFormat, ImageReader, Limits};
|
use image::{ImageReader, Limits};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Alignment, Rect, Size},
|
layout::{Alignment, Rect, Size},
|
||||||
@@ -30,6 +30,7 @@ struct ThumbnailImage {
|
|||||||
pub struct Thumbnail {
|
pub struct Thumbnail {
|
||||||
picker: Picker,
|
picker: Picker,
|
||||||
selected: Option<String>,
|
selected: Option<String>,
|
||||||
|
aspect: (u32, u32),
|
||||||
fetch: Option<ThumbnailJob>,
|
fetch: Option<ThumbnailJob>,
|
||||||
decoder: Option<Receiver<Result<ThumbnailImage, String>>>,
|
decoder: Option<Receiver<Result<ThumbnailImage, String>>>,
|
||||||
image: Option<ThumbnailImage>,
|
image: Option<ThumbnailImage>,
|
||||||
@@ -43,6 +44,7 @@ impl Thumbnail {
|
|||||||
Self {
|
Self {
|
||||||
picker: Picker::halfblocks(),
|
picker: Picker::halfblocks(),
|
||||||
selected: None,
|
selected: None,
|
||||||
|
aspect: (4, 3),
|
||||||
fetch: None,
|
fetch: None,
|
||||||
decoder: None,
|
decoder: None,
|
||||||
image: None,
|
image: None,
|
||||||
@@ -61,6 +63,19 @@ impl Thumbnail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn select(&mut self, id: &str) {
|
pub fn select(&mut self, id: &str) {
|
||||||
|
self.select_with(id, (4, 3), ThumbnailJob::start);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_url(&mut self, url: &str) {
|
||||||
|
self.select_with(url, (2, 3), ThumbnailJob::from_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_with(
|
||||||
|
&mut self,
|
||||||
|
id: &str,
|
||||||
|
aspect: (u32, u32),
|
||||||
|
start: fn(&str) -> std::io::Result<ThumbnailJob>,
|
||||||
|
) {
|
||||||
if self.selected.as_deref() == Some(id) {
|
if self.selected.as_deref() == Some(id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -72,11 +87,12 @@ impl Thumbnail {
|
|||||||
.and_then(|index| self.cached.remove(index));
|
.and_then(|index| self.cached.remove(index));
|
||||||
self.cancel();
|
self.cancel();
|
||||||
self.selected = Some(id.to_owned());
|
self.selected = Some(id.to_owned());
|
||||||
|
self.aspect = aspect;
|
||||||
if let Some(image) = cached {
|
if let Some(image) = cached {
|
||||||
self.image = Some(image);
|
self.image = Some(image);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match ThumbnailJob::start(id) {
|
match start(id) {
|
||||||
Ok(job) => self.fetch = Some(job),
|
Ok(job) => self.fetch = Some(job),
|
||||||
Err(error) => self.error = Some(format!("Could not load thumbnail: {error}")),
|
Err(error) => self.error = Some(format!("Could not load thumbnail: {error}")),
|
||||||
}
|
}
|
||||||
@@ -118,13 +134,17 @@ impl Thumbnail {
|
|||||||
if area.width == 0 || area.height == 0 || self.selected.is_none() {
|
if area.width == 0 || area.height == 0 || self.selected.is_none() {
|
||||||
return area.width;
|
return area.width;
|
||||||
}
|
}
|
||||||
let slot = left_aligned(area, thumbnail_size(area, self.picker.font_size()));
|
let slot = left_aligned(
|
||||||
|
area,
|
||||||
|
thumbnail_size(area, self.picker.font_size(), self.aspect),
|
||||||
|
);
|
||||||
|
|
||||||
let mut message = self.error.as_deref().unwrap_or("Loading thumbnail...");
|
let mut message = self.error.as_deref().unwrap_or("Loading thumbnail...");
|
||||||
if let Some(image) = self.image.as_mut() {
|
if let Some(image) = self.image.as_mut() {
|
||||||
let resize = Resize::Scale(Some(FilterType::Triangle));
|
let resize = Resize::Scale(Some(FilterType::Triangle));
|
||||||
if let Some(size) = image.protocol.size_for(resize.clone(), area.into()) {
|
let bounds = if self.aspect == (4, 3) { area } else { slot };
|
||||||
let image_area = left_aligned(area, size);
|
if let Some(size) = image.protocol.size_for(resize.clone(), bounds.into()) {
|
||||||
|
let image_area = left_aligned(bounds, size);
|
||||||
frame.render_stateful_widget(
|
frame.render_stateful_widget(
|
||||||
StatefulImage::new().resize(resize),
|
StatefulImage::new().resize(resize),
|
||||||
image_area,
|
image_area,
|
||||||
@@ -158,7 +178,7 @@ impl Thumbnail {
|
|||||||
match result {
|
match result {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
let picker = self.picker.clone();
|
let picker = self.picker.clone();
|
||||||
let id = self.selected.clone().expect("A fetch has a selected video");
|
let id = self.selected.clone().expect("A fetch has a selected image");
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
match thread::Builder::new()
|
match thread::Builder::new()
|
||||||
.name("thumbnail-decode".into())
|
.name("thumbnail-decode".into())
|
||||||
@@ -243,7 +263,9 @@ fn decode(bytes: Vec<u8>) -> Result<image::DynamicImage, String> {
|
|||||||
if bytes.len() > 2 * 1024 * 1024 {
|
if bytes.len() > 2 * 1024 * 1024 {
|
||||||
return Err("Thumbnail exceeds the 2 MiB download limit.".into());
|
return Err("Thumbnail exceeds the 2 MiB download limit.".into());
|
||||||
}
|
}
|
||||||
let mut reader = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg);
|
let mut reader = ImageReader::new(Cursor::new(bytes))
|
||||||
|
.with_guessed_format()
|
||||||
|
.map_err(|error| format!("Could not identify thumbnail format: {error}"))?;
|
||||||
let mut limits = Limits::default();
|
let mut limits = Limits::default();
|
||||||
limits.max_image_width = Some(4096);
|
limits.max_image_width = Some(4096);
|
||||||
limits.max_image_height = Some(4096);
|
limits.max_image_height = Some(4096);
|
||||||
@@ -251,21 +273,28 @@ fn decode(bytes: Vec<u8>) -> Result<image::DynamicImage, String> {
|
|||||||
reader.limits(limits);
|
reader.limits(limits);
|
||||||
reader
|
reader
|
||||||
.decode()
|
.decode()
|
||||||
.map_err(|error| format!("Could not decode thumbnail JPEG: {error}"))
|
.map_err(|error| format!("Could not decode thumbnail: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn thumbnail_size(area: Rect, font: FontSize) -> Size {
|
fn thumbnail_size(area: Rect, font: FontSize, aspect: (u32, u32)) -> Size {
|
||||||
if area.width == 0 || area.height == 0 || font.width == 0 || font.height == 0 {
|
if area.width == 0 || area.height == 0 || font.width == 0 || font.height == 0 {
|
||||||
return Size::default();
|
return Size::default();
|
||||||
}
|
}
|
||||||
// HQ thumbnails are 480x360. Match Scale's nearest-pixel rounding, then
|
// Match Scale's nearest-pixel rounding, then round up to cells without
|
||||||
// round up to cells without allocating a placeholder image.
|
// allocating a placeholder image. YouTube is 4:3; series covers reserve 2:3.
|
||||||
|
let (aspect_width, aspect_height) = (u64::from(aspect.0), u64::from(aspect.1));
|
||||||
let width = u64::from(area.width) * u64::from(font.width);
|
let width = u64::from(area.width) * u64::from(font.width);
|
||||||
let height = u64::from(area.height) * u64::from(font.height);
|
let height = u64::from(area.height) * u64::from(font.height);
|
||||||
let (width, height) = if width * 3 <= height * 4 {
|
let (width, height) = if width * aspect_height <= height * aspect_width {
|
||||||
(width, ((width * 3 + 2) / 4).max(1))
|
(
|
||||||
|
width,
|
||||||
|
((width * aspect_height + aspect_width / 2) / aspect_width).max(1),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(((height * 4 + 1) / 3).max(1), height)
|
(
|
||||||
|
((height * aspect_width + aspect_height / 2) / aspect_height).max(1),
|
||||||
|
height,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
Size::new(
|
Size::new(
|
||||||
width
|
width
|
||||||
@@ -362,10 +391,94 @@ mod tests {
|
|||||||
assert_eq!(loading_width, error_width);
|
assert_eq!(loading_width, error_width);
|
||||||
assert!(thumbnail.error.is_some());
|
assert!(thumbnail.error.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_corrupt_and_oversized_jpeg_input() {
|
fn portrait_cover_keeps_its_slot_and_actual_aspect_when_ready_and_cached() {
|
||||||
|
use ratatui::{Terminal, backend::TestBackend};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let mut thumbnail = Thumbnail::new();
|
||||||
|
let url = "https://example.test/cover.png";
|
||||||
|
thumbnail.selected = Some(url.into());
|
||||||
|
thumbnail.aspect = (2, 3);
|
||||||
|
let area = Rect::new(2, 2, 80, 8);
|
||||||
|
let slot = thumbnail_size(area, thumbnail.picker.font_size(), (2, 3));
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
|
||||||
|
let mut loading_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| loading_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loading_width, slot.width);
|
||||||
|
// The artwork need not exactly match the placeholder's 2:3 aspect.
|
||||||
|
let protocol = thumbnail
|
||||||
|
.picker
|
||||||
|
.new_resize_protocol(image::DynamicImage::new_rgb8(60, 100));
|
||||||
|
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
||||||
|
let (replies_tx, replies_rx) = mpsc::channel();
|
||||||
|
thumbnail.image = Some(ThumbnailImage {
|
||||||
|
id: url.into(),
|
||||||
|
protocol: ThreadProtocol::new(requests_tx, Some(protocol)),
|
||||||
|
replies: replies_rx,
|
||||||
|
complete: false,
|
||||||
|
});
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let response = requests_rx
|
||||||
|
.recv_timeout(Duration::from_secs(2))
|
||||||
|
.unwrap()
|
||||||
|
.resize_encode()
|
||||||
|
.unwrap();
|
||||||
|
replies_tx.send(Ok(response)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(!thumbnail.is_loading());
|
||||||
|
let size = thumbnail
|
||||||
|
.image
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.protocol
|
||||||
|
.size_for(Resize::Scale(None), slot)
|
||||||
|
.unwrap();
|
||||||
|
assert!(size.width <= slot.width && size.height <= slot.height);
|
||||||
|
let font = thumbnail.picker.font_size();
|
||||||
|
let width = u32::from(size.width) * u32::from(font.width);
|
||||||
|
let height = u32::from(size.height) * u32::from(font.height);
|
||||||
|
assert!(width.abs_diff(height * 3 / 5) <= u32::from(font.width) + u32::from(font.height));
|
||||||
|
thumbnail.cancel();
|
||||||
|
thumbnail.select_url(url);
|
||||||
|
assert!(thumbnail.fetch.is_none());
|
||||||
|
assert_eq!(thumbnail.selected.as_deref(), Some(url));
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
thumbnail.clear_cache();
|
||||||
|
assert!(thumbnail.image.is_none() && thumbnail.cached.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guesses_cover_formats_and_rejects_corrupt_or_oversized_input() {
|
||||||
assert!(decode(b"not a JPEG".to_vec()).is_err());
|
assert!(decode(b"not a JPEG".to_vec()).is_err());
|
||||||
assert!(decode(vec![0; 2 * 1024 * 1024 + 1]).is_err());
|
assert!(decode(vec![0; 2 * 1024 * 1024 + 1]).is_err());
|
||||||
|
for format in [
|
||||||
|
image::ImageFormat::Jpeg,
|
||||||
|
image::ImageFormat::Png,
|
||||||
|
image::ImageFormat::WebP,
|
||||||
|
] {
|
||||||
|
let mut bytes = Cursor::new(Vec::new());
|
||||||
|
image::DynamicImage::new_rgb8(2, 3)
|
||||||
|
.write_to(&mut bytes, format)
|
||||||
|
.unwrap();
|
||||||
|
let image = decode(bytes.into_inner()).unwrap();
|
||||||
|
assert_eq!((image.width(), image.height()), (2, 3));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use ratatui::{
|
|||||||
Frame,
|
Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
style::{Color, Style},
|
style::{Color, Style},
|
||||||
text::Line,
|
text::{Line, Span},
|
||||||
widgets::{Block, Paragraph},
|
widgets::{Block, Paragraph, Wrap},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const ACCENT: Style = Style::new().fg(Color::Cyan);
|
pub const ACCENT: Style = Style::new().fg(Color::Cyan);
|
||||||
@@ -50,3 +50,72 @@ pub fn spinner(elapsed: Duration) -> &'static str {
|
|||||||
const FRAMES: [&str; 10] = ["⠋ ", "⠙ ", "⠹ ", "⠸ ", "⠼ ", "⠴ ", "⠦ ", "⠧ ", "⠇ ", "⠏ "];
|
const FRAMES: [&str; 10] = ["⠋ ", "⠙ ", "⠹ ", "⠸ ", "⠼ ", "⠴ ", "⠦ ", "⠧ ", "⠇ ", "⠏ "];
|
||||||
FRAMES[(elapsed.as_millis() / 100 % FRAMES.len() as u128) as usize]
|
FRAMES[(elapsed.as_millis() / 100 % FRAMES.len() as u128) as usize]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn info_preview(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
thumbnail: Option<&mut crate::thumbnail::Thumbnail>,
|
||||||
|
metadata: Vec<Line<'_>>,
|
||||||
|
text: &str,
|
||||||
|
) {
|
||||||
|
let mut details = if let Some(thumbnail) = thumbnail {
|
||||||
|
let [_, image, _, mut details] = Layout::horizontal([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Fill(3),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Fill(2),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
let reclaimed = image.width - thumbnail.draw(frame, image);
|
||||||
|
details.x -= reclaimed;
|
||||||
|
details.width += reclaimed;
|
||||||
|
details.width = details.width.saturating_sub(2);
|
||||||
|
details
|
||||||
|
} else {
|
||||||
|
area.inner(ratatui::layout::Margin::new(1, 0))
|
||||||
|
};
|
||||||
|
let metadata = Paragraph::new(metadata).wrap(Wrap { trim: true });
|
||||||
|
let height = if details.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
metadata
|
||||||
|
.line_count(details.width)
|
||||||
|
.min(details.height as usize) as u16
|
||||||
|
};
|
||||||
|
let mut excerpt = details;
|
||||||
|
let gap = (excerpt.height - height).min(1);
|
||||||
|
excerpt.y += height + gap;
|
||||||
|
excerpt.height -= height + gap;
|
||||||
|
details.height = height;
|
||||||
|
frame.render_widget(metadata, details);
|
||||||
|
let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
|
||||||
|
let overflow = !text.is_empty()
|
||||||
|
&& !excerpt.is_empty()
|
||||||
|
&& paragraph.line_count(excerpt.width) > usize::from(excerpt.height);
|
||||||
|
frame.render_widget(paragraph, excerpt);
|
||||||
|
if overflow {
|
||||||
|
let buffer = frame.buffer_mut();
|
||||||
|
let y = excerpt.bottom() - 1;
|
||||||
|
let end = (excerpt.x..excerpt.right())
|
||||||
|
.rev()
|
||||||
|
.find_map(|x| {
|
||||||
|
let symbol = buffer
|
||||||
|
.cell((x, y))
|
||||||
|
.expect("excerpt is inside frame")
|
||||||
|
.symbol();
|
||||||
|
(!symbol.trim().is_empty())
|
||||||
|
.then(|| x.saturating_add(Span::raw(symbol).width() as u16))
|
||||||
|
})
|
||||||
|
.unwrap_or(excerpt.x);
|
||||||
|
let mut x = end.min(excerpt.right() - 1);
|
||||||
|
// Do not leave half of a wide character beneath the ellipsis.
|
||||||
|
if x > excerpt.x && Span::raw(buffer.cell((x - 1, y)).unwrap().symbol()).width() > 1 {
|
||||||
|
x -= 1;
|
||||||
|
}
|
||||||
|
buffer
|
||||||
|
.cell_mut((x, y))
|
||||||
|
.unwrap()
|
||||||
|
.set_symbol("…")
|
||||||
|
.set_style(Style::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+35
-1
@@ -679,6 +679,25 @@ impl ThumbnailJob {
|
|||||||
"Invalid YouTube video ID",
|
"Invalid YouTube video ID",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
Self::from_url(&format!("https://i.ytimg.com/vi/{id}/hqdefault.jpg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_url(url: &str) -> io::Result<Self> {
|
||||||
|
let uri: ureq::http::Uri = url
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Invalid artwork URL"))?;
|
||||||
|
if uri.scheme_str() != Some("https")
|
||||||
|
|| uri.host().is_none_or(str::is_empty)
|
||||||
|
|| uri
|
||||||
|
.authority()
|
||||||
|
.is_some_and(|authority| authority.as_str().contains('@'))
|
||||||
|
|| url.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"Artwork URLs must be absolute HTTPS without credentials",
|
||||||
|
));
|
||||||
|
}
|
||||||
let mut child = isolated_command(OsStr::new("curl"))
|
let mut child = isolated_command(OsStr::new("curl"))
|
||||||
.args([
|
.args([
|
||||||
"--disable",
|
"--disable",
|
||||||
@@ -686,6 +705,7 @@ impl ThumbnailJob {
|
|||||||
"--show-error",
|
"--show-error",
|
||||||
"--fail",
|
"--fail",
|
||||||
"--location",
|
"--location",
|
||||||
|
"--globoff",
|
||||||
"--proto",
|
"--proto",
|
||||||
"=https",
|
"=https",
|
||||||
"--proto-redir",
|
"--proto-redir",
|
||||||
@@ -697,7 +717,7 @@ impl ThumbnailJob {
|
|||||||
"--max-filesize",
|
"--max-filesize",
|
||||||
"2097152", // 2 MiB; read_thumbnail also enforces the limit.
|
"2097152", // 2 MiB; read_thumbnail also enforces the limit.
|
||||||
])
|
])
|
||||||
.arg(format!("https://i.ytimg.com/vi/{id}/hqdefault.jpg"))
|
.arg(url)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
@@ -1570,6 +1590,20 @@ mod tests {
|
|||||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
for url in [
|
||||||
|
"http://example.test/cover.jpg",
|
||||||
|
"file:///tmp/cover.jpg",
|
||||||
|
"ftp://example.test/cover.jpg",
|
||||||
|
"//example.test/cover.jpg",
|
||||||
|
"https://",
|
||||||
|
"https://user:secret@example.test/cover.jpg",
|
||||||
|
"https://example.test/\ncover.jpg",
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
ThumbnailJob::from_url(url),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user