Merge pull request 'Align TV fidelity and recovery behavior' (#37) from fm/tv-fidelity-followups into main
This commit is contained in:
commit
e9a92eba49
22 changed files with 524 additions and 53 deletions
|
|
@ -67,4 +67,39 @@ class ConductarrAudienceRecoveryTest {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun retryingSignedInPeopleKeepsTheUncertainSubmissionLedger() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
ConductarrPreference.initialize(context)
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
val previousUrl = ConductarrPreference.baseUrl
|
||||
FakeConductarr().use { fake ->
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||
session.authenticate()
|
||||
val libraries = LibraryReadFacade(mockk())
|
||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
||||
try {
|
||||
val identity = session.identity.value!!
|
||||
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
||||
org.justinvisser.wolfskin.design.DiscoverTitle(42, "movie", "Moon"), "mindel", "shared")
|
||||
fake.postStatus = 503
|
||||
assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed,
|
||||
identity.provider.operations(identity.operator!!).request(intent).phase)
|
||||
model.retryConductarr(server, user)
|
||||
val refreshed = session.identity.value!!
|
||||
assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed,
|
||||
refreshed.provider.operations(refreshed.operator!!).request(intent).phase)
|
||||
assertEquals(1, fake.calls.count { it.method == "POST" })
|
||||
} finally {
|
||||
model.viewModelScope.cancel()
|
||||
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class ConductarrHomeSurfaceTest {
|
|||
try {
|
||||
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false }
|
||||
rule.setContent { WolfskinTheme {
|
||||
LaunchedEffect(model) { model.loadLibrary(server, user, "shared") }
|
||||
LaunchedEffect(model) { model.loadLibrary(server, user, shell.person) }
|
||||
val snapshot = model.snapshot
|
||||
CompositionLocalProvider(LocalScreenRatings provides androidx.compose.runtime.remember(session.identity.value?.client) { ConductarrRatings(session.identity.value!!.client) }) {
|
||||
ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } }
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ class HomeRecoveryTest {
|
|||
private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||
private val personA = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
||||
private val personB = personA.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000003"), name = "Mindel")
|
||||
private val viewerA = ShellPerson("justin", "Justin", "J", false, personA.id.toString())
|
||||
private val viewerB = ShellPerson("mindel", "Mindel", "M", false, personB.id.toString())
|
||||
private val film = HomeTitle("private-film", "Person A film", "movie", held = true, runtimeSeconds = 5400)
|
||||
private val library = HomeLibrary(resume = listOf(film))
|
||||
|
||||
|
|
@ -43,7 +45,7 @@ class HomeRecoveryTest {
|
|||
val facade = mockk<HomeReadFacade>()
|
||||
coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin", retry = true) }
|
||||
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
|
||||
started.await()
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
response.completeExceptionally(IOException("fixture offline"))
|
||||
|
|
@ -63,10 +65,10 @@ class HomeRecoveryTest {
|
|||
coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||
coEvery { facade.read(personB.id) } throws IOException("fixture offline")
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin") }
|
||||
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
|
||||
startedA.await()
|
||||
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||
model.loadLibrary(server, personB, "mindel")
|
||||
model.loadLibrary(server, personB, viewerB)
|
||||
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||
assertNull(model.snapshot.savedAt)
|
||||
assertTrue(model.snapshot.rows.any { it.kind == HomeRowKind.State && it.focusable })
|
||||
|
|
@ -83,9 +85,23 @@ class HomeRecoveryTest {
|
|||
every { cache.read(any()) } returns null
|
||||
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
model.loadLibrary(server, personA, "justin")
|
||||
model.loadLibrary(server, personA, viewerA)
|
||||
assertTrue(model.snapshot.jellyfinAvailable)
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
assertNull(model.toast)
|
||||
}
|
||||
|
||||
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
|
||||
val facade = mockk<HomeReadFacade>()
|
||||
coEvery { facade.read(personA.id) } returns library
|
||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name"))
|
||||
assertTrue(model.snapshot.jellyfinAvailable)
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
model.loadLibrary(server, personA, viewerB.copy(key = "justin"))
|
||||
assertFalse(model.snapshot.jellyfinAvailable)
|
||||
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** The real host exposes usable remote focus before the remaining library chunks arrive. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class LibraryProgressiveSurfaceTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun firstChunkPaintsAndAcceptsRemoteInputBeforeTheLastChunk() {
|
||||
ConductarrPreference.initialize(rule.activity)
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin").apply {
|
||||
pickerVisible = false
|
||||
enterDestination("movies")
|
||||
focusNav(currentTab)
|
||||
}
|
||||
val user = JellyfinUser(id = UUID(0, 1), name = "Justin", serverId = UUID(0, 2), accessToken = null)
|
||||
val first = List(200) { LibraryTitle(UUID(0, it + 10L).toString(), "Film %03d".format(it), "movie") }
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val facade = mockk<LibraryReadFacade>()
|
||||
every { facade.audiences } returns null
|
||||
every { facade.readLibrary(user.id, "movies") } returns flow {
|
||||
emit(first)
|
||||
release.await()
|
||||
emit(first + LibraryTitle(UUID(0, 999).toString(), "Added film", "movie"))
|
||||
}
|
||||
val scope = "progressive:${UUID.randomUUID()}"
|
||||
val navigation = NavigationManager()
|
||||
val seeAll = WolfskinSeeAllLists()
|
||||
try {
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier ->
|
||||
WolfskinLibraryContent(rule.activity, "movies", shell, user, scope, facade, navigation, seeAll, modifier)
|
||||
} } }
|
||||
rule.waitUntil(timeoutMillis = 10_000) {
|
||||
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
shell.view.library?.source?.size == 200
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(500)
|
||||
assertFalse(shell.navFocused)
|
||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
|
||||
assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id)
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "library-first-chunk.png").absolutePath)
|
||||
release.complete(Unit)
|
||||
rule.waitUntil(timeoutMillis = 10_000) {
|
||||
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
shell.view.library?.source?.size == 201
|
||||
}
|
||||
rule.runOnIdle {
|
||||
assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id)
|
||||
assertFalse(shell.navFocused)
|
||||
}
|
||||
} finally { release.complete(Unit) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import com.github.damontecres.wholphin.ui.successQueryResult
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.api.operations.ItemsApi
|
||||
import org.jellyfin.sdk.api.operations.UserViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.justinvisser.wolfskin.tv.read.LibrarySnapshot
|
||||
import org.justinvisser.wolfskin.tv.read.LibrarySnapshotStore
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Cold libraries publish usable chunks before later SDK reads finish; only complete copies reach disk. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class LibraryReadFacadeTest {
|
||||
@Test fun firstTwoHundredTitlesPublishWhileTheNextChunkIsSuspended() = runBlocking {
|
||||
val api = mockk<ApiClient>()
|
||||
val views = mockk<UserViewsApi>()
|
||||
val items = mockk<ItemsApi>()
|
||||
every { api.userViewsApi } returns views
|
||||
every { api.itemsApi } returns items
|
||||
val user = UUID(0, 1)
|
||||
val library = BaseItemDto(id = UUID(1, 0), name = "Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES)
|
||||
coEvery { views.getUserViews(userId = user) } returns successQueryResult(listOf(library))
|
||||
val firstChunk = List(200) { index -> BaseItemDto(id = UUID(0, index + 10L), name = "Film $index", type = BaseItemKind.MOVIE) }
|
||||
val nextRead = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
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),
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers {
|
||||
if (offsets.last() == 0) successQueryResult(firstChunk, 202) else {
|
||||
assertEquals(200, offsets.last())
|
||||
nextRead.complete(Unit)
|
||||
release.await()
|
||||
successQueryResult(listOf(firstChunk.first().copy(name = "Updated film"), BaseItemDto(id = UUID(0, 999), name = "Last film", type = BaseItemKind.MOVIE)), 202)
|
||||
}
|
||||
}
|
||||
val facade = LibraryReadFacade(api)
|
||||
val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L }
|
||||
val published = CompletableDeferred<LibrarySnapshot>()
|
||||
val snapshots = mutableListOf<LibrarySnapshot>()
|
||||
val job = launch {
|
||||
store.read("server:justin", "movies") {
|
||||
facade.readLibrary(user, "movies").catch { throw AssertionError("SDK fixture read failed", it) }
|
||||
}.collect {
|
||||
snapshots += it
|
||||
if (!published.isCompleted) published.complete(it)
|
||||
}
|
||||
}
|
||||
val initial = published.await()
|
||||
assertEquals(firstChunk.map { it.id.toString() }, initial.items.map { it.id })
|
||||
nextRead.await()
|
||||
assertTrue(job.isActive)
|
||||
release.complete(Unit)
|
||||
job.join()
|
||||
val final = snapshots.last()
|
||||
assertEquals(201, final.items.size)
|
||||
assertEquals("Updated film", final.items.first().title)
|
||||
assertEquals("Last film", final.items.last().title)
|
||||
assertFalse(final.unreachable)
|
||||
val saved = store.read("server:justin", "movies") { error("Complete fresh copy must not read") }.toList().single()
|
||||
assertEquals(final.items, saved.items)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv
|
|||
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
|
@ -20,7 +21,7 @@ class LibrarySnapshotStoreTest {
|
|||
var now = 1000L
|
||||
val store = LibrarySnapshotStore(MemoryCacheStore()) { now }
|
||||
val items = listOf(LibraryTitle("a", "Film A", "movie", year = 2007, runtimeSeconds = 6000, played = true))
|
||||
store.read("server:justin", "movies") { items }.toList()
|
||||
store.read("server:justin", "movies") { flowOf(items) }.toList()
|
||||
val fresh = store.read("server:justin", "movies") { error("Fresh snapshot must not read") }.toList()
|
||||
assertEquals(items, fresh.single().items)
|
||||
now += 11 * 60_000
|
||||
|
|
@ -39,11 +40,31 @@ class LibrarySnapshotStoreTest {
|
|||
|
||||
@Test fun explicitRecoveryRefreshesEvenFreshEmptyDestination() = runBlocking {
|
||||
val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L }
|
||||
store.read("server:justin", "movies") { emptyList() }.toList()
|
||||
store.read("server:justin", "movies") { flowOf(emptyList()) }.toList()
|
||||
val current = listOf(LibraryTitle("b", "New Film", "movie"))
|
||||
val reads = store.read("server:justin", "movies", force = true) { current }.toList()
|
||||
val reads = store.read("server:justin", "movies", force = true) { flowOf(current) }.toList()
|
||||
assertTrue(reads.first().items.isEmpty())
|
||||
assertEquals(current, reads.last().items)
|
||||
assertFalse(reads.last().unreachable)
|
||||
}
|
||||
|
||||
@Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking {
|
||||
val partial = listOf(LibraryTitle("partial", "First film", "movie"))
|
||||
val complete = partial + LibraryTitle("last", "Last film", "movie")
|
||||
for (hasSaved in listOf(false, true)) {
|
||||
val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L }
|
||||
if (hasSaved) store.read("server:justin", "movies") { flowOf(complete) }.toList()
|
||||
val reads = store.read("server:justin", "movies", force = true) {
|
||||
kotlinx.coroutines.flow.flow {
|
||||
emit(partial)
|
||||
throw java.io.IOException("Later chunk failed")
|
||||
}
|
||||
}.toList()
|
||||
assertEquals(if (hasSaved) complete else partial, reads.last().items)
|
||||
assertTrue(reads.last().unreachable)
|
||||
assertEquals(if (hasSaved) 1000L else null, reads.last().savedAtMillis)
|
||||
val reopened = store.read("server:justin", "movies") { throw java.io.IOException("Offline") }.toList()
|
||||
assertEquals(if (hasSaved) complete else emptyList<LibraryTitle>(), reopened.last().items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,9 +48,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
private var catalogueSnapshot = DiscoverSnapshot()
|
||||
private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null
|
||||
|
||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
|
||||
catalogueJob?.cancel()
|
||||
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return }
|
||||
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
|
||||
if (retry && conductarr.identity.value?.signedIn != true) {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||
}
|
||||
|
|
@ -58,9 +58,11 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
/** The authenticated read path is shared by release and offline fixture-driven tests. */
|
||||
internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
||||
internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
|
||||
val revision = ++loadRevision
|
||||
if (person != user.name.orEmpty().lowercase() && !(conductarr.identity.value?.person == "${server.id}:${user.id}" && person == conductarr.identity.value?.operator?.requester)) {
|
||||
val matchesUser = person.jellyfinUserId?.let { it == user.id.toString() } ?:
|
||||
(person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && person.key == conductarr.identity.value?.operator?.requester))
|
||||
if (!matchesUser) {
|
||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), listOf(homeDiscoverState(HomeAvailability.SignedOut)), libraryAvailable = false), jellyfinAvailable = false)
|
||||
return
|
||||
}
|
||||
|
|
@ -130,7 +132,7 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
|
||||
when (action) {
|
||||
HomeAction.Discover -> openRoot(shell, "discover")
|
||||
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person.key, retry = true) }
|
||||
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) }
|
||||
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell)
|
||||
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
|
||||
is HomeAction.Play -> viewModelScope.launch {
|
||||
|
|
@ -200,7 +202,7 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
|
|||
@Composable
|
||||
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
||||
val identity by model.conductarrState.collectAsState()
|
||||
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person.key) }
|
||||
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person) }
|
||||
val view = shell.rememberedView("home")
|
||||
LaunchedEffect(view.row, model.snapshot.rows) {
|
||||
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: She
|
|||
is TitleAction.Requests -> shell.openRequests()
|
||||
is TitleAction.Hide -> if (current != null && requests != null) {
|
||||
val item = current.data.requestTitle(shell.person)
|
||||
if (action.hidden) requests.hide(item) else requests.restore(item)
|
||||
if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false)
|
||||
requests.overlay = null // TitleState already owns the native hide receipt and Undo.
|
||||
}
|
||||
else -> error("Not a Conductarr title action")
|
||||
|
|
|
|||
|
|
@ -142,7 +142,8 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
|
||||
retry = conductarr.identity.value?.signedIn != true)
|
||||
loadHousehold(user)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,19 +75,24 @@ fun WolfskinLibraryContent(
|
|||
LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) {
|
||||
if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) {
|
||||
val wasLoading = grid.status == LibraryStatus.Loading
|
||||
if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab)
|
||||
val entryRevision = shell.inputRevision
|
||||
if (fixture != null) {
|
||||
grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
|
||||
grid.status = LibraryStatus.Ready
|
||||
} else {
|
||||
snapshots.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
||||
val focusedId = grid.focusedTitle?.id
|
||||
grid.source = snapshot.items
|
||||
grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready
|
||||
focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } }
|
||||
grid.status = if (snapshot.unreachable && snapshot.items.isEmpty() && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready
|
||||
grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null
|
||||
grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None
|
||||
if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable
|
||||
if (wasLoading && shell.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
||||
}
|
||||
}
|
||||
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
||||
if (wasLoading && shell.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
||||
}
|
||||
}
|
||||
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null || LocalScreenRatings.current != null, view = view)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package org.justinvisser.wolfskin.tv.read
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -47,7 +49,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
|||
}
|
||||
|
||||
/** A destination spans every matching accessible library; containers never become titles. */
|
||||
suspend fun readLibrary(userId: UUID, destination: String): List<LibraryTitle> {
|
||||
fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow {
|
||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->
|
||||
destination(library.name.orEmpty(), library.collectionType) == destination
|
||||
}
|
||||
|
|
@ -76,8 +78,9 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
|||
)
|
||||
}
|
||||
offset += chunk.size
|
||||
if (chunk.isNotEmpty()) emit(titles.values.toList())
|
||||
} while (chunk.isNotEmpty() && offset < response.totalRecordCount)
|
||||
}
|
||||
return titles.values.toList()
|
||||
if (titles.isEmpty()) emit(emptyList())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,21 +19,25 @@ data class LibrarySnapshot(val items: List<LibraryTitle>, val savedAtMillis: Lon
|
|||
|
||||
/** Library snapshots share the existing atomic disk store, partitioned by server and person. */
|
||||
class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) {
|
||||
fun read(scope: String, destination: String, force: Boolean = false, load: suspend () -> List<LibraryTitle>): Flow<LibrarySnapshot> = flow {
|
||||
fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow {
|
||||
val key = "library:$destination"
|
||||
val saved = try { cache.read(scope, key) } catch (_: IOException) { null }
|
||||
val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } }
|
||||
if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false))
|
||||
val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong()
|
||||
if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow
|
||||
var current: List<LibraryTitle> = emptyList()
|
||||
try {
|
||||
val current = load()
|
||||
load().collect { chunk ->
|
||||
current = chunk
|
||||
if (titles == null) emit(LibrarySnapshot(chunk, null, false))
|
||||
}
|
||||
val time = now()
|
||||
if (titles != null) emit(LibrarySnapshot(current, time, false))
|
||||
try { cache.write(scope, key, CacheEntry(encode(current), null, time, time, group = "library")) } catch (_: IOException) { /* A readable server remains usable without writable local storage. */ }
|
||||
emit(LibrarySnapshot(current, time, false))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
emit(LibrarySnapshot(titles.orEmpty(), saved?.savedAtMillis.takeIf { titles != null }, true))
|
||||
emit(LibrarySnapshot(titles ?: current, saved?.savedAtMillis.takeIf { titles != null }, true))
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
|
|
|
|||
|
|
@ -533,6 +533,48 @@ person's title/person projection. Title and performer disk copies remain isolate
|
|||
by both server and authenticated user; native retry and cache-isolation regressions
|
||||
exercise these boundaries without a live service.
|
||||
|
||||
## TV fidelity and source gates
|
||||
|
||||
Library/Discover page headings use the existing exact CSS line-box layout.
|
||||
Request sheets reserve the inherited inline text descent around their ratings;
|
||||
person rows and receipt panels therefore share the locked frame's vertical
|
||||
coordinates. Settings row title/facts also retain their CSS line boxes. These
|
||||
are local component fixes: shared typography tokens, Home and caption styling
|
||||
do not shift.
|
||||
|
||||
Home ratings observations use independent six-hour, 300-entry title and
|
||||
series/season caches. A season retains each observed episode's own score and
|
||||
timestamp; changing a Jellyfin episode UUID does not lose the observation, and
|
||||
another episode or season never borrows that score. Episodes without a complete
|
||||
catalogue identity remain isolated by their Jellyfin identity.
|
||||
|
||||
Cold library grids publish each cumulative 200-item SDK chunk before the next
|
||||
read completes. An existing complete saved copy stays visible throughout a
|
||||
refresh, and only a completed read replaces it on disk. A cold later-chunk
|
||||
failure keeps the already-readable titles with a retry tail, without saving an
|
||||
incomplete copy. Appended titles preserve the focused title even when the
|
||||
destination's sort inserts them before it. Catalogue row keys use the same
|
||||
media-type/TMDB identity as their cards.
|
||||
|
||||
Undo restores a dismissed pick silently; Show again announces “Back in your
|
||||
picks.” Title pages own their own toast so the request adapter never duplicates
|
||||
it. The saved Discover retry tail is labelled More picks, not Try again twice.
|
||||
|
||||
People retry retains an already signed-in Conductarr client and its uncertain
|
||||
submission ledger; only a signed-out identity is rebuilt. Home authorizes its
|
||||
Jellyfin read by the selected person's saved Jellyfin user ID, so a lost
|
||||
Conductarr operator or a renamed household profile does not hide that user's
|
||||
own library. A different saved user ID still fails closed.
|
||||
|
||||
`make test` runs `OwnedSourceAuditTest` through the design module's JVM suite.
|
||||
It scans the owned Kotlin sources in `wolfskin-design/src/main/java` and
|
||||
`app/src/wolfskin`, rejecting untokened `.dp`/`.sp` conversions and numeric Color
|
||||
constructors outside generated tokens, token font sizes below 12 sp, raw
|
||||
household library labels, and page-counter/navigation wording. Its negative
|
||||
cases plant each violation independently. Existing native capture checks also
|
||||
verify rendered font sizes and wording. `LibraryProgressiveSurfaceTest` captures
|
||||
the usable first chunk while the final read is still suspended.
|
||||
|
||||
## Toolchain and disk use
|
||||
|
||||
`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ import androidx.compose.ui.graphics.RectangleShape
|
|||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.FirstBaseline
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
||||
|
|
@ -49,7 +50,7 @@ fun DiscoverOverlay(state: DiscoverState) {
|
|||
is DiscoverOverlayState.Hidden -> {
|
||||
ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.")
|
||||
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) }
|
||||
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) }
|
||||
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) }
|
||||
}
|
||||
}
|
||||
|
|
@ -59,7 +60,7 @@ fun DiscoverOverlay(state: DiscoverState) {
|
|||
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body),
|
||||
color = C.muted, style = DiscoverType.body)
|
||||
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) }
|
||||
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) }
|
||||
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null }
|
||||
}
|
||||
}
|
||||
|
|
@ -72,15 +73,10 @@ fun DiscoverOverlay(state: DiscoverState) {
|
|||
|
||||
@Composable
|
||||
private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
|
||||
Text(if (sheet.held) "Add for someone" else "Request", color = C.fg, style = DiscoverType.sheetTitle)
|
||||
Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
|
||||
Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}",
|
||||
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop), color = C.muted, style = DiscoverType.body)
|
||||
if (state.isSignedIn) DiscoverRatings(sheet.title.ratings, Modifier.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height + (D.sheetRatingTop + D.sheetRatingBottom).roundToPx()) {
|
||||
placeable.placeRelative(0, D.sheetRatingTop.roundToPx())
|
||||
}
|
||||
})
|
||||
Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body)
|
||||
if (state.isSignedIn) SheetRatings(sheet.title.ratings)
|
||||
when (sheet.phase) {
|
||||
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
|
||||
RequestPhase.LoadingRecipients -> {
|
||||
|
|
@ -100,6 +96,28 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
|
|||
}
|
||||
}
|
||||
|
||||
/** The inline ratings baseline includes the first mark; the inherited text strut keeps its descent. */
|
||||
@Composable
|
||||
private fun SheetRatings(ratings: List<DiscoverRating>) {
|
||||
if (ratings.isEmpty()) return
|
||||
Layout(content = {
|
||||
DiscoverRatings(ratings)
|
||||
Text(" ", color = C.muted, style = DiscoverType.body)
|
||||
}) { children, constraints ->
|
||||
val row = children[0].measure(constraints.copy(minHeight = 0))
|
||||
val strut = children[1].measure(constraints.copy(minWidth = 0, minHeight = 0))
|
||||
val mark = DiscoverType.ratings.fontSize.toPx()
|
||||
val baseline = (row.height + mark) / 2f
|
||||
val descent = maxOf(row.height - baseline, (strut.height - strut[FirstBaseline]).toFloat())
|
||||
val height = maxOf(baseline, strut[FirstBaseline].toFloat()) + descent
|
||||
val top = D.sheetRatingTop.roundToPx()
|
||||
val bottom = D.sheetRatingBottom.roundToPx()
|
||||
layout(row.width, kotlin.math.round(height).toInt() + top + bottom) {
|
||||
row.placeRelative(0, top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
|
||||
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@ class DiscoverState(
|
|||
ShellKey.Ok -> when (titleButton) {
|
||||
0 -> if (requests[requireNotNull(title).key]?.any { it.requester == shell.person.key } == true) shell.requestsVisible = true else openRequest(false)
|
||||
1 -> openRequest(true)
|
||||
2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) }
|
||||
2 -> title?.let { if (it.key in hidden) restore(it, showToast = true) else hide(it) }
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
|
@ -328,13 +328,13 @@ class DiscoverState(
|
|||
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
|
||||
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
|
||||
ShellKey.Back -> overlay = null
|
||||
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else { overlay = null; title = null; onPop() }
|
||||
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = false) else { overlay = null; title = null; onPop() }
|
||||
}
|
||||
is DiscoverOverlayState.Restore -> when (key) {
|
||||
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
|
||||
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
|
||||
ShellKey.Back -> overlay = null
|
||||
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else overlay = null
|
||||
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = true) else overlay = null
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
|
|
@ -380,9 +380,9 @@ class DiscoverState(
|
|||
}
|
||||
}
|
||||
|
||||
fun restore(item: DiscoverTitle) {
|
||||
fun restore(item: DiscoverTitle, showToast: Boolean) {
|
||||
if (!canRequest) { refresh(); return }
|
||||
hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks."
|
||||
hidden.remove(item.key); revision++; overlay = null; toast = if (showToast) "Back in your picks." else null
|
||||
updateCollectionCards()
|
||||
val profile = shell.person.key
|
||||
val version = (dismissalVersions[item.key] ?: 0) + 1
|
||||
|
|
@ -412,7 +412,7 @@ class DiscoverState(
|
|||
val lanes = service?.let {
|
||||
serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() }
|
||||
} ?: projectDiscoverLanes(snapshot, heroes, filter, hidden)
|
||||
return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "Try again", emptyList(), tail = DiscoverTail.Retry) else lanes
|
||||
return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "More picks", emptyList(), tail = DiscoverTail.Retry) else lanes
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,23 +4,45 @@ package org.justinvisser.wolfskin.design
|
|||
class HomeRatingCache(private val clock: () -> Long = System::currentTimeMillis) {
|
||||
private data class Entry(val ratings: List<HomeRating>, val readAt: Long)
|
||||
private val entries = linkedMapOf<String, Entry>()
|
||||
private val seasons = linkedMapOf<String, MutableMap<Int, Entry>>()
|
||||
|
||||
fun read(title: HomeTitle): List<HomeRating>? {
|
||||
val key = key(title)
|
||||
val entry = entries[key] ?: return null
|
||||
if (clock() - entry.readAt >= lifetimeMillis) { entries.remove(key); return null }
|
||||
val season = seasonKey(title)
|
||||
val bucket = season?.let { seasons[it] }
|
||||
val entry = if (season != null) bucket?.get(title.episode) else entries[key(title)]
|
||||
if (entry == null) return null
|
||||
if (clock() - entry.readAt >= lifetimeMillis) {
|
||||
if (season == null) entries.remove(key(title)) else {
|
||||
bucket?.remove(title.episode)
|
||||
if (bucket.isNullOrEmpty()) seasons.remove(season)
|
||||
}
|
||||
return null
|
||||
}
|
||||
return entry.ratings
|
||||
}
|
||||
|
||||
fun save(title: HomeTitle, ratings: List<HomeRating>) {
|
||||
val key = key(title)
|
||||
entries.remove(key)
|
||||
entries[key] = Entry(ratings, clock())
|
||||
while (entries.size > capacity) entries.remove(entries.keys.first())
|
||||
val entry = Entry(ratings, clock())
|
||||
val season = seasonKey(title)
|
||||
if (season != null) {
|
||||
val bucket = seasons.remove(season) ?: mutableMapOf()
|
||||
bucket[requireNotNull(title.episode)] = entry
|
||||
seasons[season] = bucket
|
||||
while (seasons.size > capacity) seasons.remove(seasons.keys.first())
|
||||
} else {
|
||||
val key = key(title)
|
||||
entries.remove(key)
|
||||
entries[key] = entry
|
||||
while (entries.size > capacity) entries.remove(entries.keys.first())
|
||||
}
|
||||
}
|
||||
|
||||
private fun key(title: HomeTitle): String = if (title.isEpisode) "episode:${title.id}" else "${title.type}:${title.tmdbId ?: title.id}"
|
||||
|
||||
private fun seasonKey(title: HomeTitle): String? =
|
||||
if (title.isEpisode && title.tmdbId != null && title.season != null && title.episode != null)
|
||||
"${title.tmdbId}:${title.season}" else null
|
||||
|
||||
companion object {
|
||||
private const val capacity = 300
|
||||
private val lifetimeMillis = (WolfskinTokens.Behaviour.cacheRatingsHours * 60 * 60 * 1000).toLong()
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ fun LibraryScreen(
|
|||
}
|
||||
LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt }
|
||||
LaunchedEffect(grid.status) {
|
||||
if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab)
|
||||
if (grid.status == LibraryStatus.Loading && !shell.navFocused) shell.focusNav(shell.currentTab)
|
||||
}
|
||||
val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1
|
||||
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
|
||||
|
|
@ -128,7 +128,10 @@ fun LibraryScreen(
|
|||
LibraryStatus.Unavailable -> item(key = "unavailable", contentType = "state") { LibraryRecovery(grid, active) }
|
||||
LibraryStatus.Ready -> if (items.isEmpty()) item(key = "empty", contentType = "state") { LibraryRecovery(grid, active && !grid.toolbar) }
|
||||
else {
|
||||
items((items.size + grid.columns - 1) / grid.columns, key = { row -> "grid-${items[row * grid.columns].id}" }, contentType = { "grid" }) { row ->
|
||||
items((items.size + grid.columns - 1) / grid.columns, key = { row ->
|
||||
val title = items[row * grid.columns]
|
||||
"grid-${if (grid.catalogue) title.catalogueKey else title.id}"
|
||||
}, contentType = { "grid" }) { row ->
|
||||
Box(Modifier.fillMaxWidth().height(rowHeight)) {
|
||||
Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
|
||||
for (column in 0 until grid.columns) {
|
||||
|
|
@ -174,7 +177,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
|
|||
Box(Modifier.fillMaxWidth().height(L.row_lhead)) {
|
||||
Row(Modifier.offset(L.safeSide, L.navBand), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsHeaderGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
|
||||
Text(title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
Text(title, modifier = Modifier.alignByBaseline().exactLineBox(WolfskinTokens.Type.screenTitle), color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle)
|
||||
savedAt?.let { SavedPill(it) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,8 +98,8 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
|
|||
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
||||
Column(Modifier.weight(1f)) {
|
||||
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
|
||||
Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
|
||||
Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
|
||||
Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (group.wolfskin) SettingsPill("Wolfskin", focused)
|
||||
SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused)
|
||||
|
|
|
|||
|
|
@ -91,6 +91,22 @@ class DiscoverKeysTest {
|
|||
assertNull(state.overlay)
|
||||
assertEquals("The Amazing Spider-Man", state.hero?.title)
|
||||
assertEquals(original, state.lanes.map { lane -> lane.id to lane.titles.map { it.key } })
|
||||
assertNull("Undo restores silently; only Show again announces the return", state.toast)
|
||||
}
|
||||
|
||||
@Test fun showAgainRestoresTheHiddenPickAndAnnouncesItsReturn() {
|
||||
replay("Round 3 · Not interested on a pick (Discover hero)")
|
||||
val title = (state.overlay as DiscoverOverlayState.Hidden).title
|
||||
press(Key.DirectionRight, Key.DirectionCenter)
|
||||
rule.runOnIdle {
|
||||
val lane = state.lanes.first { it.hidden }
|
||||
state.focus(state.lanes.indexOf(lane) + 2, lane.titles.indexOfFirst { it.key == title.key })
|
||||
}
|
||||
press(Key.DirectionCenter)
|
||||
assertTrue(state.overlay is DiscoverOverlayState.Restore)
|
||||
press(Key.DirectionCenter)
|
||||
assertFalse(state.isHidden(title))
|
||||
assertEquals("Back in your picks.", state.toast)
|
||||
}
|
||||
|
||||
@Test fun heldOkOpensSheetButNeverSubmits() {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,34 @@ class HomeRatingCacheTest {
|
|||
assertEquals(ratings, cache.read(title.copy(id = "last", tmdbId = 399)))
|
||||
}
|
||||
|
||||
@Test fun episodeScoresShareSeriesSeasonIdentityWithoutCrossingEpisodesOrSeasons() {
|
||||
var now = 0L
|
||||
val cache = HomeRatingCache { now }
|
||||
val episode = HomeTitle("episode-a", "Pilot", "episode", tmdbId = 42, season = 1, episode = 1)
|
||||
val pilot = listOf(HomeRating("tmdb", 8.1, episode = true))
|
||||
val second = listOf(HomeRating("tmdb", 7.2, episode = true))
|
||||
cache.save(episode, pilot)
|
||||
cache.save(episode.copy(id = "episode-b", episode = 2), second)
|
||||
assertEquals(pilot, cache.read(episode.copy(id = "another-jellyfin-copy")))
|
||||
assertEquals(second, cache.read(episode.copy(id = "another-second-copy", episode = 2)))
|
||||
assertNull(cache.read(episode.copy(season = 2)))
|
||||
assertNull(cache.read(episode.copy(tmdbId = 43)))
|
||||
now = 6L * 60 * 60 * 1000
|
||||
assertNull(cache.read(episode))
|
||||
}
|
||||
|
||||
@Test fun seasonCapacityCountsSeasonsNotEpisodesAndIsIndependentOfTitleCapacity() {
|
||||
val cache = HomeRatingCache { 0L }
|
||||
val episode = HomeTitle("episode-a", "Pilot", "episode", tmdbId = 42, season = 1, episode = 1)
|
||||
repeat(301) { index -> cache.save(episode.copy(id = "episode-$index", episode = index + 1), ratings) }
|
||||
repeat(300) { index -> cache.save(title.copy(tmdbId = index + 100), ratings) }
|
||||
assertEquals(ratings, cache.read(episode))
|
||||
repeat(300) { index -> cache.save(episode.copy(season = index + 2), ratings) }
|
||||
assertNull(cache.read(episode))
|
||||
assertEquals(ratings, cache.read(episode.copy(season = 301)))
|
||||
assertEquals(ratings, cache.read(title.copy(tmdbId = 100)))
|
||||
}
|
||||
|
||||
@Test fun decimalScoresFollowLocaleButPercentagesDoNot() {
|
||||
assertEquals("7,8", ratings.single().format(Locale.forLanguageTag("nl-NL")))
|
||||
assertEquals("7.8", ratings.single().format(Locale.US))
|
||||
|
|
|
|||
|
|
@ -172,6 +172,23 @@ class LibraryKeysTest {
|
|||
assertEquals("Title 18", grid.focusedTitle!!.title)
|
||||
}
|
||||
|
||||
@Test fun mixedCatalogueRowsKeepMovieAndSeriesWithTheSameNumericIdentityDistinct() {
|
||||
val titles = List(12) { index ->
|
||||
val type = if (index < 6) "movie" else "tv"
|
||||
LibraryTitle((index % 6).toString(), "$type title ${index % 6}", type, tmdbId = (index % 6).toString())
|
||||
}
|
||||
val grid = LibraryGridState("Trending", titles, catalogue = true, onTitle = { opened = it })
|
||||
shell.pickerVisible = false
|
||||
shell.enterDestination("discover")
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it) } } }
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(6, grid.index)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals("tv", opened!!.type)
|
||||
assertEquals("0", opened!!.tmdbId)
|
||||
assertEquals("tv title 0", opened!!.title)
|
||||
}
|
||||
|
||||
@Test fun scoresFollowLocaleAndUnratedSortsLast() {
|
||||
assertEquals("7,8", LibraryRating("tmdb", 7.8).formatted(Locale.forLanguageTag("nl-NL")))
|
||||
assertEquals("7.8", LibraryRating("tmdb", 7.8).formatted(Locale.US))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import java.io.File
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** The normal JVM gate rejects untokened styling, undersized typography and internal screen wording. */
|
||||
class OwnedSourceAuditTest {
|
||||
@Test fun ownedScreensUseTokensReadableTextAndViewerFacingNames() {
|
||||
val roots = listOf(File("src/main/java"), File("../app/src/wolfskin"))
|
||||
roots.forEach { assertTrue("Missing owned source root: $it", it.isDirectory) }
|
||||
val violations = roots.flatMap { root ->
|
||||
root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { source ->
|
||||
audit(source.name, source.readText()).map { "$source:$it" }
|
||||
}.toList()
|
||||
}
|
||||
assertTrue(violations.joinToString("\n"), violations.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun plantedViolationsFailEachDoneBarRule() {
|
||||
val cases = listOf(
|
||||
Triple("Screen.kt", "Box(Modifier.width(12.dp))", "unit"),
|
||||
Triple("Screen.kt", "Box(Modifier.width(12f.dp))", "unit"),
|
||||
Triple("Screen.kt", "Text(fontSize = 14.sp)", "unit"),
|
||||
Triple("Screen.kt", "Box(Modifier.width((width + gap).dp))", "unit"),
|
||||
Triple("Screen.kt", "Text(color = Color(0xFF123456))", "color"),
|
||||
Triple("Screen.kt", "Text(color = ComposeColor(red = 1f, green = 0f, blue = 0f))", "color"),
|
||||
Triple("WolfskinTokens.kt", "val tiny = TextStyle(fontSize = 11.5.sp)", "font-floor"),
|
||||
Triple("Screen.kt", "Text(\"Recs Movies\")", "wording"),
|
||||
Triple("Screen.kt", "Text(\"DGiC\")", "wording"),
|
||||
Triple("Screen.kt", "Text(\"Justin Shows\")", "wording"),
|
||||
Triple("Screen.kt", "Text(\"Next page\")", "wording"),
|
||||
Triple("Screen.kt", "Text(\"Previous page\")", "wording"),
|
||||
Triple("Screen.kt", "Text(\"Page 2\")", "wording"),
|
||||
)
|
||||
cases.forEach { (name, source, rule) ->
|
||||
assertTrue("$rule accepted $source", audit(name, source).any { it.endsWith(":$rule") })
|
||||
}
|
||||
assertTrue(audit("WolfskinTokens.kt", "val body = TextStyle(fontSize = 12.sp); val fill = Color(0xFF123456)").isEmpty())
|
||||
assertTrue(audit("Screen.kt", "import androidx.compose.ui.unit.dp\nText(\"This title can’t be read\", style = WolfskinTokens.Type.body)").isEmpty())
|
||||
}
|
||||
|
||||
private fun audit(name: String, source: String): List<String> {
|
||||
val generated = name == "WolfskinTokens.kt"
|
||||
return source.lineSequence().mapIndexedNotNull { index, line ->
|
||||
val code = line.trimStart()
|
||||
if (code.startsWith("import ") || code.startsWith("//") || code.startsWith("*") || code.startsWith("/*")) return@mapIndexedNotNull null
|
||||
val rules = buildList {
|
||||
if (!generated && units.containsMatchIn(line)) add("unit")
|
||||
if (!generated && colors.containsMatchIn(line)) add("color")
|
||||
if (fonts.findAll(line).any { it.groupValues[1].toDouble() < 12 }) add("font-floor")
|
||||
if (strings.findAll(line).any { forbidden.containsMatchIn(it.value) }) add("wording")
|
||||
}
|
||||
rules.map { "${index + 1}:$it" }
|
||||
}.flatten().toList()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val units = Regex("\\.(?:dp|sp)\\b")
|
||||
private val colors = Regex("\\b(?:Compose)?Color\\s*\\(\\s*(?:0[xX]|[0-9.]|(?:red|green|blue|alpha|color)\\s*=)")
|
||||
private val fonts = Regex("\\bfontSize\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)[fF]?\\.sp\\b")
|
||||
private val strings = Regex("\"(?:\\\\.|[^\"\\\\])*\"")
|
||||
private val forbidden = Regex("Recs (?:Movies|Shows)|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page (?:[0-9]|\\$)|\\bpagination\\b", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue