feat(core,meta,app,android,ios): title logos and runtimes

ani.zip sent each series' TVDB clear logo and every episode's running
time from the start, and neither was kept. Enrichment now stores the logo
in title_metadata.logo_url (kept across a refresh of the same entry, like
the fanart) and episode runtimes in episode_metadata.runtime, TVDB's
first and AniDB's length after it. AniList's duration lands in
TitleDetails::runtime; TMDB episode runtimes are kept too.

Schema v13 adds the two columns and marks AniList titles un-enriched
without deleting anything. TitleDetails::revision lets details written
before runtime existed refetch by id while the old ones stay on screen,
instead of blanking them in the migration as v10 did.

Clear logos are padded to a square, so each client crops them to their
visible pixels before drawing. The logo replaces the name on the
library's banner and the title page (not on a light theme's title page,
where lettering made for dark art can vanish). Episode rows show how long
they run, in place of the file size on Android and iOS; a film's runtime
joins its line of facts.
This commit is contained in:
nvrl committed 2026-10-29 16:11:24 +01:00
1 parent b5ba59b41d
commit bb5a6638c4
29 files changed
+845 -69

No files matched your search

+61
View File
@@ -132,6 +132,20 @@ impl Art<'_> {
self.of(title)
}
/// The title's name as lettering, trimmed to it, when the provider has
/// one and it has arrived. Drawn over wide art in place of the name.
pub fn logo(&mut self, title: &Title) -> Option<egui::TextureHandle> {
let url = self
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.logo_url.clone())?;
let key = logo_key(&title.key);
let engine = self.engine;
self.posters
.texture(&key, || engine.request_logo(key.clone(), url))
}
/// The picture for a title, and how to fit it.
///
/// Provider artwork first, then Proton's still, then nothing. That order is
@@ -173,6 +187,46 @@ pub fn wide_key(title_key: &str) -> String {
format!("wide:{title_key}")
}
/// A title's logo in place of its name in Display type, when there is one to
/// draw; `None` leaves the caller to set the name as it does.
///
/// At most `max_height` tall and as wide as the space allows, up to 420 px —
/// a logo the width of the column shouts. A light palette keeps the name: a
/// clear logo is lettering made for dark art, white as often as not, and over
/// a near-white veil it is gone.
pub fn logo_heading(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
max_height: f32,
) -> Option<egui::Response> {
if theme::palette().light {
return None;
}
let texture = art.logo(title)?;
let aspect = texture.aspect_ratio().max(0.1);
let width = ui.available_width().min(420.0).min(max_height * aspect);
let size = egui::vec2(width, width / aspect);
Some(
ui.add(egui::Image::new((texture.id(), size)))
.on_hover_text(art.name(title)),
)
}
/// The texture key of a title's logo.
pub fn logo_key(title_key: &str) -> String {
format!("logo:{title_key}")
}
/// `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes.
pub fn format_runtime(minutes: u32) -> String {
match (minutes / 60, minutes % 60) {
(0, minutes) => format!("{minutes} min"),
(hours, 0) => format!("{hours} h"),
(hours, minutes) => format!("{hours} h {minutes} min"),
}
}
/// "just now", "5 min ago", "3 h ago", "2 days ago": how long before `now`
/// the Unix time `at` was.
pub fn ago(at: i64, now: i64) -> String {
@@ -938,6 +992,13 @@ mod tests {
use super::*;
#[test]
fn a_running_time_reads_in_hours_and_minutes() {
assert_eq!(format_runtime(24), "24 min");
assert_eq!(format_runtime(120), "2 h");
assert_eq!(format_runtime(124), "2 h 4 min");
}
#[test]
fn times_are_formatted_by_length() {
assert_eq!(format_time(0.0), "0:00");