feat: show Miruro series artwork in descriptions

This commit is contained in:
2026-10-17 17:01:33 +02:00
parent ba04cbe158
commit 9743d1993d
8 changed files with 421 additions and 147 deletions
+155 -61
View File
@@ -24,9 +24,10 @@ use serde_json::Value;
use tubular::miruro::Identity;
use crate::{
Options, config,
Options, PreviewMode, config,
keys::{Action, Context},
miruro_browser::{self, Episode, Media, Request, Response, Worker},
thumbnail::Thumbnail,
ui,
video_state::{CollectionKind, State, WatchStatus},
youtube::{PlayerLaunch, Video},
@@ -130,14 +131,14 @@ impl Screen {
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();
match &self.content {
Content::Catalog { media, .. } => index.and_then(|i| media.get(i)).cloned(),
Content::Series { media, .. } => Some(media.clone()),
Content::Library { shows, .. } => index
.and_then(|i| shows.get(i))
.map(|show| show.media.clone()),
Content::Catalog { media, .. } => index.and_then(|i| media.get(i)),
Content::Series { media, .. } => Some(media),
Content::Library { shows, .. } => {
index.and_then(|i| shows.get(i)).map(|show| &show.media)
}
_ => None,
}
}
@@ -301,7 +302,8 @@ struct Browser {
screens: Vec<Screen>,
index: usize,
input: Option<String>,
description_focus: bool,
preview: Option<PreviewMode>,
thumbnail: Thumbnail,
help: Option<u16>,
removal: Option<(CollectionKind, Vec<String>)>,
modal: Option<LibraryModal>,
@@ -394,7 +396,8 @@ impl Browser {
})],
index: 0,
input: None,
description_focus: false,
preview: None,
thumbnail: Thumbnail::new(),
help: None,
removal: None,
modal: None,
@@ -433,7 +436,7 @@ impl Browser {
self.screens.truncate(self.index + 1);
self.screens.push(screen);
self.index += 1;
self.description_focus = false;
self.preview = None;
self.status.clear();
}
fn history(&mut self, forward: bool) {
@@ -447,7 +450,7 @@ impl Browser {
}
self.invalidate();
self.index = index;
self.description_focus = false;
self.preview = None;
self.status.clear();
self.rebuild_collections();
if matches!(&self.screen().content, Content::Series { data: None, .. }) {
@@ -469,7 +472,7 @@ impl Browser {
if let Content::Catalog { query, .. } = &self.screen().content {
self.input = Some(query.clone());
}
self.description_focus = false;
self.preview = None;
}
fn search(&mut self) {
let Content::Catalog { query, .. } = &self.screen().content else {
@@ -517,7 +520,7 @@ impl Browser {
}
}
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 { .. })
{
self.push(Screen::new(Content::Series { media, data: None }));
@@ -671,7 +674,7 @@ impl Browser {
.collect()
}
fn tags(&mut self) {
let Some(media) = self.screen().selected_media() else {
let Some(media) = self.screen().selected_media().cloned() else {
return;
};
let checked = self
@@ -684,7 +687,7 @@ impl Browser {
let names: Vec<_> = self.categories().into_iter().collect();
let mut picker = Picker::default();
picker.refilter(&names);
self.description_focus = false;
self.preview = None;
self.modal = Some(LibraryModal::Tags {
media,
checked,
@@ -699,7 +702,7 @@ impl Browser {
names.extend(self.categories());
let mut picker = Picker::default();
picker.refilter(&names);
self.description_focus = false;
self.preview = None;
self.modal = Some(LibraryModal::Categories {
names,
checked: BTreeSet::new(),
@@ -946,7 +949,8 @@ impl Browser {
fn remove(&mut self, bulk: bool) {
if matches!(self.screen().content, Content::Library { .. }) {
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(()) => {
self.rebuild_library();
self.status = "Removed saved show; episode state unchanged.".into();
@@ -974,7 +978,7 @@ impl Browser {
self.invalidate();
if bulk {
self.removal = Some((kind, ids));
self.description_focus = false;
self.preview = None;
} else {
self.remove_ids(kind, ids);
}
@@ -1000,7 +1004,10 @@ impl Browser {
fn refresh(&mut self) {
self.invalidate();
match config::load(&self.config_path) {
Ok(config) => self.config = config,
Ok(config) => {
self.config = config;
self.thumbnail.clear_cache();
}
Err(error) => {
self.fail(error);
return;
@@ -1043,15 +1050,17 @@ impl Browser {
self.screen().content,
Content::Collection { .. } | Content::Library { .. }
) {
if self.description_focus {
Context::CollectionDescription
} else {
Context::CollectionNormal
match self.preview {
Some(PreviewMode::Info) => Context::CollectionInfo,
Some(PreviewMode::Description) => Context::CollectionDescription,
None => Context::CollectionNormal,
}
} else if self.description_focus {
Context::Description
} 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) {
@@ -1176,10 +1185,9 @@ impl Browser {
| Action::VideoFirst
| Action::VideoLast),
) => self.select(action),
Some(Action::InfoOverview | Action::InfoDescription) => {
self.description_focus = !self.description_focus
}
Some(Action::InfoHide) => self.description_focus = false,
Some(Action::InfoOverview) => self.preview = Some(PreviewMode::Info),
Some(Action::InfoDescription) => self.preview = Some(PreviewMode::Description),
Some(Action::InfoHide) => self.preview = None,
Some(Action::DescriptionNext) => {
self.screen_mut().description_scroll =
self.screen().description_scroll.saturating_add(1)
@@ -1297,7 +1305,10 @@ impl Browser {
(Action::WatchLaterBrowse, "Watch later episodes"),
(Action::SavedVideoBrowse, "Saved 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::Quit, "Exit"),
(Action::Interrupt, "Exit immediately"),
@@ -1312,10 +1323,11 @@ impl Browser {
"Remove selected saved show only",
));
}
if self.config.keys.label(Action::InfoDescription)
!= self.config.keys.label(Action::InfoOverview)
if self.preview.is_some()
|| 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!(
self.screen().content,
@@ -1340,9 +1352,11 @@ impl Browser {
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([
(Action::InfoHide, "Close metadata"),
(Action::DescriptionNext, "Scroll metadata down"),
(Action::DescriptionPrevious, "Scroll metadata up"),
(Action::DescriptionPageNext, "Metadata page down"),
@@ -1364,11 +1378,12 @@ impl Browser {
&& self.help.is_none()
&& self.input.is_none()
&& self.removal.is_none()
&& !self.description_focus
&& self.preview.is_none()
{
let mut actions = vec![
(Action::Help, "help"),
(Action::SearchEdit, "search"),
(Action::InfoOverview, "info"),
(
Action::VideoPlay,
if matches!(
@@ -1425,10 +1440,20 @@ impl Browser {
(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",
&[
(Action::InfoOverview, "info"),
(Action::InfoHide, "close"),
(Action::Help, "help"),
(Action::WatchStatusWatching, "mark watching"),
@@ -1676,29 +1701,92 @@ impl Browser {
);
frame.render_widget(Paragraph::new(self.hints()).dim(), hints);
let popup = crate::preview_area(area);
if self.description_focus {
let block = Block::bordered()
.title(" Description ")
.border_style(ui::ACCENT);
let inner = block.inner(popup).inner(Margin::new(1, 0));
let mut metadata = ratatui::text::Text::from(self.screen().description.as_str());
if let Some(key) = self.screen().selected_key() {
metadata.lines.push(Line::from(self.markers(key)));
if let Some(mode) = self.preview {
let cover = self.screens[self.index]
.selected_media()
.and_then(|media| media.metadata.get("cover_url"))
.and_then(Value::as_str)
.filter(|url| !url.trim().is_empty());
if mode == PreviewMode::Info {
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(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);
self.screen_mut().description_scroll = scroll;
} else {
self.thumbnail.cancel();
}
if let Some((kind, ids)) = &self.removal {
frame.render_widget(Clear, popup);
@@ -2007,6 +2095,7 @@ pub fn run(
SetCursorStyle::SteadyBlock
)?;
let mut browser = Browser::new(options, config_path, config, state);
browser.thumbnail.query_terminal();
let started = Instant::now();
loop {
browser.update();
@@ -2140,6 +2229,9 @@ mod tests {
for (width, height) in [(120, 40), (60, 24), (24, 12)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
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
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
@@ -2164,8 +2256,10 @@ mod tests {
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
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));
assert!(!browser.description_focus);
assert!(browser.preview.is_none());
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
browser.screen_mut().description_scroll = 0;
}
@@ -2290,7 +2384,7 @@ mod tests {
.save(CollectionKind::Watching, &youtube)
.unwrap();
browser.save(CollectionKind::WatchLater);
browser.description_focus = true;
browser.preview = Some(PreviewMode::Info);
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
assert!(
browser
@@ -2298,7 +2392,7 @@ mod tests {
.contains(CollectionKind::Watching, &episode.id)
);
assert!(!browser.state.is_watched(&episode.id));
browser.description_focus = false;
browser.preview = None;
browser.collection(CollectionKind::Watching);
browser.open();
assert!(browser.pending.is_some());