Merge pull request 'Right-size card artwork and slim Jellyfin list reads' (#49) from fm/tv-speed-images into main

This commit is contained in:
justinzeus 2026-11-07 00:26:58 +01:00
commit acb72335e3
11 changed files with 153 additions and 32 deletions

View file

@ -84,7 +84,7 @@ class FirstSignInNavigationTest {
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows))
for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) {
coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true,
includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED),
includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED),
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult(
List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) })
}

View file

@ -51,7 +51,7 @@ class LibraryReadFacadeTest {
val offsets = mutableListOf<Int>()
coEvery { items.getItems(userId = user, parentId = library.id, recursive = true,
includeItemTypes = listOf(BaseItemKind.MOVIE),
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED),
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED),
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers {
if (offsets.last() == 0) successQueryResult(firstChunk, 202) else {
assertEquals(200, offsets.last())

View file

@ -33,6 +33,8 @@ import com.github.damontecres.wholphin.test.TestTracks
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.junit.Assert.*
import org.junit.After
import org.junit.Before
@ -219,6 +221,43 @@ class TitleReadFacadeTest {
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
}
@Test fun leanEpisodeListsKeepSingleItemPlaybackTracksEvenWithoutNextUp() = runTest {
val series = BaseItemDto(id = seriesId, name = "Series", type = BaseItemKind.SERIES, people = emptyList(), providerIds = mapOf("Tmdb" to "42"))
val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 3, indexNumber = 1)
coEvery { library.getItem(seriesId) } returns successResponse(series)
coEvery { library.getItem(firstId) } returns successResponse(episode)
coEvery { shows.getNextUp(any<GetNextUpRequest>()) } coAnswers {
assertTrue(firstArg<GetNextUpRequest>().fields.orEmpty().isEmpty())
successQueryResult(emptyList())
}
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(
listOf(BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 3)))
coEvery { shows.getEpisodes(any<GetEpisodesRequest>()) } coAnswers {
val fields = firstArg<GetEpisodesRequest>().fields.orEmpty()
assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PEOPLE, ItemFields.PARENT_ID) })
successQueryResult(listOf(episode.copy(mediaSources = null, overview = "Episode synopsis")))
}
val page = facade.read(seriesId, 3, preferences)
assertEquals(firstId.toString(), page.playTarget?.id)
assertEquals("MKV", page.versions.single().container)
assertEquals(listOf(3), page.versions.single().audio.map { it.index })
val saved = requireNotNull(facade.readSaved(seriesId, preferences))
assertEquals(page.versions, saved.versions)
}
@Test fun performerCreditListsOmitStreamsButKeepCharacterRoles() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(
BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor"))
coEvery { items.getItems(any<GetItemsRequest>()) } coAnswers {
val fields = firstArg<GetItemsRequest>().fields.orEmpty()
assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PARENT_ID) })
assertTrue(ItemFields.PEOPLE in fields)
successQueryResult(listOf(file(secondId).copy(people = listOf(
org.jellyfin.sdk.model.api.BaseItemPerson(id = firstId, name = "Actor", role = "Chef")))))
}
assertEquals("Chef", facade.readPerson(firstId, 0).credits.single().character)
}
@Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
coEvery { playback.savePlayVersion(any(), any()) } returns null

View file

@ -72,7 +72,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
val response = api.itemsApi.getItems(
userId = userId, parentId = library.id, recursive = true,
includeItemTypes = listOf(if (destination == "movies") BaseItemKind.MOVIE else BaseItemKind.SERIES),
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED),
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED),
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset,
limit = WolfskinTokens.Behaviour.libraryChunk.toInt(),
).content

View file

@ -53,7 +53,8 @@ class TitleReadFacade @Inject constructor(
@param:ApplicationContext private val context: Context,
) {
private val json = Json { ignoreUnknownKeys = true }
private val fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID)
private val episodeFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW)
private val creditFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.PEOPLE)
/** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */
suspend fun findHeld(tmdb: Long, media: String): UUID? {
@ -88,12 +89,12 @@ class TitleReadFacade @Inject constructor(
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened
val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList()
val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, enableResumable = true)).content.items.firstOrNull()?.let { readItem(it.id) }
}
val selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber
val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull()
val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = fields)).content.items.orEmpty() }.orEmpty()
val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }
val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = episodeFields)).content.items.orEmpty() }.orEmpty()
val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }?.let { readItem(it.id) }
val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id }
val numbers = seasons.mapNotNull { it.indexNumber }
val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences)
@ -165,7 +166,7 @@ class TitleReadFacade @Inject constructor(
} else emptyList()
val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content
val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = creditFields, startIndex = start, limit = 200,
sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty()
val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id }
if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed")

View file

@ -570,15 +570,29 @@ retain the approved frames except for the requested feedback changes:
- Every Discover heading uses a 104 px band, including a 60 px title-to-card
gap. Home heading geometry is unchanged.
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
420×630. The shared request builder removes conflicting Jellyfin size caps,
preserves tags and quality 96, and selects a suitable TMDB tier. The 64 MiB
memory cache, 128 MiB disk cache and four image-read limit remain unchanged.
420×630. The shared request builder replaces conflicting Jellyfin size/encoding
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG
quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and
400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
authentication query parameters remain intact. TMDB uses w342 for posters and
portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
decoder support and requests JPEG if WebP is unavailable. The 64 MiB memory
cache, 128 MiB disk cache and four image-read limit remain unchanged.
`HomeViewportTest` covers short/long poster, tile and wide rows, both row
directions, first focus and native Back from the actual title surface. Native
proof records real Bear season-three metadata through the title read facade;
the proof-only harness is not part of the shipped tests or APK.
Episode lists request only ProviderIds and Overview. Next up reads only identity,
then hydrates the selected playback target through the existing single-item read,
including when Next up is empty. Media sources/streams and cast remain on detail
reads for header track/version controls. Performer credits omit media sources,
streams and ParentId but retain People because their cards show character roles.
Movies/Shows grids retain ProviderIds and DateCreated; server-side SortName
ordering does not require returning SortName on every item.
## Title pages and people

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