Reduce card image transfers and list metadata payloads

This commit is contained in:
justinzeus 2026-11-06 23:54:16 +01:00
parent f4fb6e22c9
commit e340d470c1
10 changed files with 152 additions and 31 deletions

View file

@ -1,6 +1,8 @@
package org.justinvisser.wolfskin.design
import android.content.Context
import android.graphics.ImageDecoder
import android.os.Build
import android.net.Uri
import coil3.ComponentRegistry
import coil3.ImageLoader
@ -23,6 +25,8 @@ object WolfskinImages {
private var networkConfiguration: (ComponentRegistry.Builder.() -> Unit)? = null
private const val MEBIBYTE = 1024L * 1024L
private const val IMAGE_READS = 4
private const val CARD_QUALITY = "80"
private const val HERO_QUALITY = "96"
private val readPermits = Semaphore(IMAGE_READS)
/** The app supplies authenticated networking without exposing its server/session to design code. */
@ -78,32 +82,34 @@ object WolfskinImages {
private val jellyfinImagePath = Regex("/Items/[^/]+/Images/[^/]+(?:/\\d+)?", RegexOption.IGNORE_CASE)
private val tmdbSizePath = Regex("/t/p/(?:w\\d+|original)/")
private val dimensionParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight")
private val imageParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight", "quality", "format")
// Lossy WebP is mandatory from API 14; minSdk is 23. Newer platforms expose a decoder capability query.
private val supportsWebp by lazy { Build.VERSION.SDK_INT < 28 || ImageDecoder.isMimeTypeSupported("image/webp") }
private fun sourceForSize(source: Any, width: Int, height: Int): Any {
if (source !is String) return source
val uri = Uri.parse(source)
if (uri.scheme != "https" && uri.scheme != "http") return source
if (uri.host == "image.tmdb.org") {
val size = if (height > width) when {
width <= 500 -> "w500"
width <= 780 -> "w780"
else -> "original"
} else when {
width <= 780 -> "w780"
width <= 1280 -> "w1280"
else -> "original"
val size = when {
height >= width -> "w342"
width > WolfskinTokens.Image.wideRequestWidth -> "w1280"
else -> "w780"
}
return uri.buildUpon().path(uri.path?.replace(tmdbSizePath, "/t/p/$size/")).build().toString()
}
if (!jellyfinImagePath.matches(uri.path.orEmpty())) return source
val hero = width == WolfskinTokens.Image.heroRequestWidth && height == WolfskinTokens.Image.heroRequestHeight ||
width == WolfskinTokens.Image.heroPosterRequestWidth && height == WolfskinTokens.Image.heroPosterRequestHeight
val builder = uri.buildUpon().clearQuery()
for (name in uri.queryParameterNames) {
if (name.lowercase() !in dimensionParameters) {
if (name.lowercase() !in imageParameters) {
uri.getQueryParameters(name).forEach { value -> builder.appendQueryParameter(name, value) }
}
}
return builder.appendQueryParameter("fillWidth", width.toString())
.appendQueryParameter("fillHeight", height.toString()).build().toString()
.appendQueryParameter("fillHeight", height.toString())
.appendQueryParameter("format", if (hero || !supportsWebp) "Jpg" else "Webp")
.appendQueryParameter("quality", if (hero) HERO_QUALITY else CARD_QUALITY).build().toString()
}
}

View file

@ -220,10 +220,10 @@ object WolfskinTokens {
object Image {
const val posterRequestWidth = 256
const val posterRequestHeight = 384
const val tileRequestWidth = 416
const val tileRequestHeight = 234
const val wideRequestWidth = 560
const val wideRequestHeight = 315
const val tileRequestWidth = 400
const val tileRequestHeight = 225
const val wideRequestWidth = 544
const val wideRequestHeight = 306
const val heroRequestWidth = 1920
const val heroRequestHeight = 1080
const val heroPosterRequestWidth = 420

View file

@ -0,0 +1,61 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.net.Uri
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/** Card URLs bound server work and transfer size, while hero quality and cache identity stay intact. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class WolfskinImagesTest {
private data class Surface(val name: String, val width: Int, val height: Int, val tier: String, val hero: Boolean = false)
private val surfaces = listOf(
Surface("grid/Home/Discover/person poster", 256, 384, "w342"),
Surface("Home tile", 400, 225, "w780"),
Surface("Home wide", 544, 306, "w780"),
Surface("episode still", 400, 280, "w780"),
Surface("cast portrait", 200, 200, "w342"),
Surface("person portrait", 240, 240, "w342"),
Surface("hero poster", 420, 630, "w342", true),
Surface("Home/Discover/title hero", 1920, 1080, "w1280", true),
)
private fun request(source: Any, surface: Surface): Any = WolfskinImages.request(
RuntimeEnvironment.getApplication(), source, surface.width, surface.height, surface.height > surface.width).data
@Test fun jellyfinSurfacesReplaceStaleDimensionsAndEncodingWithoutLosingIdentity() {
for (surface in surfaces) {
val source = "https://jelly.example/Items/123/Images/Backdrop/0?tag=stable&api_key=kept&MaxWidth=1280&height=720&FillHeight=720&Quality=96&Format=Png"
val rewritten = request(source, surface).toString()
val uri = Uri.parse(rewritten)
assertEquals(surface.name, surface.width.toString(), uri.getQueryParameter("fillWidth"))
assertEquals(surface.name, surface.height.toString(), uri.getQueryParameter("fillHeight"))
assertEquals(surface.name, if (surface.hero) "Jpg" else "Webp", uri.getQueryParameter("format"))
assertEquals(surface.name, if (surface.hero) "96" else "80", uri.getQueryParameter("quality"))
assertEquals("stable", uri.getQueryParameter("tag"))
assertEquals("kept", uri.getQueryParameter("api_key"))
assertEquals(setOf("tag", "api_key", "fillWidth", "fillHeight", "format", "quality"), uri.queryParameterNames)
assertEquals(rewritten, request(rewritten, surface))
}
}
@Test fun tmdbSurfacesNeverDownloadOriginalArtwork() {
for (surface in surfaces) {
for (oldTier in listOf("original", "w500", "w780")) {
assertEquals(surface.name, "https://image.tmdb.org/t/p/${surface.tier}/art.jpg?identity=kept",
request("https://image.tmdb.org/t/p/$oldTier/art.jpg?identity=kept", surface))
}
}
}
@Test fun localAndUnrelatedSourcesRemainUntouched() {
for (source in listOf("file:///android_asset/poster.webp", "https://cdn.example/poster.jpg?width=800", "https://jelly.example/Users/123/Images/Primary", Uri.parse("content://art/1"))) {
assertEquals(source, request(source, surfaces.first()))
}
}
}

View file

@ -1492,8 +1492,8 @@
},
"tileRequest": {
"px": [
416,
234
400,
225
],
"decode": "ARGB_8888",
"source": "index.html:549",
@ -1501,8 +1501,8 @@
},
"wideRequest": {
"px": [
560,
315
544,
306
],
"decode": "ARGB_8888",
"source": "index.html:401",

View file

@ -2,7 +2,7 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
bb64e45654fce2cd3ba595b505fb8b5a540526cef696dfa974be25f028d3e119 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
580ed1710468ccbd97082afaa445e96ae71510ebace711cc735ea389ecb9249d src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json
36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css
3226c6fb9c7e35c5bde90aaf01630b238cfcb9a768ff60c49461e19a200c9125 tokens.json
226f4bd07c0b105f8640beff71fe2c0c95d0f8862e624cea511384a4591b44ad tokens.json