Reuse shared artwork and correct title focus edges
This commit is contained in:
parent
3bfb8cd779
commit
a8951827ba
41 changed files with 581 additions and 438 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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")) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue