Compare commits
28 commits
fm/tv-hotf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5162c299db | |||
| 278335f009 | |||
| deb24e3c9f | |||
| 556d633e88 | |||
| 34087d8eb5 | |||
| 33dc65bf18 | |||
| ef3ae3f21b | |||
| d0e38306b3 | |||
| 5306f8ddc4 | |||
| 59f4a06815 | |||
| 427b18cdc5 | |||
| 93a41ea2d3 | |||
| 1c21f50acb | |||
| e8d0c52088 | |||
| db2541feff | |||
| f37ad0f0fe | |||
| 92c5661be0 | |||
| b9ea593735 | |||
| 711dbebf28 | |||
| 6e0f9952cf | |||
| c17447c528 | |||
| 9e15dbee8c | |||
| 53b8358317 | |||
| 5a84666074 | |||
| 22850632ec | |||
| afff383b75 | |||
| d401ebc355 | |||
| e794dec253 |
75 changed files with 13108 additions and 327 deletions
|
|
@ -133,6 +133,13 @@ configure<ApplicationExtension> {
|
|||
isDebuggable = true
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
|
||||
create("optimized") {
|
||||
initWith(getByName("release"))
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
applicationIdSuffix = ".opt"
|
||||
matchingFallbacks += "release"
|
||||
}
|
||||
}
|
||||
flavorDimensions += "version"
|
||||
productFlavors {
|
||||
|
|
@ -199,6 +206,10 @@ configure<ApplicationExtension> {
|
|||
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
||||
kotlin.directories += "src/wolfskin/java"
|
||||
}
|
||||
getByName("optimized") {
|
||||
kotlin.directories += "src/release/java"
|
||||
baselineProfiles.directories += "src/optimized/baselineProfiles"
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
|
|
@ -228,6 +239,11 @@ androidComponents {
|
|||
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
if (variant.name == "defaultOptimized") {
|
||||
tasks.withType<Test>().configureEach {
|
||||
systemProperty("wolfskin.optimizedVersionName", variant.outputs.first().versionName.get())
|
||||
}
|
||||
}
|
||||
if (variant.name == "defaultDebug") {
|
||||
val packagedApks = variant.artifacts.get(SingleArtifact.APK)
|
||||
val artifactLoader = variant.artifacts.getBuiltArtifactsLoader()
|
||||
|
|
|
|||
8
app/src/optimized/baselineProfiles/baseline-prof.txt
Normal file
8
app/src/optimized/baselineProfiles/baseline-prof.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Hand-maintained startup/shell seed; supplement with device-recorded navigation profiles.
|
||||
Lcom/github/damontecres/wholphin/WholphinApplication;
|
||||
HSPLcom/github/damontecres/wholphin/WholphinApplication;->onCreate()V
|
||||
Lcom/github/damontecres/wholphin/MainActivity;
|
||||
HSPLcom/github/damontecres/wholphin/MainActivity;->onCreate(Landroid/os/Bundle;)V
|
||||
Lorg/justinvisser/wolfskin/tv/WolfskinAppKt;
|
||||
Lorg/justinvisser/wolfskin/tv/WolfskinScreenPreference;
|
||||
Lorg/justinvisser/wolfskin/tv/ConductarrPreference;
|
||||
4
app/src/optimized/res/values/strings.xml
Normal file
4
app/src/optimized/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Wolfskin (Opt)</string>
|
||||
</resources>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.util.Version
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
|
@ -18,6 +19,12 @@ class VersionCompareTests {
|
|||
val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() {
|
||||
val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName")))
|
||||
Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIsAtLeast() {
|
||||
Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.justinvisser.wolfskin.conductarr.model.ReadinessStage
|
||||
import org.justinvisser.wolfskin.design.ComingInStage
|
||||
import org.justinvisser.wolfskin.design.ShellPerson
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.justinvisser.wolfskin.conductarr.model.ActivityFeed
|
||||
|
||||
/** Recorded history separates acquisition, visibility proof, entitlement and file health. */
|
||||
class ActivityTruthProjectionTest {
|
||||
private val rows = Json { ignoreUnknownKeys = true }.decodeFromString<ActivityFeed>(
|
||||
javaClass.getResourceAsStream("/conductarr/activity-history.json")!!.bufferedReader().use { it.readText() }).items.orEmpty().associateBy { it.id }
|
||||
@Test fun recordedHistoryDoesNotClaimActiveWorkOrFailedDownloadsWithoutEvidence() {
|
||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||
val expected = mapOf(
|
||||
"request-2" to "Check needed", // Steins;Gate: represented file, failed visibility proof.
|
||||
"request-1" to "Check needed", // Yu Yu Hakusho: represented files, failed visibility proof.
|
||||
"dispatch-732" to "Check needed", // Love is Blind: same proof failure, not failed acquisition.
|
||||
"dispatch-750" to "In another library", // Spider-Man: Homecoming: held elsewhere.
|
||||
"dispatch-725" to "Removed", // One Hour Photo: deleted, absent actor.
|
||||
"dispatch-723" to "Removed", // A historical play is not a current holding.
|
||||
"dispatch-728" to "Not out yet", // The Body: unreleased.
|
||||
"dispatch-721" to "File health needs attention", // Avatar: playback fault despite a holding.
|
||||
"request-8" to "Waiting for a file", // Partition: no active transfer is established.
|
||||
"dispatch-755" to "Waiting for a file", // Netherlands: primary import not observed.
|
||||
)
|
||||
assertNull(rows.getValue("dispatch-725").comingIn().actor(viewer))
|
||||
expected.forEach { (id, label) -> assertEquals(rows.getValue(id).title, label, rows.getValue(id).comingIn().stage.label) }
|
||||
listOf("request-2", "request-1", "dispatch-732", "dispatch-721").forEach { id ->
|
||||
val source = rows.getValue(id)
|
||||
val projected = source.comingIn()
|
||||
val affected = source.stages.orEmpty().first { it.state == ReadinessStage.State.FAILED }
|
||||
assertEquals(affected.summary, projected.summary)
|
||||
assertFalse(projected.readiness[1] == "Failed")
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun currentHealthSummaryKeepsEpisodeAndSourceScopeEvenWhenVisibilityIsDone() {
|
||||
val row = rows.getValue("dispatch-721")
|
||||
val summary = "S01E15 · primary source: unreadable; S02E08 · mirror source: unreadable."
|
||||
val projected = row.copy(stages = row.stages.orEmpty().map {
|
||||
when (it.name) {
|
||||
ReadinessStage.Name.PLAYBACK -> it.copy(summary = summary)
|
||||
ReadinessStage.Name.VISIBILITY -> it.copy(state = ReadinessStage.State.DONE)
|
||||
else -> it
|
||||
}
|
||||
}).comingIn()
|
||||
assertEquals(ComingInStage.FileHealth, projected.stage)
|
||||
assertEquals(summary, projected.summary)
|
||||
}
|
||||
|
||||
@Test fun absentTransferEvidenceNeverTurnsInProgressIntoDownloading() {
|
||||
val row = rows.getValue("request-8")
|
||||
val projected = row.copy(stages = row.stages.orEmpty().map {
|
||||
if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(summary = "Dispatch accepted.") else it
|
||||
}).comingIn()
|
||||
assertEquals(ComingInStage.Waiting, projected.stage)
|
||||
}
|
||||
|
||||
@Test fun missingMembershipIsNeutralButProvenReadinessStaysReady() {
|
||||
val row = rows.getValue("dispatch-725")
|
||||
val absent = row.copy(stages = row.stages.orEmpty().map {
|
||||
if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(state = ReadinessStage.State.UNKNOWN, summary = "No current observation.") else it
|
||||
})
|
||||
assertEquals(ComingInStage.NotInLibrary, absent.comingIn().stage)
|
||||
val ready = rows.values.first { it.stages.orEmpty().any { stage -> stage.name == ReadinessStage.Name.VISIBILITY && stage.state == ReadinessStage.State.DONE } }
|
||||
assertEquals(ComingInStage.Ready, ready.comingIn().stage)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
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.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** An audience grants a grouping, not evidence that this user's Jellyfin library has titles. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class AnimeAvailabilityTest {
|
||||
private var previousFixtures = false
|
||||
|
||||
@Before fun setUp() {
|
||||
Dispatchers.setMain(Dispatchers.Unconfined)
|
||||
ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>())
|
||||
previousFixtures = ConductarrPreference.fixtures
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
ConductarrPreference.updateFixtures(previousFixtures)
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test fun configuredAnimeAudienceNeedsActualTitlesBeforeItCreatesATabOrHomeShelf() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
for ((key, token) in listOf("shared" to SHARED_TOKEN, "mindel" to MINDEL_TOKEN)) {
|
||||
for (count in listOf(0, 1)) FakeConductarr().use { fake ->
|
||||
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val user = JellyfinUser(id = UUID.randomUUID(), name = key, serverId = server.id, accessToken = token)
|
||||
conductarr.select("${server.id}:${user.id}", token, fake.url)
|
||||
conductarr.authenticate()
|
||||
val identity = conductarr.identity.value!!
|
||||
val audiences = identity.provider.audiences()!!.filter {
|
||||
it.isAnime && (it.owner == identity.operator!!.requester || identity.operator.watchUser in it.users.orEmpty())
|
||||
}
|
||||
val names = audiences.flatMap { it.libraries.orEmpty() }.distinct()
|
||||
assertTrue("The wire fixture must grant this person an Anime grouping", names.isNotEmpty())
|
||||
val views = mockk<UserViewsApi>()
|
||||
val items = mockk<ItemsApi>()
|
||||
val api = mockk<ApiClient>()
|
||||
every { api.userViewsApi } returns views
|
||||
every { api.itemsApi } returns items
|
||||
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(names.map {
|
||||
BaseItemDto(id = UUID.randomUUID(), name = it, type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS)
|
||||
})
|
||||
val titles = List(count) { BaseItemDto(id = UUID.randomUUID(), name = "An Anime series", type = BaseItemKind.SERIES) }
|
||||
coEvery { items.getItems(userId = user.id, parentId = any(), recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1) } returns successQueryResult(titles)
|
||||
val libraries = LibraryReadFacade(api)
|
||||
val session = WolfskinSession().apply {
|
||||
state = ShellState(listOf(ShellPerson(key, key, key.take(1), count == 0, user.id.toString())), key).apply {
|
||||
pickerVisible = false
|
||||
focusNav(tabs.size)
|
||||
}
|
||||
}
|
||||
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
try {
|
||||
requireNotNull(model.onForeground(server, user)).join()
|
||||
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
|
||||
assertEquals("The avatar must remain focused when Anime appears or disappears",
|
||||
session.state!!.tabs.size, session.state!!.navIndex)
|
||||
val home = HomeLibrary(destinations = mapOf("anime" to titles.map { HomeTitle(it.id.toString(), it.name.orEmpty(), "tv") }),
|
||||
destinationTotals = mapOf("anime" to count))
|
||||
assertEquals("An empty Anime audience must not create a Home shelf", count > 0,
|
||||
buildHomeRows(home, emptyList()).any { it.kind == HomeRowKind.Shelf && it.target == "anime" })
|
||||
} finally { model.viewModelScope.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.ShellKey
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class AnimeRootRemovalTest {
|
||||
private var previousFixtures = false
|
||||
private var previousUrl = ""
|
||||
|
||||
@Before fun setUp() {
|
||||
Dispatchers.setMain(Dispatchers.Unconfined)
|
||||
ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>())
|
||||
previousFixtures = ConductarrPreference.fixtures
|
||||
previousUrl = ConductarrPreference.baseUrl
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||
ConductarrPreference.updateFixtures(previousFixtures)
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test fun removedAnimeRootReturnsHomeWithoutDiscardingPushedPagesOrAvatarFocus() = runBlocking {
|
||||
val title = Destination.Wolfskin("title/tv%3A123")
|
||||
val person = Destination.Wolfskin("person/456")
|
||||
val scenarios = listOf(emptyList<Destination.Wolfskin>() to false, emptyList<Destination.Wolfskin>() to true,
|
||||
listOf(title) to false, listOf(title, person) to false)
|
||||
for ((pushed, avatarFocused) in scenarios) FakeConductarr().use { fake ->
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||
val repository = mockk<ServerRepository>(relaxed = true)
|
||||
coEvery { repository.serverDao.getServer(server.id) } returns null
|
||||
var hasTitles = true
|
||||
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||
coEvery { libraries.hasAnimeTitles(user.id) } coAnswers { hasTitles }
|
||||
val navigation = NavigationManager()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
try {
|
||||
model.load(server, user)
|
||||
requireNotNull(model.onForeground(server, user)).join()
|
||||
val shell = requireNotNull(session.state)
|
||||
assertTrue(shell.person.hasAnime)
|
||||
shell.key(ShellKey.Ok)
|
||||
shell.focusNav(shell.tabs.indexOf("anime"))
|
||||
shell.key(ShellKey.Ok)
|
||||
assertEquals(listOf(Destination.Wolfskin("anime")), navigation.backStack.toList())
|
||||
for (page in pushed) {
|
||||
navigation.navigateTo(page)
|
||||
shell.enterDestination(page.route)
|
||||
}
|
||||
if (avatarFocused) shell.focusNav(shell.tabs.size)
|
||||
|
||||
hasTitles = false
|
||||
requireNotNull(model.onForeground(server, user)).join()
|
||||
assertFalse("The foreground read removed the last accessible Anime title", "anime" in shell.tabs)
|
||||
assertEquals("Home must replace the missing root, not the current pushed page",
|
||||
listOf(Destination.Home()) + pushed, navigation.backStack.toList())
|
||||
assertEquals(pushed.lastOrNull()?.route ?: "home", shell.route)
|
||||
assertEquals("The selected root must be Home even below a pushed page", 0, shell.currentTab)
|
||||
if (avatarFocused) assertEquals(shell.tabs.size, shell.navIndex)
|
||||
|
||||
shell.key(ShellKey.Back)
|
||||
if (pushed.isEmpty()) {
|
||||
assertTrue("Back from the replacement Home must enter its nav ladder", shell.navFocused)
|
||||
assertEquals("home", shell.route)
|
||||
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||
if (!avatarFocused) assertEquals(0, shell.navIndex)
|
||||
} else {
|
||||
assertEquals("Back pops only the current page and retains the replacement Home root",
|
||||
listOf(Destination.Home()) + pushed.dropLast(1), navigation.backStack.toList())
|
||||
}
|
||||
} finally { model.viewModelScope.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,6 @@ class ConductarrProjectionTest {
|
|||
assertEquals(ComingInStage.Downloading, request.stage)
|
||||
assertEquals("Mindel", request.requesterName)
|
||||
assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true)))
|
||||
assertEquals(1, requests.openCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.last
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.ConductarrClient
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Native frames exercise recorded activity and Mindel's feed through the shipped provider. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class ConductarrTruthCaptureTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val people = listOf(ShellPerson("justin", "Justin", "J", false), ShellPerson("mindel", "Mindel", "M", false))
|
||||
|
||||
@Before fun fullscreen() {
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
}
|
||||
|
||||
@Test fun requestHistory() = requests("requests-history", "request-2")
|
||||
@Test fun requestHealth() = requests("requests-health", "dispatch-721", expanded = true)
|
||||
@Test fun requestProofDetails() = requests("requests-proof", "request-2", expanded = true)
|
||||
@Test fun requestRemoved() = requests("requests-removed", "dispatch-725")
|
||||
@Test fun discoverMovies() = discover("movie")
|
||||
@Test fun discoverSeries() = discover("tv")
|
||||
|
||||
private fun requests(name: String, focused: String, expanded: Boolean = false) {
|
||||
FakeConductarr().use { fake ->
|
||||
fake.activityBody = resource("activity-history.json")
|
||||
ConductarrClient(fake.url, JUSTIN_TOKEN, MemoryCacheStore()).use { client ->
|
||||
val snapshot = runBlocking { ConductarrProvider(client).requests().last() }.let { it.copy(items = it.items.map { row -> row.copy(poster = null) }) }
|
||||
val shell = ShellState(people, "justin").apply {
|
||||
pickerVisible = false
|
||||
enterDestination("settings")
|
||||
requests.update(snapshot)
|
||||
openRequests()
|
||||
requests.focus(focused)
|
||||
if (expanded) requests.key(ShellKey.Right)
|
||||
}
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } }
|
||||
capture(name)
|
||||
rule.onAllNodesWithText("by Unknown", substring = true).assertCountEquals(0)
|
||||
rule.onNodeWithText("Requests · 56 requests").assertExists()
|
||||
if (expanded) rule.onNodeWithText(snapshot.items.first { it.id == focused }.summary!!).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun discover(type: String) {
|
||||
FakeConductarr().use { fake ->
|
||||
fake.feedBody = resource("mindel-feed.json")
|
||||
ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client ->
|
||||
val provider = ConductarrProvider(client)
|
||||
val snapshot = runBlocking { provider.discover("mindel").last() }.let { original ->
|
||||
fun noArt(title: DiscoverTitle) = title.copy(poster = null, backdrop = null)
|
||||
original.copy(picks = original.picks.map(::noArt), seeds = original.seeds.map { it.copy(items = it.items.map(::noArt)) })
|
||||
}
|
||||
val shell = ShellState(people, "mindel").apply { pickerVisible = false; enterDestination("discover") }
|
||||
val operator = runBlocking { (client.read(org.justinvisser.wolfskin.conductarr.TvRoutes.operator()).last() as org.justinvisser.wolfskin.conductarr.ReadResult.Value).saved.value }
|
||||
val state = DiscoverState(shell, snapshot, provider.operations(requireNotNull(operator)), CoroutineScope(Dispatchers.Main.immediate))
|
||||
state.filter = type
|
||||
state.focus(state.firstShelf + state.lanes.indexOfFirst { it.title.startsWith("Because you watched") }, 0)
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) } } }
|
||||
capture("discover-$type")
|
||||
rule.onNodeWithText(if (type == "tv") "Because you watched Gilmore Girls" else "Because you watched Locke").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun capture(name: String) {
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "truth").apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||
}
|
||||
|
||||
private fun resource(name: String) = javaClass.getResourceAsStream("/conductarr/$name")!!.bufferedReader().use { it.readText() }
|
||||
}
|
||||
|
|
@ -63,7 +63,6 @@ class DiscoverLoadingFocusTest {
|
|||
rule.runOnIdle {
|
||||
assertNotNull(current.title)
|
||||
assertEquals("The Amazing Spider-Man", current.title!!.title)
|
||||
assertEquals("discover-title/${current.title!!.key}", shell.route)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import kotlinx.coroutines.flow.last
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.justinvisser.wolfskin.conductarr.ConductarrClient
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
|
||||
/** Equal captions are not equal seed identities; repeated contributions union their evidence. */
|
||||
class DiscoverSeedProjectionTest {
|
||||
@Test fun seedIdentitySurvivesTitleCollisionsAndWatchEvidenceAcrossCandidates() = runBlocking {
|
||||
FakeConductarr().use { fake ->
|
||||
val feed = fake.wire.getValue("feed").jsonObject
|
||||
val candidate = feed.getValue("movies").jsonArray.first().jsonObject
|
||||
val first = JsonObject(candidate + ("contributions" to Json.parseToJsonElement("""[
|
||||
{"media_type":"movie","tmdb_id":1,"title":"Same title","watched":true,"added":false},
|
||||
{"media_type":"tv","tmdb_id":1,"title":"Same title","watched":false,"added":true},
|
||||
{"media_type":"movie","tmdb_id":2,"watched":true,"added":false}
|
||||
]""")))
|
||||
val second = JsonObject(candidate + mapOf("tmdb_id" to JsonPrimitive(43), "contributions" to Json.parseToJsonElement("""[
|
||||
{"media_type":"movie","tmdb_id":1,"title":"Renamed seed","watched":false,"added":true}
|
||||
]""")))
|
||||
fake.feedBody = JsonObject(feed + ("movies" to JsonArray(listOf(first, second)))).toString()
|
||||
ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client ->
|
||||
val seeds = ConductarrProvider(client).discover("mindel").last().seeds
|
||||
assertEquals(listOf("movie:1", "tv:1"), seeds.map { it.key })
|
||||
assertEquals(listOf(42L, 43L), seeds.first().items.map { it.tmdb })
|
||||
assertTrue(seeds.first().watched && seeds.first().added)
|
||||
assertFalse(seeds.last().watched)
|
||||
assertTrue(seeds.last().added)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Catalogue details must reach the same late-filling cast/person/related surface as held titles. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class DiscoverTitleTimingTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() {
|
||||
FakeConductarr().use { fake ->
|
||||
fake.localArt = true
|
||||
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||
runBlocking { session.authenticate() }
|
||||
val identity = session.identity.value!!
|
||||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.findHeld(any(), any()) } returns null
|
||||
val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) }
|
||||
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true))
|
||||
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
|
||||
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||
}
|
||||
lateinit var discover: DiscoverState
|
||||
val release = CountDownLatch(1)
|
||||
try {
|
||||
rule.setContent {
|
||||
val route = (navigation.backStack.last() as Destination.Wolfskin).route
|
||||
LaunchedEffect(route) { shell.enterDestination(route) }
|
||||
discover = rememberDiscoverState(shell, { navigation.navigateTo(Destination.Wolfskin(it)) }, navigation::goBack, identity = identity, fixtures = false)
|
||||
WolfskinTheme {
|
||||
if (route.startsWith("title/")) TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false)
|
||||
else ShellSurface(shell) { DiscoverScreen(discover, it) }
|
||||
}
|
||||
}
|
||||
rule.waitUntil(30_000) { discover.snapshot.status == DiscoverStatus.Ready }
|
||||
rule.runOnIdle {
|
||||
fake.beforeResponse = { if (it.endsWith("/cast")) release.await() }
|
||||
discover.openTitle(DiscoverTitle(44, "tv", "Black Lightning"))
|
||||
}
|
||||
rule.waitForIdle()
|
||||
assertTrue("Discover details must open the shared title destination, not its header-only preview", (navigation.backStack.last() as Destination.Wolfskin).route.startsWith("title/"))
|
||||
rule.waitUntil(10_000) { fake.calls.any { it.url.encodedPath == "/catalogue/titles/44/tv/cast" } }
|
||||
rule.runOnIdle { release.countDown() }
|
||||
rule.waitUntil(30_000) {
|
||||
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true
|
||||
}
|
||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||
assertEquals("cast", model.title!!.row)
|
||||
rule.onNodeWithText("Sam Rockwell").assertIsDisplayed()
|
||||
val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "walk-fixes").apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(captures, "discover-title-cast.png").absolutePath)
|
||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||
assertEquals("related", model.title!!.row)
|
||||
rule.onNodeWithText("More like this").assertIsDisplayed()
|
||||
rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath)
|
||||
rule.onRoot().performKeyInput { pressKey(Key.Back) }
|
||||
rule.waitForIdle()
|
||||
assertNull(discover.title)
|
||||
assertEquals("discover", shell.route)
|
||||
} finally {
|
||||
release.countDown()
|
||||
model.viewModelScope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import mockwebserver3.RecordedRequest
|
|||
|
||||
internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef"
|
||||
internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210"
|
||||
internal const val JUSTIN_TOKEN = "11111111111111111111111111111111"
|
||||
|
||||
/** A loopback server supplies wire JSON, never app projections or live service state. */
|
||||
internal class FakeConductarr : Closeable {
|
||||
|
|
@ -26,6 +27,10 @@ internal class FakeConductarr : Closeable {
|
|||
@Volatile var emptyAudiences = false
|
||||
@Volatile var seasonTwoScore = 8.1
|
||||
@Volatile var localArt = false
|
||||
@Volatile var beforeResponse: ((String) -> Unit)? = null
|
||||
@Volatile var audiencesBody: String? = null
|
||||
@Volatile var feedBody: String? = null
|
||||
@Volatile var activityBody: String? = null
|
||||
private val imageBytes = java.util.Base64.getDecoder().decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
||||
|
||||
|
|
@ -37,6 +42,7 @@ internal class FakeConductarr : Closeable {
|
|||
if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder()
|
||||
.body(okio.Buffer().write(imageBytes)).build()
|
||||
val path = request.url.encodedPath
|
||||
beforeResponse?.invoke(path)
|
||||
if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus)
|
||||
if (request.method == "POST") return response(wire.getValue("hide").toString())
|
||||
if (request.method == "DELETE") return response(wire.getValue("restore").toString())
|
||||
|
|
@ -64,6 +70,9 @@ internal class FakeConductarr : Closeable {
|
|||
}
|
||||
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
|
||||
if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus)
|
||||
if (key == "audiences") audiencesBody?.let { return response(it) }
|
||||
if (key == "feed") feedBody?.let { return response(it) }
|
||||
if (key == "activity") activityBody?.let { return response(it) }
|
||||
if (notModified && request.headers["If-None-Match"] != null) return response("", 304)
|
||||
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
||||
var body = wire.getValue(key).jsonObject
|
||||
|
|
@ -71,6 +80,9 @@ internal class FakeConductarr : Closeable {
|
|||
if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) {
|
||||
body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel")))
|
||||
}
|
||||
if (key == "operator" && request.headers["Authorization"]?.contains(JUSTIN_TOKEN) == true) {
|
||||
body = JsonObject(body + mapOf("name" to JsonPrimitive("Justin"), "requester" to JsonPrimitive("justin"), "subject" to JsonPrimitive("jellyfin:justin"), "watch_user" to JsonPrimitive("justin")))
|
||||
}
|
||||
if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") {
|
||||
body = JsonObject(body + ("media_type" to JsonPrimitive("tv")))
|
||||
if (key == "discover") body = JsonObject(body + ("items" to JsonArray(listOf(JsonObject(body.getValue("items").jsonArray.first().jsonObject + mapOf("media_type" to JsonPrimitive("tv"), "tmdb_id" to JsonPrimitive(44), "title" to JsonPrimitive("Dark")))))))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.*
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
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.cancel
|
||||
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.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Fresh login exercises the actual NavDisplay and renders user-prefixed Jellyfin libraries. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class FirstSignInNavigationTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var previousUrl: String
|
||||
private var previousFixtures = false
|
||||
private var previousScreens = true
|
||||
|
||||
@Before fun captureGlobalPreferences() {
|
||||
ConductarrPreference.initialize(rule.activity)
|
||||
WolfskinScreenPreference.initialize(rule.activity)
|
||||
previousUrl = ConductarrPreference.baseUrl
|
||||
previousFixtures = ConductarrPreference.fixtures
|
||||
previousScreens = WolfskinScreenPreference.enabled
|
||||
rule.mainClock.autoAdvance = true
|
||||
}
|
||||
|
||||
@After fun restoreGlobalPreferences() {
|
||||
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||
ConductarrPreference.updateFixtures(previousFixtures)
|
||||
WolfskinScreenPreference.enabled = previousScreens
|
||||
rule.mainClock.autoAdvance = true
|
||||
}
|
||||
|
||||
@Test fun firstContinueThenMoviesRendersMindelsGridWithoutReenteringThePicker() {
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
WolfskinScreenPreference.enabled = true
|
||||
FakeConductarr().use { fake ->
|
||||
fake.audiencesBody = """{"audiences":[{"is_anime":false,"libraries":["Mindel Movies"],"media_type":"movie","owner":"mindel","users":["mindel"]},{"is_anime":false,"libraries":["Mindel Shows"],"media_type":"tv","owner":"mindel","users":["mindel"]}]}"""
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val user = JellyfinUser(id = UUID.randomUUID(), name = "mindel", serverId = server.id, accessToken = MINDEL_TOKEN)
|
||||
val repository = mockk<ServerRepository>(relaxed = true)
|
||||
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
|
||||
val api = mockk<ApiClient>()
|
||||
val views = mockk<UserViewsApi>()
|
||||
val items = mockk<ItemsApi>()
|
||||
every { api.userViewsApi } returns views
|
||||
every { api.itemsApi } returns items
|
||||
val movies = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES)
|
||||
val shows = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Shows", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS)
|
||||
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),
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult(
|
||||
List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) })
|
||||
}
|
||||
val navigation = NavigationManager()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
||||
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf(
|
||||
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
|
||||
try {
|
||||
rule.setContent {
|
||||
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), Modifier, model,
|
||||
homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} })
|
||||
}
|
||||
rule.waitUntil(10_000) {
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||
conductarr.identity.value?.signedIn == true && model.libraries.audiences != null
|
||||
}
|
||||
rule.onNodeWithText("Mindel").assertIsDisplayed()
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
|
||||
rule.onNodeWithText("Desperate Housewives").assertIsDisplayed()
|
||||
assertFalse("Continue must close the session's real picker, not a discarded launch shell", session.state!!.pickerVisible)
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput {
|
||||
pressKey(Key.DirectionUp)
|
||||
pressKey(Key.DirectionRight)
|
||||
pressKey(Key.DirectionRight)
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
rule.waitForIdle()
|
||||
rule.waitUntil(10_000) {
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||
rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||
rule.onAllNodesWithText("Mindel Movies title 0")[0].assertIsDisplayed()
|
||||
rule.onNodeWithText("Desperate Housewives").assertDoesNotExist()
|
||||
assertEquals(6, session.state!!.view.library!!.source.size)
|
||||
assertFalse(session.state!!.person.hasAnime)
|
||||
rule.onNodeWithText("Shows").performClick()
|
||||
rule.waitForIdle()
|
||||
try {
|
||||
rule.waitUntil(timeoutMillis = 15_000) {
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||
rule.onAllNodes(hasTestTag("library-grid") and
|
||||
hasAnyDescendant(hasText("Mindel Shows title 0"))).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
} catch (timeout: ComposeTimeoutException) {
|
||||
val shell = session.state!!
|
||||
throw AssertionError("Active=${navigation.backStack.last()}, shell=${shell.route}, " +
|
||||
"movies=${shell.rememberedView("movies").library?.source?.size}, " +
|
||||
"shows=${shell.rememberedView("shows").library?.source?.size}/" +
|
||||
"${shell.rememberedView("shows").library?.status}", timeout)
|
||||
}
|
||||
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||
assertEquals(29, session.state!!.view.library!!.source.size)
|
||||
rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed()
|
||||
rule.onNodeWithText("Home").performClick()
|
||||
rule.waitForIdle()
|
||||
rule.onNodeWithText("Desperate Housewives").assertIsDisplayed()
|
||||
assertFalse("Returning Home must not reopen the picker", session.state!!.pickerVisible)
|
||||
} finally { model.viewModelScope.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toPixelMap
|
||||
import androidx.compose.ui.test.captureToImage
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Scrolled cards must be occluded across the entire persistent navigation band. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class NavBandOcclusionTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun scrolledRowsNeverShowThroughTheNavBand() {
|
||||
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
|
||||
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||
}
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier ->
|
||||
LazyColumn(state = shell.view.listState, modifier = modifier.fillMaxSize()) {
|
||||
items(20) { Box(Modifier.fillMaxSize().height(200.dp).background(Color.Red)) }
|
||||
}
|
||||
} } }
|
||||
rule.runOnIdle { runBlocking { shell.view.listState.scrollToItem(2) } }
|
||||
rule.waitForIdle()
|
||||
val pixels = rule.onRoot().captureToImage().toPixelMap()
|
||||
val background = WolfskinTokens.Color.bg
|
||||
for (y in listOf(5, 50, 135)) {
|
||||
val actual = pixels[20, y]
|
||||
assertEquals("Nav red at y=$y", background.red, actual.red, 1f / 255f)
|
||||
assertEquals("Nav green at y=$y", background.green, actual.green, 1f / 255f)
|
||||
assertEquals("Nav blue at y=$y", background.blue, actual.blue, 1f / 255f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.*
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
import io.mockk.*
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** A selected identity gets a new app ViewModel without opening a second launch picker. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class PersonPickerSessionTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var previousUrl: String
|
||||
private var previousFixtures = false
|
||||
private var previousScreens = true
|
||||
private val models = mutableListOf<WolfskinShellViewModel>()
|
||||
private lateinit var session: WolfskinSession
|
||||
|
||||
@Before fun capturePreferences() {
|
||||
ConductarrPreference.initialize(rule.activity)
|
||||
WolfskinScreenPreference.initialize(rule.activity)
|
||||
previousUrl = ConductarrPreference.baseUrl
|
||||
previousFixtures = ConductarrPreference.fixtures
|
||||
previousScreens = WolfskinScreenPreference.enabled
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
WolfskinScreenPreference.enabled = true
|
||||
}
|
||||
|
||||
@After fun restorePreferences() {
|
||||
models.forEach { it.viewModelScope.cancel() }
|
||||
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||
ConductarrPreference.updateFixtures(previousFixtures)
|
||||
WolfskinScreenPreference.enabled = previousScreens
|
||||
}
|
||||
|
||||
private fun select(initial: Int, selected: Int, fromAvatar: Boolean = false, fast: Boolean = false, moves: List<Key> = emptyList()) {
|
||||
FakeConductarr().use { fake ->
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN, "Shared" to SHARED_TOKEN).map { (name, token) ->
|
||||
JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token)
|
||||
}
|
||||
var current by mutableStateOf(CurrentUser(server, users[initial]))
|
||||
val repository = mockk<ServerRepository>(relaxed = true)
|
||||
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
||||
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
|
||||
val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||
every { setup.navigateTo(any()) } answers { current = (firstArg() as SetupDestination.AppContent).current }
|
||||
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
coEvery { libraries.hasAnimeTitles(any()) } returns false
|
||||
session = WolfskinSession()
|
||||
try {
|
||||
rule.setContent {
|
||||
key(current.user.id) {
|
||||
val navigation = remember { NavigationManager() }
|
||||
val model = remember {
|
||||
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) }
|
||||
}
|
||||
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
|
||||
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
viewModel = model, homeContent = { _, _, user, modifier ->
|
||||
androidx.tv.material3.Text("Home for ${user.name}", modifier)
|
||||
})
|
||||
}
|
||||
}
|
||||
awaitLoaded(users[initial])
|
||||
if (fromAvatar) {
|
||||
press(listOf(Key.DirectionCenter))
|
||||
press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter))
|
||||
}
|
||||
val actions = mutableListOf<ShellAction.Person>()
|
||||
rule.runOnIdle {
|
||||
val dispatch = session.onAction
|
||||
session.onAction = { action ->
|
||||
if (action is ShellAction.Person) actions.add(action)
|
||||
dispatch(action)
|
||||
}
|
||||
}
|
||||
if (fast) press(moves + Key.DirectionCenter) else {
|
||||
press(moves)
|
||||
press(listOf(Key.DirectionCenter))
|
||||
}
|
||||
awaitLoaded(users[selected])
|
||||
rule.runOnIdle {
|
||||
assertFalse("The replacement app must not reopen the picker after one OK", session.state!!.pickerVisible)
|
||||
assertEquals("home", session.state!!.route)
|
||||
assertEquals(users[selected].id.toString(), session.state!!.person.jellyfinUserId)
|
||||
assertEquals(listOf(users[selected].id.toString()), actions.map { it.person.jellyfinUserId })
|
||||
assertEquals(if (initial == selected) 1 else 2, models.size)
|
||||
}
|
||||
rule.onNodeWithText("Home for ${users[selected].name}").assertIsDisplayed()
|
||||
} finally { models.forEach { it.viewModelScope.cancel() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitLoaded(user: JellyfinUser) {
|
||||
try {
|
||||
rule.waitUntil(10_000) {
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||
models.lastOrNull()?.signedInPerson == user.name!!.lowercase() &&
|
||||
models.last().conductarr.identity.value?.signedIn == true &&
|
||||
models.last().conductarr.identity.value?.person?.endsWith(":${user.id}") == true &&
|
||||
session.state?.people?.size == 3 &&
|
||||
models.last().viewModelScope.coroutineContext[Job]!!.children.none { it.isActive }
|
||||
}
|
||||
} catch (timeout: ComposeTimeoutException) {
|
||||
throw AssertionError("Expected ${user.name}; models=${models.size}, signedIn=${models.last().signedInPerson}, " +
|
||||
"identity=${models.last().conductarr.identity.value?.person}, picker=${session.state?.pickerVisible}, " +
|
||||
"person=${session.state?.person}, people=${session.state?.people?.size}", timeout)
|
||||
}
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun press(keys: List<Key>) {
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } }
|
||||
}
|
||||
|
||||
@Test fun launchRightThenOneOkKeepsPickerClosed() = select(0, 1, moves = listOf(Key.DirectionRight))
|
||||
@Test fun launchLeftThenOneOkKeepsPickerClosed() = select(1, 0, moves = listOf(Key.DirectionLeft))
|
||||
@Test fun avatarRightThenOneOkKeepsPickerClosed() = select(0, 1, fromAvatar = true, moves = listOf(Key.DirectionRight))
|
||||
@Test fun avatarLeftThenOneOkKeepsPickerClosed() = select(1, 0, fromAvatar = true, moves = listOf(Key.DirectionLeft))
|
||||
@Test fun initialPersonNeedsOnlyOneOk() = select(0, 0)
|
||||
@Test fun gearAndBackThenOneOkKeepsPickerClosed() = select(0, 2, moves = listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
|
||||
@Test fun fastLeftLeftOkKeepsPickerClosed() = select(2, 0, fast = true, moves = listOf(Key.DirectionLeft, Key.DirectionLeft))
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.*
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
import io.mockk.*
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.last
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.*
|
||||
import org.justinvisser.wolfskin.design.ShellKey
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Picker changes invalidate identity immediately without closing sockets on the UI thread. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class PersonSwitchThreadingTest {
|
||||
private val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
private lateinit var previousUrl: String
|
||||
private var previousFixtures = false
|
||||
|
||||
@Before fun setUp() {
|
||||
Dispatchers.setMain(Dispatchers.Unconfined)
|
||||
ConductarrPreference.initialize(context)
|
||||
previousUrl = ConductarrPreference.baseUrl
|
||||
previousFixtures = ConductarrPreference.fixtures
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||
ConductarrPreference.updateFixtures(previousFixtures)
|
||||
Dispatchers.resetMain()
|
||||
unmockkConstructor(ConductarrClient::class)
|
||||
}
|
||||
|
||||
@Test fun pickerSwitchesKeepPersonDataAndDisposeEveryClientOffMainWithoutWaiting() = runBlocking {
|
||||
assertSame(Looper.getMainLooper(), Looper.myLooper())
|
||||
val closeThreads = CopyOnWriteArrayList<Boolean>()
|
||||
val releaseCloses = CountDownLatch(1)
|
||||
val closed = CountDownLatch(4)
|
||||
val disposalErrors = CopyOnWriteArrayList<Throwable>()
|
||||
val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, error -> disposalErrors.add(error) })
|
||||
val failFirstClose = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||
mockkConstructor(ConductarrClient::class)
|
||||
every { anyConstructed<ConductarrClient>().close() } answers {
|
||||
val onMain = Looper.myLooper() == Looper.getMainLooper()
|
||||
closeThreads.add(onMain)
|
||||
check(!onMain) { "StrictMode: Conductarr socket close on main" }
|
||||
check(releaseCloses.await(10, TimeUnit.SECONDS)) { "Picker waited for socket disposal" }
|
||||
callOriginal()
|
||||
closed.countDown()
|
||||
if (failFirstClose.getAndSet(false)) throw java.io.IOException("Socket disposal fixture failure")
|
||||
}
|
||||
FakeConductarr().use { fake ->
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope)
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
|
||||
val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN),
|
||||
justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN))
|
||||
val repository = mockk<ServerRepository>(relaxed = true)
|
||||
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
||||
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
|
||||
val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
val clients = mutableListOf<ConductarrClient>()
|
||||
try {
|
||||
model.load(server, justin)
|
||||
requireNotNull(model.onForeground(server, justin)).join()
|
||||
clients.add(conductarr.identity.value!!.client)
|
||||
val shell = session.state!!
|
||||
shell.key(ShellKey.Ok)
|
||||
for (index in listOf(1, 2, 0)) {
|
||||
val switched = CompletableDeferred<CurrentUser>()
|
||||
every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) }
|
||||
shell.focusNav(shell.tabs.size)
|
||||
shell.key(ShellKey.Ok)
|
||||
shell.focusPerson(index)
|
||||
shell.key(ShellKey.Ok)
|
||||
assertEquals(users[index].id.toString(), shell.person.jellyfinUserId)
|
||||
assertEquals("home", shell.route)
|
||||
assertFalse(shell.pickerVisible)
|
||||
assertNull("Old identity must not serve the newly selected person", conductarr.identity.value)
|
||||
val current = withTimeout(5_000) { switched.await() }
|
||||
assertEquals(users[index].id, current.user.id)
|
||||
model.load(current.server, current.user)
|
||||
requireNotNull(model.onForeground(server, current.user)).join()
|
||||
assertEquals(users[index].name!!.lowercase(), conductarr.identity.value!!.operator!!.requester)
|
||||
assertEquals(users[index].name!!.lowercase(), model.signedInPerson)
|
||||
clients.add(conductarr.identity.value!!.client)
|
||||
}
|
||||
conductarr.close()
|
||||
conductarr.close()
|
||||
model.viewModelScope.cancel()
|
||||
releaseCloses.countDown()
|
||||
assertTrue("Every retired client must finish disposal", withContext(Dispatchers.IO) { closed.await(5, TimeUnit.SECONDS) })
|
||||
assertEquals(listOf(false, false, false, false), closeThreads.toList())
|
||||
for (client in clients) {
|
||||
val result = client.read(TvRoutes.operator(), ReadTrigger.FORCE).last()
|
||||
assertEquals("Retired clients cannot start another network read", ClientError.Unreachable, (result as ReadResult.Failure).error)
|
||||
}
|
||||
disposalScope.coroutineContext.job.children.toList().joinAll()
|
||||
assertTrue("A failing socket close must be contained", disposalErrors.isEmpty())
|
||||
} finally {
|
||||
releaseCloses.countDown()
|
||||
model.viewModelScope.cancel()
|
||||
withContext(Dispatchers.IO) { conductarr.close() }
|
||||
disposalScope.coroutineContext.job.children.toList().joinAll()
|
||||
disposalScope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun rapidPickerChoicesIgnoreLateSignInAndCanReturnToTheOriginalPerson() = runBlocking {
|
||||
FakeConductarr().use { fake ->
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
|
||||
val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN),
|
||||
justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN))
|
||||
val repository = mockk<ServerRepository>(relaxed = true)
|
||||
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
||||
val pending = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
coEvery { repository.changeUser(server, any()) } coAnswers {
|
||||
val user = secondArg<JellyfinUser>()
|
||||
if (user.id == users[1].id) {
|
||||
pending.complete(Unit)
|
||||
withContext(NonCancellable) { release.await() }
|
||||
}
|
||||
CurrentUser(server, user)
|
||||
}
|
||||
val switched = CompletableDeferred<CurrentUser>()
|
||||
val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||
every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) }
|
||||
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
try {
|
||||
model.load(server, justin)
|
||||
requireNotNull(model.onForeground(server, justin)).join()
|
||||
val shell = session.state!!
|
||||
shell.key(ShellKey.Ok)
|
||||
for (index in listOf(1, 2, 0)) {
|
||||
shell.focusNav(shell.tabs.size)
|
||||
shell.key(ShellKey.Ok)
|
||||
shell.focusPerson(index)
|
||||
shell.key(ShellKey.Ok)
|
||||
if (index == 1) withTimeout(5_000) { pending.await() }
|
||||
}
|
||||
release.complete(Unit)
|
||||
val current = withTimeout(5_000) { switched.await() }
|
||||
assertEquals(justin.id, current.user.id)
|
||||
verify(exactly = 1) { setup.navigateTo(any()) }
|
||||
model.load(server, current.user)
|
||||
requireNotNull(model.onForeground(server, current.user)).join()
|
||||
assertEquals(justin.id.toString(), shell.person.jellyfinUserId)
|
||||
assertEquals("justin", model.signedInPerson)
|
||||
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
|
||||
} finally {
|
||||
release.complete(Unit)
|
||||
model.viewModelScope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun homeAndTitleRetriesReplaceSignedOutClientsWithoutMainThreadDisposal() = runBlocking {
|
||||
val closeThreads = CopyOnWriteArrayList<Boolean>()
|
||||
val closed = CountDownLatch(3)
|
||||
val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
mockkConstructor(ConductarrClient::class)
|
||||
every { anyConstructed<ConductarrClient>().close() } answers {
|
||||
val onMain = Looper.myLooper() == Looper.getMainLooper()
|
||||
closeThreads.add(onMain)
|
||||
check(!onMain) { "StrictMode: retry socket close on main" }
|
||||
callOriginal()
|
||||
closed.countDown()
|
||||
}
|
||||
FakeConductarr().use { fake ->
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope)
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
|
||||
val repository = mockk<ServerRepository>()
|
||||
every { repository.currentUser } returns user
|
||||
every { repository.currentServer } returns server
|
||||
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
|
||||
coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
|
||||
org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true)))
|
||||
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
|
||||
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository)
|
||||
try {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)
|
||||
conductarr.authenticate()
|
||||
fake.status = 401
|
||||
conductarr.authenticate()
|
||||
assertFalse(conductarr.identity.value!!.signedIn)
|
||||
fake.status = 200
|
||||
homeModel.load(server, user, org.justinvisser.wolfskin.design.ShellPerson("justin", "Justin", "J", false, user.id.toString()), retry = true)
|
||||
withTimeout(5_000) { conductarr.identity.first { it?.signedIn == true } }
|
||||
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
|
||||
assertTrue(homeModel.snapshot.rows.any { row -> row.cards.any { it.id == "justin-film" } })
|
||||
fake.status = 401
|
||||
conductarr.authenticate()
|
||||
assertFalse(conductarr.identity.value!!.signedIn)
|
||||
fake.status = 200
|
||||
titleModel.retrySignIn()
|
||||
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
|
||||
assertTrue(conductarr.identity.value!!.signedIn)
|
||||
} finally {
|
||||
homeModel.viewModelScope.cancel()
|
||||
titleModel.viewModelScope.cancel()
|
||||
conductarr.close()
|
||||
disposalScope.coroutineContext.job.children.toList().joinAll()
|
||||
disposalScope.cancel()
|
||||
}
|
||||
assertEquals(0L, closed.count)
|
||||
assertEquals(listOf(false, false, false), closeThreads.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.flow.last
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Saved-login I/O cannot leave an interactive but disconnected launch shell. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class ShellStartupTimingTest {
|
||||
@Before fun setMain() { Dispatchers.setMain(Dispatchers.Unconfined) }
|
||||
@After fun resetMain() { Dispatchers.resetMain() }
|
||||
|
||||
@Test fun firstFramePickerAndEveryTabWorkBeforeSavedPeopleArrive() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
ConductarrPreference.initialize(context)
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
val previous = ConductarrPreference.baseUrl
|
||||
FakeConductarr().use { fake ->
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||
conductarr.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||
conductarr.authenticate()
|
||||
val activityEntered = CompletableDeferred<Unit>()
|
||||
val releaseActivity = java.util.concurrent.CountDownLatch(1)
|
||||
fake.beforeResponse = {
|
||||
if (it == "/api/v1/activity") {
|
||||
activityEntered.complete(Unit)
|
||||
releaseActivity.await()
|
||||
}
|
||||
}
|
||||
val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() }
|
||||
withTimeout(5_000) { activityEntered.await() }
|
||||
val savedPeople = CompletableDeferred<Unit>()
|
||||
val savedPeopleStarted = CompletableDeferred<Unit>()
|
||||
val repository = mockk<ServerRepository>(relaxed = true)
|
||||
coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeopleStarted.complete(Unit); savedPeople.await(); null }
|
||||
val navigation = mockk<NavigationManager>(relaxed = true)
|
||||
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
val load = launch { model.load(server, user) }
|
||||
try {
|
||||
withTimeout(5_000) { savedPeopleStarted.await() }
|
||||
val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" }
|
||||
assertEquals(PeopleStatus.Ready, shell.peopleStatus)
|
||||
assertEquals(user.id.toString(), shell.person.jellyfinUserId)
|
||||
shell.key(ShellKey.Ok)
|
||||
for (route in listOf("movies", "shows", "discover")) {
|
||||
shell.focusNav(shell.tabs.indexOf(route))
|
||||
shell.key(ShellKey.Ok)
|
||||
verify { navigation.replace(Destination.Wolfskin(route)) }
|
||||
assertEquals(route, shell.route)
|
||||
}
|
||||
savedPeople.complete(Unit)
|
||||
load.join()
|
||||
assertSame(shell, session.state)
|
||||
assertEquals("discover", shell.route)
|
||||
assertFalse("Activity must still be pending while the picker and tabs are usable", activity.isCompleted)
|
||||
} finally {
|
||||
releaseActivity.countDown()
|
||||
activity.cancelAndJoin()
|
||||
savedPeople.complete(Unit)
|
||||
load.cancelAndJoin()
|
||||
model.viewModelScope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
ConductarrPreference.updateBaseUrl(previous)
|
||||
}
|
||||
|
||||
@Test fun switchingPersonDuringHouseholdReadKeepsTheNewIdentityAndReturnsHome() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
ConductarrPreference.initialize(context)
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
val previous = ConductarrPreference.baseUrl
|
||||
FakeConductarr().use { fake ->
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val shared = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||
val mindel = shared.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN)
|
||||
val repository = mockk<ServerRepository>(relaxed = true)
|
||||
coEvery { repository.serverDao.getServer(server.id) } returns
|
||||
com.github.damontecres.wholphin.data.model.JellyfinServerUsers(server, listOf(shared, mindel))
|
||||
val switched = CompletableDeferred<Unit>()
|
||||
coEvery { repository.changeUser(server, mindel) } coAnswers {
|
||||
switched.complete(Unit)
|
||||
com.github.damontecres.wholphin.data.CurrentUser(server, mindel)
|
||||
}
|
||||
val navigation = mockk<NavigationManager>(relaxed = true)
|
||||
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
val pending = CompletableDeferred<Unit>()
|
||||
val release = java.util.concurrent.CountDownLatch(1)
|
||||
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||
fake.beforeResponse = {
|
||||
if (it == "/curation/library-audiences" && firstAudience.getAndSet(false)) {
|
||||
pending.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
try {
|
||||
model.load(server, shared)
|
||||
withTimeout(5_000) { pending.await() }
|
||||
val shell = requireNotNull(session.state)
|
||||
shell.key(ShellKey.Ok)
|
||||
shell.enterDestination("discover")
|
||||
shell.focusNav(shell.tabs.size)
|
||||
shell.key(ShellKey.Ok)
|
||||
shell.focusPerson(1)
|
||||
shell.key(ShellKey.Ok)
|
||||
assertEquals("home", shell.route)
|
||||
verify { navigation.replace(Destination.Home()) }
|
||||
withTimeout(5_000) { switched.await() }
|
||||
model.load(server, mindel)
|
||||
requireNotNull(model.onForeground(server, mindel)).join()
|
||||
release.countDown()
|
||||
assertSame(shell, session.state)
|
||||
assertEquals("mindel", model.signedInPerson)
|
||||
assertEquals(mindel.id.toString(), shell.person.jellyfinUserId)
|
||||
assertEquals("Mindel", shell.person.name)
|
||||
assertEquals("mindel", conductarr.identity.value!!.operator!!.requester)
|
||||
assertEquals("home", shell.route)
|
||||
} finally {
|
||||
release.countDown()
|
||||
model.viewModelScope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
ConductarrPreference.updateBaseUrl(previous)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
|
|
@ -35,6 +36,7 @@ import org.justinvisser.wolfskin.design.TitleVersion
|
|||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -152,7 +154,10 @@ class TitleAsyncFocusTest {
|
|||
assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id)
|
||||
rule.runOnIdle { viewModel.title!!.toast = null }
|
||||
press(Key.DirectionCenter)
|
||||
rule.waitUntil(timeoutMillis = 5000) { reads.get() == 2 && viewModel.title?.toast != null }
|
||||
rule.waitUntil(timeoutMillis = 5000) {
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||
reads.get() == 2 && viewModel.title?.toast != null
|
||||
}
|
||||
rule.waitForIdle()
|
||||
assertEquals("Saved title", viewModel.title!!.data.item.name)
|
||||
assertEquals("20:42", shell.navSavedAt)
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class TitleCaptureTest {
|
|||
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
||||
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
||||
data.episodes.forEach { item ->
|
||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 416, 234)) }
|
||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) }
|
||||
}
|
||||
}
|
||||
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
||||
|
|
@ -215,4 +215,5 @@ class TitleCaptureTest {
|
|||
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
|
||||
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
||||
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
||||
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ class TitleReadFacadeTest {
|
|||
every { repository.currentServer } returns null
|
||||
every { repository.currentUser } returns null
|
||||
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
|
||||
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(emptyList())
|
||||
val context = mockk<Context> { every { filesDir } returns directory.root }
|
||||
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
|
||||
}
|
||||
|
|
@ -103,6 +104,29 @@ class TitleReadFacadeTest {
|
|||
mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams)))
|
||||
}
|
||||
|
||||
@Test fun heldMovieResolvesCatalogueIdentityWhenTheDetailOmitsProviderIds() = runTest {
|
||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(
|
||||
listOf(file(firstId).copy(providerIds = mapOf("Tmdb" to "42"))))
|
||||
val page = facade.read(firstId, null, preferences)
|
||||
assertEquals(42L, page.item.tmdbId)
|
||||
assertEquals(firstId.toString(), page.watchTarget.id)
|
||||
assertEquals(firstId.toString(), TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}.target?.id)
|
||||
FakeConductarr().use { fake ->
|
||||
ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()).use { session ->
|
||||
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||
session.authenticate()
|
||||
val screen = TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}
|
||||
ConductarrTitlePages(session.identity.value!!).title(requireNotNull(page.item.tmdbId),
|
||||
org.justinvisser.wolfskin.conductarr.MediaType.MOVIE, page).collect { screen.update(it) }
|
||||
assertEquals(listOf("header", "cast", "related"), screen.rows)
|
||||
assertEquals("Sam Rockwell", screen.data.cast.single().name)
|
||||
assertEquals(firstId.toString(), screen.target?.id)
|
||||
assertTrue(screen.data.item.held)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||
facade.read(firstId, null, preferences)
|
||||
|
|
|
|||
2568
app/src/test/resources/conductarr/activity-history.json
Normal file
2568
app/src/test/resources/conductarr/activity-history.json
Normal file
File diff suppressed because it is too large
Load diff
8143
app/src/test/resources/conductarr/mindel-feed.json
Normal file
8143
app/src/test/resources/conductarr/mindel-feed.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -16,6 +17,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.File
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.justinvisser.wolfskin.conductarr.CacheEntry
|
||||
import org.justinvisser.wolfskin.conductarr.CacheStore
|
||||
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||
|
||||
/** Advanced-only endpoint and fixture controls contain no credentials. */
|
||||
|
|
@ -50,6 +54,15 @@ object ConductarrPreference {
|
|||
object ConductarrModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun session(@ApplicationContext context: Context): ConductarrSession =
|
||||
ConductarrSession(FileCacheStore(File(context.noBackupFilesDir, "conductarr")))
|
||||
fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope): ConductarrSession =
|
||||
ConductarrSession(ConductarrDiskCache(context), scope)
|
||||
}
|
||||
|
||||
/** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */
|
||||
private class ConductarrDiskCache(context: Context) : CacheStore {
|
||||
private val store by lazy { FileCacheStore(File(context.noBackupFilesDir, "conductarr")) }
|
||||
override fun read(scope: String, key: String) = store.read(scope, key)
|
||||
override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) = store.write(scope, key, entry, capacity)
|
||||
override fun remove(scope: String, key: String) = store.remove(scope, key)
|
||||
override fun clear(scope: String) = store.clear(scope)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,20 +41,62 @@ internal fun DiscoverTitle.homeTitle(): HomeTitle = HomeTitle(
|
|||
overview = overview, poster = poster, backdrop = backdrop, genres = genres, tmdbId = tmdb.toInt(),
|
||||
)
|
||||
|
||||
/** Activity is history; stage evidence must not turn a past dispatch into a live transfer. */
|
||||
internal fun RequestProgress.comingIn(): ComingInRequest {
|
||||
val stagesByName = stages.orEmpty().associateBy { it.name }
|
||||
val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION]
|
||||
val visibility = stagesByName[ReadinessStage.Name.VISIBILITY]
|
||||
val playback = stagesByName[ReadinessStage.Name.PLAYBACK]
|
||||
val stage = when {
|
||||
stages.orEmpty().any { it.state == ReadinessStage.State.FAILED } -> ComingInStage.Failed
|
||||
acquisition?.summary == "The title was deleted after it was dispatched." -> ComingInStage.Removed
|
||||
acquisition?.summary == "The title is not released yet; there is nothing to acquire." -> ComingInStage.NotOutYet
|
||||
playback?.state == ReadinessStage.State.FAILED || playback?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.FileHealth
|
||||
visibility?.summary?.startsWith("The title is only in libraries this viewer does not see: ") == true -> ComingInStage.HeldElsewhere
|
||||
visibility?.summary == "The title is not in a library this viewer sees." -> ComingInStage.NotInLibrary
|
||||
visibility?.state == ReadinessStage.State.FAILED || visibility?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.CheckNeeded
|
||||
stagesByName[ReadinessStage.Name.REQUEST]?.state == ReadinessStage.State.FAILED || acquisition?.state == ReadinessStage.State.FAILED -> ComingInStage.Failed
|
||||
visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready
|
||||
awaitingApproval == true -> ComingInStage.Waiting
|
||||
visibility?.state in setOf(ReadinessStage.State.IN_PROGRESS, ReadinessStage.State.PARTIAL) || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
|
||||
acquisition?.state == ReadinessStage.State.IN_PROGRESS -> ComingInStage.Downloading
|
||||
acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Searching
|
||||
stages.orEmpty().any { it.state in setOf(ReadinessStage.State.UNKNOWN, ReadinessStage.State.STALE, ReadinessStage.State.CONFLICTING) } -> ComingInStage.Checking
|
||||
else -> ComingInStage.Waiting
|
||||
awaitingApproval == true || acquisition?.summary == "Waiting for Seerr's approval to reach dispatch." -> ComingInStage.Waiting
|
||||
acquisition?.summary == "Dispatched; waiting for the Arr to import a file." -> ComingInStage.WaitingForFile
|
||||
visibility?.state == ReadinessStage.State.IN_PROGRESS || visibility?.state == ReadinessStage.State.PARTIAL || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
|
||||
acquisition?.state == ReadinessStage.State.IN_PROGRESS && acquisition.summary.startsWith("Downloading", ignoreCase = true) -> ComingInStage.Downloading
|
||||
acquisition?.state == ReadinessStage.State.NONE && acquisition.summary.startsWith("Searching", ignoreCase = true) -> ComingInStage.Searching
|
||||
acquisition?.state == ReadinessStage.State.IN_PROGRESS || acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Waiting
|
||||
else -> ComingInStage.Checking
|
||||
}
|
||||
val detail = when (stage) {
|
||||
ComingInStage.FileHealth -> playback
|
||||
ComingInStage.CheckNeeded, ComingInStage.HeldElsewhere, ComingInStage.NotInLibrary, ComingInStage.Ready -> visibility
|
||||
ComingInStage.Failed -> stages.orEmpty().firstOrNull { it.state == ReadinessStage.State.FAILED }
|
||||
else -> acquisition
|
||||
}
|
||||
return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key,
|
||||
requester.name, actor?.requester, actor?.name, stage)
|
||||
requester.name, actor?.requester, actor?.name, stage, summary = detail?.summary,
|
||||
readiness = ReadinessStage.Name.entries.map { stagesByName[it].readinessWord() })
|
||||
}
|
||||
|
||||
/** Expanded readiness describes each observed stage, not a synthetic download failure. */
|
||||
private fun ReadinessStage?.readinessWord(): String = when {
|
||||
this == null -> "Unknown"
|
||||
name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Needs attention"
|
||||
name == ReadinessStage.Name.VISIBILITY && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Check needed"
|
||||
name == ReadinessStage.Name.ACQUISITION && summary == "The title was deleted after it was dispatched." -> "Removed"
|
||||
name == ReadinessStage.Name.ACQUISITION && summary == "The title is not released yet; there is nothing to acquire." -> "Not out yet"
|
||||
name == ReadinessStage.Name.ACQUISITION && summary.startsWith("Dispatched; the Arr has represented files") -> "Files present"
|
||||
name == ReadinessStage.Name.ACQUISITION && summary == "Dispatched; waiting for the Arr to import a file." -> "Waiting for a file"
|
||||
name == ReadinessStage.Name.VISIBILITY && summary.startsWith("The title is only in libraries this viewer does not see: ") -> "Another library"
|
||||
name == ReadinessStage.Name.VISIBILITY && summary == "The title is not in a library this viewer sees." -> "Not in library"
|
||||
name == ReadinessStage.Name.ACQUISITION && state == ReadinessStage.State.IN_PROGRESS && summary.startsWith("Downloading", ignoreCase = true) -> "Downloading"
|
||||
name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.DONE || state == ReadinessStage.State.PARTIAL) -> "Recorded play"
|
||||
name == ReadinessStage.Name.PLAYBACK && state == ReadinessStage.State.UNKNOWN -> "Not checked yet"
|
||||
else -> when (state) {
|
||||
ReadinessStage.State.DONE -> "Done"
|
||||
ReadinessStage.State.FAILED -> "Failed"
|
||||
ReadinessStage.State.IN_PROGRESS -> "In progress"
|
||||
ReadinessStage.State.PARTIAL -> "Partial"
|
||||
ReadinessStage.State.NONE -> "Not yet"
|
||||
ReadinessStage.State.UNKNOWN -> "Unknown"
|
||||
ReadinessStage.State.STALE -> "Last observed"
|
||||
ReadinessStage.State.CONFLICTING -> "Check needed"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,10 +57,14 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
|
|||
val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it }
|
||||
val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE)
|
||||
val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name }
|
||||
val picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) }
|
||||
val seeds = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).flatMap { candidate ->
|
||||
candidate.contributions.orEmpty().mapNotNull { seed -> seed.title?.let { it to candidate.discoverTitle(genres) } }
|
||||
}.groupBy({ it.first }, { it.second }).map { DiscoverSeed(it.key, it.value) }
|
||||
val candidates = feed?.movies.orEmpty() + feed?.shows.orEmpty()
|
||||
val picks = candidates.map { it.discoverTitle(genres) }
|
||||
val seeds = candidates.flatMapIndexed { index, candidate ->
|
||||
candidate.contributions.orEmpty().filter { !it.title.isNullOrBlank() }.map { it to picks[index] }
|
||||
}.groupBy { (seed, _) -> "${seed.mediaType.name.lowercase()}:${seed.tmdbId}" }.map { (key, contributions) ->
|
||||
DiscoverSeed(key, requireNotNull(contributions.first().first.title), contributions.map { it.second },
|
||||
watched = contributions.any { it.first.watched }, added = contributions.any { it.first.added })
|
||||
}
|
||||
val hidden = hiddenJob.await()?.dismissals.orEmpty().map {
|
||||
DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,17 @@ package org.justinvisser.wolfskin.tv
|
|||
|
||||
import java.io.Closeable
|
||||
import java.security.MessageDigest
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.justinvisser.wolfskin.conductarr.*
|
||||
import org.justinvisser.wolfskin.conductarr.model.Operator
|
||||
import timber.log.Timber
|
||||
|
||||
/** A selected Jellyfin login owns every provider and all in-memory observations. */
|
||||
data class ConductarrIdentity(
|
||||
|
|
@ -21,53 +26,85 @@ data class ConductarrIdentity(
|
|||
)
|
||||
|
||||
/** Tokens are borrowed from upstream, never persisted or exposed as observable state. */
|
||||
class ConductarrSession(private val cache: CacheStore) : Closeable {
|
||||
class ConductarrSession(
|
||||
private val cache: CacheStore,
|
||||
private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) : Closeable {
|
||||
private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null)
|
||||
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
|
||||
private var selection: ByteArray? = null
|
||||
@Volatile private var generation = 0L
|
||||
|
||||
@Synchronized
|
||||
fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) {
|
||||
val fingerprint = MessageDigest.getInstance("SHA-256")
|
||||
.digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray())
|
||||
if (!retry && selection?.contentEquals(fingerprint) == true) return
|
||||
generation++
|
||||
val currentGeneration = generation
|
||||
mutableIdentity.value?.client?.close()
|
||||
mutableIdentity.value = null
|
||||
selection = fingerprint
|
||||
if (token.isNullOrBlank()) return
|
||||
val client = try {
|
||||
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event ->
|
||||
if (event == ClientEvent.NEEDS_SIGN_IN && generation == currentGeneration) {
|
||||
mutableIdentity.update { it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) }
|
||||
}
|
||||
})
|
||||
} catch (_: IllegalArgumentException) { return }
|
||||
mutableIdentity.value = ConductarrIdentity(person, client, ConductarrProvider(client))
|
||||
val previous: ConductarrClient?
|
||||
val currentGeneration: Long
|
||||
synchronized(this) {
|
||||
if (!retry && selection?.contentEquals(fingerprint) == true) return
|
||||
generation++
|
||||
currentGeneration = generation
|
||||
previous = mutableIdentity.value?.client
|
||||
mutableIdentity.value = null
|
||||
selection = fingerprint
|
||||
}
|
||||
try {
|
||||
val client = if (token.isNullOrBlank()) null else try {
|
||||
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event ->
|
||||
if (event == ClientEvent.NEEDS_SIGN_IN) {
|
||||
mutableIdentity.update {
|
||||
if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (_: IllegalArgumentException) { null }
|
||||
synchronized(this) {
|
||||
if (generation == currentGeneration) {
|
||||
mutableIdentity.value = client?.let { ConductarrIdentity(person, it, ConductarrProvider(it)) }
|
||||
} else dispose(client)
|
||||
}
|
||||
} finally {
|
||||
dispose(previous)
|
||||
}
|
||||
}
|
||||
|
||||
/** Disk identity paints first; a 401 always removes it, even after an earlier saved emission. */
|
||||
suspend fun authenticate() {
|
||||
val selected = mutableIdentity.value ?: return
|
||||
selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result ->
|
||||
if (mutableIdentity.value?.client !== selected.client) return@collect
|
||||
when (result) {
|
||||
is ReadResult.Value -> mutableIdentity.update { it?.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) }
|
||||
is ReadResult.Value -> mutableIdentity.update {
|
||||
if (it?.client === selected.client) it.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) else it
|
||||
}
|
||||
is ReadResult.Failure -> mutableIdentity.update {
|
||||
if (result.error == ClientError.NeedsSignIn) it?.copy(operator = null, signedIn = false, error = result.error)
|
||||
else it?.copy(error = result.error)
|
||||
if (it?.client !== selected.client) it
|
||||
else if (result.error == ClientError.NeedsSignIn) it.copy(operator = null, signedIn = false, error = result.error)
|
||||
else it.copy(error = result.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove read authority synchronously; socket disposal finishes independently on IO. */
|
||||
@Synchronized
|
||||
override fun close() {
|
||||
generation++
|
||||
mutableIdentity.value?.client?.close()
|
||||
val previous = mutableIdentity.value?.client
|
||||
mutableIdentity.value = null
|
||||
selection = null
|
||||
dispose(previous)
|
||||
}
|
||||
|
||||
private fun dispose(client: ConductarrClient?) {
|
||||
if (client == null) return
|
||||
// The application scope outlives the screen replaced by a person switch.
|
||||
disposalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
client.close()
|
||||
} catch (_: Exception) {
|
||||
Timber.w("Conductarr client disposal failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,18 +95,6 @@ internal fun rememberDiscoverState(
|
|||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.title?.key, state) {
|
||||
val title = state.title ?: return@LaunchedEffect
|
||||
if (!fixtures && identity?.signedIn == true) {
|
||||
val media = if (title.type == "movie") MediaType.MOVIE else MediaType.TV
|
||||
identity.provider.detail(title.tmdb, media).collect { result ->
|
||||
val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value }
|
||||
if (detail != null && state.title?.key == title.key) state.updateTitle(title.copy(
|
||||
runtimeSeconds = detail.runtime?.times(60)?.toInt(), genres = detail.genres.orEmpty(),
|
||||
seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size, heldBy = identity.provider.holders(detail.title)))
|
||||
}
|
||||
}
|
||||
}
|
||||
val result = if (fixtures) fixtureState ?: state else state
|
||||
LaunchedEffect(shell.route, result) {
|
||||
result.enterRoute(shell.route)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
catalogueJob?.cancel()
|
||||
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)
|
||||
withContext(Dispatchers.IO) {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||
}
|
||||
}
|
||||
loadLibrary(server, user, person, retry)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,6 @@ internal suspend fun refreshRequestsWhileStarted(state: ShellState, refresh: ()
|
|||
refresh()
|
||||
while (currentCoroutineContext().isActive) {
|
||||
delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L)
|
||||
if (state.requestsVisible || state.requests.snapshot.openCount > 0) refresh()
|
||||
if (state.requestsVisible || state.requests.snapshot.hasUnsettledRequests) refresh()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,7 +210,9 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
internal suspend fun retrySignIn() {
|
||||
val user = repository.currentUser ?: return
|
||||
val server = repository.currentServer ?: return
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||
withContext(Dispatchers.IO) {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||
}
|
||||
conductarr.authenticate()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
|
@ -60,6 +63,7 @@ import org.justinvisser.wolfskin.design.DiscoverScreen
|
|||
class WolfskinSession @Inject constructor() {
|
||||
var state by mutableStateOf<ShellState?>(null)
|
||||
var serverId: String? = null
|
||||
internal var fixtureMode: Boolean? = null
|
||||
var onAction: (ShellAction) -> Unit = {}
|
||||
}
|
||||
|
||||
|
|
@ -85,24 +89,29 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
var signedInPerson by mutableStateOf("")
|
||||
private set
|
||||
private var identityJob: kotlinx.coroutines.Job? = null
|
||||
private var fixtureMode: Boolean? = null
|
||||
private var personSwitchJob: kotlinx.coroutines.Job? = null
|
||||
private val personSwitchMutex = Mutex()
|
||||
|
||||
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
|
||||
WolfskinScreenPreference.initialize(context)
|
||||
ConductarrPreference.initialize(context)
|
||||
withContext(Dispatchers.IO) {
|
||||
WolfskinScreenPreference.initialize(context)
|
||||
ConductarrPreference.initialize(context)
|
||||
}
|
||||
identityJob?.cancel()
|
||||
libraries.audiences = null
|
||||
if (ConductarrPreference.fixtures) conductarr.close()
|
||||
else conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl)
|
||||
else withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) }
|
||||
signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase()
|
||||
if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) {
|
||||
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
|
||||
val people = readShellPeople(context, saved.ifEmpty { listOf(user) })
|
||||
session.serverId = server.id.toString()
|
||||
fixtureMode = ConductarrPreference.fixtures
|
||||
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
|
||||
}
|
||||
session.onAction = { action -> handleAction(action, server, user) }
|
||||
if (session.state == null || session.serverId != server.id.toString() || session.fixtureMode != ConductarrPreference.fixtures) {
|
||||
session.serverId = server.id.toString()
|
||||
session.fixtureMode = ConductarrPreference.fixtures
|
||||
val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) }
|
||||
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
|
||||
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
|
||||
val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) }
|
||||
session.state?.updatePeople(savedPeople)
|
||||
}
|
||||
identityJob = viewModelScope.launch {
|
||||
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
||||
}
|
||||
|
|
@ -123,12 +132,19 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
|
||||
audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() }
|
||||
?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it }
|
||||
val hasAnime = try {
|
||||
libraries.hasAnimeTitles(user.id)
|
||||
} catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.hasAnime ?: false
|
||||
}
|
||||
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
|
||||
val updated = session.state?.people.orEmpty().map { person ->
|
||||
val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) }
|
||||
val current = person.jellyfinUserId == user.id.toString()
|
||||
person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key,
|
||||
name = if (current) identity.operator!!.name else profile?.name ?: person.name,
|
||||
hasAnime = audiences?.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) } ?: person.hasAnime)
|
||||
hasAnime = if (current) hasAnime else person.hasAnime)
|
||||
}
|
||||
session.state?.updatePeople(updated)
|
||||
signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson
|
||||
|
|
@ -142,8 +158,10 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
|
||||
retry = conductarr.identity.value?.signedIn != true)
|
||||
withContext(Dispatchers.IO) {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
|
||||
retry = conductarr.identity.value?.signedIn != true)
|
||||
}
|
||||
loadHousehold(user)
|
||||
}
|
||||
|
||||
|
|
@ -174,10 +192,17 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
|
||||
private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) {
|
||||
when (action) {
|
||||
is ShellAction.Destination -> if (action.route == "settings") navigation.navigateTo(Destination.Wolfskin("settings")) else navigation.replace(if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route))
|
||||
is ShellAction.Destination -> {
|
||||
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
|
||||
when {
|
||||
action.route == "settings" -> navigation.navigateTo(target)
|
||||
action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target
|
||||
else -> navigation.replace(target)
|
||||
}
|
||||
}
|
||||
ShellAction.Pop -> navigation.goBack()
|
||||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
||||
is ShellAction.Person -> selectPerson(action.person, server, user)
|
||||
is ShellAction.Person -> selectPerson(action.person, server)
|
||||
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
||||
ShellAction.RetryRequests -> viewModelScope.launch {
|
||||
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
|
||||
|
|
@ -187,26 +212,29 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) {
|
||||
private fun selectPerson(person: ShellPerson, server: JellyfinServer) {
|
||||
navigation.replace(Destination.Home())
|
||||
if (person.jellyfinUserId == user.id.toString() || person.key == signedInPerson) return
|
||||
if (person.key == signedInPerson) return
|
||||
identityJob?.cancel()
|
||||
personSwitchJob?.cancel()
|
||||
signedInPerson = ""
|
||||
conductarr.close()
|
||||
libraries.audiences = null
|
||||
viewModelScope.launch {
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
||||
}
|
||||
if (saved == null || saved.isProtected) {
|
||||
signedInPerson = ""
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val current = repository.changeUser(server, saved)
|
||||
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
signedInPerson = ""
|
||||
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
||||
personSwitchJob = viewModelScope.launch {
|
||||
personSwitchMutex.withLock {
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
||||
}
|
||||
if (saved == null || saved.isProtected) return@launch
|
||||
try {
|
||||
val current = repository.changeUser(server, saved)
|
||||
coroutineContext.ensureActive()
|
||||
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
coroutineContext.ensureActive()
|
||||
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -230,6 +258,8 @@ fun WolfskinApp(
|
|||
preferences: UserPreferences,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: WolfskinShellViewModel = hiltViewModel(),
|
||||
homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit =
|
||||
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
|
||||
) {
|
||||
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
|
||||
|
|
@ -274,17 +304,23 @@ fun WolfskinApp(
|
|||
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||
entryProvider = { destination ->
|
||||
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
||||
// Cached entries must not retain the launch shell after sign-in completes.
|
||||
val state = LocalShellState.current
|
||||
val discover = LocalDiscoverState.current
|
||||
val route = when (destination) {
|
||||
is Destination.Home -> "home"
|
||||
is Destination.Wolfskin -> destination.route
|
||||
is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString())
|
||||
else -> "pushed"
|
||||
}
|
||||
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }
|
||||
val isCurrent = navigationManager.backStack.lastOrNull() == destination
|
||||
LaunchedEffect(destination, state, isCurrent) {
|
||||
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
|
||||
}
|
||||
if (destination.fullScreen) {
|
||||
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
||||
} else {
|
||||
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
||||
BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
||||
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
||||
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
||||
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
||||
|
|
@ -297,7 +333,7 @@ fun WolfskinApp(
|
|||
when {
|
||||
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
|
||||
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
|
||||
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
|
||||
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
|
||||
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ fun WolfskinLibraryContent(
|
|||
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
||||
}
|
||||
}
|
||||
val snapshots = remember(context) { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) }
|
||||
val snapshots = remember(context) { lazy { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) } }
|
||||
val grid = remember(shell.person.key, route, view) {
|
||||
view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/").takeIf { it.startsWith("${shell.person.key}/") } ?: "") else LibraryGridState(
|
||||
title = route.replaceFirstChar { it.uppercase() }, items = emptyList(),
|
||||
|
|
@ -81,7 +81,8 @@ fun WolfskinLibraryContent(
|
|||
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 store = withContext(Dispatchers.IO) { snapshots.value }
|
||||
store.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
||||
val focusedId = grid.focusedTitle?.id
|
||||
grid.source = snapshot.items
|
||||
focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } }
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import org.justinvisser.wolfskin.design.HomeTitle
|
|||
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
|
||||
@Singleton
|
||||
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
|
||||
private val store = FileCacheStore(File(context.filesDir, "wolfskin-home"))
|
||||
private val store by lazy { FileCacheStore(File(context.filesDir, "wolfskin-home")) }
|
||||
|
||||
fun read(scope: String): Pair<HomeLibrary, Long>? {
|
||||
val entry = store.read(scope, KEY) ?: return null
|
||||
|
|
|
|||
|
|
@ -48,6 +48,18 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Audience metadata names a grouping; only this user's visible titles make Anime available. */
|
||||
suspend fun hasAnimeTitles(userId: UUID): Boolean {
|
||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
|
||||
for (library in libraries) {
|
||||
if (destination(library.name.orEmpty(), library.collectionType) != "anime") continue
|
||||
val titles = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1).content.items.orEmpty()
|
||||
if (titles.isNotEmpty()) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** A destination spans every matching accessible library; containers never become titles. */
|
||||
fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow {
|
||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->
|
||||
|
|
|
|||
|
|
@ -83,8 +83,8 @@ class TitleReadFacade @Inject constructor(
|
|||
/** An episode opens its series with that exact episode as both play and watched target. */
|
||||
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
|
||||
val file = cacheFile(id)
|
||||
val opened = api.userLibraryApi.getItem(id).content
|
||||
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content else opened
|
||||
val opened = readItem(id)
|
||||
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 }
|
||||
|
|
@ -107,6 +107,20 @@ class TitleReadFacade @Inject constructor(
|
|||
result.copy(trailerUrl = trailer)
|
||||
}
|
||||
|
||||
/** User-library detail may omit metadata fields; request catalogue identity and cast explicitly. */
|
||||
private suspend fun readItem(id: UUID): BaseItemDto {
|
||||
val item = api.userLibraryApi.getItem(id).content
|
||||
if (item.providerIds.orEmpty().keys.any { it.equals("Tmdb", true) } && item.people != null) return item
|
||||
val metadata = try {
|
||||
api.itemsApi.getItems(GetItemsRequest(ids = listOf(id), userId = repository.currentUser?.id,
|
||||
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.PEOPLE))).content.items.orEmpty().firstOrNull { it.id == id }
|
||||
} catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
null
|
||||
} ?: return item
|
||||
return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people)
|
||||
}
|
||||
|
||||
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */
|
||||
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
|
||||
|
||||
|
|
|
|||
292
docker/README.md
292
docker/README.md
|
|
@ -1,8 +1,8 @@
|
|||
# Wolfskin TV container builds
|
||||
|
||||
Run from a full Git checkout with the upstream `v*` and `p*` tags available. The
|
||||
host needs Docker, GNU Make, Bash, Git, and `flock` (util-linux), not Java or the
|
||||
Android SDK.
|
||||
host needs Docker, GNU Make, Bash, Git, `realpath` (GNU coreutils), and `flock`
|
||||
(util-linux), not Java or the Android SDK.
|
||||
|
||||
```sh
|
||||
git fetch origin --tags
|
||||
|
|
@ -27,6 +27,85 @@ Upstream enables JVM unit tests only for debug variants; there is no
|
|||
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
|
||||
task without changing upstream's test configuration.
|
||||
|
||||
## Optimized side-by-side comparison
|
||||
|
||||
The `optimized` build type inherits `release`: R8 optimization/minification,
|
||||
resource shrinking, non-debuggable code, dependency consumer keep rules and
|
||||
dependency baseline profiles. It changes only signing (the Android debug key),
|
||||
package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`).
|
||||
The launcher icon/banner and release-compatible version name are unchanged:
|
||||
the shared upgrade/updater parser rejects extra suffixes such as `-opt`.
|
||||
`make apk` remains the clean, release-key-signed production build. Debug and
|
||||
optimized containers share the persistent debug key described below; release
|
||||
signing is unchanged.
|
||||
|
||||
Build all four default-flavor outputs in one invocation through the container
|
||||
wrapper, without a release keystore. On a host whose Gradle cache already holds
|
||||
the pinned native extensions and their metadata:
|
||||
|
||||
```sh
|
||||
bash docker/gradle.sh optimized --offline \
|
||||
-PWholphinExtensionsUsername=JustinZeus \
|
||||
-PWholphinExtensionsPassword=unused \
|
||||
:app:assembleDefaultOptimized
|
||||
```
|
||||
|
||||
The unused password is not a credential: offline resolution never contacts
|
||||
GitHub Packages. A cold cache instead needs a read-packages credential supplied
|
||||
through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and
|
||||
`ORG_GRADLE_PROJECT_WholphinExtensionsPassword` hooks inside the build container,
|
||||
or securely seeded cached artifacts. No release signing inputs are needed.
|
||||
Without native extension configuration, the existing dependency selection uses
|
||||
the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a
|
||||
playback-complete comparison. Inspect native libraries before installing.
|
||||
|
||||
Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a,
|
||||
armeabi-v7a and x86_64. Keep
|
||||
`app/build/outputs/mapping/defaultOptimized/mapping.txt` with those APKs to
|
||||
deobfuscate crashes. This variant shares release library variants and the release
|
||||
`debugHomeSnapshot` implementation, not `src/debug` or its preview JSON/artwork.
|
||||
`BuildConfig.DEBUG` remains false: no preview-fixture setting, debug StrictMode
|
||||
or Compose diagnostic stack traces.
|
||||
|
||||
This is a fresh app sandbox: sign in with Jellyfin Quick Connect or the normal
|
||||
server/user flow again; it does not inherit the debug/release app's logins or
|
||||
preferences. **Wolfskin screens** defaults on, and the same Advanced switch can
|
||||
restore upstream screens. The same Conductarr door
|
||||
(`https://wolfskin-tv.justinvisser.org`) and URL override apply. The selected
|
||||
Jellyfin user's token is borrowed for Conductarr; no app-specific TV credential
|
||||
or embedded secret is added. All other settings retain release behavior,
|
||||
including update checks; a production release APK is not an update for this
|
||||
separate package/debug signer.
|
||||
|
||||
`androidx.profileinstaller` and library-provided profiles already exist. There
|
||||
is no recorded app baseline-profile source or Macrobenchmark module.
|
||||
`src/optimized/baselineProfiles/baseline-prof.txt` adds a hand-maintained seed: application and
|
||||
activity classes plus their `onCreate` methods, `WolfskinAppKt`,
|
||||
`WolfskinScreenPreference` and `ConductarrPreference`. AGP merges it with library
|
||||
profiles, rewrites it through R8 and packages `assets/dexopt/baseline.prof` and
|
||||
`baseline.profm`. It is not a measured navigation profile or evidence of a speedup.
|
||||
To replace the seed, add an AndroidX Baseline Profile producer/Macrobenchmark
|
||||
module, record cold launch, Quick Connect/sign-in, person selection, Home,
|
||||
Libraries, Discover, title pages and playback on a suitable device, then rebuild
|
||||
and compare startup/frame timings. Device-driven recording and runtime checks
|
||||
remain separate from an APK build.
|
||||
The optimized source set explicitly registers that profile directory; a bare
|
||||
`src/optimized/baseline-prof.txt` is not consumed by this AGP configuration.
|
||||
|
||||
Static verification: use `aapt2 dump badging` for package/label/SDKs and absence
|
||||
of `application-debuggable`; `apksigner verify --print-certs` for the debug
|
||||
signer; `unzip -l` for both dexopt assets, native ABI contents and absence of
|
||||
`assets/wolfskin-*`/`assets/library/` fixtures. Review R8 warnings and effective
|
||||
consumer rules rather than adding blanket keeps. JVM tests do not prove
|
||||
minified Hilt/Room/serialization/JNI runtime safety; exercise sign-in, real
|
||||
Conductarr reads, artwork, database reopen and each player on the comparison box.
|
||||
`help --task :app:testDefaultOptimizedUnitTest` confirms that this non-debug
|
||||
variant has no JVM unit-test task, just like release. The existing debug JVM
|
||||
`VersionCompareTests` receives the actual optimized version name from the variant
|
||||
API and checks it with the same `Version.fromString` used by upgrade/update handling.
|
||||
Build logic is otherwise checked through actual APK assembly and static inspection.
|
||||
|
||||
|
||||
## Upstream v1.1.0 integration
|
||||
|
||||
The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and
|
||||
|
|
@ -104,12 +183,44 @@ household labels, requester capabilities and library audiences after sign-in.
|
|||
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
|
||||
only when the Advanced debug fixture switch is enabled; release excludes it.
|
||||
|
||||
Person selection immediately clears the old Conductarr identity and Jellyfin
|
||||
read gate before saved-login I/O. Detached clients are disposed exactly once on
|
||||
the application's IO scope, after the replacement (or signed-out) identity is
|
||||
published; a slow or failing socket close cannot block the picker or abort a
|
||||
switch. Cleanup outlives the replaced screen. Retry and session replacement use
|
||||
the same disposal owner. Superseded sign-ins are cancelled and serialized so a
|
||||
late reply cannot navigate back to an earlier person.
|
||||
|
||||
The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its
|
||||
server and shell state, not to the user-scoped ViewModel. Switching people replaces
|
||||
the upstream app entry and ViewModel but retains the same-server, same-mode shell:
|
||||
the picker stays closed on Home after the first OK. A cold start still opens it,
|
||||
and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real
|
||||
shell and user-entry replacement for launch/avatar Left and Right, the initial
|
||||
person, gear and back, and fast Left-Left-OK; each selection emits one person action.
|
||||
|
||||
Preference and people-fixture reads, Conductarr client construction (including
|
||||
Home/title retries), and private-cache directory initialization run on IO.
|
||||
`PersonSwitchThreadingTest` replays the real picker across Justin → Mindel →
|
||||
Shared → Justin while all closes are held pending, verifies retired clients
|
||||
cannot start reads, contains a failing close, and checks a burst of choices
|
||||
against a late sign-in reply. The thread-recording close fake rejects the main
|
||||
Looper; actual TLS/StrictMode verification remains a real-device check.
|
||||
|
||||
Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous
|
||||
See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...`
|
||||
destination is an explicit title-family integration stub exposing only Discover's
|
||||
request and pick actions. Unheld titles have no trailer control until the pinned
|
||||
Conductarr title contract supplies a trailer.
|
||||
|
||||
Named “Because you watched” shelves require watched contribution evidence.
|
||||
Seeds are grouped by media type and TMDB identity, never by caption; repeated
|
||||
contributions retain both watched/added flags. The current feed has no per-seed
|
||||
watch timestamp, so eligible shelves keep wire order. Added-only contributions
|
||||
remain available in the general For-you collections, but make no named shelf.
|
||||
Titleless contributions do not make a shelf. Recommendations remain acquisition
|
||||
suggestions, not a filter to the viewer's current library.
|
||||
|
||||
The debug-only fixture and local art live in
|
||||
`app/src/debug/assets/wolfskin-discover/`. The Advanced preview switch selects a
|
||||
network-free fixture host; real providers are the default in debug and release.
|
||||
|
|
@ -248,14 +359,24 @@ are recorded differences, not a claim of browser pixel equality.
|
|||
|
||||
## Requests under Settings
|
||||
|
||||
The Requests row and Coming-in sheet share one immutable household snapshot.
|
||||
Ready requests do not count as open. Rows sort Failed, Downloading/Almost ready,
|
||||
Checking/Searching, Waiting, Ready. The bounded window shows five rows, or four
|
||||
with readiness expanded. Right expands Request/Download/Library/Playback; Left
|
||||
collapses; Up/Down collapse while moving. Opening a title preserves the request
|
||||
for Back, and closing the sheet restores its opener. Selecting a root or changing
|
||||
person clears the title-return link. Shared's authenticated actor always reads
|
||||
“by Shared”, never “by you”.
|
||||
The Requests row and Coming-in sheet share one immutable household history snapshot.
|
||||
Settings counts returned requests, including Ready, rather than calling every
|
||||
non-Ready row “open”. An unavailable empty snapshot has no count. Deleted rows
|
||||
read Removed; absent holdings read Not in any library; unreleased titles read
|
||||
Not out yet; holdings outside the recipient's libraries read In another library.
|
||||
Waiting for an import is not evidence of an active download. Downloading and
|
||||
Searching require explicit acquisition signals. Visibility-proof faults read
|
||||
Check needed; playback faults read File health needs attention, even when a
|
||||
library holding is present. Right expands the actual stage evidence and the
|
||||
relevant summary, including episode/source names when supplied by that summary.
|
||||
The activity feed currently supplies only a title-level summary for Avatar.
|
||||
|
||||
Attention-needed rows sort first, then active acquisition/checks, waiting and
|
||||
settled history. The bounded window shows five rows, or four with readiness
|
||||
expanded. Left collapses; Up/Down collapse while moving. Opening a title preserves
|
||||
the request for Back, and closing the sheet restores its opener. Selecting a root
|
||||
or changing person clears the title-return link. Shared's authenticated actor
|
||||
always reads “by Shared”, never “by you”; absent actors have no “by” clause.
|
||||
|
||||
The debug fixture switch loads `app/src/debug/assets/wolfskin-requests.json` and
|
||||
its eight local posters. Shared and focused-rating records also feed the native
|
||||
|
|
@ -307,12 +428,22 @@ type and TMDB identity. Back restores the exact Coming-in row and expansion;
|
|||
the temporary `request-title/...` placeholder is removed. TV sign-in, release
|
||||
and device deployment remain separate.
|
||||
|
||||
`ActivityTruthProjectionTest` feeds recorded activity JSON through the TV adapter
|
||||
for Steins;Gate, Yu Yu Hakusho, Love is Blind, Spider-Man: Homecoming, deleted
|
||||
One Hour Photo, unreleased The Body, Avatar health and Partition waiting.
|
||||
`ConductarrTruthCaptureTest` renders those provider snapshots and Mindel's recorded
|
||||
recommendation feed on the native Compose surfaces at 1920×1080, writing
|
||||
`build/wolfskin-captures/truth/`. Artwork is deliberately omitted to keep these
|
||||
captures offline; missing-art rendering, tokens, focus, sheet geometry and shelf
|
||||
spacing are the shipped components.
|
||||
|
||||
## Libraries and See all
|
||||
|
||||
Movies, Shows and Anime use the shell's retained view and a continuous six-column
|
||||
lazy grid. Up from its first row reaches Sort/Filter, then the nav; Back preserves
|
||||
the card and scroll. Sort cycles A–Z, Recently added and Rating. The letter rail
|
||||
exists only at 200 titles with A–Z selected. Card OK activates on key-up through
|
||||
lazy grid, reduced to five columns while the letter rail is visible so scaled
|
||||
posters cannot overlap it. Up from the first row reaches Sort/Filter, then the nav;
|
||||
Back preserves the card and scroll. Sort cycles A–Z, Recently added and Rating.
|
||||
The letter rail exists only at 200 titles with A–Z selected. Card OK activates on key-up through
|
||||
the same shell hold guard, without a long-press menu.
|
||||
Down clamps a short final title row before entering the separate Try again / Show
|
||||
more row; an empty slot cannot activate recovery. An unavailable library hides
|
||||
|
|
@ -347,6 +478,65 @@ music keep their upstream destinations.
|
|||
failure/retry, stale copies, and person/server isolation. Library captures are written
|
||||
to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`.
|
||||
|
||||
### Roomy card spacing
|
||||
|
||||
The generated design tokens use 40 px card gaps (44 px for cast), 84 px shelf
|
||||
heading bands, 68 px shelf bottom gaps and 524 px grid rows at 1920×1080.
|
||||
Posters remain 256×384 px; tiles are 400×225 px and wide cards 544×306 px,
|
||||
preserving six/four/three shelf slots. Library and See all toolbars occupy 136 px:
|
||||
chips stay at y 252–320 and the first poster row starts at y 372 (52 px clear).
|
||||
Discover and season-chip rows retain their 104 px height.
|
||||
|
||||
Cards and cast circles draw a single 4 px accent stroke centred on the art edge,
|
||||
above the content, with no background gap. Card lift parameters are unchanged;
|
||||
cast portraits and episode tiles now receive that lift too. The stroke and art
|
||||
scale together at 1.08.
|
||||
Title shelves reserve vertical drawing room for the scaled ring.
|
||||
Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px
|
||||
for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px.
|
||||
|
||||
Poster progress uses the independent 18 px title-card inset, not caption spacing:
|
||||
the track remains 220 px wide on a 256 px poster. Library focus-follow restarts
|
||||
when the effective column count, focused row or header geometry changes, retaining
|
||||
deep focus across live snapshots that add or remove the letter rail.
|
||||
`LibrarySpacingRegressionTest` renders both progress geometry and the unchanged
|
||||
focused index across the 199↔200-title boundary.
|
||||
|
||||
`LibraryCaptureTest.spacingShowsFirstFocused`,
|
||||
`HomeCaptureTest.spacingNextUpFirstFocused` and
|
||||
`TitleCaptureTest.spacingCastFirstFocused` render the amended reference states
|
||||
at native resolution. Their files are `library/spacing-shows.png`,
|
||||
`home/spacing-next-up.png` and `st-114.png` under `build/wolfskin-captures/`.
|
||||
These captures are recordings, not automated reference-pixel assertions; pair
|
||||
measurements are a separate check. Platform Roboto metrics and fixture ordering
|
||||
are not browser pixel parity.
|
||||
|
||||
### Shared focus rings
|
||||
|
||||
The same `focusRing` stroke now covers pill/icon buttons (hero, title, state,
|
||||
recovery and sheet actions), the nav avatar, picker people and gear. It is always
|
||||
accent, 4 px / 2 dp wide, centred on the surface edge with no background gap.
|
||||
The former outside-gap renderer is removed. The stroke draws after opaque
|
||||
content, so neither half can be covered by the background or portrait.
|
||||
|
||||
Control tokens are `Focus.ringControlGap = 0` and `ringControlRing = 2.dp`;
|
||||
avatar and picker tokens use the same geometry. Existing scales remain 1.05 for
|
||||
buttons and 1.08 for picker people/gear; the nav avatar remains unscaled. Card
|
||||
and button lifts remain; avatars, people and gear gain no shadow. Existing
|
||||
clearance accommodates the smaller outside reach without layout movement.
|
||||
|
||||
Filled-only chips, nav pills, Settings/sheet/Coming-in rows and the A–Z rail
|
||||
are unchanged. Selected-only chip/icon/radio borders and decorative borders
|
||||
are not focus rings. These distinctions are explicit in the inventory and
|
||||
reference states rather than silently adding rings to filled controls.
|
||||
|
||||
`FocusRingTest.ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus` checks the
|
||||
two outside and two inside pixels against opaque content for pill, circle and
|
||||
card radii, including clearing the stroke on blur. The focused hero Details,
|
||||
nav avatar, picker, Discover chip, Library toolbar chip and title Request
|
||||
captures exercise the actual screens at 1920×1080. Browser and native glyph
|
||||
metrics differ; their ring offsets are measured relative to each surface edge.
|
||||
|
||||
## Title pages and people
|
||||
|
||||
`TitleSurface` owns the film/series/episode header, season chips, episode tiles,
|
||||
|
|
@ -473,12 +663,38 @@ Request recipients include authorized Conductarr profiles without saved Jellyfin
|
|||
logins; that does not create selectable TV logins. Late name reads preserve the
|
||||
open chooser's selected recipient and footer focus.
|
||||
|
||||
The launch picker publishes the current Jellyfin user and working navigation before
|
||||
the saved-login database read. Saved people and Conductarr household metadata fill
|
||||
that same shell later; slow activity or catalogue reads cannot hold its first usable
|
||||
frame. Discover details navigate to the shared title page, including cast, performer
|
||||
navigation and related titles, rather than the header-only design preview. Held
|
||||
items whose detail omits ProviderIds or People get an explicit Jellyfin field read
|
||||
before catalogue enrichment, without changing their playback identity. Scrolled
|
||||
navigation draws an opaque band; the unscrolled hero treatment is unchanged.
|
||||
Cached `NavDisplay` entries read the current shell and Discover composition locals,
|
||||
not the temporary launch shell captured when the entry was first created. Otherwise
|
||||
the screen can retain skeleton people, disconnected tab actions and an open picker
|
||||
even after the real session is ready.
|
||||
Only the current navigation entry may set the shared route or enable the outer
|
||||
host's Back handler; outgoing entries can remain composed during a transition.
|
||||
Nested title handlers retain their existing lifecycle-based behaviour.
|
||||
|
||||
Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's
|
||||
authenticated fetcher. The app's network interceptor removes Authorization from
|
||||
every non-Jellyfin artwork origin (scheme, host and port), including redirects;
|
||||
current Jellyfin artwork still authenticates. A failed or empty library-audience response keeps
|
||||
the existing mapping or Jellyfin name rule. Returning to the foreground retries
|
||||
household metadata without rebuilding the selected identity.
|
||||
An Anime audience grants a library grouping, not proof of owned titles. Household
|
||||
refresh enables Anime only after a one-title Jellyfin read confirms visible series
|
||||
in an accessible Anime library for the signed-in user. This runs after the live
|
||||
picker is published; failures retain that user's last confirmed availability.
|
||||
Empty Anime Home shelves remain omitted.
|
||||
When availability changes, focus follows the avatar or a surviving destination;
|
||||
removing Anime never leaves an out-of-range nav selection. If Anime was the root,
|
||||
both the shell and navigation stack replace it with Home while retaining pushed
|
||||
title/person pages. `AnimeRootRemovalTest` covers active-root Back, avatar focus,
|
||||
and the real navigation stack with one or two pushed pages.
|
||||
|
||||
| Screen seam | Production supplier |
|
||||
| --- | --- |
|
||||
|
|
@ -504,6 +720,21 @@ and a same-host/different-port origin with an authenticated upstream parent,
|
|||
and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers
|
||||
operator success plus failed/empty audiences, name-rule retention and foreground healing.
|
||||
|
||||
`ShellStartupTimingTest` holds saved-login and real-wire activity reads pending
|
||||
while exercising the picker, early tabs and a person switch. `TitleReadFacadeTest`
|
||||
covers omitted held-film provider ids with authenticated cast/related enrichment.
|
||||
`DiscoverTitleTimingTest` delays the cast reply, exercises real remote Down/Back
|
||||
and writes native cast/related captures to `walk-fixes/`.
|
||||
`NavBandOcclusionTest` checks pixels across the entire scrolled navigation band.
|
||||
`FirstSignInNavigationTest` starts the full app host with no existing shell and
|
||||
immediate signed-in replies, then continues as Mindel and renders her six-film
|
||||
Movies grid and 29-series Shows grid from owner-prefixed Jellyfin views. It also
|
||||
checks that Continue closes the real session picker and selecting Home does not
|
||||
reopen it.
|
||||
`AnimeAvailabilityTest` reads the signed-in audience wire shape and checks Shared
|
||||
and Mindel with empty and nonempty Jellyfin libraries: no empty Anime nav item or
|
||||
Home shelf, while a real visible series enables both.
|
||||
|
||||
Not done here: deploying the dedicated hostname or widening server authority;
|
||||
hardware installation/playback verification; releases, signing or version changes.
|
||||
Unheld trailers remain absent because the server contract has no trailer field.
|
||||
|
|
@ -611,6 +842,41 @@ whose parent directory already exists. All callers sharing a volume must use
|
|||
the same lock path on the Docker host. Do not delete the file while builds are
|
||||
running or waiting. The lock releases automatically when the invocation exits.
|
||||
|
||||
## Persistent debug signing
|
||||
|
||||
`make apk-debug` and non-release `docker/gradle.sh` invocations (including
|
||||
`:app:assembleDefaultOptimized`) use one host keystore, outside the checkout:
|
||||
|
||||
```text
|
||||
/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore
|
||||
```
|
||||
|
||||
Set `WOLFSKIN_DEBUG_KEYSTORE` to another file in a dedicated directory outside
|
||||
the checkout when building on another host, for example:
|
||||
|
||||
```sh
|
||||
WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug
|
||||
```
|
||||
|
||||
The wrapper creates a missing key once using the build image's `keytool`: RSA
|
||||
2048, 10,000-day validity, alias `androiddebugkey`, store/key passwords `android`,
|
||||
and subject `CN=Android Debug`. The containing directory is mode `0700`, the
|
||||
file mode `0600`. Existing keys are reused, never regenerated; generation errors
|
||||
abort the build rather than falling back to an ephemeral container key.
|
||||
The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with
|
||||
`ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container.
|
||||
Keep and back up this file across clean builds, disposable worktrees, container
|
||||
rebuilds and Gradle cache resets. Never commit it. The standard debug passwords
|
||||
are public; this key is for local debug/optimized builds, not release signing.
|
||||
|
||||
**One-time reinstall:** the first build with the stable key still needs one
|
||||
uninstall+reinstall on the box because the signer changes once; later ones
|
||||
install in place with `adb install -r`, retaining sign-ins and settings.
|
||||
The initial uninstall removes app data, so sign in again once after that switch.
|
||||
Deleting/replacing the persistent key or choosing a different override changes
|
||||
the signer again and requires another reinstall.
|
||||
|
||||
|
||||
## Release credentials
|
||||
|
||||
The default escrow is
|
||||
|
|
|
|||
|
|
@ -35,5 +35,36 @@ if [[ $mode == release ]]; then
|
|||
exec ./gradlew "$@"
|
||||
' bash "${gradle_arguments[@]}" "$@"
|
||||
else
|
||||
debug_keystore=$(realpath -m "${WOLFSKIN_DEBUG_KEYSTORE:-/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore}")
|
||||
case "$debug_keystore" in
|
||||
"$root"|"$root"/*)
|
||||
printf 'Debug keystore must be outside the checkout: %s\n' "$debug_keystore" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
signing_directory=$(dirname "$debug_keystore")
|
||||
mkdir -p "$signing_directory"
|
||||
chmod 700 "$signing_directory"
|
||||
if [[ ! -e $debug_keystore ]]; then
|
||||
temporary_directory=$(mktemp -d "$signing_directory/.debug-keystore.XXXXXX")
|
||||
trap 'rm -rf -- "$temporary_directory"' EXIT
|
||||
if ! docker run --rm --platform linux/amd64 --user "$(id -u):$(id -g)" \
|
||||
--mount "type=bind,src=$temporary_directory,dst=/run/signing" \
|
||||
"$image" keytool -genkeypair -noprompt -keystore /run/signing/debug.keystore \
|
||||
-storetype JKS -storepass android -keypass android -alias androiddebugkey \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug"; then
|
||||
printf 'Failed to generate persistent debug keystore: %s\n' "$debug_keystore" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 600 "$temporary_directory/debug.keystore"
|
||||
mv "$temporary_directory/debug.keystore" "$debug_keystore"
|
||||
fi
|
||||
if [[ ! -s $debug_keystore ]]; then
|
||||
printf 'Missing or empty persistent debug keystore: %s\n' "$debug_keystore" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 600 "$debug_keystore"
|
||||
arguments+=(--env ANDROID_USER_HOME=/tmp/.android
|
||||
--mount "type=bind,src=$debug_keystore,dst=/tmp/.android/debug.keystore,readonly")
|
||||
docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit,
|
|||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
||||
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
||||
.background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
|
||||
|
|
|
|||
|
|
@ -157,7 +157,8 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
|
|||
Column(Modifier.weight(1f)) {
|
||||
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
|
||||
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
|
||||
val actor = item.actor(viewer)
|
||||
Text("for ${item.recipient(viewer)}" + (actor?.let { " · by $it" } ?: ""), Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
|
||||
style = ComingInTypography.caption, color = secondary)
|
||||
FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
|
||||
|
|
@ -168,14 +169,17 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
|
|||
}
|
||||
if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin),
|
||||
horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) {
|
||||
item.stage.readiness.forEachIndexed { index, word ->
|
||||
item.readiness.forEachIndexed { index, word ->
|
||||
Row {
|
||||
val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" }
|
||||
val glyph = when (word) { "Done", "Files present", "Recorded play", "Another library" -> "check"; "Failed", "Check needed", "Needs attention" -> "alert"; "Not yet", "Unknown", "Not checked yet", "Not in library", "Removed", "Not out yet" -> null; else -> "clock" }
|
||||
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight })
|
||||
Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (expanded) item.summary?.let { summary ->
|
||||
Text(summary, Modifier.padding(top = T.readinessMargin), style = ComingInTypography.readiness, color = secondary)
|
||||
}
|
||||
}
|
||||
if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,21 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.setValue
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
/** Household acquisition stages, ordered independently from their display wording. */
|
||||
/** History states distinguish acquisition work from library evidence and playback health. */
|
||||
enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List<String>) {
|
||||
Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")),
|
||||
FileHealth("File health needs attention", 0, "alert", listOf("Done", "Files present", "See details", "Needs attention")),
|
||||
CheckNeeded("Check needed", 0, "alert", listOf("Done", "Files present", "Check needed", "Not checked yet")),
|
||||
Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")),
|
||||
AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")),
|
||||
Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")),
|
||||
Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")),
|
||||
Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")),
|
||||
WaitingForFile("Waiting for a file", 3, "clock", listOf("Done", "Waiting for a file", "Not yet", "Not checked yet")),
|
||||
NotOutYet("Not out yet", 3, "clock", listOf("Done", "Not out yet", "Not yet", "Not checked yet")),
|
||||
HeldElsewhere("In another library", 4, "check", listOf("Done", "Files present", "Another library", "Not checked yet")),
|
||||
Removed("Removed", 4, "clock", listOf("Done", "Removed", "Not in library", "Not checked yet")),
|
||||
NotInLibrary("Not in any library", 4, "clock", listOf("Done", "Not observed", "Not in library", "Not checked yet")),
|
||||
Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")),
|
||||
}
|
||||
|
||||
|
|
@ -33,13 +40,15 @@ data class ComingInRequest(
|
|||
val stage: ComingInStage,
|
||||
val poster: Any? = null,
|
||||
val ratings: List<ComingInRating> = emptyList(),
|
||||
val summary: String? = null,
|
||||
val readiness: List<String> = stage.readiness,
|
||||
) {
|
||||
val ratingKey = "$mediaType:$tmdbId"
|
||||
fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName
|
||||
fun actor(viewer: ShellPerson): String = when {
|
||||
fun actor(viewer: ShellPerson): String? = when {
|
||||
actorKey == "shared" -> "Shared"
|
||||
actorKey != null && actorKey == viewer.key -> "you"
|
||||
else -> actorName ?: "Unknown"
|
||||
else -> actorName?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +63,13 @@ data class ComingInSnapshot(
|
|||
val savedAt: String? = null,
|
||||
) {
|
||||
val ordered = items.sortedBy { it.stage.rank }
|
||||
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready }
|
||||
val requestCount: Int? get() = if (status == ComingInStatus.Ready || items.isNotEmpty()) items.size else null
|
||||
val hasUnsettledRequests: Boolean get() = items.any {
|
||||
when (it.stage) {
|
||||
ComingInStage.Ready, ComingInStage.Removed, ComingInStage.NotInLibrary, ComingInStage.HeldElsewhere, ComingInStage.NotOutYet -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A bounded window and stable request id preserve focus across refreshes and title round trips. */
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = {
|
|||
val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") }
|
||||
DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items)
|
||||
}
|
||||
return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
|
||||
return DiscoverSnapshot(picks, groups.map { (key, group) -> DiscoverSeed(key, group.first, group.second.toList(), watched = true, added = false) },
|
||||
services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
|
||||
}
|
||||
|
||||
fun service(service: DiscoverService, type: String): List<DiscoverLane> {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
|
|||
val result = mutableListOf<DiscoverLane>()
|
||||
fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) }
|
||||
if (snapshot.status == DiscoverStatus.Ready) {
|
||||
val groups = snapshot.seeds.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3)
|
||||
val groups = snapshot.seeds.asSequence().filter { it.watched && it.title.isNotBlank() }
|
||||
.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }
|
||||
.filter { it.items.isNotEmpty() }.take(3).toList()
|
||||
for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) {
|
||||
val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden }
|
||||
val count = all.count { it.key !in seen }
|
||||
|
|
@ -18,9 +20,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
|
|||
if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all)
|
||||
}
|
||||
val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt())
|
||||
groups.forEachIndexed { index, seed ->
|
||||
groups.forEach { seed ->
|
||||
val items = take(seed.items, size)
|
||||
if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
|
||||
if (items.size >= 3) result += DiscoverLane("pk-w-${seed.key}", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
|
||||
}
|
||||
}
|
||||
val displayed = heroes.mapTo(mutableSetOf()) { it.key }
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ data class DiscoverTitle(
|
|||
/** Observed source marks keep labels separate from score values. */
|
||||
data class DiscoverRating(val source: String, val score: Double, val label: String? = null)
|
||||
|
||||
/** A contribution group preserves feed order rather than assigning a visible rank. */
|
||||
data class DiscoverSeed(val title: String, val items: List<DiscoverTitle>)
|
||||
/** A typed seed identity and its contribution evidence retain wire order without inventing recency. */
|
||||
data class DiscoverSeed(val key: String, val title: String, val items: List<DiscoverTitle>, val watched: Boolean, val added: Boolean)
|
||||
|
||||
/** Services carry their catalogue id and a text wordmark, never third-party logos. */
|
||||
data class DiscoverService(val id: Long, val name: String, val hue: Float)
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ private fun DiscoverStatePanel(state: DiscoverState) {
|
|||
@Composable
|
||||
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
|
||||
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button))
|
||||
.clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
|||
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
|
||||
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
|
||||
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
||||
.background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card))
|
||||
.border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card))
|
||||
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ class DiscoverState(
|
|||
fun openTitle(item: DiscoverTitle) {
|
||||
title = item; titleButton = 0
|
||||
if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key)
|
||||
onPush("discover-title/${item.key}")
|
||||
onPush("title/${android.net.Uri.encode(item.key)}")
|
||||
}
|
||||
fun openCollection(lane: DiscoverLane) {
|
||||
var titles = lane.fullTitles.associateBy { it.key }
|
||||
|
|
@ -237,7 +237,7 @@ class DiscoverState(
|
|||
/** Card hold/release handling belongs to the shell, including the shared catalogue grid. */
|
||||
override fun key(key: ShellKey): Boolean {
|
||||
if (overlay != null) { overlayKey(key); return true }
|
||||
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false
|
||||
if (shell.route != "discover" && !shell.route.startsWith("discover-") && !shell.route.startsWith("title/")) return false
|
||||
if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true }
|
||||
if (title == null) collection?.let {
|
||||
if (key == ShellKey.Back) { collection = null; onPop(); return true }
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/** Only transforms animate: focus never changes measured geometry. */
|
||||
|
|
@ -34,12 +34,12 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
|
|||
return graphicsLayer { scaleX = animated; scaleY = animated }
|
||||
}
|
||||
|
||||
/** Content lifts are distinct from the white person/gear rings. */
|
||||
/** Lift shadows remain specific to cards and buttons. */
|
||||
enum class FocusLift { None, Card, Button }
|
||||
|
||||
/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */
|
||||
/** Every focus ring straddles the surface edge above content, without a background gap. */
|
||||
@Composable
|
||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None): Modifier {
|
||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
|
||||
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
|
||||
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
|
||||
val shadow = if (lift == FocusLift.None) this else {
|
||||
|
|
@ -54,12 +54,15 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color:
|
|||
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
|
||||
}
|
||||
}
|
||||
return shadow.drawBehind {
|
||||
if (ringAlpha > 0f) {
|
||||
val outer = (gap + width).toPx()
|
||||
val inner = gap.toPx()
|
||||
drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer))
|
||||
drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner))
|
||||
return shadow.drawWithCache {
|
||||
val stroke = Stroke(width.toPx())
|
||||
val corner = CornerRadius(radius.toPx())
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
if (ringAlpha > 0f) {
|
||||
drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = ringAlpha),
|
||||
cornerRadius = corner, style = stroke)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ class LibraryGridState(
|
|||
var inRail by mutableStateOf(false)
|
||||
var railLetter by mutableStateOf('#')
|
||||
private var railReturn = 0
|
||||
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
|
||||
val columns: Int get() = WolfskinTokens.Behaviour.gridColumns.toInt() - if (railVisible) 1 else 0
|
||||
val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" }
|
||||
val hasToolbar: Boolean get() = !catalogue || mixed
|
||||
val items: List<LibraryTitle> by derivedStateOf {
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ fun LibraryPoster(
|
|||
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
|
||||
val progress = title.progress
|
||||
if (progress > 0f && progress < 1f) {
|
||||
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap)
|
||||
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) {
|
||||
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap)
|
||||
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) {
|
||||
Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,13 +88,13 @@ fun LibraryScreen(
|
|||
}
|
||||
val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1
|
||||
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
|
||||
LaunchedEffect(grid.index, grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) {
|
||||
val index = if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index
|
||||
val row = index.coerceAtLeast(0) / grid.columns
|
||||
if (grid.toolbar || row == 0 && active) {
|
||||
val focusedRow = (if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index).coerceAtLeast(0) / grid.columns
|
||||
LaunchedEffect(grid.index, grid.columns, focusedRow, headerCount, rowHeight, density,
|
||||
grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) {
|
||||
if (grid.toolbar || focusedRow == 0 && active) {
|
||||
animateLibraryScroll(view, headerCount, 0f, density)
|
||||
} else if (row > 0) {
|
||||
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() }
|
||||
} else if (focusedRow > 0) {
|
||||
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * focusedRow - L.pinnedLine).toPx() }
|
||||
animateLibraryScroll(view, headerCount, target, density)
|
||||
}
|
||||
}
|
||||
|
|
@ -186,7 +186,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
|
|||
|
||||
@Composable
|
||||
private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) {
|
||||
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) {
|
||||
Row(Modifier.fillMaxWidth().height(L.row_chipsGrid).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) {
|
||||
val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All")
|
||||
labels.forEachIndexed { index, label ->
|
||||
val focused = active && grid.toolbar && grid.tool == index
|
||||
|
|
@ -266,7 +266,7 @@ private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int,
|
|||
val current = with(density) {
|
||||
(if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead +
|
||||
if (first < headerCount) WolfskinTokens.Shell.zero else
|
||||
(if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
|
||||
(if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
|
||||
} + view.listState.firstVisibleItemScrollOffset
|
||||
animateFrameScroll(view.listState, target - current)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,13 +71,12 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
|||
val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height")
|
||||
val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color")
|
||||
val density = LocalDensity.current.density
|
||||
val band = remember(scrolled) {
|
||||
if (scrolled) Brush.verticalGradient(0f to C.bg, S.navSolidStop to C.bg, 1f to C.bg.copy(alpha = 0f))
|
||||
else Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
|
||||
val band = remember {
|
||||
Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
|
||||
}
|
||||
Box(modifier.fillMaxWidth().height(L.navBand)) {
|
||||
Canvas(Modifier.matchParentSize()) {
|
||||
drawRect(band)
|
||||
if (scrolled) drawRect(C.bg) else drawRect(band)
|
||||
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
|
||||
}
|
||||
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
|
||||
|
|
@ -112,7 +111,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
|||
val avatar = boxes.last()
|
||||
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
|
||||
Modifier.offset(avatar.left, avatar.top)
|
||||
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
|
||||
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing)
|
||||
.focusProperties { canFocus = false }
|
||||
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ val SETTINGS_GROUPS = listOf(
|
|||
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
|
||||
)
|
||||
|
||||
/** The household request count shares the sheet's snapshot and excludes Ready. */
|
||||
/** The count describes returned request history, not an inferred queue of active downloads. */
|
||||
@Composable
|
||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) {
|
||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) {
|
||||
val list = state.view.listState
|
||||
val density = LocalDensity.current
|
||||
LaunchedEffect(state.settingsRow, state.navFocused) {
|
||||
|
|
@ -78,7 +78,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
|
|||
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
|
||||
Box(Modifier.height(L.row_srow)) {
|
||||
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
|
||||
if (group.key == "requests") openRequests else null,
|
||||
if (group.key == "requests") requestCount else null,
|
||||
Modifier.offset(L.safeSide, S.settingsRowTop)) {
|
||||
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
|
||||
}
|
||||
|
|
@ -89,7 +89,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
|
|||
|
||||
/** The inverse focus fill has no focus ring and never changes row layout. */
|
||||
@Composable
|
||||
fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val foreground = if (focused) C.onFocus else C.fg
|
||||
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
||||
|
|
@ -97,7 +97,7 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
|
|||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
|
||||
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 ""
|
||||
val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: ""
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ data class ShellPerson(val key: String, val name: String, val initial: String, v
|
|||
|
||||
/** The shell owns keys; the integration owns navigation and authenticated user changes. */
|
||||
sealed interface ShellAction {
|
||||
data class Destination(val route: String) : ShellAction
|
||||
data class Destination(val route: String, val preservePushed: Boolean = false) : ShellAction
|
||||
data class Person(val person: ShellPerson) : ShellAction
|
||||
data class HandOff(val group: String) : ShellAction
|
||||
data object Pop : ShellAction
|
||||
|
|
@ -50,14 +50,23 @@ class ShellState(
|
|||
var people by mutableStateOf(people)
|
||||
private set
|
||||
|
||||
/** Refresh household labels without discarding this person's navigation memory. */
|
||||
/** Refresh household data without losing valid nav focus or this person's view memory. */
|
||||
fun updatePeople(value: List<ShellPerson>) {
|
||||
val focusedTab = tabs.getOrNull(navIndex)
|
||||
val avatarFocused = navIndex == tabs.size
|
||||
val selected = value.firstOrNull { it.jellyfinUserId != null && it.jellyfinUserId == person.jellyfinUserId }
|
||||
?: value.firstOrNull { it.key == person.key }
|
||||
people = value
|
||||
if (selected != null) person = selected
|
||||
pickerIndex = people.indexOf(person).coerceAtLeast(0)
|
||||
val removedRoot = rootRoute !in tabs
|
||||
if (removedRoot) {
|
||||
if (route == rootRoute) route = "home"
|
||||
rootRoute = "home"
|
||||
}
|
||||
preparePersonMemory()
|
||||
navIndex = if (avatarFocused) tabs.size else tabs.indexOf(focusedTab).takeIf { it >= 0 } ?: currentTab
|
||||
if (removedRoot) onAction(ShellAction.Destination("home", preservePushed = true))
|
||||
}
|
||||
var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first())
|
||||
private set
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ fun ShellSurface(
|
|||
state: ShellState,
|
||||
modifier: Modifier = Modifier,
|
||||
handlesContentKeys: Boolean = true,
|
||||
settingsOpenRequests: Int? = null,
|
||||
settingsRequestCount: Int? = null,
|
||||
overlay: @Composable () -> Unit = {},
|
||||
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
||||
) {
|
||||
|
|
@ -91,7 +91,7 @@ fun ShellSurface(
|
|||
if (state.pickerVisible) {
|
||||
WhosWatching(state)
|
||||
} else {
|
||||
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||
if (state.requestsVisible) ComingInSheet(state)
|
||||
overlay()
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs
|
|||
height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) {
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)) {
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
|
||||
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod
|
|||
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
|
||||
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||
.background(background, shape)
|
||||
.then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
|
||||
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
|
||||
|
|
|
|||
|
|
@ -51,9 +51,10 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
|||
val focusOwner = LocalTitleFocusOwner.current
|
||||
val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() }
|
||||
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) }
|
||||
Column(Modifier.height(D.castHeight)) {
|
||||
Box(Modifier.height(D.castHeight)) {
|
||||
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2),
|
||||
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
||||
if (state.data.castStatus == TitleReadStatus.Loading) {
|
||||
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||
Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
|
||||
|
|
@ -63,7 +64,7 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
|||
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
|
||||
.onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) }
|
||||
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
|
||||
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape))
|
||||
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardRing, FocusLift.Card).clip(CircleShape))
|
||||
Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
|
|
@ -85,9 +86,11 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
|||
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
|
||||
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
|
||||
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) }
|
||||
Column(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
|
||||
Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
|
||||
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
|
||||
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2),
|
||||
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
|
||||
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||
Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
|
|
@ -112,7 +115,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
|||
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
|
||||
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
||||
} else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
||||
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
|
||||
TitleTileArtwork(item)
|
||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ private fun PeoplePicker(state: ShellState) {
|
|||
state.focusPerson(index); state.key(ShellKey.Ok)
|
||||
}, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
|
||||
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerGap, WolfskinTokens.Focus.ringPickerRing, C.fg))
|
||||
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing))
|
||||
Spacer(Modifier.height(S.pickerNameGap))
|
||||
Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName)
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ private fun PeoplePicker(state: ShellState) {
|
|||
val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1)
|
||||
val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2
|
||||
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
|
||||
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarRing)
|
||||
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
|
||||
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
|
||||
if (state.gearFocused) {
|
||||
|
|
@ -106,7 +106,7 @@ fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String
|
|||
textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) {
|
||||
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
|
||||
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
|
||||
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
|
||||
|
|
|
|||
|
|
@ -112,9 +112,9 @@ object WolfskinTokens {
|
|||
val navSolidAfter = 20.0.dp
|
||||
val pinnedLine = 84.0.dp
|
||||
val windowAhead = 350.0.dp
|
||||
val rowHeadingBand = 30.0.dp
|
||||
val rowGapAfter = 22.0.dp
|
||||
val gridRowGap = 14.0.dp
|
||||
val rowHeadingBand = 42.0.dp
|
||||
val rowGapAfter = 34.0.dp
|
||||
val gridRowGap = 22.0.dp
|
||||
val row_hero = 410.0.dp
|
||||
val row_phero = 370.0.dp
|
||||
val row_section = 75.0.dp
|
||||
|
|
@ -146,7 +146,7 @@ object WolfskinTokens {
|
|||
val settingsRowWidth = 650.0.dp
|
||||
val settingsRowHeight = 52.0.dp
|
||||
val stateWidth = 550.0.dp
|
||||
val captionOffset = 9.0.dp
|
||||
val captionOffset = 19.0.dp
|
||||
val badge = 24.0.dp
|
||||
val badgeInset = 7.0.dp
|
||||
val progressBar = 4.0.dp
|
||||
|
|
@ -159,36 +159,37 @@ object WolfskinTokens {
|
|||
val personPortrait = 120.0.dp
|
||||
val thumbWidth = 48.0.dp
|
||||
val thumbHeight = 72.0.dp
|
||||
val row_chipsGrid = 68.0.dp
|
||||
}
|
||||
object Card {
|
||||
val posterW = 128.0.dp
|
||||
val posterH = 192.0.dp
|
||||
val posterGap = 12.0.dp
|
||||
val posterPitch = 140.0.dp
|
||||
val posterCaptionBand = 38.0.dp
|
||||
val posterGap = 20.0.dp
|
||||
val posterPitch = 148.0.dp
|
||||
val posterCaptionBand = 48.0.dp
|
||||
const val posterSlots = 6
|
||||
val tileW = 208.0.dp
|
||||
val tileH = 117.0.dp
|
||||
val tileGap = 12.0.dp
|
||||
val tileW = 200.0.dp
|
||||
val tileH = 112.5.dp
|
||||
val tileGap = 20.0.dp
|
||||
val tilePitch = 220.0.dp
|
||||
val tileCaptionBand = 0.0.dp
|
||||
const val tileSlots = 4
|
||||
val wideW = 280.0.dp
|
||||
val wideH = 157.5.dp
|
||||
val wideGap = 12.0.dp
|
||||
val wideW = 272.0.dp
|
||||
val wideH = 153.0.dp
|
||||
val wideGap = 20.0.dp
|
||||
val widePitch = 292.0.dp
|
||||
val wideCaptionBand = 0.0.dp
|
||||
const val wideSlots = 3
|
||||
val entryW = 128.0.dp
|
||||
val entryH = 72.0.dp
|
||||
val entryGap = 12.0.dp
|
||||
val entryPitch = 140.0.dp
|
||||
val entryGap = 20.0.dp
|
||||
val entryPitch = 148.0.dp
|
||||
val entryCaptionBand = 0.0.dp
|
||||
const val entrySlots = 6
|
||||
val personW = 100.0.dp
|
||||
val personH = 145.0.dp
|
||||
val personGap = 14.0.dp
|
||||
val personPitch = 114.0.dp
|
||||
val personH = 155.0.dp
|
||||
val personGap = 22.0.dp
|
||||
val personPitch = 122.0.dp
|
||||
val personCaptionBand = 0.0.dp
|
||||
const val personSlots = 7
|
||||
}
|
||||
|
|
@ -196,14 +197,14 @@ object WolfskinTokens {
|
|||
const val scaleCard = 1.08f
|
||||
const val scaleControl = 1.05f
|
||||
const val scaleRow = 1.02f
|
||||
val ringCardGap = 2.0.dp
|
||||
val ringCardGap = 0.0.dp
|
||||
val ringCardRing = 2.0.dp
|
||||
val ringButtonGap = 2.0.dp
|
||||
val ringButtonRing = 2.0.dp
|
||||
val ringAvatarGap = 2.0.dp
|
||||
val ringControlGap = 0.0.dp
|
||||
val ringControlRing = 2.0.dp
|
||||
val ringAvatarGap = 0.0.dp
|
||||
val ringAvatarRing = 2.0.dp
|
||||
val ringPickerGap = 3.0.dp
|
||||
val ringPickerRing = 3.0.dp
|
||||
val ringPickerGap = 0.0.dp
|
||||
val ringPickerRing = 2.0.dp
|
||||
const val fillInverse = "background color.fg, text color.onFocus"
|
||||
}
|
||||
object Motion {
|
||||
|
|
@ -429,8 +430,8 @@ object WolfskinTokens {
|
|||
val progressBottom = 15.0.dp
|
||||
val progressGap = 9.0.dp
|
||||
val progressLift = -7.0.dp
|
||||
val castHeight = 197.0.dp
|
||||
val castNameTop = 108.0.dp
|
||||
val castHeight = 231.0.dp
|
||||
val castNameTop = 118.0.dp
|
||||
val castNameSide = 6.0.dp
|
||||
val personTop = 85.0.dp
|
||||
val personGap = 24.0.dp
|
||||
|
|
@ -456,7 +457,7 @@ object WolfskinTokens {
|
|||
val infoRowGap = 3.0.dp
|
||||
val infoColumnGap = 10.0.dp
|
||||
val infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
|
||||
val gridHeight = 244.0.dp
|
||||
val gridHeight = 262.0.dp
|
||||
val minimumEndPadding = 40.0.dp
|
||||
val ratingGap = 10.0.dp
|
||||
val ratingMarkGap = 4.0.dp
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ class DiscoverCaptureTest {
|
|||
}
|
||||
|
||||
@Test fun st029_discover() = capture("st-029")
|
||||
@Test fun focusFilterChip() = capture("st-120") { it.focus(1, 0) }
|
||||
@Test fun st030_picksCollection() = capture("st-030") { state -> state.openCollection(state.lanes.first { it.id == "pk-movie" }) }
|
||||
@Test fun st031_hidden() = capture("st-031") { hidden(it) }
|
||||
@Test fun st032_restore() {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class DiscoverKeysTest {
|
|||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
|
||||
if (shell.route == "discover" || shell.route.startsWith("discover-")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
|
||||
if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import org.junit.Test
|
|||
class DiscoverLaneProjectionTest {
|
||||
@Test fun discardedSeedGroupRemainsAvailableInCatalogue() {
|
||||
val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") }
|
||||
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("Seed", titles.subList(3, 5))),
|
||||
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("movie:10", "Seed", titles.subList(3, 5), watched = true, added = false)),
|
||||
lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready)
|
||||
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
|
||||
assertEquals(listOf("pk-movie", "trending"), rows.map { it.id })
|
||||
|
|
@ -30,4 +30,25 @@ class DiscoverLaneProjectionTest {
|
|||
val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden))
|
||||
assertEquals(listOf(listOf(series)), filtered.map { it.titles })
|
||||
}
|
||||
|
||||
@Test fun addedOnlySeedsKeepTheirPicksWithoutClaimingTheyWereWatched() {
|
||||
val titles = (1L..9L).map { DiscoverTitle(it, "movie", "Film $it") }
|
||||
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(
|
||||
DiscoverSeed("movie:10", "Added seed", titles, watched = false, added = true)), status = DiscoverStatus.Ready)
|
||||
val lanes = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
|
||||
assertEquals(listOf("Movies for you"), lanes.map { it.title })
|
||||
assertEquals(titles, lanes.single().fullTitles)
|
||||
}
|
||||
|
||||
@Test fun watchedLanesKeepWireOrderAndStableIdentityWhenTitlesCollide() {
|
||||
val titles = (1L..24L).map { DiscoverTitle(it, "movie", "Film $it") }
|
||||
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(
|
||||
DiscoverSeed("movie:50", "Added seed", titles.take(8), watched = false, added = true),
|
||||
DiscoverSeed("tv:70", "Same title", titles.subList(8, 16), watched = true, added = true),
|
||||
DiscoverSeed("movie:70", "Same title", titles.subList(16, 24), watched = true, added = false)),
|
||||
status = DiscoverStatus.Ready)
|
||||
val lanes = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
|
||||
assertEquals(listOf("pk-movie", "pk-w-tv:70", "pk-w-movie:70"), lanes.map { it.id })
|
||||
assertEquals(listOf(titles.subList(8, 16), titles.subList(16, 24)), lanes.drop(1).map { it.titles })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Opaque content must not cover either half of the shared accent focus stroke. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class FocusRingTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus() {
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
val focused = mutableStateOf(true)
|
||||
val radius = mutableStateOf(WolfskinTokens.Radius.button)
|
||||
rule.setContent {
|
||||
Box(Modifier.requiredSize(120.dp).background(WolfskinTokens.Color.bg)) {
|
||||
Box(Modifier.offset(40.dp, 40.dp).size(60.dp)
|
||||
.focusRing(focused.value, radius.value, WolfskinTokens.Focus.ringControlRing)
|
||||
.background(WolfskinTokens.Color.surface, RoundedCornerShape(radius.value)))
|
||||
}
|
||||
}
|
||||
val file = File.createTempFile("focus-ring-", ".png")
|
||||
try {
|
||||
for (corner in listOf(WolfskinTokens.Radius.button, 30.dp, WolfskinTokens.Radius.card)) {
|
||||
rule.runOnIdle { radius.value = corner }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
for ((x, expected) in listOf(77 to WolfskinTokens.Color.bg, 78 to WolfskinTokens.Color.accent,
|
||||
79 to WolfskinTokens.Color.accent, 80 to WolfskinTokens.Color.accent,
|
||||
81 to WolfskinTokens.Color.accent, 82 to WolfskinTokens.Color.surface)) {
|
||||
assertPixel("Left edge x=$x, radius=$corner", expected.toArgb(), bitmap.getPixel(x, 140))
|
||||
assertPixel("Top edge y=$x, radius=$corner", expected.toArgb(), bitmap.getPixel(140, x))
|
||||
}
|
||||
bitmap.recycle()
|
||||
}
|
||||
rule.runOnIdle { focused.value = false }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
assertEquals(WolfskinTokens.Color.bg.toArgb(), bitmap.getPixel(79, 140))
|
||||
assertEquals(WolfskinTokens.Color.surface.toArgb(), bitmap.getPixel(80, 140))
|
||||
bitmap.recycle()
|
||||
} finally { file.delete() }
|
||||
}
|
||||
private fun assertPixel(label: String, expected: Int, actual: Int) {
|
||||
// Circular edge coverage can differ by a few channel values at pixel centres.
|
||||
for (shift in listOf(0, 8, 16, 24)) {
|
||||
val difference = kotlin.math.abs((expected ushr shift and 255) - (actual ushr shift and 255))
|
||||
assertTrue("$label: channel $shift differs by $difference", difference <= 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -115,5 +115,7 @@ class HomeCaptureTest {
|
|||
@Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2)
|
||||
@Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4)
|
||||
@Test fun st103_heroRatings() = capture("st-103")
|
||||
@Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "nu")
|
||||
@Test fun focusHeroDetails() = capture("focus-hero-details", column = 1)
|
||||
@Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,15 @@ class LibraryCaptureTest {
|
|||
@Test fun st112_cachedWhileDown() = capture("st-112", reachable = false, cached = true)
|
||||
@Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false)
|
||||
@Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = false)
|
||||
@Test fun spacingShowsFirstFocused() = capture("spacing-shows", "mindel", "shows")
|
||||
@Test fun focusToolbarChip() = capture("focus-toolbar-chip") { it.toolbar = true; it.tool = 0 }
|
||||
@Test fun spacingLetterRailClearsFocusedFifthColumn() = capture("spacing-letter-rail") { grid ->
|
||||
val original = grid.source
|
||||
grid.source = List(200) { index ->
|
||||
original[index % original.size].copy(id = "rail-$index", title = "${('A'.code + index / 10).toChar()} Title $index")
|
||||
}
|
||||
grid.index = 4
|
||||
}
|
||||
}
|
||||
|
||||
fun libraryFixture(): LibraryFixture = LibraryFixture(LibraryCaptureTest::class.java.getResourceAsStream("/wolfskin-library.json")!!.bufferedReader().use { it.readText() }, "/library/")
|
||||
|
|
|
|||
|
|
@ -112,18 +112,25 @@ class LibraryKeysTest {
|
|||
val titles = (0 until 200).map { index -> LibraryTitle("$index", "${('A'.code + index / 10).toChar()}Title $index", "movie") }
|
||||
val grid = LibraryGridState("Movies", titles)
|
||||
assertTrue(grid.railVisible)
|
||||
grid.index = 5
|
||||
grid.index = 4
|
||||
grid.key(ShellKey.Right)
|
||||
assertTrue(grid.inRail)
|
||||
grid.key(ShellKey.Down)
|
||||
assertEquals('B', grid.railLetter)
|
||||
grid.key(ShellKey.Back)
|
||||
assertEquals(5, grid.index)
|
||||
assertEquals(4, grid.index)
|
||||
assertFalse(grid.inRail)
|
||||
grid.key(ShellKey.Down)
|
||||
assertEquals(9, grid.index)
|
||||
grid.key(ShellKey.Up)
|
||||
assertEquals(4, grid.index)
|
||||
grid.key(ShellKey.Right); grid.key(ShellKey.Down); grid.key(ShellKey.Ok)
|
||||
assertEquals('B', grid.focusedTitle!!.letter)
|
||||
grid.source = titles.take(199)
|
||||
assertFalse(grid.railVisible)
|
||||
grid.index = 4
|
||||
grid.key(ShellKey.Right)
|
||||
assertEquals(5, grid.index)
|
||||
grid.source = titles; grid.sort = 1
|
||||
assertFalse(grid.railVisible)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class LibrarySpacingRegressionTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
@Test fun partiallyWatchedPosterKeepsItsOriginalProgressInsets() {
|
||||
val title = LibraryTitle("partial", "Partially watched", "movie", progress = .5f)
|
||||
rule.setContent { WolfskinTheme { LibraryPoster(title, false, false, false) } }
|
||||
val poster = rule.onNodeWithTag("poster-partial").fetchSemanticsNode().boundsInRoot
|
||||
val track = rule.onNodeWithTag("poster-progress-partial").fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(256f, poster.width, .1f)
|
||||
assertEquals(220f, track.width, .1f)
|
||||
assertEquals(18f, track.left - poster.left, .1f)
|
||||
assertEquals(18f, poster.right - track.right, .1f)
|
||||
}
|
||||
|
||||
@Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() {
|
||||
val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") }
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") }
|
||||
val grid = LibraryGridState("Shows", titles.take(199)).apply { index = 198 }
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it) } } }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val poster = rule.onNodeWithTag("poster-198")
|
||||
poster.assertIsDisplayed()
|
||||
val original = poster.fetchSemanticsNode().boundsInRoot
|
||||
for (count in listOf(200, 199)) {
|
||||
rule.runOnIdle { grid.source = titles.take(count) }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
poster.assertIsDisplayed()
|
||||
val current = poster.fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(198, grid.index)
|
||||
assertEquals("198", grid.focusedTitle!!.id)
|
||||
assertEquals(original.top, current.top, 1f)
|
||||
assertEquals(original.height, current.height, 1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -53,7 +53,6 @@ class RequestsKeysTest {
|
|||
assertEquals(path.getInt("presses"), presses.length())
|
||||
for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index)))
|
||||
rule.onNodeWithTag("coming-in").assertExists()
|
||||
rule.onNodeWithText("Requests · 5 open").assertExists()
|
||||
assertEquals("settings", state.route)
|
||||
assertEquals("x5", state.requests.focusedId)
|
||||
assertFalse(state.navFocused)
|
||||
|
|
@ -144,7 +143,5 @@ class RequestsKeysTest {
|
|||
val viewer = fixturePeople().single { it.key == "shared" }
|
||||
assertEquals("Shared", shared.actor(viewer))
|
||||
assertEquals("Justin", shared.recipient(viewer))
|
||||
assertEquals(5, snapshot.openCount)
|
||||
assertEquals(listOf(0, 1, 1, 2, 3, 4), snapshot.ordered.map { it.stage.rank })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,12 +38,12 @@ class ShellCaptureTest {
|
|||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
}
|
||||
|
||||
private fun capture(name: String, openRequests: Int? = null, configure: (ShellState) -> Unit = {}) {
|
||||
private fun capture(name: String, requestCount: Int? = null, configure: (ShellState) -> Unit = {}) {
|
||||
val state = ShellState(fixturePeople(), "justin")
|
||||
configure(state)
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsOpenRequests = openRequests)
|
||||
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsRequestCount = requestCount)
|
||||
}
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
|
|
@ -54,6 +54,7 @@ class ShellCaptureTest {
|
|||
}
|
||||
|
||||
@Test fun st001_launch() = capture("st-001")
|
||||
@Test fun focusNavAvatar() = capture("focus-nav-avatar") { it.pickerVisible = false; it.focusNav(it.tabs.size) }
|
||||
@Test fun st002_gear() {
|
||||
capture("st-002") { it.pickerIndex = 2; it.gearFocused = true }
|
||||
val layouts = mutableListOf<TextLayoutResult>()
|
||||
|
|
@ -66,7 +67,7 @@ class ShellCaptureTest {
|
|||
@Test fun st011_rememberedNav() = capture("st-011") { it.pickerVisible = false; it.navFocused = true; it.view.row = 4; it.view.cards["row-4"] = 2 }
|
||||
@Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 }
|
||||
@Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
|
||||
@Test fun st092_requestsCountFixture() = capture("st-092", openRequests = 5) { it.pickerVisible = false; it.enterDestination("settings") }
|
||||
@Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") }
|
||||
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"id": "library",
|
||||
"title": "Libraries: Movies, Shows, Anime (one continuous 6-column grid)",
|
||||
"title": "Libraries: Movies, Shows, Anime (continuous grid; five columns with letter rail)",
|
||||
"specSection": "6.4",
|
||||
"source": "index.html:1403-1426, 1563-1611",
|
||||
"layout": [
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
"token": null,
|
||||
"title": "",
|
||||
"yPx": 236,
|
||||
"heightPx": 104,
|
||||
"heightPx": 136,
|
||||
"cards": null,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -33,8 +33,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 340,
|
||||
"heightPx": 488,
|
||||
"yPx": 372,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -45,8 +45,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 828,
|
||||
"heightPx": 488,
|
||||
"yPx": 896,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -57,8 +57,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 1316,
|
||||
"heightPx": 488,
|
||||
"yPx": 1420,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -69,8 +69,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 1804,
|
||||
"heightPx": 488,
|
||||
"yPx": 1944,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -81,8 +81,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 2292,
|
||||
"heightPx": 488,
|
||||
"yPx": 2468,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -93,8 +93,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 2780,
|
||||
"heightPx": 488,
|
||||
"yPx": 2992,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -105,8 +105,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 3268,
|
||||
"heightPx": 488,
|
||||
"yPx": 3516,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -117,8 +117,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 3756,
|
||||
"heightPx": 488,
|
||||
"yPx": 4040,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -129,8 +129,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 4244,
|
||||
"heightPx": 488,
|
||||
"yPx": 4564,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -141,8 +141,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 4732,
|
||||
"heightPx": 488,
|
||||
"yPx": 5088,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -153,8 +153,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 5220,
|
||||
"heightPx": 488,
|
||||
"yPx": 5612,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -165,8 +165,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 5708,
|
||||
"heightPx": 488,
|
||||
"yPx": 6136,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -177,8 +177,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 6196,
|
||||
"heightPx": 488,
|
||||
"yPx": 6660,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
@ -189,8 +189,8 @@
|
|||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 6684,
|
||||
"heightPx": 488,
|
||||
"yPx": 7184,
|
||||
"heightPx": 524,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@
|
|||
"progressBottom": {"dp": 15, "px": 30, "source": "index.html:217"},
|
||||
"progressGap": {"dp": 9, "px": 18, "source": "index.html:217"},
|
||||
"progressLift": {"dp": -7, "px": -14, "source": "index.html:217"},
|
||||
"castHeight": {"dp": 197, "px": 394, "source": "index.html:1018"},
|
||||
"castNameTop": {"dp": 108, "px": 216, "source": "index.html:289"},
|
||||
"castHeight": {"dp": 231, "px": 462, "source": "index.html:1018"},
|
||||
"castNameTop": {"dp": 118, "px": 236, "source": "index.html:289"},
|
||||
"castNameSide": {"dp": 6, "px": 12, "source": "index.html:289"},
|
||||
"personTop": {"dp": 85, "px": 170, "source": "index.html:293"},
|
||||
"personGap": {"dp": 24, "px": 48, "source": "index.html:293"},
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
"infoRowGap": {"dp": 3, "px": 6, "source": "index.html:318"},
|
||||
"infoColumnGap": {"dp": 10, "px": 20, "source": "index.html:318"},
|
||||
"infoKey": {"$type": "typography", "sp": 12, "px": 24, "weight": 650, "$value": {"fontWeight": 650}, "source": "index.html:319"},
|
||||
"gridHeight": {"dp": 244, "px": 488, "source": "index.html:1019"},
|
||||
"gridHeight": {"dp": 262, "px": 524, "source": "index.html:1019"},
|
||||
"minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"},
|
||||
"ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"},
|
||||
"ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"},
|
||||
|
|
|
|||
|
|
@ -81,9 +81,9 @@
|
|||
--layout-navSolidAfter: 40.0px;
|
||||
--layout-pinnedLine: 168.0px;
|
||||
--layout-windowAhead: 700.0px;
|
||||
--layout-rowHeadingBand: 60.0px;
|
||||
--layout-rowGapAfter: 44.0px;
|
||||
--layout-gridRowGap: 28.0px;
|
||||
--layout-rowHeadingBand: 84.0px;
|
||||
--layout-rowGapAfter: 68.0px;
|
||||
--layout-gridRowGap: 44.0px;
|
||||
--layout-row_hero: 820.0px;
|
||||
--layout-row_phero: 740.0px;
|
||||
--layout-row_section: 150.0px;
|
||||
|
|
@ -111,7 +111,7 @@
|
|||
--layout-sheetWidth: 800.0px;
|
||||
--layout-sheetRow: 100.0px;
|
||||
--layout-stateWidth: 1100.0px;
|
||||
--layout-captionOffset: 18.0px;
|
||||
--layout-captionOffset: 38.0px;
|
||||
--layout-badge: 48.0px;
|
||||
--layout-badgeInset: 14.0px;
|
||||
--layout-progressBar: 8.0px;
|
||||
|
|
@ -122,6 +122,7 @@
|
|||
--layout-navAvatar: 64.0px;
|
||||
--layout-castCircle: 200.0px;
|
||||
--layout-personPortrait: 240.0px;
|
||||
--layout-row_chipsGrid: 136.0px;
|
||||
--motion-fast: 140ms;
|
||||
--motion-base: 160ms;
|
||||
--motion-scroll: 180ms;
|
||||
|
|
@ -273,8 +274,8 @@
|
|||
--title-progressBottom: 30.0px;
|
||||
--title-progressGap: 18.0px;
|
||||
--title-progressLift: -14.0px;
|
||||
--title-castHeight: 394.0px;
|
||||
--title-castNameTop: 216.0px;
|
||||
--title-castHeight: 462.0px;
|
||||
--title-castNameTop: 236.0px;
|
||||
--title-castNameSide: 12.0px;
|
||||
--title-personTop: 170.0px;
|
||||
--title-personGap: 48.0px;
|
||||
|
|
@ -300,7 +301,7 @@
|
|||
--title-infoRowGap: 6.0px;
|
||||
--title-infoColumnGap: 20.0px;
|
||||
--title-infoKey: 24.0px;
|
||||
--title-gridHeight: 488.0px;
|
||||
--title-gridHeight: 524.0px;
|
||||
--title-minimumEndPadding: 80.0px;
|
||||
--title-ratingGap: 20.0px;
|
||||
--title-ratingMarkGap: 8.0px;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"$description": "Wolfskin TV design tokens, extracted by script from the locked prototype (data/tv-v2/opus-final-2026-11-05/index.html, sha256 ee8844fc...). Values are CSS px on the 1920x1080 stage; the box renders 1920x1080 at 320 dpi = density 2.0, so dp/sp = px / 2. Format follows the W3C design-tokens draft ($type/$value); extra keys: px, dp|sp, source (page file:line), usedBy, page (the page value where q1 consolidated it).",
|
||||
"$description": "Wolfskin TV design tokens, spec revision 4: approved Level 2 card spacing and one accent straddle focus ring for cards, controls, avatars and picker people. Source: data/tv-v2/focus-2026-11-06/index.html. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2. Earlier prototype source coordinates remain provenance; revision 4 focus geometry is recorded explicitly in each focus ring token.",
|
||||
"meta": {
|
||||
"density": 2.0,
|
||||
"stagePx": [
|
||||
|
|
@ -10,8 +10,8 @@
|
|||
960,
|
||||
540
|
||||
],
|
||||
"source": "data/tv-v2/opus-final-2026-11-05/index.html",
|
||||
"sourceSha256": "ee8844fc6d0237a3727de0d9454db8e5cf3bb14083fb7877d7bb0f4233c6e23d",
|
||||
"source": "data/tv-v2/focus-2026-11-06/index.html",
|
||||
"sourceSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d",
|
||||
"generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py"
|
||||
},
|
||||
"color": {
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
"$value": "#07070a",
|
||||
"css": "#07070a",
|
||||
"source": "index.html:10",
|
||||
"usedBy": "app background; stage; focus-ring gap colour; hero/detail scrim end; nav band when solid"
|
||||
"usedBy": "app background; stage; hero/detail scrim end; nav band when solid"
|
||||
},
|
||||
"surface": {
|
||||
"$type": "color",
|
||||
|
|
@ -773,27 +773,27 @@
|
|||
},
|
||||
"rowHeadingBand": {
|
||||
"$type": "dimension",
|
||||
"$value": "30dp",
|
||||
"px": 60,
|
||||
"dp": 30.0,
|
||||
"$value": "42dp",
|
||||
"px": 84,
|
||||
"dp": 42.0,
|
||||
"source": "index.html:1018",
|
||||
"usedBy": "shelf row: heading band (44 px heading at top 0) before the cards start at y + 60"
|
||||
"usedBy": "shelf row: heading band (44 px heading at top 0, then 40 px) before the cards start at y + 84 (HEAD_OFF)"
|
||||
},
|
||||
"rowGapAfter": {
|
||||
"$type": "dimension",
|
||||
"$value": "34dp",
|
||||
"px": 68,
|
||||
"dp": 34.0,
|
||||
"source": "index.html:1018",
|
||||
"usedBy": "shelf row: space after cards (+ caption band) before the next row; shelf row height = 84 + card h + caption + 68"
|
||||
},
|
||||
"gridRowGap": {
|
||||
"$type": "dimension",
|
||||
"$value": "22dp",
|
||||
"px": 44,
|
||||
"dp": 22.0,
|
||||
"source": "index.html:1018",
|
||||
"usedBy": "shelf row: space after cards (+ caption band) before the next row; shelf row height = 60 + card h + caption + 44"
|
||||
},
|
||||
"gridRowGap": {
|
||||
"$type": "dimension",
|
||||
"$value": "14dp",
|
||||
"px": 28,
|
||||
"dp": 14.0,
|
||||
"source": "index.html:1019",
|
||||
"usedBy": "grid row height = 384 + 76 + 28 = 488 (library, See all, person)"
|
||||
"usedBy": "grid row height = 384 + 96 + 44 = 524 (library, See all, person)"
|
||||
},
|
||||
"row_hero": {
|
||||
"$type": "dimension",
|
||||
|
|
@ -825,7 +825,7 @@
|
|||
"px": 104,
|
||||
"dp": 52.0,
|
||||
"source": "index.html:1021",
|
||||
"usedBy": "chip row (Discover filters, library toolbar, See all/service type chips, seasons)"
|
||||
"usedBy": "chip row above shelves (Discover filters, service type chips, seasons); 104 px"
|
||||
},
|
||||
"row_note": {
|
||||
"$type": "dimension",
|
||||
|
|
@ -1041,10 +1041,10 @@
|
|||
},
|
||||
"captionOffset": {
|
||||
"$type": "dimension",
|
||||
"px": 18,
|
||||
"dp": 9,
|
||||
"px": 38,
|
||||
"dp": 19,
|
||||
"source": "index.html:1242",
|
||||
"usedBy": "caption under a focused poster starts 18 px below the card bottom (capHtml top = headH + h + 18); width 640"
|
||||
"usedBy": "caption under a focused poster starts 38 px below the card bottom (capHtml top = headH + h + CAP_OFF; was 18, amended 2026-11-06); width 640"
|
||||
},
|
||||
"plateInset": {
|
||||
"$type": "dimension",
|
||||
|
|
@ -1171,6 +1171,14 @@
|
|||
},
|
||||
"source": "index.html:204",
|
||||
"usedBy": "letter rail (only for destinations with >= 200 titles)"
|
||||
},
|
||||
"row_chipsGrid": {
|
||||
"$type": "dimension",
|
||||
"$value": "68dp",
|
||||
"px": 136,
|
||||
"dp": 68.0,
|
||||
"source": "index.html:rowH",
|
||||
"usedBy": "chip row directly above a grid (library toolbar, See all type chips): 136 px, so 52 px from the chips to the first card row (was 104 / 20 px)"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
|
|
@ -1179,16 +1187,16 @@
|
|||
"px": {
|
||||
"w": 256,
|
||||
"h": 384,
|
||||
"gap": 24,
|
||||
"pitch": 280,
|
||||
"captionBand": 76
|
||||
"gap": 40,
|
||||
"pitch": 296,
|
||||
"captionBand": 96
|
||||
},
|
||||
"dp": {
|
||||
"w": 128.0,
|
||||
"h": 192.0,
|
||||
"gap": 12.0,
|
||||
"pitch": 140.0,
|
||||
"captionBand": 38.0
|
||||
"gap": 20.0,
|
||||
"pitch": 148.0,
|
||||
"captionBand": 48.0
|
||||
},
|
||||
"ratio": "2:3",
|
||||
"fullSlotsPerRow": 6,
|
||||
|
|
@ -1198,16 +1206,16 @@
|
|||
"tile": {
|
||||
"$type": "card",
|
||||
"px": {
|
||||
"w": 416,
|
||||
"h": 234,
|
||||
"gap": 24,
|
||||
"w": 400,
|
||||
"h": 225,
|
||||
"gap": 40,
|
||||
"pitch": 440,
|
||||
"captionBand": 0
|
||||
},
|
||||
"dp": {
|
||||
"w": 208.0,
|
||||
"h": 117.0,
|
||||
"gap": 12.0,
|
||||
"w": 200.0,
|
||||
"h": 112.5,
|
||||
"gap": 20.0,
|
||||
"pitch": 220.0,
|
||||
"captionBand": 0.0
|
||||
},
|
||||
|
|
@ -1219,16 +1227,16 @@
|
|||
"wide": {
|
||||
"$type": "card",
|
||||
"px": {
|
||||
"w": 560,
|
||||
"h": 315,
|
||||
"gap": 24,
|
||||
"w": 544,
|
||||
"h": 306,
|
||||
"gap": 40,
|
||||
"pitch": 584,
|
||||
"captionBand": 0
|
||||
},
|
||||
"dp": {
|
||||
"w": 280.0,
|
||||
"h": 157.5,
|
||||
"gap": 12.0,
|
||||
"w": 272.0,
|
||||
"h": 153.0,
|
||||
"gap": 20.0,
|
||||
"pitch": 292.0,
|
||||
"captionBand": 0.0
|
||||
},
|
||||
|
|
@ -1242,15 +1250,15 @@
|
|||
"px": {
|
||||
"w": 256,
|
||||
"h": 144,
|
||||
"gap": 24,
|
||||
"pitch": 280,
|
||||
"gap": 40,
|
||||
"pitch": 296,
|
||||
"captionBand": 0
|
||||
},
|
||||
"dp": {
|
||||
"w": 128.0,
|
||||
"h": 72.0,
|
||||
"gap": 12.0,
|
||||
"pitch": 140.0,
|
||||
"gap": 20.0,
|
||||
"pitch": 148.0,
|
||||
"captionBand": 0.0
|
||||
},
|
||||
"ratio": "16:9",
|
||||
|
|
@ -1262,22 +1270,22 @@
|
|||
"$type": "card",
|
||||
"px": {
|
||||
"w": 200,
|
||||
"h": 290,
|
||||
"gap": 28,
|
||||
"pitch": 228,
|
||||
"h": 310,
|
||||
"gap": 44,
|
||||
"pitch": 244,
|
||||
"captionBand": 0
|
||||
},
|
||||
"dp": {
|
||||
"w": 100.0,
|
||||
"h": 145.0,
|
||||
"gap": 14.0,
|
||||
"pitch": 114.0,
|
||||
"h": 155.0,
|
||||
"gap": 22.0,
|
||||
"pitch": 122.0,
|
||||
"captionBand": 0.0
|
||||
},
|
||||
"ratio": "1:1 circle",
|
||||
"fullSlotsPerRow": 7,
|
||||
"source": "index.html:660",
|
||||
"usedBy": "Cast row: 200 px circle + name + character, 290 px tall"
|
||||
"usedBy": "Cast row: 200 px circle + name + character, 310 tall (name starts 236 below the top)"
|
||||
}
|
||||
},
|
||||
"focus": {
|
||||
|
|
@ -1318,44 +1326,60 @@
|
|||
},
|
||||
"ringCard": {
|
||||
"$type": "shadow",
|
||||
"$value": "0 0 0 4px var(--bg),0 0 0 8px var(--accent),0 18px 50px rgb(0 0 0 / .65)",
|
||||
"gapPx": 4,
|
||||
"$value": "0 0 0 2px var(--accent), inset 0 0 0 2px var(--accent) (drawn above the art), 0 18px 50px rgb(0 0 0 / .65)",
|
||||
"gapPx": 0,
|
||||
"ringPx": 4,
|
||||
"gapDp": 2,
|
||||
"gapDp": 0,
|
||||
"ringDp": 2,
|
||||
"gapColor": "color.bg",
|
||||
"ringColor": "color.accent",
|
||||
"liftShadow": "0 18px 50px rgb(0 0 0 / .65)",
|
||||
"source": "index.html:134",
|
||||
"usedBy": "focused card: a 4 px bg-coloured gap then a 4 px lilac ring OUTSIDE the art (never covers it), plus a lift shadow; drawn on the circle for cast cards"
|
||||
"usedBy": "focused card (poster, tile, wide, entry) and cast circle: one 4 px lilac ring centred on the art edge, 2 px outside and 2 px over the art, no gap, plus a lift shadow (Justin 2026-11-06: \"2px inset, 4px ring\")",
|
||||
"outsidePx": 2,
|
||||
"insidePx": 2
|
||||
},
|
||||
"ringButton": {
|
||||
"ringControl": {
|
||||
"$type": "shadow",
|
||||
"$value": "0 0 0 4px var(--bg),0 0 0 8px var(--accent),0 12px 40px rgb(0 0 0 / .5)",
|
||||
"gapPx": 4,
|
||||
"$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent),0 12px 40px rgb(0 0 0 / .5)",
|
||||
"gapPx": 0,
|
||||
"ringPx": 4,
|
||||
"gapDp": 0,
|
||||
"ringDp": 2,
|
||||
"outsidePx": 2,
|
||||
"insidePx": 2,
|
||||
"alignment": "straddle",
|
||||
"ringColor": "color.accent",
|
||||
"liftShadow": "0 12px 40px rgb(0 0 0 / .5)",
|
||||
"source": "index.html:110",
|
||||
"usedBy": "focused pill and icon buttons (primary stays white, secondary lightens to buttonSecondaryFocused)"
|
||||
"source": "index.html:111",
|
||||
"usedBy": "focused pill and icon buttons, including hero, title, recovery and sheet actions; existing fills, text colours, scale and lift unchanged"
|
||||
},
|
||||
"ringAvatar": {
|
||||
"$type": "shadow",
|
||||
"$value": "0 0 0 4px var(--bg),0 0 0 8px var(--fg)",
|
||||
"gapPx": 4,
|
||||
"$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)",
|
||||
"gapPx": 0,
|
||||
"ringPx": 4,
|
||||
"ringColor": "color.fg",
|
||||
"source": "index.html:92",
|
||||
"usedBy": "focused nav avatar and focused gear (white ring, not lilac)"
|
||||
"gapDp": 0,
|
||||
"ringDp": 2,
|
||||
"outsidePx": 2,
|
||||
"insidePx": 2,
|
||||
"alignment": "straddle",
|
||||
"ringColor": "color.accent",
|
||||
"source": "index.html:92,312",
|
||||
"usedBy": "focused nav avatar and gear; existing scale unchanged, no lift"
|
||||
},
|
||||
"ringPicker": {
|
||||
"$type": "shadow",
|
||||
"$value": "0 0 0 6px var(--bg),0 0 0 12px var(--fg)",
|
||||
"gapPx": 6,
|
||||
"ringPx": 6,
|
||||
"ringColor": "color.fg",
|
||||
"source": "index.html:263",
|
||||
"usedBy": "focused person in Who's watching (white 6 + 6 ring; name turns fg)"
|
||||
"$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)",
|
||||
"gapPx": 0,
|
||||
"ringPx": 4,
|
||||
"gapDp": 0,
|
||||
"ringDp": 2,
|
||||
"outsidePx": 2,
|
||||
"insidePx": 2,
|
||||
"alignment": "straddle",
|
||||
"ringColor": "color.accent",
|
||||
"source": "index.html:265",
|
||||
"usedBy": "focused person in Who's watching; scale 1.08, name turns fg, no lift"
|
||||
},
|
||||
"fillInverse": {
|
||||
"$type": "rule",
|
||||
|
|
@ -1589,4 +1613,4 @@
|
|||
"usedBy": "Coming-in sheet shows 5 rows (4 while one is expanded), window follows focus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
|
||||
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
|
||||
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
|
||||
1c805ae8eacbca1c63fbf91e19d7f9fea6c27b54e709e91f6bd46720d5048a41 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
7ed093cc63b0fd6f47fe951da5df26ee7258865b8d0a51345a408ab64f037de0 title-tokens.json
|
||||
3b14b60264e1f97c02be3e57d0c0bd9477e982044ee944c8c8a5b71e939fa8fb tokens.css
|
||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||
c5ca037f08a0ab36139bf3805f1e0305e4c8e31e0d198cf056c791476950b96b src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json
|
||||
b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css
|
||||
3b8b2112917dbd9fb16030cc1f3de2c35b5b6775136d16600b55ffd83936da69 tokens.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue