Reuse shared artwork and correct title focus edges

This commit is contained in:
justinzeus 2026-11-05 19:58:36 +01:00
parent 3bfb8cd779
commit a8951827ba
41 changed files with 581 additions and 438 deletions

View file

@ -134,7 +134,11 @@ class TitleCaptureTest {
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
@Test fun st051_episodeRestart() = capture(51, "episode-sex-and-the-country-2001-s4e9", "mindel") { state, _ -> option(state, "restart") }
@Test fun st052_filmCast() = capture(52) { state, _ -> state.focus("cast", 0) }
@Test fun st052_filmCast() = capture(52) { state, shell ->
state.focus("cast", 0)
state.key(ShellKey.Right, true, 0, shell)
state.key(ShellKey.Right, false, 0, shell)
}
@Test fun st053_seriesCast() = capture(53, "Andor") { state, _ -> state.focus("cast", 0) }
@Test fun st054_initialsCast() = capture(54, "Spirited Away", "shared") { state, _ -> state.focus("cast", 0) }
@Test fun st055_castLoading() = capture(55, transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Loading) })
@ -162,7 +166,9 @@ class TitleCaptureTest {
@Test fun st067_trailerFallback() = capture(67, "The Amazing Spider-Man")
@Test fun st068_requested() = capture(68, "The Amazing Spider-Man", transform = { it.copy(requestedStage = "Searching", requestActor = "Justin") })
@Test fun st069_missingArt() = capture(69, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(poster = null, backdrop = null)) })
@Test fun st070_offline() = capture(70, "The Amazing Spider-Man", transform = { it.copy(connection = TitleConnection.Offline) })
@Test fun st070_offline() = capture(70, "The Amazing Spider-Man", transform = {
it.copy(connection = TitleConnection.Offline, savedAt = "20:42", item = it.item.copy(ratings = emptyList()))
})
@Test fun st104_relatedPlate() = capture(104, "The Girl Who Leapt Through Time") { state, _ -> state.focus("related", state.data.related.indexOfFirst { it.name == "INVINCIBLE" }.coerceAtLeast(0)) }
@Test fun st106_heldRatings() = capture(106, "American Gangster", "shared")
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")

View file

@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
@ -9,6 +10,10 @@ import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.json.JSONArray
import org.json.JSONObject
@ -50,7 +55,10 @@ class TitleKeysTest {
private fun render(title: String = "Locke", transform: (org.justinvisser.wolfskin.design.TitlePageData) -> org.justinvisser.wolfskin.design.TitlePageData = { it }) {
shell = ShellState(fixture.people, "justin", shellEffects::add).apply { pickerVisible = false; pickerLaunch = false; enterDestination(titleRoute(title)) }
state = TitleState(transform(fixture.title(title, "justin")), shell.person, effects::add)
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
rule.setContent { WolfskinTheme {
BackHandler { state.back(shell) }
TitleSurface(state, shell)
} }
}
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
@ -251,4 +259,34 @@ class TitleKeysTest {
press(Key.DirectionDown)
assertEquals(expected, state.row)
}
@Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() {
render("Andor")
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("Add for someone", useUnmergedTree = true)
.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
val layout = layouts.single()
assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth)
assertEquals(1, layout.lineCount)
}
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
render(transform = { it.copy(status = TitleReadStatus.Loading) })
rule.onNodeWithText("Cast").assertDoesNotExist()
press(Key.DirectionDown)
assertTrue(shell.navFocused)
assertEquals("header", state.row)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertEquals(listOf(TitleAction.Pop), effects)
}
@Test fun unratedFactsDoNotReserveAnEmptyRatingsSlot() {
render("The Amazing Spider-Man", transform = {
it.copy(item = it.item.copy(ratings = emptyList(), genres = listOf("Drama"), runtimeSeconds = 0),
cast = emptyList(), related = emptyList())
})
val year = rule.onNodeWithText("2012", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val genre = rule.onNodeWithText("Drama", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
}
}

View file

@ -18,7 +18,7 @@ import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleConnection
import org.justinvisser.wolfskin.design.TitleItem
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitleRating
import org.justinvisser.wolfskin.design.LibraryRating
import org.justinvisser.wolfskin.design.TitleState
import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
@ -39,14 +39,14 @@ class TitleLocaleTest {
try {
Locale.setDefault(locale)
val person = ShellPerson("justin", "Justin", "J", false)
val item = TitleItem("movie:1", "Title", "movie", ratings = listOf(TitleRating("tmdb", 7.8, "tmdb")))
val item = TitleItem("movie:1", "Title", "movie", ratings = listOf(LibraryRating("tmdb", 7.8)))
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("title/movie:1") }
val state = TitleState(TitlePageData(item, related = listOf(item.copy(id = "movie:2")), connection = TitleConnection.SignedIn), person) {}
state.focus("related", 0)
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
rule.mainClock.advanceTimeBy(1000)
val layouts = mutableListOf<TextLayoutResult>()
rule.onAllNodesWithText(expected).onLast().performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) }
rule.onAllNodesWithText(expected, useUnmergedTree = true).onLast().performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) }
assertEquals(1, layouts.single().lineCount)
assertFalse(layouts.single().didOverflowWidth)
} finally { Locale.setDefault(previous) }

