mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
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:
1 parent
b5ba59b41d
commit
bb5a6638c4
29 files changed
+845
-69
No files matched your search
@@ -2812,12 +2812,18 @@ fn changed_art(
|
||||
.map(|(url, _)| url.to_owned()),
|
||||
found.and_then(|metadata| metadata.poster_url.clone()),
|
||||
found.and_then(|metadata| metadata.wide_art().map(str::to_owned)),
|
||||
found.and_then(|metadata| metadata.logo_url.clone()),
|
||||
]
|
||||
};
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
for key in before.keys().chain(after.keys()) {
|
||||
let (old, new) = (urls(before, key), urls(after, key));
|
||||
let texture_keys = [key.clone(), ui::poster_key(key), ui::wide_key(key)];
|
||||
let texture_keys = [
|
||||
key.clone(),
|
||||
ui::poster_key(key),
|
||||
ui::wide_key(key),
|
||||
ui::logo_key(key),
|
||||
];
|
||||
for ((old, new), texture) in old.iter().zip(&new).zip(texture_keys) {
|
||||
if old != new {
|
||||
keys.push(texture);
|
||||
@@ -3073,6 +3079,7 @@ mod tests {
|
||||
kind: TitleKind::Series,
|
||||
poster_url: Some(url.into()),
|
||||
backdrop_url: None,
|
||||
logo_url: None,
|
||||
rating: None,
|
||||
genres: Vec::new(),
|
||||
episodes: None,
|
||||
|
||||
@@ -170,6 +170,21 @@ const THUMBNAIL_MAX_EDGE: u32 = 640;
|
||||
/// best there is.
|
||||
pub const WIDE_MAX_EDGE: u32 = 1920;
|
||||
|
||||
/// The longest edge of a title's logo once its padding is trimmed. Drawn at
|
||||
/// most a few hundred pixels wide; this leaves room for a 2× display.
|
||||
const LOGO_MAX_EDGE: u32 = 800;
|
||||
|
||||
/// How a fetched picture is fitted before it becomes a texture.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Fit {
|
||||
/// Scaled down to this edge, nothing else.
|
||||
Scaled(u32),
|
||||
/// A logo: cropped to what is not transparent, then scaled to
|
||||
/// [`LOGO_MAX_EDGE`]. TVDB pads every clear logo to a square, and the
|
||||
/// padding would otherwise decide where it sits and how big it is drawn.
|
||||
Logo,
|
||||
}
|
||||
|
||||
/// What the background side tells the UI.
|
||||
pub enum Event {
|
||||
/// The share list changed on disk.
|
||||
@@ -2794,18 +2809,29 @@ impl Engine {
|
||||
/// pixels rather than a tile's 640. A 640 px still stretched to 1600 was
|
||||
/// most of why those looked soft.
|
||||
pub fn request_art(&self, title_key: String, url: String, max_edge: u32) {
|
||||
self.fetch_art(title_key, url, Fit::Scaled(max_edge));
|
||||
}
|
||||
|
||||
/// [`Engine::request_poster`], for a title's logo: trimmed to its
|
||||
/// lettering. See [`Fit::Logo`].
|
||||
pub fn request_logo(&self, key: String, url: String) {
|
||||
self.fetch_art(key, url, Fit::Logo);
|
||||
}
|
||||
|
||||
fn fetch_art(&self, title_key: String, url: String, fit: Fit) {
|
||||
let engine = self.clone();
|
||||
// A size of its own on disk: the tile-sized copy of the same URL is
|
||||
// already there under the plain name, scaled down.
|
||||
let name = match max_edge {
|
||||
THUMBNAIL_MAX_EDGE => format!("{}.img", digest(&url)),
|
||||
edge => format!("{}-{edge}.img", digest(&url)),
|
||||
let name = match fit {
|
||||
Fit::Scaled(THUMBNAIL_MAX_EDGE) => format!("{}.img", digest(&url)),
|
||||
Fit::Scaled(edge) => format!("{}-{edge}.img", digest(&url)),
|
||||
Fit::Logo => format!("{}-logo.img", digest(&url)),
|
||||
};
|
||||
let path = self.dirs.poster_cache().join(name);
|
||||
|
||||
self.runtime.spawn(async move {
|
||||
if let Ok(bytes) = tokio::fs::read(&path).await {
|
||||
match decode_scaled(&bytes, max_edge) {
|
||||
match decode_fitted(&bytes, fit) {
|
||||
Ok(decoded) => {
|
||||
if let Some(smaller) = &decoded.smaller {
|
||||
store_image(&path, smaller).await;
|
||||
@@ -2832,7 +2858,7 @@ impl Engine {
|
||||
}
|
||||
};
|
||||
|
||||
match decode_scaled(&bytes, max_edge) {
|
||||
match decode_fitted(&bytes, fit) {
|
||||
Ok(decoded) => {
|
||||
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
|
||||
engine.emit(Event::Poster {
|
||||
@@ -2984,11 +3010,31 @@ fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<Decoded> {
|
||||
|
||||
/// [`decode_thumbnail`], to `max_edge` rather than a tile's size.
|
||||
fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> {
|
||||
let decoded = image::load_from_memory(bytes)?;
|
||||
decode_fitted(bytes, Fit::Scaled(max_edge))
|
||||
}
|
||||
|
||||
/// [`decode_scaled`], fitted as `fit` says. A trimmed logo is handed back
|
||||
/// for the cache like a scaled picture is, so the trim is paid once.
|
||||
fn decode_fitted(bytes: &[u8], fit: Fit) -> anyhow::Result<Decoded> {
|
||||
let mut decoded = image::load_from_memory(bytes)?;
|
||||
let mut changed = false;
|
||||
let max_edge = match fit {
|
||||
Fit::Scaled(edge) => edge,
|
||||
Fit::Logo => {
|
||||
if let Some(trimmed) = trim_alpha(&decoded) {
|
||||
decoded = trimmed;
|
||||
changed = true;
|
||||
}
|
||||
LOGO_MAX_EDGE
|
||||
}
|
||||
};
|
||||
let (decoded, smaller) = if decoded.width().max(decoded.height()) > max_edge {
|
||||
let scaled = decoded.thumbnail(max_edge, max_edge);
|
||||
let encoded = encode_for_cache(&scaled);
|
||||
(scaled, encoded)
|
||||
} else if changed {
|
||||
let encoded = encode_for_cache(&decoded);
|
||||
(decoded, encoded)
|
||||
} else {
|
||||
(decoded, None)
|
||||
};
|
||||
@@ -3000,6 +3046,36 @@ fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `image` cropped to the pixels that are not (nearly) transparent, or
|
||||
/// `None` when there is nothing to crop — no padding, no alpha, or nothing
|
||||
/// but padding.
|
||||
fn trim_alpha(image: &image::DynamicImage) -> Option<image::DynamicImage> {
|
||||
/// Below this the pixel is anti-aliasing haze or compression noise, not
|
||||
/// lettering.
|
||||
const VISIBLE: u8 = 8;
|
||||
if !image.color().has_alpha() {
|
||||
return None;
|
||||
}
|
||||
let rgba = image.to_rgba8();
|
||||
let (mut left, mut top, mut right, mut bottom) = (u32::MAX, u32::MAX, 0, 0);
|
||||
for (x, y, pixel) in rgba.enumerate_pixels() {
|
||||
if pixel[3] > VISIBLE {
|
||||
left = left.min(x);
|
||||
top = top.min(y);
|
||||
right = right.max(x);
|
||||
bottom = bottom.max(y);
|
||||
}
|
||||
}
|
||||
if left > right {
|
||||
return None;
|
||||
}
|
||||
let (width, height) = (right - left + 1, bottom - top + 1);
|
||||
if (width, height) == (rgba.width(), rgba.height()) {
|
||||
return None;
|
||||
}
|
||||
Some(image.crop_imm(left, top, width, height))
|
||||
}
|
||||
|
||||
/// Encode a scaled picture for the cache: PNG when it has transparency to keep
|
||||
/// (a logo), JPEG otherwise. `None` if encoding failed, which leaves the cache
|
||||
/// holding the original — slower to load, never wrong.
|
||||
@@ -3261,6 +3337,29 @@ mod image_cache_tests {
|
||||
cache.upload(&ctx);
|
||||
assert!(cache.textures.is_empty());
|
||||
}
|
||||
|
||||
/// TVDB pads a clear logo to a square; trimmed, it is only its lettering.
|
||||
#[test]
|
||||
fn a_padded_logo_is_trimmed_to_its_lettering() {
|
||||
let mut padded = image::RgbaImage::new(100, 100);
|
||||
for x in 20..80 {
|
||||
for y in 40..60 {
|
||||
padded.put_pixel(x, y, image::Rgba([255, 255, 255, 255]));
|
||||
}
|
||||
}
|
||||
// Haze below the visibility threshold does not count.
|
||||
padded.put_pixel(2, 2, image::Rgba([255, 255, 255, 3]));
|
||||
let trimmed = trim_alpha(&image::DynamicImage::ImageRgba8(padded)).expect("trimmed");
|
||||
assert_eq!((trimmed.width(), trimmed.height()), (60, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_with_nothing_to_trim_is_left_alone() {
|
||||
let opaque = image::DynamicImage::ImageRgb8(image::RgbImage::new(10, 10));
|
||||
assert!(trim_alpha(&opaque).is_none());
|
||||
let blank = image::DynamicImage::ImageRgba8(image::RgbaImage::new(10, 10));
|
||||
assert!(trim_alpha(&blank).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -274,15 +274,17 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
||||
}
|
||||
ui.label(ui::muted(facts.join(" · ")));
|
||||
ui.add_space(theme::space::XXS);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
theme::Role::Display
|
||||
.rich(art.name(title))
|
||||
.strong()
|
||||
.color(theme::text()),
|
||||
)
|
||||
.truncate(),
|
||||
);
|
||||
if ui::logo_heading(ui, art, title, 110.0).is_none() {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
theme::Role::Display
|
||||
.rich(art.name(title))
|
||||
.strong()
|
||||
.color(theme::text()),
|
||||
)
|
||||
.truncate(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -323,7 +323,9 @@ fn header(
|
||||
// Text in a column a person can read, with the picture to the
|
||||
// right of it rather than under it.
|
||||
ui.set_max_width(ui.available_width().min(820.0));
|
||||
ui.label(theme::Role::Display.rich(&shown).strong());
|
||||
if ui::logo_heading(ui, art, title, 120.0).is_none() {
|
||||
ui.label(theme::Role::Display.rich(&shown).strong());
|
||||
}
|
||||
// Whichever of the share's name and the provider's is not the one
|
||||
// shown. Worth showing rather than only replacing: a viewer should
|
||||
// be able to tell what the match actually matched, and find the
|
||||
@@ -629,6 +631,15 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
||||
if let Some(year) = title.year {
|
||||
parts.push(year.to_string());
|
||||
}
|
||||
// A film's running time is what it costs to start it. A series' is its
|
||||
// episodes', which each row says.
|
||||
if title.kind == TitleKind::Film
|
||||
&& let Some(minutes) = found
|
||||
.and_then(|found| found.details.as_ref())
|
||||
.and_then(|details| details.runtime)
|
||||
{
|
||||
parts.push(ui::format_runtime(minutes));
|
||||
}
|
||||
if title.kind == TitleKind::Series {
|
||||
if title.seasons.len() > 1 {
|
||||
parts.push(ui::library::plural(title.seasons.len(), "season"));
|
||||
@@ -701,6 +712,7 @@ fn episode_row(
|
||||
found.name.clone(),
|
||||
found.overview.clone(),
|
||||
found.air_date.clone(),
|
||||
found.runtime,
|
||||
)
|
||||
});
|
||||
let watched = episode.is_watched();
|
||||
@@ -732,7 +744,7 @@ fn episode_row(
|
||||
.unwrap_or_default();
|
||||
let name = found
|
||||
.as_ref()
|
||||
.and_then(|(name, _, _)| name.clone())
|
||||
.and_then(|(name, ..)| name.clone())
|
||||
.unwrap_or_else(|| episode.detail().to_string());
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
if !numbering.is_empty() {
|
||||
@@ -762,9 +774,10 @@ fn episode_row(
|
||||
ui.label(ui::muted(details(
|
||||
episode,
|
||||
found.as_ref().and_then(|f| f.2.as_deref()),
|
||||
found.as_ref().and_then(|f| f.3),
|
||||
)));
|
||||
|
||||
if let Some((_, Some(overview), _)) = &found {
|
||||
if let Some((_, Some(overview), ..)) = &found {
|
||||
let mut job = egui::text::LayoutJob::simple(
|
||||
overview.clone(),
|
||||
theme::Role::Caption.font(),
|
||||
@@ -836,9 +849,9 @@ fn episode_row(
|
||||
ui.add_space(theme::space::XXS);
|
||||
}
|
||||
|
||||
/// The grey line under an episode's name: when it aired, how big it is, and
|
||||
/// where it would resume.
|
||||
fn details(episode: &Episode, air_date: Option<&str>) -> String {
|
||||
/// The grey line under an episode's name: when it aired, how long it runs,
|
||||
/// how big it is, and where it would resume.
|
||||
fn details(episode: &Episode, air_date: Option<&str>, runtime: Option<u32>) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(at) = episode.resume_at() {
|
||||
let left = episode
|
||||
@@ -853,6 +866,9 @@ fn details(episode: &Episode, air_date: Option<&str>) -> String {
|
||||
if let Some(date) = air_date {
|
||||
parts.push(date.to_owned());
|
||||
}
|
||||
if let Some(minutes) = runtime {
|
||||
parts.push(ui::format_runtime(minutes));
|
||||
}
|
||||
match episode.node.size {
|
||||
// A file the share says is empty is one that will not play — an upload
|
||||
// that never finished, usually. Worth saying on the row rather than
|
||||
|
||||
Reference in new issue
Block a user