diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index bddb55e..477fd94 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -628,13 +628,7 @@ private fun featured(titles: List, resumable: List): Tit private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) { val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) } Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) { - RemoteArtwork( - title.backdropUrl ?: title.posterUrl, - title.displayName, - Modifier.fillMaxSize(), - fallback = title.thumbnailSource, - labelled = false, - ) + Backdrop(title, Modifier.fillMaxSize()) Box( Modifier.fillMaxSize().background( Brush.verticalGradient(0.35f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.85f)), diff --git a/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt b/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt index 8a5df88..a1a66bb 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap @@ -34,6 +35,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.text.font.FontWeight import io.narl.protonstream.native.NativeRuntime import kotlinx.coroutines.Dispatchers @@ -112,6 +114,29 @@ internal fun RemoteArtwork( } } +/** + * A title's art across the top of a page: its backdrop, or with none, its + * poster as a wash of its own colours — decoded tiny and blurred, so it reads as + * the poster's light rather than a blown-up slice of it. The desktop's + * `Fit::Ambient`. Android 11 and older cannot blur; there the tiny decode, + * stretched, is what softens it. + */ +@Composable +internal fun Backdrop(title: TitleRecord, modifier: Modifier = Modifier) { + val wash = title.backdropUrl == null && title.posterUrl != null + RemoteArtwork( + title.backdropUrl ?: title.posterUrl, + title.displayName, + if (wash) modifier.blur(WASH_BLUR) else modifier, + fallback = title.thumbnailSource, + labelled = false, + maxEdge = if (wash) WASH_EDGE else null, + ) +} + +private val WASH_EDGE = 48.dp +private val WASH_BLUR = 24.dp + /** * A title's logo — its name as lettering — in place of [fallback], once it has * loaded. [fallback] stands until then, and for good when there is none, so a diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index 5283cd0..13e0bca 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -183,13 +183,7 @@ internal fun TitleScreen( // the page says, and a framed thumbnail with a margin round it read // as one card among the many below it. Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) { - RemoteArtwork( - title.backdropUrl ?: title.posterUrl, - title.displayName, - Modifier.fillMaxSize(), - fallback = title.thumbnailSource, - labelled = false, - ) + Backdrop(title, Modifier.fillMaxSize()) Box( Modifier.fillMaxSize().background( Brush.verticalGradient( diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index e33da3d..5ce42bb 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -2813,6 +2813,8 @@ fn changed_art( 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()), + // The poster again: the blurred band is made from it. + found.and_then(|metadata| metadata.poster_url.clone()), ] }; let mut keys: Vec = Vec::new(); @@ -2823,6 +2825,7 @@ fn changed_art( ui::poster_key(key), ui::wide_key(key), ui::logo_key(key), + ui::ambient_key(key), ]; for ((old, new), texture) in old.iter().zip(&new).zip(texture_keys) { if old != new { @@ -3153,12 +3156,16 @@ mod tests { record("added", Some("w")), ] .into(); - // The tile and the cover are both drawn from the poster here; the - // wide art has nothing to be. + // The tile, the cover and the blurred band are all drawn from the + // poster here; the wide art has nothing to be. assert_eq!( changed_art(&before, &after), vec![ "added", + "ambient:added", + "ambient:dropped", + "ambient:found", + "ambient:moved", "dropped", "found", "moved", diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index f372db6..d125605 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -174,6 +174,15 @@ pub const WIDE_MAX_EDGE: u32 = 1920; /// most a few hundred pixels wide; this leaves room for a 2× display. const LOGO_MAX_EDGE: u32 = 800; +/// The size a poster is squeezed to for [`Fit::Ambient`]: 16:9, and small +/// enough that what is left of it is colour and light rather than shapes. +const AMBIENT_SIZE: (u32, u32) = (64, 36); + +/// How far the squeezed poster is blurred, in its own pixels. With linear +/// filtering stretching it across the window, this is what keeps the stretch +/// from showing its pixels. +const AMBIENT_BLUR: f32 = 3.0; + /// How a fetched picture is fitted before it becomes a texture. #[derive(Clone, Copy)] enum Fit { @@ -183,6 +192,11 @@ enum Fit { /// [`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, + /// A poster made into a wash of its own colours, for a band across the + /// window when a title has no wide art: squeezed to [`AMBIENT_SIZE`] and + /// blurred. A poster cropped to that shape was a blown-up slice of it, + /// which read as broken; this reads as the poster's light. + Ambient, } /// What the background side tells the UI. @@ -2810,6 +2824,11 @@ impl Engine { self.fetch_art(key, url, Fit::Logo); } + /// [`Engine::request_poster`], blurred for a band. See [`Fit::Ambient`]. + pub fn request_ambient(&self, key: String, url: String) { + self.fetch_art(key, url, Fit::Ambient); + } + 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 @@ -2818,6 +2837,7 @@ impl Engine { Fit::Scaled(THUMBNAIL_MAX_EDGE) => format!("{}.img", digest(&url)), Fit::Scaled(edge) => format!("{}-{edge}.img", digest(&url)), Fit::Logo => format!("{}-logo.img", digest(&url)), + Fit::Ambient => format!("{}-ambient.img", digest(&url)), }; let path = self.dirs.poster_cache().join(name); @@ -3019,6 +3039,18 @@ fn decode_fitted(bytes: &[u8], fit: Fit) -> anyhow::Result { } LOGO_MAX_EDGE } + Fit::Ambient => { + // Already blurred when it came out of the cache: only a picture + // of another size is the poster itself. + let (width, height) = AMBIENT_SIZE; + if (decoded.width(), decoded.height()) != AMBIENT_SIZE { + decoded = decoded + .resize_exact(width, height, image::imageops::FilterType::Triangle) + .blur(AMBIENT_BLUR); + changed = true; + } + width + } }; let (decoded, smaller) = if decoded.width().max(decoded.height()) > max_edge { let scaled = decoded.thumbnail(max_edge, max_edge); @@ -3345,6 +3377,29 @@ mod image_cache_tests { assert_eq!((trimmed.width(), trimmed.height()), (60, 20)); } + /// A poster for a band is squeezed and blurred once; what the cache keeps + /// is already the wash, and decoding it again leaves it as it is. + #[test] + fn a_poster_for_a_band_becomes_a_small_blurred_wash_once() { + let mut poster = image::RgbImage::new(200, 300); + for y in 0..150 { + for x in 0..200 { + poster.put_pixel(x, y, image::Rgb([255, 255, 255])); + } + } + let poster = encode_for_cache(&image::DynamicImage::ImageRgb8(poster)).expect("encode"); + let decoded = decode_fitted(&poster, Fit::Ambient).expect("decode"); + assert_eq!(decoded.image.size, [64, 36]); + // White above, black below, and a ramp between rather than an edge. + let middle = decoded.image.pixels[18 * 64 + 32]; + assert!(middle.r() > 40 && middle.r() < 215, "{middle:?}"); + + let cached = decoded.smaller.expect("the wash is what is cached"); + let again = decode_fitted(&cached, Fit::Ambient).expect("decode"); + assert_eq!(again.image.size, [64, 36]); + assert!(again.smaller.is_none()); + } + #[test] fn a_picture_with_nothing_to_trim_is_left_alone() { let opaque = image::DynamicImage::ImageRgb8(image::RgbImage::new(10, 10)); diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index 470541b..1b257c0 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -230,6 +230,34 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec inner.right() + INSET { + painter.add( + egui::epaint::RectShape::filled( + at, + egui::CornerRadius::same(theme::radius::MD), + egui::Color32::WHITE.gamma_multiply(art_in), + ) + .with_texture( + poster.id(), + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + ), + ); + } + } ui.scope_builder( egui::UiBuilder::new() .max_rect(inner) diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index 37965af..b9bb217 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -107,7 +107,9 @@ impl Art<'_> { } /// Art for a strip across the whole window, at full size: fanart, else - /// AniList's banner, else whatever [`Art::of`] has. + /// AniList's banner, else the poster blurred into a wash of its colours — + /// see [`crate::engine::Engine::request_ambient`] — else whatever + /// [`Art::of`] has. /// /// Its own texture rather than the tile's: a 640 px tile picture /// stretched to a 1600 px hero is soft, and a 1920 px one in every tile @@ -128,10 +130,35 @@ impl Art<'_> { { return Some((texture, ArtShape::Landscape)); } + } else if let Some(url) = self.poster_url(title) { + let key = ambient_key(&title.key); + let engine = self.engine; + if let Some(texture) = self + .posters + .texture(&key, || engine.request_ambient(key.clone(), url)) + { + return Some((texture, ArtShape::Landscape)); + } } self.of(title) } + /// Whether `title` has a picture made for a strip across the window, rather + /// than [`Art::wide`] standing one in. + pub fn has_wide_art(&self, title: &Title) -> bool { + self.metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()) + .is_some_and(|metadata| metadata.wide_art().is_some()) + } + + fn poster_url(&self, title: &Title) -> Option { + self.metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()) + .and_then(|metadata| metadata.poster_url.clone()) + } + /// 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 { @@ -187,6 +214,11 @@ pub fn wide_key(title_key: &str) -> String { format!("wide:{title_key}") } +/// The texture key of a title's poster blurred for a band. +pub fn ambient_key(title_key: &str) -> String { + format!("ambient:{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. /// diff --git a/docs/METADATA.md b/docs/METADATA.md index 980dbfd..d062765 100644 --- a/docs/METADATA.md +++ b/docs/METADATA.md @@ -240,6 +240,19 @@ With TMDB itself chosen, enrichment now makes the same request for the logo, and backdrops are fetched at `w1280` rather than `w780`, since they fill the width of the title page. +**A band that never looks broken (done).** A title with no wide art drew +its poster cropped to the band's shape: a blown-up slice of the middle of it. +Now the poster is squeezed to 64×36 and blurred once, when it is fetched +(`Fit::Ambient`; the cache keeps the wash, not the poster), and that is +stretched across the band — the poster's colours and light rather than a +crop. The library's banner then stands the poster itself at its right. Android +and iOS blur the poster the same way (Android 12 and later; older versions +decode it tiny, which softens it less). + +**Quality from file names (done).** `naming::quality` reads `2160p`, `HDR`, +`10-bit` and `Dual-Audio` out of a release name, and a title's page shows what +most of its files say. No release group is read. + Still to do: - **Local matching** against the offline database, so only ids leave the diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 18b893e..da31617 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -76,6 +76,8 @@ Click a title to open its page. **Esc** goes back. - With posters and descriptions on, a show's logo takes the place of its name at the top of the page and on the library's banner, and each episode says how long it runs. A film says how long it runs in the line under its name. +- A title with no wide picture shows its poster blurred across the top of the page instead, and + on the library's banner the poster itself beside it. - The line under the name also says what most of the files are, when their names say it: **4K**, **1080p**, **HDR**, **10-bit** or **Dual audio**. diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index 6695a49..8e822bf 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -494,7 +494,7 @@ private struct FeaturedBanner: View { let onOpen: () -> Void var body: some View { - RemoteArtwork(title.backdropUrl ?? title.posterUrl, title.displayName, fallback: title.thumbnailSource, labelled: false) + Backdrop(title: title) .aspectRatio(4 / 3, contentMode: .fit) .overlay { LinearGradient( diff --git a/ios/ProtonStream/UI/RemoteArtwork.swift b/ios/ProtonStream/UI/RemoteArtwork.swift index b356495..8ae0d63 100644 --- a/ios/ProtonStream/UI/RemoteArtwork.swift +++ b/ios/ProtonStream/UI/RemoteArtwork.swift @@ -81,6 +81,19 @@ struct RemoteArtwork: View { } } +/// A title's art across the top of a page: its backdrop, or with none, its +/// poster as a wash of its own colours — blurred, so it reads as the poster's +/// light rather than a blown-up slice of it. The desktop's `Fit::Ambient`. +struct Backdrop: View { + let title: TitleRecord + + var body: some View { + let wash = title.backdropUrl == nil && title.posterUrl != nil + RemoteArtwork(title.backdropUrl ?? title.posterUrl, title.displayName, fallback: title.thumbnailSource, labelled: false) + .blur(radius: wash ? 24 : 0, opaque: true) + } +} + /// A title's logo — its name as lettering — in place of `fallback`, once it /// has loaded. `fallback` stands until then, and for good when there is none, /// so a title without a logo looks as it always did and one with a logo only diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index 803983f..413b428 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -119,7 +119,7 @@ struct TitleView: View { /// Edge to edge, fading into the page: the art is the first thing the page /// says. private func backdrop(height: CGFloat, top: CGFloat) -> some View { - RemoteArtwork(title.backdropUrl ?? title.posterUrl, title.displayName, fallback: title.thumbnailSource, labelled: false) + Backdrop(title: title) .frame(height: height) .frame(maxWidth: .infinity) .clipped()