View file

@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv
import android.app.Application
import android.content.Context
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
@ -12,8 +13,11 @@ import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.ui.successQueryResult
import com.github.damontecres.wholphin.ui.successResponse
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
@ -29,6 +33,7 @@ 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.junit.Assert.*
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -40,6 +45,7 @@ import org.justinvisser.wolfskin.design.TitleState
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.RuntimeEnvironment
import java.util.UUID
/** SDK replies stay in-process; disk reload and identity isolation exercise the real facade. */
@ -59,9 +65,15 @@ class TitleReadFacadeTest {
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000002")
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003")
private val seasonId = UUID.fromString("00000000-0000-0000-0000-000000000004")
private val sourceId = UUID.fromString("00000000-0000-0000-0000-000000000005").toString()
private val sourceId = UUID.fromString("00000000-0000-0000-0000-000000000005").toString().replace("-", "")
@Before fun setup() {
// BaseItem formats runtime using application resources; no Hilt/network application is started.
val app = mockk<WholphinApplication> {
every { resources } returns RuntimeEnvironment.getApplication().resources
}
mockkObject(WholphinApplication.Companion)
every { WholphinApplication.instance } returns app
every { api.userLibraryApi } returns library
every { api.itemsApi } returns items
every { api.tvShowsApi } returns shows
@ -72,6 +84,10 @@ class TitleReadFacadeTest {
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
}
@After fun teardown() {
unmockkObject(WholphinApplication.Companion)
}
private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE): BaseItemDto {
val source = TestTracks.Builder().addVideo().addAudio().addSubtitle().buildMediaSourceInfo()
val streams = source.mediaStreams.orEmpty().map { stream ->
@ -142,4 +158,12 @@ class TitleReadFacadeTest {
assertEquals(listOf(2), data.seasonNumbers)
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
}
@Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
coEvery { playback.savePlayVersion(any(), any()) } returns null
val data = facade.read(firstId, null, preferences)
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Version(data.watchTarget, data.versions.single()))
coVerify(exactly = 1) { playback.savePlayVersion(firstId, UUID.fromString("00000000-0000-0000-0000-000000000005")) }
}
}

View file

@ -235,8 +235,10 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen
LaunchedEffect(title?.data?.status) {
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
}
BackHandler { title?.back() }
val person = viewModel.person
BackHandler(enabled = title != null) {
title?.back(shell)
}
if (person != null && route.startsWith("person/")) PersonSurface(person, shell, modifier)
else if (title != null) TitleSurface(title, shell, modifier)
}

View file

