mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(app,android,ios): a blurred poster where a title has no wide art
A title with no fanart, banner or backdrop drew its poster cropped to the band's shape across the title page and the library's banner: a blown-up slice of the middle of it, which read as broken. Now the poster is squeezed to 64x36 and blurred once when it is fetched, the cache keeps that wash, and it is stretched across the band. The library's banner stands the sharp poster at its right over it. Android blurs the poster it already had (Android 12 and later; older versions decode it tiny), and iOS likewise. METADATA.md also records the quality badges of the commit before.
This commit is contained in:
1 parent
f4c08d03c7
commit
3ebfd1a57b
12 files changed
+182
-19
No files matched your search
@@ -628,13 +628,7 @@ private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): 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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String> = 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",
|
||||
|
||||
@@ -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<Decoded> {
|
||||
}
|
||||
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));
|
||||
|
||||
@@ -230,6 +230,34 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
||||
rect.bottom() - 28.0,
|
||||
),
|
||||
);
|
||||
|
||||
// With no wide art the banner is the poster's wash, so the poster itself
|
||||
// stands at the right, where wide art keeps its subject — over the veils,
|
||||
// and only where it clears the text.
|
||||
if !art.has_wide_art(title)
|
||||
&& let Some((poster, pstr_core::metadata::ArtShape::Portrait)) = art.poster(title)
|
||||
{
|
||||
const INSET: f32 = 28.0;
|
||||
let height = rect.height() - 2.0 * INSET;
|
||||
let size = egui::vec2(height * 2.0 / 3.0, height);
|
||||
let at = egui::Rect::from_min_size(
|
||||
egui::pos2(rect.right() - 2.0 * INSET - size.x, rect.top() + INSET),
|
||||
size,
|
||||
);
|
||||
if at.left() > 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)
|
||||
|
||||
@@ -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<String> {
|
||||
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<egui::TextureHandle> {
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user