@ -14,7 +14,7 @@ import org.justinvisser.wolfskin.design.TitleConnection
import org.justinvisser.wolfskin.design.TitleItem
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitlePerson
import org.justinvisser.wolfskin.design.TitleRating
import org.justinvisser.wolfskin.design.LibraryRating
import org.justinvisser.wolfskin.design.TitleTrack
import org.justinvisser.wolfskin.design.TitleVersion
import java.util.Locale
@ -80,23 +80,23 @@ class TitleFixture(private val fixture: JSONObject) {
private fun art(path: String?): String? = path?.takeIf { it in availableArt }?.let { "file:///android_asset/wolfskin-titles/$it" }
private fun ratings(item: JSONObject): List<TitleRating> {
private fun ratings(item: JSONObject): List<LibraryRating> {
val episode = item.optString("type") == "episode"
if (episode) {
val series = all.firstOrNull { it.optString("title") == item.optString("seriesTitle") }
val base = series?.let { ratings(it) }.orEmpty()
val score = item.optDouble("communityRating", Double.NaN)
return if (score in 0.0..10.0) base.filter { it.source != "tmdb" } + TitleRating("tmdb", score, "tmdb", true) else base
return if (score in 0.0..10.0) base.filter { it.source != "tmdb" } + LibraryRating("tmdb", score, episode = true) else base
}
val ratings = item.optJSONObject("ratings") ?: JSONObject().apply {
if (!item.isNull("criticRating")) put("rtCritics", item.getDouble("criticRating"))
if (!item.isNull("communityRating")) put("tmdb", item.getDouble("communityRating"))
}
return buildList {
if (!ratings.isNull("rtCritics")) add(TitleRating("rtCritics", ratings.getDouble("rtCritics"), when (ratings.optString("rtCriticsLabel")) { "Rotten" -> "rotten"; "Fresh", "Certified Fresh" -> "fresh"; else -> "tomato" }))
if (!ratings.isNull("rtAudience")) add(TitleRating("rtAudience", ratings.getDouble("rtAudience"), if (ratings.optString("rtAudienceLabel") == "Spilled") "spilled" else "upright"))
if (item.optString("type") == "movie" && (!ratings.has("imdbVotes") || ratings.optInt("imdbVotes") > 0) && !ratings.isNull("imdb")) add(TitleRating("imdb", ratings.getDouble("imdb"), "imdb"))
if (isEmpty() && (!ratings.has("tmdbVotes") || ratings.optInt("tmdbVotes") > 0) && !ratings.isNull("tmdb")) add(TitleRating("tmdb", ratings.getDouble("tmdb"), "tmdb"))
if (!ratings.isNull("rtCritics")) add(LibraryRating("rtCritics", ratings.getDouble("rtCritics"), ratings.optString("rtCriticsLabel").takeIf { it.isNotEmpty() }))
if (!ratings.isNull("rtAudience")) add(LibraryRating("rtAudience", ratings.getDouble("rtAudience"), ratings.optString("rtAudienceLabel").takeIf { it.isNotEmpty() }))
if (item.optString("type") == "movie" && (!ratings.has("imdbVotes") || ratings.optInt("imdbVotes") > 0) && !ratings.isNull("imdb")) add(LibraryRating("imdb", ratings.getDouble("imdb")))
if (isEmpty() && (!ratings.has("tmdbVotes") || ratings.optInt("tmdbVotes") > 0) && !ratings.isNull("tmdb")) add(LibraryRating("tmdb", ratings.getDouble("tmdb")))
}.filter { it.score in 0.0..if (it.source.startsWith("rt")) 100.0 else 10.0 }
}

View file

@ -1,31 +0,0 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import org.justinvisser.wolfskin.design.WolfskinTokens
import javax.inject.Inject
import javax.inject.Singleton
/** All owned screens share byte-bounded caches and four image reads rather than a heap percentage. */
@Singleton
class WolfskinImageLoader @Inject constructor(@param:ApplicationContext context: Context) {
private val client = OkHttpClient.Builder().dispatcher(Dispatcher().apply {
maxRequests = WolfskinTokens.Image.concurrentReadsImage
maxRequestsPerHost = maxRequests
}).build()
val loader: ImageLoader = ImageLoader.Builder(context)
.memoryCache(MemoryCache.Builder().maxSizeBytes(WolfskinTokens.Image.memoryCacheMB.toLong() * 1024 * 1024).build())
.diskCache(DiskCache.Builder().directory(context.cacheDir.resolve("wolfskin-images"))
.maxSizeBytes(WolfskinTokens.Image.diskCacheMB.toLong() * 1024 * 1024).build())
.crossfade(false)
.components { add(OkHttpNetworkFetcherFactory(callFactory = { client })) }
.build()
}

View file

@ -25,6 +25,7 @@ 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.request.GetNextUpRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.json.JSONObject
import org.justinvisser.wolfskin.design.TitleAction
import org.justinvisser.wolfskin.design.TitleConnection
@ -97,7 +98,10 @@ class TitleReadFacade @Inject constructor(
/** Track selection uses the same repository consumed by the upstream player. */
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
when (action) {
is TitleAction.Version -> playback.savePlayVersion(UUID.fromString(action.item.id), UUID.fromString(action.version.id))
is TitleAction.Version -> playback.savePlayVersion(
UUID.fromString(action.item.id),
requireNotNull(action.version.id.toUUIDOrNull()) { "Invalid Jellyfin media source identifier" },
)
is TitleAction.Track -> {
val id = UUID.fromString(action.item.id)
val item = BaseItem(api.userLibraryApi.getItem(id).content)