Compare commits
No commits in common. "main" and "fm/tv-hotfix-snapshot-write" have entirely different histories.
main
...
fm/tv-hotf
93 changed files with 442 additions and 13567 deletions
|
|
@ -133,13 +133,6 @@ configure<ApplicationExtension> {
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
}
|
}
|
||||||
|
|
||||||
create("optimized") {
|
|
||||||
initWith(getByName("release"))
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
applicationIdSuffix = ".opt"
|
|
||||||
matchingFallbacks += "release"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
flavorDimensions += "version"
|
flavorDimensions += "version"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
|
|
@ -206,10 +199,6 @@ configure<ApplicationExtension> {
|
||||||
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
||||||
kotlin.directories += "src/wolfskin/java"
|
kotlin.directories += "src/wolfskin/java"
|
||||||
}
|
}
|
||||||
getByName("optimized") {
|
|
||||||
kotlin.directories += "src/release/java"
|
|
||||||
baselineProfiles.directories += "src/optimized/baselineProfiles"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
|
|
@ -239,11 +228,6 @@ androidComponents {
|
||||||
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
||||||
output.outputFileName = outputFileName
|
output.outputFileName = outputFileName
|
||||||
}
|
}
|
||||||
if (variant.name == "defaultOptimized") {
|
|
||||||
tasks.withType<Test>().configureEach {
|
|
||||||
systemProperty("wolfskin.optimizedVersionName", variant.outputs.first().versionName.get())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (variant.name == "defaultDebug") {
|
if (variant.name == "defaultDebug") {
|
||||||
val packagedApks = variant.artifacts.get(SingleArtifact.APK)
|
val packagedApks = variant.artifacts.get(SingleArtifact.APK)
|
||||||
val artifactLoader = variant.artifacts.getBuiltArtifactsLoader()
|
val artifactLoader = variant.artifacts.getBuiltArtifactsLoader()
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
# 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;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name" translatable="false">Wolfskin (Opt)</string>
|
|
||||||
</resources>
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.github.damontecres.wholphin.test
|
package com.github.damontecres.wholphin.test
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
|
||||||
import com.github.damontecres.wholphin.util.Version
|
import com.github.damontecres.wholphin.util.Version
|
||||||
import org.junit.Assert
|
import org.junit.Assert
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -19,12 +18,6 @@ class VersionCompareTests {
|
||||||
val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234")
|
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
|
@Test
|
||||||
fun testIsAtLeast() {
|
fun testIsAtLeast() {
|
||||||
Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0))
|
Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0))
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
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,6 +41,7 @@ class ConductarrProjectionTest {
|
||||||
assertEquals(ComingInStage.Downloading, request.stage)
|
assertEquals(ComingInStage.Downloading, request.stage)
|
||||||
assertEquals("Mindel", request.requesterName)
|
assertEquals("Mindel", request.requesterName)
|
||||||
assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true)))
|
assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true)))
|
||||||
|
assertEquals(1, requests.openCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
// 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,6 +63,7 @@ class DiscoverLoadingFocusTest {
|
||||||
rule.runOnIdle {
|
rule.runOnIdle {
|
||||||
assertNotNull(current.title)
|
assertNotNull(current.title)
|
||||||
assertEquals("The Amazing Spider-Man", current.title!!.title)
|
assertEquals("The Amazing Spider-Man", current.title!!.title)
|
||||||
|
assertEquals("discover-title/${current.title!!.key}", shell.route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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,7 +11,6 @@ import mockwebserver3.RecordedRequest
|
||||||
|
|
||||||
internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef"
|
internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef"
|
||||||
internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210"
|
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. */
|
/** A loopback server supplies wire JSON, never app projections or live service state. */
|
||||||
internal class FakeConductarr : Closeable {
|
internal class FakeConductarr : Closeable {
|
||||||
|
|
@ -27,10 +26,6 @@ internal class FakeConductarr : Closeable {
|
||||||
@Volatile var emptyAudiences = false
|
@Volatile var emptyAudiences = false
|
||||||
@Volatile var seasonTwoScore = 8.1
|
@Volatile var seasonTwoScore = 8.1
|
||||||
@Volatile var localArt = false
|
@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(
|
private val imageBytes = java.util.Base64.getDecoder().decode(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
||||||
|
|
||||||
|
|
@ -42,7 +37,6 @@ internal class FakeConductarr : Closeable {
|
||||||
if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder()
|
if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder()
|
||||||
.body(okio.Buffer().write(imageBytes)).build()
|
.body(okio.Buffer().write(imageBytes)).build()
|
||||||
val path = request.url.encodedPath
|
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" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus)
|
||||||
if (request.method == "POST") return response(wire.getValue("hide").toString())
|
if (request.method == "POST") return response(wire.getValue("hide").toString())
|
||||||
if (request.method == "DELETE") return response(wire.getValue("restore").toString())
|
if (request.method == "DELETE") return response(wire.getValue("restore").toString())
|
||||||
|
|
@ -70,9 +64,6 @@ internal class FakeConductarr : Closeable {
|
||||||
}
|
}
|
||||||
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
|
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
|
||||||
if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus)
|
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 (notModified && request.headers["If-None-Match"] != null) return response("", 304)
|
||||||
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
||||||
var body = wire.getValue(key).jsonObject
|
var body = wire.getValue(key).jsonObject
|
||||||
|
|
@ -80,9 +71,6 @@ internal class FakeConductarr : Closeable {
|
||||||
if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) {
|
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")))
|
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") {
|
if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") {
|
||||||
body = JsonObject(body + ("media_type" to JsonPrimitive("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")))))))
|
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")))))))
|
||||||
|
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
@ -1,252 +0,0 @@
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
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,7 +1,6 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.Looper
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
|
@ -36,7 +35,6 @@ import org.justinvisser.wolfskin.design.TitleVersion
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.Shadows
|
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -154,10 +152,7 @@ class TitleAsyncFocusTest {
|
||||||
assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id)
|
assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id)
|
||||||
rule.runOnIdle { viewModel.title!!.toast = null }
|
rule.runOnIdle { viewModel.title!!.toast = null }
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
rule.waitUntil(timeoutMillis = 5000) {
|
rule.waitUntil(timeoutMillis = 5000) { reads.get() == 2 && viewModel.title?.toast != null }
|
||||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
|
||||||
reads.get() == 2 && viewModel.title?.toast != null
|
|
||||||
}
|
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertEquals("Saved title", viewModel.title!!.data.item.name)
|
assertEquals("Saved title", viewModel.title!!.data.item.name)
|
||||||
assertEquals("20:42", shell.navSavedAt)
|
assertEquals("20:42", shell.navSavedAt)
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ class TitleCaptureTest {
|
||||||
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
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.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
||||||
data.episodes.forEach { item ->
|
data.episodes.forEach { item ->
|
||||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) }
|
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 416, 234)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
||||||
|
|
@ -215,5 +215,4 @@ class TitleCaptureTest {
|
||||||
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
|
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
|
||||||
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
@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 st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
||||||
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,11 @@ class TitleKeysTest {
|
||||||
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() {
|
||||||
|
render("Andor")
|
||||||
|
assertEquals(603f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
||||||
render(transform = { data ->
|
render(transform = { data ->
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,6 @@ class TitleReadFacadeTest {
|
||||||
every { repository.currentServer } returns null
|
every { repository.currentServer } returns null
|
||||||
every { repository.currentUser } returns null
|
every { repository.currentUser } returns null
|
||||||
coEvery { playback.getSelectedTracks(any(), any(), any()) } 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 }
|
val context = mockk<Context> { every { filesDir } returns directory.root }
|
||||||
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
|
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
|
||||||
}
|
}
|
||||||
|
|
@ -104,29 +103,6 @@ class TitleReadFacadeTest {
|
||||||
mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams)))
|
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 {
|
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
||||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||||
facade.read(firstId, null, preferences)
|
facade.read(firstId, null, preferences)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -17,9 +16,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Singleton
|
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
|
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||||
|
|
||||||
/** Advanced-only endpoint and fixture controls contain no credentials. */
|
/** Advanced-only endpoint and fixture controls contain no credentials. */
|
||||||
|
|
@ -54,15 +50,6 @@ object ConductarrPreference {
|
||||||
object ConductarrModule {
|
object ConductarrModule {
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope): ConductarrSession =
|
fun session(@ApplicationContext context: Context): ConductarrSession =
|
||||||
ConductarrSession(ConductarrDiskCache(context), scope)
|
ConductarrSession(FileCacheStore(File(context.noBackupFilesDir, "conductarr")))
|
||||||
}
|
|
||||||
|
|
||||||
/** 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,62 +41,20 @@ internal fun DiscoverTitle.homeTitle(): HomeTitle = HomeTitle(
|
||||||
overview = overview, poster = poster, backdrop = backdrop, genres = genres, tmdbId = tmdb.toInt(),
|
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 {
|
internal fun RequestProgress.comingIn(): ComingInRequest {
|
||||||
val stagesByName = stages.orEmpty().associateBy { it.name }
|
val stagesByName = stages.orEmpty().associateBy { it.name }
|
||||||
val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION]
|
val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION]
|
||||||
val visibility = stagesByName[ReadinessStage.Name.VISIBILITY]
|
val visibility = stagesByName[ReadinessStage.Name.VISIBILITY]
|
||||||
val playback = stagesByName[ReadinessStage.Name.PLAYBACK]
|
|
||||||
val stage = when {
|
val stage = when {
|
||||||
acquisition?.summary == "The title was deleted after it was dispatched." -> ComingInStage.Removed
|
stages.orEmpty().any { it.state == ReadinessStage.State.FAILED } -> ComingInStage.Failed
|
||||||
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
|
visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready
|
||||||
awaitingApproval == true || acquisition?.summary == "Waiting for Seerr's approval to reach dispatch." -> ComingInStage.Waiting
|
awaitingApproval == true -> ComingInStage.Waiting
|
||||||
acquisition?.summary == "Dispatched; waiting for the Arr to import a file." -> ComingInStage.WaitingForFile
|
visibility?.state in setOf(ReadinessStage.State.IN_PROGRESS, ReadinessStage.State.PARTIAL) || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
|
||||||
visibility?.state == ReadinessStage.State.IN_PROGRESS || visibility?.state == ReadinessStage.State.PARTIAL || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
|
acquisition?.state == ReadinessStage.State.IN_PROGRESS -> ComingInStage.Downloading
|
||||||
acquisition?.state == ReadinessStage.State.IN_PROGRESS && acquisition.summary.startsWith("Downloading", ignoreCase = true) -> ComingInStage.Downloading
|
acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Searching
|
||||||
acquisition?.state == ReadinessStage.State.NONE && acquisition.summary.startsWith("Searching", ignoreCase = true) -> ComingInStage.Searching
|
stages.orEmpty().any { it.state in setOf(ReadinessStage.State.UNKNOWN, ReadinessStage.State.STALE, ReadinessStage.State.CONFLICTING) } -> ComingInStage.Checking
|
||||||
acquisition?.state == ReadinessStage.State.IN_PROGRESS || acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Waiting
|
else -> 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,
|
return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key,
|
||||||
requester.name, actor?.requester, actor?.name, stage, summary = detail?.summary,
|
requester.name, actor?.requester, actor?.name, stage)
|
||||||
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,14 +57,10 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
|
||||||
val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it }
|
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 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 genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name }
|
||||||
val candidates = feed?.movies.orEmpty() + feed?.shows.orEmpty()
|
val picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) }
|
||||||
val picks = candidates.map { it.discoverTitle(genres) }
|
val seeds = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).flatMap { candidate ->
|
||||||
val seeds = candidates.flatMapIndexed { index, candidate ->
|
candidate.contributions.orEmpty().mapNotNull { seed -> seed.title?.let { it to candidate.discoverTitle(genres) } }
|
||||||
candidate.contributions.orEmpty().filter { !it.title.isNullOrBlank() }.map { it to picks[index] }
|
}.groupBy({ it.first }, { it.second }).map { DiscoverSeed(it.key, it.value) }
|
||||||
}.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 {
|
val hidden = hiddenJob.await()?.dismissals.orEmpty().map {
|
||||||
DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath))
|
DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,12 @@ package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import java.security.MessageDigest
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.justinvisser.wolfskin.conductarr.*
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
import org.justinvisser.wolfskin.conductarr.model.Operator
|
import org.justinvisser.wolfskin.conductarr.model.Operator
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
/** A selected Jellyfin login owns every provider and all in-memory observations. */
|
/** A selected Jellyfin login owns every provider and all in-memory observations. */
|
||||||
data class ConductarrIdentity(
|
data class ConductarrIdentity(
|
||||||
|
|
@ -26,85 +21,53 @@ data class ConductarrIdentity(
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Tokens are borrowed from upstream, never persisted or exposed as observable state. */
|
/** Tokens are borrowed from upstream, never persisted or exposed as observable state. */
|
||||||
class ConductarrSession(
|
class ConductarrSession(private val cache: CacheStore) : Closeable {
|
||||||
private val cache: CacheStore,
|
|
||||||
private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
|
||||||
) : Closeable {
|
|
||||||
private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null)
|
private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null)
|
||||||
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
|
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
|
||||||
private var selection: ByteArray? = null
|
private var selection: ByteArray? = null
|
||||||
@Volatile private var generation = 0L
|
@Volatile private var generation = 0L
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) {
|
fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) {
|
||||||
val fingerprint = MessageDigest.getInstance("SHA-256")
|
val fingerprint = MessageDigest.getInstance("SHA-256")
|
||||||
.digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray())
|
.digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray())
|
||||||
val previous: ConductarrClient?
|
if (!retry && selection?.contentEquals(fingerprint) == true) return
|
||||||
val currentGeneration: Long
|
generation++
|
||||||
synchronized(this) {
|
val currentGeneration = generation
|
||||||
if (!retry && selection?.contentEquals(fingerprint) == true) return
|
mutableIdentity.value?.client?.close()
|
||||||
generation++
|
mutableIdentity.value = null
|
||||||
currentGeneration = generation
|
selection = fingerprint
|
||||||
previous = mutableIdentity.value?.client
|
if (token.isNullOrBlank()) return
|
||||||
mutableIdentity.value = null
|
val client = try {
|
||||||
selection = fingerprint
|
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event ->
|
||||||
}
|
if (event == ClientEvent.NEEDS_SIGN_IN && generation == currentGeneration) {
|
||||||
try {
|
mutableIdentity.update { it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) }
|
||||||
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) {
|
} catch (_: IllegalArgumentException) { return }
|
||||||
mutableIdentity.update {
|
mutableIdentity.value = ConductarrIdentity(person, client, ConductarrProvider(client))
|
||||||
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. */
|
/** Disk identity paints first; a 401 always removes it, even after an earlier saved emission. */
|
||||||
suspend fun authenticate() {
|
suspend fun authenticate() {
|
||||||
val selected = mutableIdentity.value ?: return
|
val selected = mutableIdentity.value ?: return
|
||||||
selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result ->
|
selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result ->
|
||||||
|
if (mutableIdentity.value?.client !== selected.client) return@collect
|
||||||
when (result) {
|
when (result) {
|
||||||
is ReadResult.Value -> mutableIdentity.update {
|
is ReadResult.Value -> mutableIdentity.update { it?.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) }
|
||||||
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 {
|
is ReadResult.Failure -> mutableIdentity.update {
|
||||||
if (it?.client !== selected.client) it
|
if (result.error == ClientError.NeedsSignIn) it?.copy(operator = null, signedIn = false, error = result.error)
|
||||||
else if (result.error == ClientError.NeedsSignIn) it.copy(operator = null, signedIn = false, error = result.error)
|
else it?.copy(error = result.error)
|
||||||
else it.copy(error = result.error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove read authority synchronously; socket disposal finishes independently on IO. */
|
|
||||||
@Synchronized
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
generation++
|
generation++
|
||||||
val previous = mutableIdentity.value?.client
|
mutableIdentity.value?.client?.close()
|
||||||
mutableIdentity.value = null
|
mutableIdentity.value = null
|
||||||
selection = 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,6 +95,18 @@ 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
|
val result = if (fixtures) fixtureState ?: state else state
|
||||||
LaunchedEffect(shell.route, result) {
|
LaunchedEffect(shell.route, result) {
|
||||||
result.enterRoute(shell.route)
|
result.enterRoute(shell.route)
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,7 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
catalogueJob?.cancel()
|
catalogueJob?.cancel()
|
||||||
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
|
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
|
||||||
if (retry && conductarr.identity.value?.signedIn != true) {
|
if (retry && conductarr.identity.value?.signedIn != true) {
|
||||||
withContext(Dispatchers.IO) {
|
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
loadLibrary(server, user, person, retry)
|
loadLibrary(server, user, person, retry)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,6 @@ internal suspend fun refreshRequestsWhileStarted(state: ShellState, refresh: ()
|
||||||
refresh()
|
refresh()
|
||||||
while (currentCoroutineContext().isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L)
|
delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L)
|
||||||
if (state.requestsVisible || state.requests.snapshot.hasUnsettledRequests) refresh()
|
if (state.requestsVisible || state.requests.snapshot.openCount > 0) refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -210,9 +210,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
internal suspend fun retrySignIn() {
|
internal suspend fun retrySignIn() {
|
||||||
val user = repository.currentUser ?: return
|
val user = repository.currentUser ?: return
|
||||||
val server = repository.currentServer ?: return
|
val server = repository.currentServer ?: return
|
||||||
withContext(Dispatchers.IO) {
|
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
|
||||||
}
|
|
||||||
conductarr.authenticate()
|
conductarr.authenticate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -63,7 +60,6 @@ import org.justinvisser.wolfskin.design.DiscoverScreen
|
||||||
class WolfskinSession @Inject constructor() {
|
class WolfskinSession @Inject constructor() {
|
||||||
var state by mutableStateOf<ShellState?>(null)
|
var state by mutableStateOf<ShellState?>(null)
|
||||||
var serverId: String? = null
|
var serverId: String? = null
|
||||||
internal var fixtureMode: Boolean? = null
|
|
||||||
var onAction: (ShellAction) -> Unit = {}
|
var onAction: (ShellAction) -> Unit = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,29 +85,24 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
var signedInPerson by mutableStateOf("")
|
var signedInPerson by mutableStateOf("")
|
||||||
private set
|
private set
|
||||||
private var identityJob: kotlinx.coroutines.Job? = null
|
private var identityJob: kotlinx.coroutines.Job? = null
|
||||||
private var personSwitchJob: kotlinx.coroutines.Job? = null
|
private var fixtureMode: Boolean? = null
|
||||||
private val personSwitchMutex = Mutex()
|
|
||||||
|
|
||||||
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
|
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
|
||||||
withContext(Dispatchers.IO) {
|
WolfskinScreenPreference.initialize(context)
|
||||||
WolfskinScreenPreference.initialize(context)
|
ConductarrPreference.initialize(context)
|
||||||
ConductarrPreference.initialize(context)
|
|
||||||
}
|
|
||||||
identityJob?.cancel()
|
identityJob?.cancel()
|
||||||
libraries.audiences = null
|
libraries.audiences = null
|
||||||
if (ConductarrPreference.fixtures) conductarr.close()
|
if (ConductarrPreference.fixtures) conductarr.close()
|
||||||
else withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) }
|
else 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()
|
signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase()
|
||||||
session.onAction = { action -> handleAction(action, server, user) }
|
if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) {
|
||||||
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 saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
|
||||||
val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) }
|
val people = readShellPeople(context, saved.ifEmpty { listOf(user) })
|
||||||
session.state?.updatePeople(savedPeople)
|
session.serverId = server.id.toString()
|
||||||
|
fixtureMode = ConductarrPreference.fixtures
|
||||||
|
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
|
||||||
}
|
}
|
||||||
|
session.onAction = { action -> handleAction(action, server, user) }
|
||||||
identityJob = viewModelScope.launch {
|
identityJob = viewModelScope.launch {
|
||||||
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
||||||
}
|
}
|
||||||
|
|
@ -132,19 +123,12 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
|
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() }
|
audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() }
|
||||||
?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it }
|
?.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 updated = session.state?.people.orEmpty().map { person ->
|
||||||
val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) }
|
val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) }
|
||||||
val current = person.jellyfinUserId == user.id.toString()
|
val current = person.jellyfinUserId == user.id.toString()
|
||||||
person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key,
|
person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key,
|
||||||
name = if (current) identity.operator!!.name else profile?.name ?: person.name,
|
name = if (current) identity.operator!!.name else profile?.name ?: person.name,
|
||||||
hasAnime = if (current) hasAnime else person.hasAnime)
|
hasAnime = audiences?.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) } ?: person.hasAnime)
|
||||||
}
|
}
|
||||||
session.state?.updatePeople(updated)
|
session.state?.updatePeople(updated)
|
||||||
signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson
|
signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson
|
||||||
|
|
@ -158,10 +142,8 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
|
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
|
||||||
withContext(Dispatchers.IO) {
|
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
|
||||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
|
retry = conductarr.identity.value?.signedIn != true)
|
||||||
retry = conductarr.identity.value?.signedIn != true)
|
|
||||||
}
|
|
||||||
loadHousehold(user)
|
loadHousehold(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,17 +174,10 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
|
|
||||||
private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) {
|
private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) {
|
||||||
when (action) {
|
when (action) {
|
||||||
is ShellAction.Destination -> {
|
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))
|
||||||
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()
|
ShellAction.Pop -> navigation.goBack()
|
||||||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
||||||
is ShellAction.Person -> selectPerson(action.person, server)
|
is ShellAction.Person -> selectPerson(action.person, server, user)
|
||||||
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
||||||
ShellAction.RetryRequests -> viewModelScope.launch {
|
ShellAction.RetryRequests -> viewModelScope.launch {
|
||||||
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
|
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
|
||||||
|
|
@ -212,29 +187,26 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectPerson(person: ShellPerson, server: JellyfinServer) {
|
private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) {
|
||||||
navigation.replace(Destination.Home())
|
navigation.replace(Destination.Home())
|
||||||
if (person.key == signedInPerson) return
|
if (person.jellyfinUserId == user.id.toString() || person.key == signedInPerson) return
|
||||||
identityJob?.cancel()
|
|
||||||
personSwitchJob?.cancel()
|
|
||||||
signedInPerson = ""
|
|
||||||
conductarr.close()
|
conductarr.close()
|
||||||
libraries.audiences = null
|
libraries.audiences = null
|
||||||
personSwitchJob = viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
personSwitchMutex.withLock {
|
val saved = withContext(Dispatchers.IO) {
|
||||||
val saved = withContext(Dispatchers.IO) {
|
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
||||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
}
|
||||||
}
|
if (saved == null || saved.isProtected) {
|
||||||
if (saved == null || saved.isProtected) return@launch
|
signedInPerson = ""
|
||||||
try {
|
return@launch
|
||||||
val current = repository.changeUser(server, saved)
|
}
|
||||||
coroutineContext.ensureActive()
|
try {
|
||||||
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
val current = repository.changeUser(server, saved)
|
||||||
} catch (exception: Exception) {
|
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
} catch (exception: Exception) {
|
||||||
coroutineContext.ensureActive()
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
signedInPerson = ""
|
||||||
}
|
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -258,8 +230,6 @@ fun WolfskinApp(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WolfskinShellViewModel = hiltViewModel(),
|
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()
|
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||||
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
|
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
|
||||||
|
|
@ -304,23 +274,17 @@ fun WolfskinApp(
|
||||||
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||||
entryProvider = { destination ->
|
entryProvider = { destination ->
|
||||||
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
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) {
|
val route = when (destination) {
|
||||||
is Destination.Home -> "home"
|
is Destination.Home -> "home"
|
||||||
is Destination.Wolfskin -> destination.route
|
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())
|
is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString())
|
||||||
else -> "pushed"
|
else -> "pushed"
|
||||||
}
|
}
|
||||||
val isCurrent = navigationManager.backStack.lastOrNull() == destination
|
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }
|
||||||
LaunchedEffect(destination, state, isCurrent) {
|
|
||||||
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
|
|
||||||
}
|
|
||||||
if (destination.fullScreen) {
|
if (destination.fullScreen) {
|
||||||
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
||||||
} else {
|
} else {
|
||||||
BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
||||||
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
||||||
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
||||||
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
||||||
|
|
@ -333,7 +297,7 @@ fun WolfskinApp(
|
||||||
when {
|
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 || 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)
|
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
|
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
|
||||||
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
|
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)
|
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)
|
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/") }
|
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val snapshots = remember(context) { lazy { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) } }
|
val snapshots = remember(context) { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) }
|
||||||
val grid = remember(shell.person.key, route, view) {
|
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(
|
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(),
|
title = route.replaceFirstChar { it.uppercase() }, items = emptyList(),
|
||||||
|
|
@ -81,8 +81,7 @@ fun WolfskinLibraryContent(
|
||||||
grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
|
grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
|
||||||
grid.status = LibraryStatus.Ready
|
grid.status = LibraryStatus.Ready
|
||||||
} else {
|
} else {
|
||||||
val store = withContext(Dispatchers.IO) { snapshots.value }
|
snapshots.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
||||||
store.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
|
||||||
val focusedId = grid.focusedTitle?.id
|
val focusedId = grid.focusedTitle?.id
|
||||||
grid.source = snapshot.items
|
grid.source = snapshot.items
|
||||||
focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } }
|
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. */
|
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
|
||||||
@Singleton
|
@Singleton
|
||||||
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
|
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
|
||||||
private val store by lazy { FileCacheStore(File(context.filesDir, "wolfskin-home")) }
|
private val store = FileCacheStore(File(context.filesDir, "wolfskin-home"))
|
||||||
|
|
||||||
fun read(scope: String): Pair<HomeLibrary, Long>? {
|
fun read(scope: String): Pair<HomeLibrary, Long>? {
|
||||||
val entry = store.read(scope, KEY) ?: return null
|
val entry = store.read(scope, KEY) ?: return null
|
||||||
|
|
|
||||||
|
|
@ -48,18 +48,6 @@ 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. */
|
/** A destination spans every matching accessible library; containers never become titles. */
|
||||||
fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow {
|
fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow {
|
||||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ import org.justinvisser.wolfskin.design.TitlePageData
|
||||||
import org.justinvisser.wolfskin.design.TitlePerson
|
import org.justinvisser.wolfskin.design.TitlePerson
|
||||||
import org.justinvisser.wolfskin.design.TitleTrack
|
import org.justinvisser.wolfskin.design.TitleTrack
|
||||||
import org.justinvisser.wolfskin.design.TitleVersion
|
import org.justinvisser.wolfskin.design.TitleVersion
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -84,8 +83,8 @@ class TitleReadFacade @Inject constructor(
|
||||||
/** An episode opens its series with that exact episode as both play and watched target. */
|
/** 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) {
|
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
|
||||||
val file = cacheFile(id)
|
val file = cacheFile(id)
|
||||||
val opened = readItem(id)
|
val opened = api.userLibraryApi.getItem(id).content
|
||||||
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened
|
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content 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 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 {
|
val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
|
||||||
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
|
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
|
||||||
|
|
@ -108,20 +107,6 @@ class TitleReadFacade @Inject constructor(
|
||||||
result.copy(trailerUrl = trailer)
|
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. */
|
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */
|
||||||
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
|
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
|
||||||
|
|
||||||
|
|
@ -190,44 +175,27 @@ class TitleReadFacade @Inject constructor(
|
||||||
streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
|
streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
|
||||||
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
|
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
|
||||||
}
|
}
|
||||||
val pageItem = mapItem(item).copy(seasons = seasonNumbers.size)
|
return TitlePageData(mapItem(item).copy(seasons = seasonNumbers.size), target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it) },
|
||||||
return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) },
|
rows.first { it.id.toString() == watchId }.let { mapItem(it) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
|
||||||
rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
|
|
||||||
if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1,
|
if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1,
|
||||||
item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url },
|
item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url },
|
||||||
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
|
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
|
||||||
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
|
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
|
||||||
},
|
},
|
||||||
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) },
|
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it) },
|
||||||
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapItem(item: BaseItemDto, seriesBackdrop: String? = null): TitleItem = TitleItem(
|
private fun mapItem(item: BaseItemDto): TitleItem = TitleItem(
|
||||||
item.id.toString(), item.name.orEmpty(), when (item.type) { BaseItemKind.MOVIE -> "movie"; BaseItemKind.EPISODE -> "episode"; else -> "tv" },
|
item.id.toString(), item.name.orEmpty(), when (item.type) { BaseItemKind.MOVIE -> "movie"; BaseItemKind.EPISODE -> "episode"; else -> "tv" },
|
||||||
item.productionYear, (item.runTimeTicks ?: 0) / 10_000_000, overview = item.overview.orEmpty(), genres = item.genres.orEmpty(),
|
item.productionYear, (item.runTimeTicks ?: 0) / 10_000_000, overview = item.overview.orEmpty(), genres = item.genres.orEmpty(),
|
||||||
poster = item.imageTags?.get(ImageType.PRIMARY)?.let { images.getItemImageUrl(item.id, ImageType.PRIMARY, maxWidth = 256, maxHeight = 384, tag = it) },
|
poster = item.imageTags?.get(ImageType.PRIMARY)?.let { images.getItemImageUrl(item.id, ImageType.PRIMARY, maxWidth = 256, maxHeight = 384, tag = it) },
|
||||||
backdrop = if (item.type == BaseItemKind.EPISODE) episodeArtwork(item, seriesBackdrop) else item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = WolfskinTokens.Image.heroRequestWidth, maxHeight = WolfskinTokens.Image.heroRequestHeight, tag = it) },
|
backdrop = item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = 1280, maxHeight = 720, tag = it) },
|
||||||
held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000,
|
held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000,
|
||||||
seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0,
|
seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0,
|
||||||
tmdbId = item.providerIds?.entries?.firstOrNull { it.key.equals("Tmdb", true) }?.value?.toLongOrNull(),
|
tmdbId = item.providerIds?.entries?.firstOrNull { it.key.equals("Tmdb", true) }?.value?.toLongOrNull(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun episodeArtwork(item: BaseItemDto, seriesBackdrop: String?): String? {
|
|
||||||
val type = when {
|
|
||||||
item.imageTags?.get(ImageType.PRIMARY) != null -> ImageType.PRIMARY
|
|
||||||
item.imageTags?.get(ImageType.THUMB) != null -> ImageType.THUMB
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (type != null) return images.getItemImageUrl(item.id, type,
|
|
||||||
maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = item.imageTags?.get(type))
|
|
||||||
return seriesBackdrop ?: item.parentBackdropItemId?.let { parent ->
|
|
||||||
item.parentBackdropImageTags?.firstOrNull()?.let { tag ->
|
|
||||||
images.getItemImageUrl(parent, ImageType.BACKDROP,
|
|
||||||
maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json")
|
private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json")
|
||||||
private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json")
|
private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json")
|
||||||
private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified()))
|
private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified()))
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
# Wolfskin TV specification — revision 5
|
|
||||||
|
|
||||||
2026-11-06. This feedback amendment applies to the approved revision-4 specification and frozen frames at `data/tv-v2/focus-2026-11-06/` (prototype SHA-256 `1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d`). Unchanged geometry, navigation, typography, colours and motion remain governed by those frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The amendment's prototype delta is `data/tv-v2/feedback-2026-11-06/`.
|
|
||||||
|
|
||||||
## Who’s watching
|
|
||||||
|
|
||||||
The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption. Down still reaches it only from the final person; Up returns to that person.
|
|
||||||
|
|
||||||
## Focus visibility
|
|
||||||
|
|
||||||
“Centered” means consistent visibility, not geometric centering. Keep the approved shelf rule: one-card lookahead, shifting after the second-last visible slot, with first/last positions clamped to the actual contents. Short rows do not manufacture empty scroll space. Poster, tile, wide, service, episode, cast and related shelves follow the same rule.
|
|
||||||
|
|
||||||
The focused card and its ring remain fully inside the viewport, with at least the Level 2 gap (40 px) of horizontal clearance at each screen edge. Focused rows clear the nav band and the bottom edge. Preserve the approved 168 px heading pin and initial fully-visible-row exceptions; hero/header anchors remain zero. First entry, row changes, row ends and Back from a title must reconcile retained logical selection with the actual scroll position. A frame is recorded as positioned only after its scroll finishes. Discover uses the approved six poster slots, not a rounded-down five-slot approximation.
|
|
||||||
|
|
||||||
Library and Person grids retain six columns and the existing Level 2 row spacing. The final Person column keeps the full poster width; horizontal padding must not shrink it.
|
|
||||||
|
|
||||||
## Episode artwork and dimensions
|
|
||||||
|
|
||||||
Episode cards use **400×280 px (200×140 dp)** art: 55 px / 24.4% taller than the previous 400×225 px tile. Other tile and wide-card sizes are unchanged. The episode shelf is `84 + 280 + 68 = 432 px`; its heading band and 68 px clearance before Cast remain Level 2. Cast moves down by the additional card height rather than overlapping it.
|
|
||||||
|
|
||||||
Choose available art in this order:
|
|
||||||
|
|
||||||
1. Episode `Primary` image — Jellyfin stores episode stills under this type.
|
|
||||||
2. Episode `Thumb` image.
|
|
||||||
3. Series `Backdrop`, centre-cropped to the episode slot.
|
|
||||||
4. The existing missing-art treatment when none is available.
|
|
||||||
|
|
||||||
A series-page read supplies its already-read backdrop to the episode projection; it does not make one series request per episode. Saved pages rebuild the same projection from cached metadata. Thumbnail reads and prefetch use the 400×280 px episode request, not the unrelated poster dimensions.
|
|
||||||
|
|
||||||
Read-only Jellyfin evidence: all ten The Bear season-three episodes have Primary tags and no episode Backdrop tags. Tomorrow’s Primary source is 2100×1182; the series Backdrop is 3840×2160. These are existing aired files, not missing/unaired episode placeholders.
|
|
||||||
|
|
||||||
## Episode summaries and series header spacing
|
|
||||||
|
|
||||||
In episode context, the summary belongs to the episode: focused episode first, explicitly opened episode next, otherwise the Up-next episode. Use the show overview only when no episode context is selected. An absent episode overview stays absent instead of substituting the show's description. The focused episode card can show two ellipsised lines of its own overview below its episode label.
|
|
||||||
|
|
||||||
A held series header measures its actual copy, actions and action-caption band, then leaves **40 px** before the Seasons row. Remove the unconditional 900 px series-header spacer. Films, non-held titles and loading/error layouts retain their approved fixed header geometry. Measured series geometry also drives the frame offsets and focus-follow calculation, preventing season/episode rows from scrolling to stale offsets. Long titles or summaries may grow the header; controls must never overlap the seasons.
|
|
||||||
|
|
||||||
## Hero image quality and memory
|
|
||||||
|
|
||||||
| Surface | Requested/decode bound | Format policy |
|
|
||||||
|---|---|---|
|
|
||||||
| Home and Discover hero; title backdrop | 1920×1080 | ARGB, 7.91 MiB at the bound |
|
|
||||||
| Upright hero poster | 420×630 | RGB565 where supported, 0.50 MiB |
|
|
||||||
| Episode card | 400×280 | ARGB, 0.43 MiB |
|
|
||||||
| Library poster, ordinary tile/wide, cast | Existing slot dimensions | Existing RGB565/ARGB policy |
|
|
||||||
|
|
||||||
Jellyfin image URLs discard conflicting width/height/max/fill caps and request the consuming slot while preserving image tags, quality and other query parameters. Jellyfin quality remains **96**. TMDB heroes use the original source rather than the previous 780 px source; Coil constrains decoding to the panel. Upright TMDB sheets use a suitable poster tier. Intentionally blurred poster-only washes remain bounded to the upright source, not an oversized portrait bitmap. Decode fitting prevents allocation of an oversized off-ratio source merely to fill a crop.
|
|
||||||
|
|
||||||
The shared memory cache remains **64 MiB**, disk cache **128 MiB**, image concurrency **4**. Three 1080p hero bitmaps consume 23.73 MiB, leaving 40.27 MiB of the cache for other art. These are allocation bounds, not measured TV resident-memory claims; do not request or decode 4K hero bitmaps on the 2 GB armv7 box.
|
|
||||||
|
|
||||||
Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. With quality 96, Jellyfin returned a 1281×720 JPEG of 438,329 bytes for the old fill request and a 1921×1080 JPEG of 982,435 bytes for the new request; the extra source pixel is Jellyfin aspect-ratio rounding. The display decode stays bounded to 1920×1080.
|
|
||||||
|
|
||||||
## Discover lane spacing
|
|
||||||
|
|
||||||
Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged.
|
|
||||||
|
|
||||||
## Sources and verification
|
|
||||||
|
|
||||||
Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`.
|
|
||||||
|
|
||||||
The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation.
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
{
|
|
||||||
"specRevision": 5,
|
|
||||||
"base": "data/tv-v2/focus-2026-11-06/index.html",
|
|
||||||
"baseSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d",
|
|
||||||
"stagePx": [1920, 1080],
|
|
||||||
"density": 2,
|
|
||||||
"picker": {
|
|
||||||
"gearAlignment": "center of the complete people row",
|
|
||||||
"gearRing": "same accent straddle ring as picker people",
|
|
||||||
"gearFocusScale": 1.08,
|
|
||||||
"navigation": "unchanged: Down from the final person only"
|
|
||||||
},
|
|
||||||
"focus": {
|
|
||||||
"horizontalRule": "second-last-slot lookahead; clamp first/last to real contents",
|
|
||||||
"minimumScreenSideMarginPx": 40,
|
|
||||||
"headingPinPx": 168,
|
|
||||||
"geometricCentering": false,
|
|
||||||
"discoverPosterSlots": 6
|
|
||||||
},
|
|
||||||
"episode": {
|
|
||||||
"artPx": [400, 280],
|
|
||||||
"shelfHeightPx": 432,
|
|
||||||
"gapBeforeCastPx": 68,
|
|
||||||
"artFallback": ["episode Primary", "episode Thumb", "series Backdrop crop", "missing art"],
|
|
||||||
"summaryContext": ["focused episode", "explicitly opened episode", "up-next episode", "show when no episode context"],
|
|
||||||
"focusedCardSummaryLines": 2
|
|
||||||
},
|
|
||||||
"seriesHeader": {
|
|
||||||
"height": "measured copy, actions and caption band plus gap",
|
|
||||||
"gapBeforeSeasonsPx": 40,
|
|
||||||
"otherTitleHeaders": "unchanged"
|
|
||||||
},
|
|
||||||
"discover": {
|
|
||||||
"allLaneHeadingBandPx": 104,
|
|
||||||
"allLaneHeadingToCardGapPx": 60,
|
|
||||||
"posterShelfHeightPx": 652,
|
|
||||||
"serviceShelfHeightPx": 316,
|
|
||||||
"homeHeadingBand": "unchanged"
|
|
||||||
},
|
|
||||||
"images": {
|
|
||||||
"heroDecodeBoundPx": [1920, 1080],
|
|
||||||
"heroPosterDecodeBoundPx": [420, 630],
|
|
||||||
"jellyfinQuality": 96,
|
|
||||||
"memoryCacheMiB": 64,
|
|
||||||
"diskCacheMiB": 128,
|
|
||||||
"concurrentReads": 4
|
|
||||||
},
|
|
||||||
"nativeFrames": "https://pages.justinvisser.org/wolfskin/tv-fb1-visual/",
|
|
||||||
"specification": "../../tv-spec/spec.md"
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Wolfskin TV · feedback delta · revision 5</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: dark; font: 16px/1.5 system-ui, sans-serif; }
|
|
||||||
body { max-width: 1440px; margin: 32px auto; padding: 0 24px; background: #07070a; color: #f4f4f6; }
|
|
||||||
a { color: #cfb0f6; } nav { display: flex; flex-wrap: wrap; gap: 24px; }
|
|
||||||
section { margin: 48px 0; } h2 { margin-bottom: 8px; }
|
|
||||||
img { display: block; width: 100%; height: auto; margin: 16px 0; border: 1px solid #33333b; }
|
|
||||||
summary { cursor: pointer; } .note { color: #b9b9c1; }
|
|
||||||
</style>
|
|
||||||
<h1>Wolfskin TV · feedback delta</h1>
|
|
||||||
<p>Specification revision 5. The approved frames remain the baseline; these native 1920×1080 frames show only the requested feedback changes. This is a frame prototype, not a simulated Jellyfin client.</p>
|
|
||||||
<nav><a href="#picker">Who’s watching</a><a href="#home">Home</a><a href="#discover">Discover</a><a href="#series">Series</a><a href="#episodes">Episodes</a><a href="#person">Person</a><a href="delta.json">Exact delta values</a></nav>
|
|
||||||
<section id="picker"><h2>Who’s watching</h2><p>Gear centred beneath everyone, with the person-style focus ring.</p>
|
|
||||||
<img width="1920" height="1080" alt="Native picker after: Settings centred below the complete people row" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/picker.png">
|
|
||||||
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native picker before: gear below the final person" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/picker.png"></details></section>
|
|
||||||
<section id="home"><h2>Home</h2><p>Approved edge scrolling retained. Short/long poster, tile and wide rows, row changes and native title Back exercise the same visibility contract.</p>
|
|
||||||
<img loading="lazy" width="1920" height="1080" alt="Native Home after: focused card safely inside a long shelf" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/home.png">
|
|
||||||
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Home baseline" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/home.png"></details>
|
|
||||||
<details><summary>Real-source hero request comparison</summary><p class="note">Same native renderer and source artwork: old 720p response, then panel-sized response. Intentionally blurred poster washes are unchanged.</p><img loading="lazy" width="1920" height="1080" alt="Native hero using the old 720p source response" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/home-hero.png"><img loading="lazy" width="1920" height="1080" alt="Native hero using the panel-resolution source response" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/home-hero.png"></details></section>
|
|
||||||
<section id="discover"><h2>Discover</h2><p>60 px from lane title to cards; all lanes use the same 104 px heading band.</p>
|
|
||||||
<img loading="lazy" width="1920" height="1080" alt="Native Discover after: more space above every lane's cards" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/discover.png">
|
|
||||||
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Discover before: smaller heading gap" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/discover.png"></details></section>
|
|
||||||
<section id="series"><h2>Series header</h2><p>Episode-context summary and a measured header instead of the empty fixed-height spacer. Season chips and thumbnail artwork enter the initial view.</p>
|
|
||||||
<img loading="lazy" width="1920" height="1080" alt="Native Bear series header after: episode overview and visible thumbnail row" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/series.png">
|
|
||||||
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Bear series header before: show overview and empty space before seasons" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/series.png"></details></section>
|
|
||||||
<section id="episodes"><h2>Episode shelf</h2><p>Real episode Primary artwork, 400×280 px cards and the focused episode's own overview. Cast retains its Level 2 clearance.</p>
|
|
||||||
<img loading="lazy" width="1920" height="1080" alt="Native Bear episodes after: taller thumbnail cards, focused overview and Cast below" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/episodes.png">
|
|
||||||
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Bear episodes before: monitor placeholders" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/episodes.png"></details></section>
|
|
||||||
<section id="person"><h2>Person</h2><p>Six full-width poster columns; no shrinking of the final column.</p>
|
|
||||||
<img loading="lazy" width="1920" height="1080" alt="Native Person after" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/person.png">
|
|
||||||
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Person before" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/person.png"></details></section>
|
|
||||||
</html>
|
|
||||||
324
docker/README.md
324
docker/README.md
|
|
@ -1,8 +1,8 @@
|
||||||
# Wolfskin TV container builds
|
# Wolfskin TV container builds
|
||||||
|
|
||||||
Run from a full Git checkout with the upstream `v*` and `p*` tags available. The
|
Run from a full Git checkout with the upstream `v*` and `p*` tags available. The
|
||||||
host needs Docker, GNU Make, Bash, Git, `realpath` (GNU coreutils), and `flock`
|
host needs Docker, GNU Make, Bash, Git, and `flock` (util-linux), not Java or the
|
||||||
(util-linux), not Java or the Android SDK.
|
Android SDK.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git fetch origin --tags
|
git fetch origin --tags
|
||||||
|
|
@ -27,85 +27,6 @@ Upstream enables JVM unit tests only for debug variants; there is no
|
||||||
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
|
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
|
||||||
task without changing upstream's test configuration.
|
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
|
## Upstream v1.1.0 integration
|
||||||
|
|
||||||
The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and
|
The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and
|
||||||
|
|
@ -169,7 +90,7 @@ The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box
|
||||||
padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width
|
padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width
|
||||||
ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%.
|
ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%.
|
||||||
|
|
||||||
The small gear centred beneath the complete people row opens Settings with Requests focused.
|
The small gear under the final person opens Settings with Requests focused.
|
||||||
Settings groups hand off to existing upstream pages; Requests opens the household
|
Settings groups hand off to existing upstream pages; Requests opens the household
|
||||||
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
|
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
|
||||||
rollout switch: turning it off renders upstream Home and library destinations with
|
rollout switch: turning it off renders upstream Home and library destinations with
|
||||||
|
|
@ -183,44 +104,12 @@ household labels, requester capabilities and library audiences after sign-in.
|
||||||
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
|
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.
|
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
|
Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous
|
||||||
See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...`
|
See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...`
|
||||||
destination is an explicit title-family integration stub exposing only Discover's
|
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
|
request and pick actions. Unheld titles have no trailer control until the pinned
|
||||||
Conductarr title contract supplies a trailer.
|
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
|
The debug-only fixture and local art live in
|
||||||
`app/src/debug/assets/wolfskin-discover/`. The Advanced preview switch selects a
|
`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.
|
network-free fixture host; real providers are the default in debug and release.
|
||||||
|
|
@ -359,24 +248,14 @@ are recorded differences, not a claim of browser pixel equality.
|
||||||
|
|
||||||
## Requests under Settings
|
## Requests under Settings
|
||||||
|
|
||||||
The Requests row and Coming-in sheet share one immutable household history snapshot.
|
The Requests row and Coming-in sheet share one immutable household snapshot.
|
||||||
Settings counts returned requests, including Ready, rather than calling every
|
Ready requests do not count as open. Rows sort Failed, Downloading/Almost ready,
|
||||||
non-Ready row “open”. An unavailable empty snapshot has no count. Deleted rows
|
Checking/Searching, Waiting, Ready. The bounded window shows five rows, or four
|
||||||
read Removed; absent holdings read Not in any library; unreleased titles read
|
with readiness expanded. Right expands Request/Download/Library/Playback; Left
|
||||||
Not out yet; holdings outside the recipient's libraries read In another library.
|
collapses; Up/Down collapse while moving. Opening a title preserves the request
|
||||||
Waiting for an import is not evidence of an active download. Downloading and
|
for Back, and closing the sheet restores its opener. Selecting a root or changing
|
||||||
Searching require explicit acquisition signals. Visibility-proof faults read
|
person clears the title-return link. Shared's authenticated actor always reads
|
||||||
Check needed; playback faults read File health needs attention, even when a
|
“by Shared”, never “by you”.
|
||||||
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
|
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
|
its eight local posters. Shared and focused-rating records also feed the native
|
||||||
|
|
@ -428,22 +307,12 @@ type and TMDB identity. Back restores the exact Coming-in row and expansion;
|
||||||
the temporary `request-title/...` placeholder is removed. TV sign-in, release
|
the temporary `request-title/...` placeholder is removed. TV sign-in, release
|
||||||
and device deployment remain separate.
|
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
|
## Libraries and See all
|
||||||
|
|
||||||
Movies, Shows and Anime use the shell's retained view and a continuous six-column
|
Movies, Shows and Anime use the shell's retained view and a continuous six-column
|
||||||
lazy grid, reduced to five columns while the letter rail is visible so scaled
|
lazy grid. Up from its first row reaches Sort/Filter, then the nav; Back preserves
|
||||||
posters cannot overlap it. Up from the first row reaches Sort/Filter, then the nav;
|
the card and scroll. Sort cycles A–Z, Recently added and Rating. The letter rail
|
||||||
Back preserves the card and scroll. Sort cycles A–Z, Recently added and Rating.
|
exists only at 200 titles with A–Z selected. Card OK activates on key-up through
|
||||||
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.
|
the same shell hold guard, without a long-press menu.
|
||||||
Down clamps a short final title row before entering the separate Try again / Show
|
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
|
more row; an empty slot cannot activate recovery. An unavailable library hides
|
||||||
|
|
@ -478,95 +347,6 @@ music keep their upstream destinations.
|
||||||
failure/retry, stale copies, and person/server isolation. Library captures are written
|
failure/retry, stale copies, and person/server isolation. Library captures are written
|
||||||
to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`.
|
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.
|
|
||||||
|
|
||||||
### TV feedback corrections (spec revision 5)
|
|
||||||
|
|
||||||
The [revision-5 amendment](../data/tv-spec/spec.md) and
|
|
||||||
[native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html)
|
|
||||||
retain the approved frames except for the requested feedback changes:
|
|
||||||
|
|
||||||
- The picker gear is centred under everyone and uses the picker-person ring.
|
|
||||||
- Shelf tracks share second-last-slot lookahead, count clamping and settled
|
|
||||||
offsets. Home records completed frame positioning rather than an animation
|
|
||||||
that may still be cancelled; first mount restores the intended shelf item.
|
|
||||||
Discover uses all six approved poster slots. Person keeps the final column
|
|
||||||
at full poster width.
|
|
||||||
- Episode images choose episode Primary, then Thumb, then the already-read
|
|
||||||
series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance.
|
|
||||||
Episode context uses the episode overview, with two summary lines on the
|
|
||||||
focused card. Held-series headers measure their copy/actions and leave 40 px
|
|
||||||
before seasons instead of reserving a fixed empty 900 px header.
|
|
||||||
- Every Discover heading uses a 104 px band, including a 60 px title-to-card
|
|
||||||
gap. Home heading geometry is unchanged.
|
|
||||||
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
|
|
||||||
420×630. The shared request builder removes conflicting Jellyfin size caps,
|
|
||||||
preserves tags and quality 96, and selects a suitable TMDB tier. The 64 MiB
|
|
||||||
memory cache, 128 MiB disk cache and four image-read limit remain unchanged.
|
|
||||||
|
|
||||||
`HomeViewportTest` covers short/long poster, tile and wide rows, both row
|
|
||||||
directions, first focus and native Back from the actual title surface. Native
|
|
||||||
proof records real Bear season-three metadata through the title read facade;
|
|
||||||
the proof-only harness is not part of the shipped tests or APK.
|
|
||||||
|
|
||||||
|
|
||||||
## Title pages and people
|
## Title pages and people
|
||||||
|
|
||||||
`TitleSurface` owns the film/series/episode header, season chips, episode tiles,
|
`TitleSurface` owns the film/series/episode header, season chips, episode tiles,
|
||||||
|
|
@ -693,38 +473,12 @@ Request recipients include authorized Conductarr profiles without saved Jellyfin
|
||||||
logins; that does not create selectable TV logins. Late name reads preserve the
|
logins; that does not create selectable TV logins. Late name reads preserve the
|
||||||
open chooser's selected recipient and footer focus.
|
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
|
Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's
|
||||||
authenticated fetcher. The app's network interceptor removes Authorization from
|
authenticated fetcher. The app's network interceptor removes Authorization from
|
||||||
every non-Jellyfin artwork origin (scheme, host and port), including redirects;
|
every non-Jellyfin artwork origin (scheme, host and port), including redirects;
|
||||||
current Jellyfin artwork still authenticates. A failed or empty library-audience response keeps
|
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
|
the existing mapping or Jellyfin name rule. Returning to the foreground retries
|
||||||
household metadata without rebuilding the selected identity.
|
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 |
|
| Screen seam | Production supplier |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|
@ -750,21 +504,6 @@ and a same-host/different-port origin with an authenticated upstream parent,
|
||||||
and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers
|
and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers
|
||||||
operator success plus failed/empty audiences, name-rule retention and foreground healing.
|
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;
|
Not done here: deploying the dedicated hostname or widening server authority;
|
||||||
hardware installation/playback verification; releases, signing or version changes.
|
hardware installation/playback verification; releases, signing or version changes.
|
||||||
Unheld trailers remain absent because the server contract has no trailer field.
|
Unheld trailers remain absent because the server contract has no trailer field.
|
||||||
|
|
@ -872,41 +611,6 @@ 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
|
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.
|
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
|
## Release credentials
|
||||||
|
|
||||||
The default escrow is
|
The default escrow is
|
||||||
|
|
|
||||||
|
|
@ -35,36 +35,5 @@ if [[ $mode == release ]]; then
|
||||||
exec ./gradlew "$@"
|
exec ./gradlew "$@"
|
||||||
' bash "${gradle_arguments[@]}" "$@"
|
' bash "${gradle_arguments[@]}" "$@"
|
||||||
else
|
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[@]}" "$@"
|
docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"discover": {
|
"discover": {
|
||||||
"shelfHeadingBand": {"dp": 52, "px": 104, "source": "feedback-2026-11-06: heading-to-card gap 60 px on every Discover lane"},
|
|
||||||
"sheetPaddingX": {
|
"sheetPaddingX": {
|
||||||
"$type": "dimension",
|
"$type": "dimension",
|
||||||
"px": 64,
|
"px": 64,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit,
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||||
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
||||||
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||||
.background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding),
|
.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) {
|
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))
|
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
|
||||||
|
|
|
||||||
|
|
@ -157,8 +157,7 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
|
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
|
||||||
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
val actor = item.actor(viewer)
|
Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
|
||||||
Text("for ${item.recipient(viewer)}" + (actor?.let { " · by $it" } ?: ""), Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
|
|
||||||
style = ComingInTypography.caption, color = secondary)
|
style = ComingInTypography.caption, color = secondary)
|
||||||
FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
|
FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
|
||||||
|
|
@ -169,17 +168,14 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
|
||||||
}
|
}
|
||||||
if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin),
|
if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin),
|
||||||
horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) {
|
horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) {
|
||||||
item.readiness.forEachIndexed { index, word ->
|
item.stage.readiness.forEachIndexed { index, word ->
|
||||||
Row {
|
Row {
|
||||||
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" }
|
val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" }
|
||||||
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight })
|
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)
|
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))
|
if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,14 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
/** History states distinguish acquisition work from library evidence and playback health. */
|
/** Household acquisition stages, ordered independently from their display wording. */
|
||||||
enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List<String>) {
|
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")),
|
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")),
|
Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")),
|
||||||
AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "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")),
|
Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")),
|
||||||
Searching("Searching", 2, "clock", listOf("Done", "Searching", "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")),
|
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")),
|
Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,15 +33,13 @@ data class ComingInRequest(
|
||||||
val stage: ComingInStage,
|
val stage: ComingInStage,
|
||||||
val poster: Any? = null,
|
val poster: Any? = null,
|
||||||
val ratings: List<ComingInRating> = emptyList(),
|
val ratings: List<ComingInRating> = emptyList(),
|
||||||
val summary: String? = null,
|
|
||||||
val readiness: List<String> = stage.readiness,
|
|
||||||
) {
|
) {
|
||||||
val ratingKey = "$mediaType:$tmdbId"
|
val ratingKey = "$mediaType:$tmdbId"
|
||||||
fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName
|
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 == "shared" -> "Shared"
|
||||||
actorKey != null && actorKey == viewer.key -> "you"
|
actorKey != null && actorKey == viewer.key -> "you"
|
||||||
else -> actorName?.takeIf { it.isNotBlank() }
|
else -> actorName ?: "Unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,13 +54,7 @@ data class ComingInSnapshot(
|
||||||
val savedAt: String? = null,
|
val savedAt: String? = null,
|
||||||
) {
|
) {
|
||||||
val ordered = items.sortedBy { it.stage.rank }
|
val ordered = items.sortedBy { it.stage.rank }
|
||||||
val requestCount: Int? get() = if (status == ComingInStatus.Ready || items.isNotEmpty()) items.size else null
|
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready }
|
||||||
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. */
|
/** A bounded window and stable request id preserve focus across refreshes and title round trips. */
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,8 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
|
||||||
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
|
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
|
||||||
}) else null }
|
}) else null }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val request = remember(context, path, poster, dim) {
|
val request = remember(context, path, poster) {
|
||||||
val upright = poster || dim
|
WolfskinImages.request(context, path, if (poster) 256 else 1280, if (poster) 384 else 720, poster)
|
||||||
WolfskinImages.request(context, path,
|
|
||||||
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
|
|
||||||
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
|
|
||||||
}
|
}
|
||||||
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
||||||
else AsyncImage(
|
else AsyncImage(
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,7 @@ 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") }
|
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)
|
DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items)
|
||||||
}
|
}
|
||||||
return DiscoverSnapshot(picks, groups.map { (key, group) -> DiscoverSeed(key, group.first, group.second.toList(), watched = true, added = false) },
|
return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
|
||||||
services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun service(service: DiscoverService, type: String): List<DiscoverLane> {
|
fun service(service: DiscoverService, type: String): List<DiscoverLane> {
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
|
||||||
val result = mutableListOf<DiscoverLane>()
|
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) }
|
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) {
|
if (snapshot.status == DiscoverStatus.Ready) {
|
||||||
val groups = snapshot.seeds.asSequence().filter { it.watched && it.title.isNotBlank() }
|
val groups = snapshot.seeds.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3)
|
||||||
.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")) {
|
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 all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden }
|
||||||
val count = all.count { it.key !in seen }
|
val count = all.count { it.key !in seen }
|
||||||
|
|
@ -20,9 +18,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)
|
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())
|
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.forEach { seed ->
|
groups.forEachIndexed { index, seed ->
|
||||||
val items = take(seed.items, size)
|
val items = take(seed.items, size)
|
||||||
if (items.size >= 3) result += DiscoverLane("pk-w-${seed.key}", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
|
if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val displayed = heroes.mapTo(mutableSetOf()) { it.key }
|
val displayed = heroes.mapTo(mutableSetOf()) { it.key }
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ data class DiscoverTitle(
|
||||||
/** Observed source marks keep labels separate from score values. */
|
/** Observed source marks keep labels separate from score values. */
|
||||||
data class DiscoverRating(val source: String, val score: Double, val label: String? = null)
|
data class DiscoverRating(val source: String, val score: Double, val label: String? = null)
|
||||||
|
|
||||||
/** A typed seed identity and its contribution evidence retain wire order without inventing recency. */
|
/** A contribution group preserves feed order rather than assigning a visible rank. */
|
||||||
data class DiscoverSeed(val key: String, val title: String, val items: List<DiscoverTitle>, val watched: Boolean, val added: Boolean)
|
data class DiscoverSeed(val title: String, val items: List<DiscoverTitle>)
|
||||||
|
|
||||||
/** Services carry their catalogue id and a text wordmark, never third-party logos. */
|
/** 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)
|
data class DiscoverService(val id: Long, val name: String, val hue: Float)
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero)
|
add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero)
|
||||||
if (!state.hasCatalogue) add(L.row_state) else {
|
if (!state.hasCatalogue) add(L.row_state) else {
|
||||||
if (note) add(L.row_note)
|
if (note) add(L.row_note)
|
||||||
state.lanes.forEach { lane -> add(D.shelfHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
|
state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +197,7 @@ private fun DiscoverStatePanel(state: DiscoverState) {
|
||||||
@Composable
|
@Composable
|
||||||
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
|
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
|
||||||
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
||||||
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.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) {
|
.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))
|
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -19,13 +22,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
|
||||||
|
|
@ -42,23 +47,30 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
||||||
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
|
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
|
||||||
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0))
|
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0))
|
||||||
val active = state.contentFocused && state.focusedRow == row
|
val active = state.contentFocused && state.focusedRow == row
|
||||||
val slots = K.posterSlots
|
val slots = ((L.contentWidth + K.posterGap) / K.posterPitch).toInt()
|
||||||
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
|
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
|
||||||
val density = LocalDensity.current
|
var initialized by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(column, count, active) {
|
LaunchedEffect(column, count) {
|
||||||
val target = shelfFirst(column, first, slots, count)
|
var target = first
|
||||||
|
if (column > target + slots - 2) target = column - (slots - 2)
|
||||||
|
if (column < target + 1) target = (column - 1).coerceAtLeast(0)
|
||||||
|
target = target.coerceIn(0, (count - slots).coerceAtLeast(0))
|
||||||
state.shell.view.cards["first:${lane.id}"] = target
|
state.shell.view.cards["first:${lane.id}"] = target
|
||||||
cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() })
|
val offset = cards.layoutInfo.visibleItemsInfo.firstOrNull { it.index == target }?.offset
|
||||||
|
if (initialized && offset != null) {
|
||||||
|
if (offset != 0) cards.animateScrollBy(offset.toFloat(), tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
||||||
|
} else cards.scrollToItem(target)
|
||||||
|
initialized = true
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
|
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
|
||||||
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1)
|
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1)
|
||||||
LazyRow(state = cards, userScrollEnabled = false,
|
LazyRow(state = cards, userScrollEnabled = false,
|
||||||
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
|
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
|
||||||
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
|
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
|
||||||
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
|
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 ->
|
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)
|
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||||
.background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.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))
|
.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) {
|
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
|
||||||
|
|
@ -85,7 +97,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
||||||
Caption(
|
Caption(
|
||||||
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
|
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
|
||||||
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
|
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
|
||||||
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
|
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), L.rowHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
|
||||||
accentFirstFact = lane.hidden,
|
accentFirstFact = lane.hidden,
|
||||||
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
|
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ class DiscoverState(
|
||||||
fun openTitle(item: DiscoverTitle) {
|
fun openTitle(item: DiscoverTitle) {
|
||||||
title = item; titleButton = 0
|
title = item; titleButton = 0
|
||||||
if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key)
|
if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key)
|
||||||
onPush("title/${android.net.Uri.encode(item.key)}")
|
onPush("discover-title/${item.key}")
|
||||||
}
|
}
|
||||||
fun openCollection(lane: DiscoverLane) {
|
fun openCollection(lane: DiscoverLane) {
|
||||||
var titles = lane.fullTitles.associateBy { it.key }
|
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. */
|
/** Card hold/release handling belongs to the shell, including the shared catalogue grid. */
|
||||||
override fun key(key: ShellKey): Boolean {
|
override fun key(key: ShellKey): Boolean {
|
||||||
if (overlay != null) { overlayKey(key); return true }
|
if (overlay != null) { overlayKey(key); return true }
|
||||||
if (shell.route != "discover" && !shell.route.startsWith("discover-") && !shell.route.startsWith("title/")) return false
|
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false
|
||||||
if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true }
|
if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true }
|
||||||
if (title == null) collection?.let {
|
if (title == null) collection?.let {
|
||||||
if (key == ShellKey.Back) { collection = null; onPop(); return true }
|
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.draw.dropShadow
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
||||||
/** Only transforms animate: focus never changes measured geometry. */
|
/** 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 }
|
return graphicsLayer { scaleX = animated; scaleY = animated }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lift shadows remain specific to cards and buttons. */
|
/** Content lifts are distinct from the white person/gear rings. */
|
||||||
enum class FocusLift { None, Card, Button }
|
enum class FocusLift { None, Card, Button }
|
||||||
|
|
||||||
/** Every focus ring straddles the surface edge above content, without a background gap. */
|
/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
|
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None): Modifier {
|
||||||
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
|
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
|
||||||
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
|
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
|
||||||
val shadow = if (lift == FocusLift.None) this else {
|
val shadow = if (lift == FocusLift.None) this else {
|
||||||
|
|
@ -54,15 +54,12 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift
|
||||||
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
|
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return shadow.drawWithCache {
|
return shadow.drawBehind {
|
||||||
val stroke = Stroke(width.toPx())
|
if (ringAlpha > 0f) {
|
||||||
val corner = CornerRadius(radius.toPx())
|
val outer = (gap + width).toPx()
|
||||||
onDrawWithContent {
|
val inner = gap.toPx()
|
||||||
drawContent()
|
drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer))
|
||||||
if (ringAlpha > 0f) {
|
drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner))
|
||||||
drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = ringAlpha),
|
|
||||||
cornerRadius = corner, style = stroke)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,5 +107,9 @@ internal val HomeRow.focusSlots: Int get() = when (cardKind) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */
|
/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */
|
||||||
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int =
|
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int {
|
||||||
shelfFirst(column, previous, focusSlots, count)
|
var first = previous
|
||||||
|
if (column > first + focusSlots - 2) first = column - (focusSlots - 2)
|
||||||
|
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
|
||||||
|
return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,17 +61,25 @@ fun HomeScreen(
|
||||||
LaunchedEffect(view.row, snapshot.rows) {
|
LaunchedEffect(view.row, snapshot.rows) {
|
||||||
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) }
|
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) }
|
||||||
}
|
}
|
||||||
LaunchedEffect(view, view.row, view.home.resetScroll, heights) {
|
LaunchedEffect(view) {
|
||||||
|
if (view.home.positionedKey != null) {
|
||||||
|
view.home.focusedKey?.let { view.home.tracks[it] }?.let { track ->
|
||||||
|
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
|
||||||
|
}
|
||||||
|
view.listState.scrollToItem(view.listState.firstVisibleItemIndex, view.listState.firstVisibleItemScrollOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) {
|
||||||
val key = snapshot.rows.getOrNull(view.row)?.id
|
val key = snapshot.rows.getOrNull(view.row)?.id
|
||||||
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect
|
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect
|
||||||
val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll
|
val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll
|
||||||
val target = if (topRequested) 0f else with(density) {
|
val target = if (topRequested) 0f else with(density) {
|
||||||
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
|
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
|
||||||
}.coerceAtLeast(0f)
|
}.coerceAtLeast(0f)
|
||||||
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
|
|
||||||
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
|
|
||||||
view.home.positionedKey = key
|
view.home.positionedKey = key
|
||||||
view.home.positionedReset = view.home.resetScroll
|
view.home.positionedReset = view.home.resetScroll
|
||||||
|
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
|
||||||
|
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
|
||||||
}
|
}
|
||||||
val cache = view.home.ratings
|
val cache = view.home.ratings
|
||||||
val ratingsReader = LocalScreenRatings.current
|
val ratingsReader = LocalScreenRatings.current
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
|
@ -14,8 +16,9 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
|
@ -40,12 +43,18 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
|
||||||
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
|
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
|
||||||
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
|
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
|
||||||
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
|
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
|
||||||
|
val targetFirst by rememberUpdatedState(first)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
var positioned by remember(track) { mutableStateOf(false) }
|
LaunchedEffect(track) {
|
||||||
LaunchedEffect(track, first, focused, width, density) {
|
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
|
||||||
if (!positioned) track.scrollToItem(first)
|
var positionedFirst = targetFirst
|
||||||
else track.scrollShelfTo(first, with(density) { (width + K.posterGap).toPx() })
|
snapshotFlow { targetFirst }.collectLatest { target ->
|
||||||
positioned = true
|
if (target != positionedFirst) {
|
||||||
|
positionedFirst = target
|
||||||
|
val pitch = with(density) { (width + K.posterGap).toPx() }
|
||||||
|
track.animateScrollBy((target - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
|
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
|
||||||
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
|
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ class LibraryGridState(
|
||||||
var inRail by mutableStateOf(false)
|
var inRail by mutableStateOf(false)
|
||||||
var railLetter by mutableStateOf('#')
|
var railLetter by mutableStateOf('#')
|
||||||
private var railReturn = 0
|
private var railReturn = 0
|
||||||
val columns: Int get() = WolfskinTokens.Behaviour.gridColumns.toInt() - if (railVisible) 1 else 0
|
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
|
||||||
val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" }
|
val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" }
|
||||||
val hasToolbar: Boolean get() = !catalogue || mixed
|
val hasToolbar: Boolean get() = !catalogue || mixed
|
||||||
val items: List<LibraryTitle> by derivedStateOf {
|
val items: List<LibraryTitle> by derivedStateOf {
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,8 @@ fun LibraryPoster(
|
||||||
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
|
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
|
||||||
val progress = title.progress
|
val progress = title.progress
|
||||||
if (progress > 0f && progress < 1f) {
|
if (progress > 0f && progress < 1f) {
|
||||||
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap)
|
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap)
|
||||||
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) {
|
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) {
|
||||||
Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, 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 headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1
|
||||||
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
|
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
|
||||||
val focusedRow = (if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index).coerceAtLeast(0) / grid.columns
|
LaunchedEffect(grid.index, grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) {
|
||||||
LaunchedEffect(grid.index, grid.columns, focusedRow, headerCount, rowHeight, density,
|
val index = if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index
|
||||||
grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) {
|
val row = index.coerceAtLeast(0) / grid.columns
|
||||||
if (grid.toolbar || focusedRow == 0 && active) {
|
if (grid.toolbar || row == 0 && active) {
|
||||||
animateLibraryScroll(view, headerCount, 0f, density)
|
animateLibraryScroll(view, headerCount, 0f, density)
|
||||||
} else if (focusedRow > 0) {
|
} else if (row > 0) {
|
||||||
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * focusedRow - L.pinnedLine).toPx() }
|
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() }
|
||||||
animateLibraryScroll(view, headerCount, target, density)
|
animateLibraryScroll(view, headerCount, target, density)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -186,7 +186,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) {
|
private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) {
|
||||||
Row(Modifier.fillMaxWidth().height(L.row_chipsGrid).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) {
|
Row(Modifier.fillMaxWidth().height(L.row_chips).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")
|
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 ->
|
labels.forEachIndexed { index, label ->
|
||||||
val focused = active && grid.toolbar && grid.tool == index
|
val focused = active && grid.toolbar && grid.tool == index
|
||||||
|
|
@ -266,7 +266,7 @@ private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int,
|
||||||
val current = with(density) {
|
val current = with(density) {
|
||||||
(if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead +
|
(if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead +
|
||||||
if (first < headerCount) WolfskinTokens.Shell.zero else
|
if (first < headerCount) WolfskinTokens.Shell.zero else
|
||||||
(if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
|
(if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
|
||||||
} + view.listState.firstVisibleItemScrollOffset
|
} + view.listState.firstVisibleItemScrollOffset
|
||||||
animateFrameScroll(view.listState, target - current)
|
animateFrameScroll(view.listState, target - current)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,12 +71,13 @@ 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 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 color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color")
|
||||||
val density = LocalDensity.current.density
|
val density = LocalDensity.current.density
|
||||||
val band = remember {
|
val band = remember(scrolled) {
|
||||||
Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
|
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)))
|
||||||
}
|
}
|
||||||
Box(modifier.fillMaxWidth().height(L.navBand)) {
|
Box(modifier.fillMaxWidth().height(L.navBand)) {
|
||||||
Canvas(Modifier.matchParentSize()) {
|
Canvas(Modifier.matchParentSize()) {
|
||||||
if (scrolled) drawRect(C.bg) else drawRect(band)
|
drawRect(band)
|
||||||
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
|
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
|
||||||
}
|
}
|
||||||
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
|
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
|
||||||
|
|
@ -111,7 +112,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
||||||
val avatar = boxes.last()
|
val avatar = boxes.last()
|
||||||
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
|
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
|
||||||
Modifier.offset(avatar.left, avatar.top)
|
Modifier.offset(avatar.left, avatar.top)
|
||||||
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing)
|
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
|
||||||
.focusProperties { canFocus = false }
|
.focusProperties { canFocus = false }
|
||||||
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
|
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
|
||||||
Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
|
Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
|
||||||
}
|
}
|
||||||
itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
|
itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
|
||||||
Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||||
row.forEachIndexed { column, item ->
|
row.forEachIndexed { column, item ->
|
||||||
val index = rowIndex * columns + column
|
val index = rowIndex * columns + column
|
||||||
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }
|
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** The count describes returned request history, not an inferred queue of active downloads. */
|
/** The household request count shares the sheet's snapshot and excludes Ready. */
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) {
|
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) {
|
||||||
val list = state.view.listState
|
val list = state.view.listState
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
LaunchedEffect(state.settingsRow, state.navFocused) {
|
LaunchedEffect(state.settingsRow, state.navFocused) {
|
||||||
|
|
@ -78,7 +78,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount:
|
||||||
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
|
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
|
||||||
Box(Modifier.height(L.row_srow)) {
|
Box(Modifier.height(L.row_srow)) {
|
||||||
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
|
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
|
||||||
if (group.key == "requests") requestCount else null,
|
if (group.key == "requests") openRequests else null,
|
||||||
Modifier.offset(L.safeSide, S.settingsRowTop)) {
|
Modifier.offset(L.safeSide, S.settingsRowTop)) {
|
||||||
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
|
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +89,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount:
|
||||||
|
|
||||||
/** The inverse focus fill has no focus ring and never changes row layout. */
|
/** The inverse focus fill has no focus ring and never changes row layout. */
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
val foreground = if (focused) C.onFocus else C.fg
|
val foreground = if (focused) C.onFocus else C.fg
|
||||||
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||||
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
||||||
|
|
@ -97,7 +97,7 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modi
|
||||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
|
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
|
||||||
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: ""
|
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
|
||||||
Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
|
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)
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
package org.justinvisser.wolfskin.design
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
|
|
||||||
/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */
|
|
||||||
internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int {
|
|
||||||
var first = previous
|
|
||||||
if (column > first + slots - 2) first = column - (slots - 2)
|
|
||||||
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
|
|
||||||
return first.coerceIn(0, (count - slots).coerceAtLeast(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
|
|
||||||
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
|
|
||||||
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
|
|
||||||
if (distance != 0f) animateScrollBy(distance, tween(WolfskinTokens.Motion.base,
|
|
||||||
easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
|
||||||
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
|
|
||||||
}
|
|
||||||
|
|
@ -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. */
|
/** The shell owns keys; the integration owns navigation and authenticated user changes. */
|
||||||
sealed interface ShellAction {
|
sealed interface ShellAction {
|
||||||
data class Destination(val route: String, val preservePushed: Boolean = false) : ShellAction
|
data class Destination(val route: String) : ShellAction
|
||||||
data class Person(val person: ShellPerson) : ShellAction
|
data class Person(val person: ShellPerson) : ShellAction
|
||||||
data class HandOff(val group: String) : ShellAction
|
data class HandOff(val group: String) : ShellAction
|
||||||
data object Pop : ShellAction
|
data object Pop : ShellAction
|
||||||
|
|
@ -50,23 +50,14 @@ class ShellState(
|
||||||
var people by mutableStateOf(people)
|
var people by mutableStateOf(people)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/** Refresh household data without losing valid nav focus or this person's view memory. */
|
/** Refresh household labels without discarding this person's navigation memory. */
|
||||||
fun updatePeople(value: List<ShellPerson>) {
|
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 }
|
val selected = value.firstOrNull { it.jellyfinUserId != null && it.jellyfinUserId == person.jellyfinUserId }
|
||||||
?: value.firstOrNull { it.key == person.key }
|
?: value.firstOrNull { it.key == person.key }
|
||||||
people = value
|
people = value
|
||||||
if (selected != null) person = selected
|
if (selected != null) person = selected
|
||||||
pickerIndex = people.indexOf(person).coerceAtLeast(0)
|
pickerIndex = people.indexOf(person).coerceAtLeast(0)
|
||||||
val removedRoot = rootRoute !in tabs
|
|
||||||
if (removedRoot) {
|
|
||||||
if (route == rootRoute) route = "home"
|
|
||||||
rootRoute = "home"
|
|
||||||
}
|
|
||||||
preparePersonMemory()
|
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())
|
var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first())
|
||||||
private set
|
private set
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ fun ShellSurface(
|
||||||
state: ShellState,
|
state: ShellState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
handlesContentKeys: Boolean = true,
|
handlesContentKeys: Boolean = true,
|
||||||
settingsRequestCount: Int? = null,
|
settingsOpenRequests: Int? = null,
|
||||||
overlay: @Composable () -> Unit = {},
|
overlay: @Composable () -> Unit = {},
|
||||||
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
||||||
) {
|
) {
|
||||||
|
|
@ -91,7 +91,7 @@ fun ShellSurface(
|
||||||
if (state.pickerVisible) {
|
if (state.pickerVisible) {
|
||||||
WhosWatching(state)
|
WhosWatching(state)
|
||||||
} else {
|
} else {
|
||||||
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||||
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||||
if (state.requestsVisible) ComingInSheet(state)
|
if (state.requestsVisible) ComingInSheet(state)
|
||||||
overlay()
|
overlay()
|
||||||
|
|
|
||||||
|
|
@ -98,14 +98,14 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
|
||||||
for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat()
|
for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat()
|
||||||
ColorFilter.colorMatrix(matrix)
|
ColorFilter.colorMatrix(matrix)
|
||||||
}
|
}
|
||||||
TitleArtwork(item.poster, WolfskinTokens.Image.heroPosterRequestWidth, WolfskinTokens.Image.heroPosterRequestHeight,
|
TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight,
|
||||||
Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }
|
Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }
|
||||||
.blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
|
.blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
|
||||||
val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) }
|
val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) }
|
||||||
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
|
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
|
||||||
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
|
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
|
||||||
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
|
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
|
||||||
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
|
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape))
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxSize().background(horizontal))
|
Box(Modifier.fillMaxSize().background(horizontal))
|
||||||
Box(Modifier.fillMaxSize().background(vertical))
|
Box(Modifier.fillMaxSize().background(vertical))
|
||||||
|
|
@ -131,13 +131,13 @@ fun TitleTileArtwork(item: TitleItem) {
|
||||||
val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply {
|
val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply {
|
||||||
for (index in 0 until 15) values[index] *= D.tileAmbientBrightness
|
for (index in 0 until 15) values[index] *= D.tileAmbientBrightness
|
||||||
}) }
|
}) }
|
||||||
TitleArtwork(item.poster, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter)
|
TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter)
|
||||||
val height = D.episodeCardHeight - D.tileSheetInset * 2
|
val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2
|
||||||
val shape = remember { RoundedCornerShape(D.tileSheetRadius) }
|
val shape = remember { RoundedCornerShape(D.tileSheetRadius) }
|
||||||
val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) }
|
val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) }
|
||||||
TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(),
|
TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(),
|
||||||
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true)
|
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true)
|
||||||
} else TitleArtwork(item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize())
|
} else TitleArtwork(item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs
|
||||||
height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) {
|
height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) {
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||||
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
|
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)) {
|
||||||
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
|
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)
|
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)
|
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 }
|
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
||||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
||||||
.background(background, shape)
|
.background(background, shape)
|
||||||
.then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
|
.then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
|
||||||
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
|
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,6 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
|
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
|
||||||
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
|
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
|
||||||
val item = state.data.item
|
val item = state.data.item
|
||||||
val episode = when {
|
|
||||||
state.row == "episodes" -> state.data.episodes.getOrNull(state.column)
|
|
||||||
state.data.watchTarget.kind == "episode" -> state.data.watchTarget
|
|
||||||
else -> state.data.playTarget?.takeIf { it.kind == "episode" }
|
|
||||||
}
|
|
||||||
val type = if (item.kind == "movie") "Movie" else "Series"
|
val type = if (item.kind == "movie") "Movie" else "Series"
|
||||||
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
|
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
|
||||||
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
@ -68,7 +63,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine)
|
Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
|
Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
|
||||||
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
|
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
|
||||||
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
||||||
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -91,10 +90,6 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
val list = shell.view.listState
|
val list = shell.view.listState
|
||||||
var wheelPending by remember { mutableStateOf(false) }
|
var wheelPending by remember { mutableStateOf(false) }
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
|
||||||
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
|
|
||||||
state.data.item.kind == "tv" && state.data.item.seasons > 0
|
|
||||||
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
|
|
||||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
|
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
|
||||||
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
@ -110,19 +105,19 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
if (state.data.related.isNotEmpty()) add("related")
|
if (state.data.related.isNotEmpty()) add("related")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val rowOffsets = remember(displayedRows, density, headerHeight) {
|
val rowOffsets = remember(displayedRows, density) {
|
||||||
FloatArray(displayedRows.size + 1).apply {
|
FloatArray(displayedRows.size + 1).apply {
|
||||||
displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row, headerHeight).toPx() } }
|
displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row).toPx() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val position by remember(list, rowOffsets) { derivedStateOf {
|
val position by remember(list, rowOffsets) { derivedStateOf {
|
||||||
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
||||||
} }
|
} }
|
||||||
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) {
|
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows) {
|
||||||
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
||||||
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
|
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
|
||||||
val y = rowOffsets[index]
|
val y = rowOffsets[index]
|
||||||
val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
|
val height = with(density) { titleRowHeight(state.row).toPx() }
|
||||||
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
|
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
|
||||||
else y - with(density) { L.pinnedLine.toPx() }
|
else y - with(density) { L.pinnedLine.toPx() }
|
||||||
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
|
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
|
||||||
|
|
@ -153,11 +148,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
|
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
|
||||||
val active = state.row == id && !shell.navFocused && state.sheet == null
|
val active = state.row == id && !shell.navFocused && state.sheet == null
|
||||||
when (id) {
|
when (id) {
|
||||||
"header" -> Box(Modifier.height(headerHeight)) {
|
"header" -> Box(Modifier.height(L.row_dhead)) { TitleHeader(state, active) }
|
||||||
TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size ->
|
|
||||||
measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap
|
|
||||||
} else Modifier)
|
|
||||||
}
|
|
||||||
"seasons" -> TitleSeasons(state, active)
|
"seasons" -> TitleSeasons(state, active)
|
||||||
"episodes" -> TitleMediaShelf(state, true, active)
|
"episodes" -> TitleMediaShelf(state, true, active)
|
||||||
"cast" -> TitleCast(state, active)
|
"cast" -> TitleCast(state, active)
|
||||||
|
|
@ -165,16 +156,16 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
}
|
}
|
||||||
} }
|
} }
|
||||||
item(key = "end-padding", contentType = "spacer") {
|
item(key = "end-padding", contentType = "spacer") {
|
||||||
Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last(), headerHeight)).coerceAtLeast(D.minimumEndPadding)))
|
Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last())).coerceAtLeast(D.minimumEndPadding)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) {
|
private fun titleRowHeight(row: String): Dp = when (row) {
|
||||||
"header" -> headerHeight
|
"header" -> L.row_dhead
|
||||||
"seasons" -> L.row_chips
|
"seasons" -> L.row_chips
|
||||||
"episodes" -> L.rowHeadingBand + D.episodeCardHeight + L.rowGapAfter
|
"episodes" -> L.rowHeadingBand + WolfskinTokens.Card.tileH + L.rowGapAfter
|
||||||
"cast" -> D.castHeight
|
"cast" -> D.castHeight
|
||||||
else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter
|
else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
|
||||||
queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight)
|
queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight)
|
||||||
"episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) {
|
"episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) {
|
||||||
val item = data.episodes[index]
|
val item = data.episodes[index]
|
||||||
queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight)
|
queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight)
|
||||||
}
|
}
|
||||||
"related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots))
|
"related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots))
|
||||||
queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true)
|
queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package org.justinvisser.wolfskin.design
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -38,7 +40,6 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
|
|
@ -49,13 +50,10 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
||||||
val track = rememberLazyListState()
|
val track = rememberLazyListState()
|
||||||
val focusOwner = LocalTitleFocusOwner.current
|
val focusOwner = LocalTitleFocusOwner.current
|
||||||
val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() }
|
val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() }
|
||||||
LaunchedEffect(state.column, active, state.data.cast.size) {
|
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) }
|
||||||
if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size)
|
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))
|
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2),
|
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
||||||
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
|
||||||
if (state.data.castStatus == TitleReadStatus.Loading) {
|
if (state.data.castStatus == TitleReadStatus.Loading) {
|
||||||
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
|
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||||
Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
|
Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
|
||||||
|
|
@ -65,7 +63,7 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
||||||
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
|
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
|
||||||
.onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) }
|
.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}")) {
|
.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.ringCardRing, FocusLift.Card).clip(CircleShape))
|
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).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) {
|
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.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)
|
Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||||
|
|
@ -82,18 +80,14 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
||||||
val titles = if (episodes) state.data.episodes else state.data.related
|
val titles = if (episodes) state.data.episodes else state.data.related
|
||||||
val id = if (episodes) "episodes" else "related"
|
val id = if (episodes) "episodes" else "related"
|
||||||
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
|
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
|
||||||
val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH
|
val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
|
||||||
val track = rememberLazyListState()
|
val track = rememberLazyListState()
|
||||||
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
|
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() }
|
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
|
||||||
LaunchedEffect(state.column, active, titles.size) {
|
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) }
|
||||||
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
|
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))
|
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, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
|
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||||
.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) {
|
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
|
||||||
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
|
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||||
Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||||
|
|
@ -117,15 +111,14 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
||||||
val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } }
|
val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } }
|
||||||
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
|
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
|
||||||
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
||||||
} else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
} else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||||
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
|
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
|
||||||
TitleTileArtwork(item)
|
TitleTileArtwork(item)
|
||||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
||||||
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
||||||
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
}
|
||||||
val progress = item.progress
|
val progress = item.progress
|
||||||
if (progress > 0f && progress < 1f) {
|
if (progress > 0f && progress < 1f) {
|
||||||
|
|
@ -159,8 +152,15 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply the same lookahead and settled-offset rule to cast, episodes and related shelves. */
|
/** Keep a full lookahead card, but do not recenter a row when reversing direction. */
|
||||||
private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float, count: Int) {
|
private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float) {
|
||||||
val previous = firstVisibleItemIndex + (firstVisibleItemScrollOffset / pitch).roundToInt()
|
val first = firstVisibleItemIndex
|
||||||
scrollShelfTo(shelfFirst(column, previous, slots, count), pitch)
|
val next = when {
|
||||||
|
column < first -> column
|
||||||
|
column >= first + slots - 1 -> (column - slots + 2).coerceAtLeast(0)
|
||||||
|
else -> first
|
||||||
|
}
|
||||||
|
if (next != first) animateScrollBy((next - first) * pitch - firstVisibleItemScrollOffset, titleTrackScrollSpec)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val titleTrackScrollSpec = tween<Float>(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,12 @@ fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height
|
||||||
.requiredSize(width + inset * 2, height + inset * 2)
|
.requiredSize(width + inset * 2, height + inset * 2)
|
||||||
.blur(if (hero) H.heroBlur else H.cardBlur)
|
.blur(if (hero) H.heroBlur else H.cardBlur)
|
||||||
.graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } }
|
.graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } }
|
||||||
SlotImage(poster, if (hero) WolfskinTokens.Layout.heroSheetWidth else width, if (hero) WolfskinTokens.Layout.heroSheetHeight else height, washModifier, colorFilter = wash, onError = { failed.value = true })
|
SlotImage(poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true })
|
||||||
val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset
|
val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset
|
||||||
val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f)
|
val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f)
|
||||||
val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop)
|
val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop)
|
||||||
SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true })
|
SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true })
|
||||||
} else SlotImage(source, width, height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true })
|
} else SlotImage(source, if (hero) WolfskinTokens.Card.posterW * 5 else width, if (hero) WolfskinTokens.Card.posterW * 5 * (9f / 16f) else height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true })
|
||||||
} else if (!hero) MediaArtworkFallback(title, type == "movie", showTitle = !landscape)
|
} else if (!hero) MediaArtworkFallback(title, type == "movie", showTitle = !landscape)
|
||||||
else HomeAmbient(Modifier.fillMaxSize())
|
else HomeAmbient(Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,15 +46,16 @@ private fun PeoplePicker(state: ShellState) {
|
||||||
state.focusPerson(index); state.key(ShellKey.Ok)
|
state.focusPerson(index); state.key(ShellKey.Ok)
|
||||||
}, horizontalAlignment = Alignment.CenterHorizontally) {
|
}, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
|
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
|
||||||
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing))
|
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerGap, WolfskinTokens.Focus.ringPickerRing, C.fg))
|
||||||
Spacer(Modifier.height(S.pickerNameGap))
|
Spacer(Modifier.height(S.pickerNameGap))
|
||||||
Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName)
|
Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val left = (S.stageWidth - L.gear) / 2
|
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)
|
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
|
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
|
||||||
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
|
.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))
|
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
|
||||||
if (state.gearFocused) {
|
if (state.gearFocused) {
|
||||||
|
|
@ -105,7 +106,7 @@ fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String
|
||||||
textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) {
|
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
|
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||||
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
||||||
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
|
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
|
||||||
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
|
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
|
||||||
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
|
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import coil3.ComponentRegistry
|
import coil3.ComponentRegistry
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.disk.DiskCache
|
import coil3.disk.DiskCache
|
||||||
|
|
@ -11,7 +10,6 @@ import coil3.request.crossfade
|
||||||
import coil3.memory.MemoryCache
|
import coil3.memory.MemoryCache
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.allowRgb565
|
import coil3.request.allowRgb565
|
||||||
import coil3.size.Scale
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
|
@ -63,7 +61,7 @@ object WolfskinImages {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest =
|
fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest =
|
||||||
ImageRequest.Builder(context).data(sourceForSize(source, width, height)).size(width, height).scale(Scale.FIT).allowRgb565(poster).build()
|
ImageRequest.Builder(context).data(source).size(width, height).allowRgb565(poster).build()
|
||||||
|
|
||||||
fun prefetch(context: Context, row: HomeRow) {
|
fun prefetch(context: Context, row: HomeRow) {
|
||||||
val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 }
|
val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 }
|
||||||
|
|
@ -75,35 +73,4 @@ object WolfskinImages {
|
||||||
if (source != null) loader(context).enqueue(request(context, source, width, height, poster))
|
if (source != null) loader(context).enqueue(request(context, source, width, height, poster))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val jellyfinImagePath = Regex("/Items/[^/]+/Images/[^/]+(?:/\\d+)?", RegexOption.IGNORE_CASE)
|
|
||||||
private val tmdbSizePath = Regex("/t/p/(?:w\\d+|original)/")
|
|
||||||
private val dimensionParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight")
|
|
||||||
|
|
||||||
private fun sourceForSize(source: Any, width: Int, height: Int): Any {
|
|
||||||
if (source !is String) return source
|
|
||||||
val uri = Uri.parse(source)
|
|
||||||
if (uri.scheme != "https" && uri.scheme != "http") return source
|
|
||||||
if (uri.host == "image.tmdb.org") {
|
|
||||||
val size = if (height > width) when {
|
|
||||||
width <= 500 -> "w500"
|
|
||||||
width <= 780 -> "w780"
|
|
||||||
else -> "original"
|
|
||||||
} else when {
|
|
||||||
width <= 780 -> "w780"
|
|
||||||
width <= 1280 -> "w1280"
|
|
||||||
else -> "original"
|
|
||||||
}
|
|
||||||
return uri.buildUpon().path(uri.path?.replace(tmdbSizePath, "/t/p/$size/")).build().toString()
|
|
||||||
}
|
|
||||||
if (!jellyfinImagePath.matches(uri.path.orEmpty())) return source
|
|
||||||
val builder = uri.buildUpon().clearQuery()
|
|
||||||
for (name in uri.queryParameterNames) {
|
|
||||||
if (name.lowercase() !in dimensionParameters) {
|
|
||||||
uri.getQueryParameters(name).forEach { value -> builder.appendQueryParameter(name, value) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return builder.appendQueryParameter("fillWidth", width.toString())
|
|
||||||
.appendQueryParameter("fillHeight", height.toString()).build().toString()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,9 +112,9 @@ object WolfskinTokens {
|
||||||
val navSolidAfter = 20.0.dp
|
val navSolidAfter = 20.0.dp
|
||||||
val pinnedLine = 84.0.dp
|
val pinnedLine = 84.0.dp
|
||||||
val windowAhead = 350.0.dp
|
val windowAhead = 350.0.dp
|
||||||
val rowHeadingBand = 42.0.dp
|
val rowHeadingBand = 30.0.dp
|
||||||
val rowGapAfter = 34.0.dp
|
val rowGapAfter = 22.0.dp
|
||||||
val gridRowGap = 22.0.dp
|
val gridRowGap = 14.0.dp
|
||||||
val row_hero = 410.0.dp
|
val row_hero = 410.0.dp
|
||||||
val row_phero = 370.0.dp
|
val row_phero = 370.0.dp
|
||||||
val row_section = 75.0.dp
|
val row_section = 75.0.dp
|
||||||
|
|
@ -146,7 +146,7 @@ object WolfskinTokens {
|
||||||
val settingsRowWidth = 650.0.dp
|
val settingsRowWidth = 650.0.dp
|
||||||
val settingsRowHeight = 52.0.dp
|
val settingsRowHeight = 52.0.dp
|
||||||
val stateWidth = 550.0.dp
|
val stateWidth = 550.0.dp
|
||||||
val captionOffset = 19.0.dp
|
val captionOffset = 9.0.dp
|
||||||
val badge = 24.0.dp
|
val badge = 24.0.dp
|
||||||
val badgeInset = 7.0.dp
|
val badgeInset = 7.0.dp
|
||||||
val progressBar = 4.0.dp
|
val progressBar = 4.0.dp
|
||||||
|
|
@ -159,37 +159,36 @@ object WolfskinTokens {
|
||||||
val personPortrait = 120.0.dp
|
val personPortrait = 120.0.dp
|
||||||
val thumbWidth = 48.0.dp
|
val thumbWidth = 48.0.dp
|
||||||
val thumbHeight = 72.0.dp
|
val thumbHeight = 72.0.dp
|
||||||
val row_chipsGrid = 68.0.dp
|
|
||||||
}
|
}
|
||||||
object Card {
|
object Card {
|
||||||
val posterW = 128.0.dp
|
val posterW = 128.0.dp
|
||||||
val posterH = 192.0.dp
|
val posterH = 192.0.dp
|
||||||
val posterGap = 20.0.dp
|
val posterGap = 12.0.dp
|
||||||
val posterPitch = 148.0.dp
|
val posterPitch = 140.0.dp
|
||||||
val posterCaptionBand = 48.0.dp
|
val posterCaptionBand = 38.0.dp
|
||||||
const val posterSlots = 6
|
const val posterSlots = 6
|
||||||
val tileW = 200.0.dp
|
val tileW = 208.0.dp
|
||||||
val tileH = 112.5.dp
|
val tileH = 117.0.dp
|
||||||
val tileGap = 20.0.dp
|
val tileGap = 12.0.dp
|
||||||
val tilePitch = 220.0.dp
|
val tilePitch = 220.0.dp
|
||||||
val tileCaptionBand = 0.0.dp
|
val tileCaptionBand = 0.0.dp
|
||||||
const val tileSlots = 4
|
const val tileSlots = 4
|
||||||
val wideW = 272.0.dp
|
val wideW = 280.0.dp
|
||||||
val wideH = 153.0.dp
|
val wideH = 157.5.dp
|
||||||
val wideGap = 20.0.dp
|
val wideGap = 12.0.dp
|
||||||
val widePitch = 292.0.dp
|
val widePitch = 292.0.dp
|
||||||
val wideCaptionBand = 0.0.dp
|
val wideCaptionBand = 0.0.dp
|
||||||
const val wideSlots = 3
|
const val wideSlots = 3
|
||||||
val entryW = 128.0.dp
|
val entryW = 128.0.dp
|
||||||
val entryH = 72.0.dp
|
val entryH = 72.0.dp
|
||||||
val entryGap = 20.0.dp
|
val entryGap = 12.0.dp
|
||||||
val entryPitch = 148.0.dp
|
val entryPitch = 140.0.dp
|
||||||
val entryCaptionBand = 0.0.dp
|
val entryCaptionBand = 0.0.dp
|
||||||
const val entrySlots = 6
|
const val entrySlots = 6
|
||||||
val personW = 100.0.dp
|
val personW = 100.0.dp
|
||||||
val personH = 155.0.dp
|
val personH = 145.0.dp
|
||||||
val personGap = 22.0.dp
|
val personGap = 14.0.dp
|
||||||
val personPitch = 122.0.dp
|
val personPitch = 114.0.dp
|
||||||
val personCaptionBand = 0.0.dp
|
val personCaptionBand = 0.0.dp
|
||||||
const val personSlots = 7
|
const val personSlots = 7
|
||||||
}
|
}
|
||||||
|
|
@ -197,14 +196,14 @@ object WolfskinTokens {
|
||||||
const val scaleCard = 1.08f
|
const val scaleCard = 1.08f
|
||||||
const val scaleControl = 1.05f
|
const val scaleControl = 1.05f
|
||||||
const val scaleRow = 1.02f
|
const val scaleRow = 1.02f
|
||||||
val ringCardGap = 0.0.dp
|
val ringCardGap = 2.0.dp
|
||||||
val ringCardRing = 2.0.dp
|
val ringCardRing = 2.0.dp
|
||||||
val ringControlGap = 0.0.dp
|
val ringButtonGap = 2.0.dp
|
||||||
val ringControlRing = 2.0.dp
|
val ringButtonRing = 2.0.dp
|
||||||
val ringAvatarGap = 0.0.dp
|
val ringAvatarGap = 2.0.dp
|
||||||
val ringAvatarRing = 2.0.dp
|
val ringAvatarRing = 2.0.dp
|
||||||
val ringPickerGap = 0.0.dp
|
val ringPickerGap = 3.0.dp
|
||||||
val ringPickerRing = 2.0.dp
|
val ringPickerRing = 3.0.dp
|
||||||
const val fillInverse = "background color.fg, text color.onFocus"
|
const val fillInverse = "background color.fg, text color.onFocus"
|
||||||
}
|
}
|
||||||
object Motion {
|
object Motion {
|
||||||
|
|
@ -224,12 +223,8 @@ object WolfskinTokens {
|
||||||
const val tileRequestHeight = 234
|
const val tileRequestHeight = 234
|
||||||
const val wideRequestWidth = 560
|
const val wideRequestWidth = 560
|
||||||
const val wideRequestHeight = 315
|
const val wideRequestHeight = 315
|
||||||
const val heroRequestWidth = 1920
|
const val heroRequestWidth = 1280
|
||||||
const val heroRequestHeight = 1080
|
const val heroRequestHeight = 720
|
||||||
const val heroPosterRequestWidth = 420
|
|
||||||
const val heroPosterRequestHeight = 630
|
|
||||||
const val episodeRequestWidth = 400
|
|
||||||
const val episodeRequestHeight = 280
|
|
||||||
const val castRequestWidth = 200
|
const val castRequestWidth = 200
|
||||||
const val castRequestHeight = 200
|
const val castRequestHeight = 200
|
||||||
const val memoryCacheMB = 64.0f
|
const val memoryCacheMB = 64.0f
|
||||||
|
|
@ -420,8 +415,6 @@ object WolfskinTokens {
|
||||||
const val sheetOpacity = 0.5f
|
const val sheetOpacity = 0.5f
|
||||||
}
|
}
|
||||||
object Title {
|
object Title {
|
||||||
val episodeCardHeight = 140.0.dp
|
|
||||||
val seriesHeaderGap = 20.0.dp
|
|
||||||
val lineGap = 11.0.dp
|
val lineGap = 11.0.dp
|
||||||
val lineLift = -2.0.dp
|
val lineLift = -2.0.dp
|
||||||
val overviewGap = 17.0.dp
|
val overviewGap = 17.0.dp
|
||||||
|
|
@ -436,8 +429,8 @@ object WolfskinTokens {
|
||||||
val progressBottom = 15.0.dp
|
val progressBottom = 15.0.dp
|
||||||
val progressGap = 9.0.dp
|
val progressGap = 9.0.dp
|
||||||
val progressLift = -7.0.dp
|
val progressLift = -7.0.dp
|
||||||
val castHeight = 231.0.dp
|
val castHeight = 197.0.dp
|
||||||
val castNameTop = 118.0.dp
|
val castNameTop = 108.0.dp
|
||||||
val castNameSide = 6.0.dp
|
val castNameSide = 6.0.dp
|
||||||
val personTop = 85.0.dp
|
val personTop = 85.0.dp
|
||||||
val personGap = 24.0.dp
|
val personGap = 24.0.dp
|
||||||
|
|
@ -463,7 +456,7 @@ object WolfskinTokens {
|
||||||
val infoRowGap = 3.0.dp
|
val infoRowGap = 3.0.dp
|
||||||
val infoColumnGap = 10.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 infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
|
||||||
val gridHeight = 262.0.dp
|
val gridHeight = 244.0.dp
|
||||||
val minimumEndPadding = 40.0.dp
|
val minimumEndPadding = 40.0.dp
|
||||||
val ratingGap = 10.0.dp
|
val ratingGap = 10.0.dp
|
||||||
val ratingMarkGap = 4.0.dp
|
val ratingMarkGap = 4.0.dp
|
||||||
|
|
@ -615,7 +608,6 @@ object WolfskinTokens {
|
||||||
val posterProgressBottom = 6.0.dp
|
val posterProgressBottom = 6.0.dp
|
||||||
}
|
}
|
||||||
object Discover {
|
object Discover {
|
||||||
val shelfHeadingBand = 52.0.dp
|
|
||||||
val sheetPaddingX = 32.0.dp
|
val sheetPaddingX = 32.0.dp
|
||||||
val sheetPaddingY = 40.0.dp
|
val sheetPaddingY = 40.0.dp
|
||||||
val sheetGap = 9.0.dp
|
val sheetGap = 9.0.dp
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,6 @@ class DiscoverCaptureTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun st029_discover() = capture("st-029")
|
@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 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 st031_hidden() = capture("st-031") { hidden(it) }
|
||||||
@Test fun st032_restore() {
|
@Test fun st032_restore() {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ class DiscoverKeysTest {
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
WolfskinTheme {
|
WolfskinTheme {
|
||||||
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
|
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
|
||||||
if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
|
if (shell.route == "discover" || shell.route.startsWith("discover-")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import org.junit.Test
|
||||||
class DiscoverLaneProjectionTest {
|
class DiscoverLaneProjectionTest {
|
||||||
@Test fun discardedSeedGroupRemainsAvailableInCatalogue() {
|
@Test fun discardedSeedGroupRemainsAvailableInCatalogue() {
|
||||||
val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") }
|
val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") }
|
||||||
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("movie:10", "Seed", titles.subList(3, 5), watched = true, added = false)),
|
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("Seed", titles.subList(3, 5))),
|
||||||
lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready)
|
lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready)
|
||||||
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
|
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
|
||||||
assertEquals(listOf("pk-movie", "trending"), rows.map { it.id })
|
assertEquals(listOf("pk-movie", "trending"), rows.map { it.id })
|
||||||
|
|
@ -30,25 +30,4 @@ class DiscoverLaneProjectionTest {
|
||||||
val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden))
|
val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden))
|
||||||
assertEquals(listOf(listOf(series)), filtered.map { it.titles })
|
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 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
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,7 +115,5 @@ class HomeCaptureTest {
|
||||||
@Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2)
|
@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 st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4)
|
||||||
@Test fun st103_heroRatings() = capture("st-103")
|
@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")
|
@Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
package org.justinvisser.wolfskin.design
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.input.key.Key
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
|
||||||
import androidx.compose.ui.test.onRoot
|
|
||||||
import androidx.compose.ui.test.performKeyInput
|
|
||||||
import androidx.compose.ui.test.pressKey
|
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
|
||||||
import java.io.File
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
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
|
|
||||||
|
|
||||||
/** Row ends, mixed card widths and real title Back navigation keep the selected art inside the view. */
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
||||||
class HomeViewportTest {
|
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
|
||||||
|
|
||||||
@Test fun rowEndsRowChangesAndTitleBackPreserveTheFocusedCardMargin() {
|
|
||||||
val cards = HomeFixture(rule.activity).snapshot("justin").rows.flatMap { it.cards }.distinctBy { it.id }.take(12)
|
|
||||||
val rows = HomeCardKind.entries.flatMap { kind ->
|
|
||||||
val slots = when (kind) { HomeCardKind.Poster -> 6; HomeCardKind.Tile -> 4; HomeCardKind.Wide -> 3 }
|
|
||||||
listOf(slots, cards.size).map { count -> HomeRow("${kind.name}-$count", HomeRowKind.Shelf,
|
|
||||||
"${kind.name} shelf", cards.take(count), kind) }
|
|
||||||
}
|
|
||||||
val snapshot = HomeSnapshot(rows)
|
|
||||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
|
|
||||||
var title by mutableStateOf<TitleState?>(null)
|
|
||||||
rule.setContent { WolfskinTheme {
|
|
||||||
val page = title
|
|
||||||
if (page != null) TitleSurface(page, shell)
|
|
||||||
else ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action ->
|
|
||||||
if (action is HomeAction.Details) {
|
|
||||||
shell.enterDestination("title/viewport")
|
|
||||||
title = TitleState(TitlePageData(TitleItem(action.title.id, action.title.title, "movie", held = true)), shell.person) { effect ->
|
|
||||||
if (effect == TitleAction.Pop) { shell.enterDestination("home"); title = null }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} }
|
|
||||||
} }
|
|
||||||
for ((index, row) in rows.withIndex()) {
|
|
||||||
if (index > 0) press(Key.DirectionDown)
|
|
||||||
assertVisible(shell, rows)
|
|
||||||
repeat(row.count) { press(Key.DirectionLeft) }
|
|
||||||
assertVisible(shell, rows)
|
|
||||||
repeat(row.count - 1) { press(Key.DirectionRight) }
|
|
||||||
assertVisible(shell, rows)
|
|
||||||
capture("viewport-${row.id}")
|
|
||||||
press(Key.DirectionCenter)
|
|
||||||
rule.onNodeWithTag("title-page").assertIsDisplayed()
|
|
||||||
press(Key.Back)
|
|
||||||
assertVisible(shell, rows)
|
|
||||||
}
|
|
||||||
repeat(rows.lastIndex) { press(Key.DirectionUp); assertVisible(shell, rows) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun press(key: Key) {
|
|
||||||
rule.onRoot().performKeyInput { pressKey(key) }
|
|
||||||
rule.mainClock.advanceTimeBy(1000)
|
|
||||||
rule.waitForIdle()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun assertVisible(shell: ShellState, rows: List<HomeRow>) {
|
|
||||||
rule.mainClock.advanceTimeBy(1000)
|
|
||||||
rule.waitForIdle()
|
|
||||||
val row = rows[shell.view.row]
|
|
||||||
val column = shell.view.cards[row.id] ?: 0
|
|
||||||
val bounds = rule.onNodeWithTag("home-card:${row.id}:$column").fetchSemanticsNode().boundsInRoot
|
|
||||||
val density = rule.density.density
|
|
||||||
val margin = WolfskinTokens.Card.posterGap.value * density
|
|
||||||
val stage = WolfskinTokens.Shell.stageWidth.value * density
|
|
||||||
assertTrue("Focused card is clipped: ${row.id}:$column $bounds", bounds.width >= row.cardWidth.value * density)
|
|
||||||
assertTrue("Focused card needs side margins: ${row.id}:$column $bounds", bounds.left >= margin && bounds.right <= stage - margin)
|
|
||||||
assertTrue("Focused row must clear the nav: ${row.id}:$column $bounds", bounds.top >= WolfskinTokens.Layout.navBand.value * density)
|
|
||||||
assertTrue("Focused card must clear the bottom: ${row.id}:$column $bounds", bounds.bottom <= WolfskinTokens.Shell.stageHeight.value * density - margin)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun capture(name: String) {
|
|
||||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "home").apply { mkdirs() }
|
|
||||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -82,15 +82,6 @@ class LibraryCaptureTest {
|
||||||
@Test fun st112_cachedWhileDown() = capture("st-112", reachable = false, cached = true)
|
@Test fun st112_cachedWhileDown() = capture("st-112", reachable = false, cached = true)
|
||||||
@Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false)
|
@Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false)
|
||||||
@Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = 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/")
|
fun libraryFixture(): LibraryFixture = LibraryFixture(LibraryCaptureTest::class.java.getResourceAsStream("/wolfskin-library.json")!!.bufferedReader().use { it.readText() }, "/library/")
|
||||||
|
|
|
||||||
|
|
@ -112,25 +112,18 @@ class LibraryKeysTest {
|
||||||
val titles = (0 until 200).map { index -> LibraryTitle("$index", "${('A'.code + index / 10).toChar()}Title $index", "movie") }
|
val titles = (0 until 200).map { index -> LibraryTitle("$index", "${('A'.code + index / 10).toChar()}Title $index", "movie") }
|
||||||
val grid = LibraryGridState("Movies", titles)
|
val grid = LibraryGridState("Movies", titles)
|
||||||
assertTrue(grid.railVisible)
|
assertTrue(grid.railVisible)
|
||||||
grid.index = 4
|
grid.index = 5
|
||||||
grid.key(ShellKey.Right)
|
grid.key(ShellKey.Right)
|
||||||
assertTrue(grid.inRail)
|
assertTrue(grid.inRail)
|
||||||
grid.key(ShellKey.Down)
|
grid.key(ShellKey.Down)
|
||||||
assertEquals('B', grid.railLetter)
|
assertEquals('B', grid.railLetter)
|
||||||
grid.key(ShellKey.Back)
|
grid.key(ShellKey.Back)
|
||||||
assertEquals(4, grid.index)
|
assertEquals(5, grid.index)
|
||||||
assertFalse(grid.inRail)
|
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)
|
grid.key(ShellKey.Right); grid.key(ShellKey.Down); grid.key(ShellKey.Ok)
|
||||||
assertEquals('B', grid.focusedTitle!!.letter)
|
assertEquals('B', grid.focusedTitle!!.letter)
|
||||||
grid.source = titles.take(199)
|
grid.source = titles.take(199)
|
||||||
assertFalse(grid.railVisible)
|
assertFalse(grid.railVisible)
|
||||||
grid.index = 4
|
|
||||||
grid.key(ShellKey.Right)
|
|
||||||
assertEquals(5, grid.index)
|
|
||||||
grid.source = titles; grid.sort = 1
|
grid.source = titles; grid.sort = 1
|
||||||
assertFalse(grid.railVisible)
|
assertFalse(grid.railVisible)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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,6 +53,7 @@ class RequestsKeysTest {
|
||||||
assertEquals(path.getInt("presses"), presses.length())
|
assertEquals(path.getInt("presses"), presses.length())
|
||||||
for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index)))
|
for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index)))
|
||||||
rule.onNodeWithTag("coming-in").assertExists()
|
rule.onNodeWithTag("coming-in").assertExists()
|
||||||
|
rule.onNodeWithText("Requests · 5 open").assertExists()
|
||||||
assertEquals("settings", state.route)
|
assertEquals("settings", state.route)
|
||||||
assertEquals("x5", state.requests.focusedId)
|
assertEquals("x5", state.requests.focusedId)
|
||||||
assertFalse(state.navFocused)
|
assertFalse(state.navFocused)
|
||||||
|
|
@ -143,5 +144,7 @@ class RequestsKeysTest {
|
||||||
val viewer = fixturePeople().single { it.key == "shared" }
|
val viewer = fixturePeople().single { it.key == "shared" }
|
||||||
assertEquals("Shared", shared.actor(viewer))
|
assertEquals("Shared", shared.actor(viewer))
|
||||||
assertEquals("Justin", shared.recipient(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
|
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, requestCount: Int? = null, configure: (ShellState) -> Unit = {}) {
|
private fun capture(name: String, openRequests: Int? = null, configure: (ShellState) -> Unit = {}) {
|
||||||
val state = ShellState(fixturePeople(), "justin")
|
val state = ShellState(fixturePeople(), "justin")
|
||||||
configure(state)
|
configure(state)
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
WolfskinTheme {
|
WolfskinTheme {
|
||||||
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsRequestCount = requestCount)
|
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsOpenRequests = openRequests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rule.mainClock.advanceTimeBy(1000)
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
|
@ -54,7 +54,6 @@ class ShellCaptureTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun st001_launch() = capture("st-001")
|
@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() {
|
@Test fun st002_gear() {
|
||||||
capture("st-002") { it.pickerIndex = 2; it.gearFocused = true }
|
capture("st-002") { it.pickerIndex = 2; it.gearFocused = true }
|
||||||
val layouts = mutableListOf<TextLayoutResult>()
|
val layouts = mutableListOf<TextLayoutResult>()
|
||||||
|
|
@ -67,7 +66,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 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 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 st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
|
||||||
@Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") }
|
@Test fun st092_requestsCountFixture() = capture("st-092", openRequests = 5) { it.pickerVisible = false; it.enterDestination("settings") }
|
||||||
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
|
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"id": "library",
|
"id": "library",
|
||||||
"title": "Libraries: Movies, Shows, Anime (continuous grid; five columns with letter rail)",
|
"title": "Libraries: Movies, Shows, Anime (one continuous 6-column grid)",
|
||||||
"specSection": "6.4",
|
"specSection": "6.4",
|
||||||
"source": "index.html:1403-1426, 1563-1611",
|
"source": "index.html:1403-1426, 1563-1611",
|
||||||
"layout": [
|
"layout": [
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
"token": null,
|
"token": null,
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 236,
|
"yPx": 236,
|
||||||
"heightPx": 136,
|
"heightPx": 104,
|
||||||
"cards": null,
|
"cards": null,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -33,8 +33,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 372,
|
"yPx": 340,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -45,8 +45,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 896,
|
"yPx": 828,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -57,8 +57,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 1420,
|
"yPx": 1316,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -69,8 +69,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 1944,
|
"yPx": 1804,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -81,8 +81,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 2468,
|
"yPx": 2292,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -93,8 +93,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 2992,
|
"yPx": 2780,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -105,8 +105,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 3516,
|
"yPx": 3268,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -117,8 +117,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 4040,
|
"yPx": 3756,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -129,8 +129,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 4564,
|
"yPx": 4244,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -141,8 +141,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 5088,
|
"yPx": 4732,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -153,8 +153,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 5612,
|
"yPx": 5220,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -165,8 +165,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 6136,
|
"yPx": 5708,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -177,8 +177,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 6660,
|
"yPx": 6196,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
@ -189,8 +189,8 @@
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"token": "poster",
|
"token": "poster",
|
||||||
"title": "",
|
"title": "",
|
||||||
"yPx": 7184,
|
"yPx": 6684,
|
||||||
"heightPx": 524,
|
"heightPx": 488,
|
||||||
"cards": 6,
|
"cards": 6,
|
||||||
"focusable": true,
|
"focusable": true,
|
||||||
"endsInSeeAll": false,
|
"endsInSeeAll": false,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
{
|
{
|
||||||
"title": {
|
"title": {
|
||||||
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: episode art height +24.4%"},
|
|
||||||
"seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"},
|
|
||||||
"lineGap": {"dp": 11, "px": 22, "source": "index.html:214"},
|
"lineGap": {"dp": 11, "px": 22, "source": "index.html:214"},
|
||||||
"lineLift": {"dp": -2, "px": -4, "source": "index.html:214"},
|
"lineLift": {"dp": -2, "px": -4, "source": "index.html:214"},
|
||||||
"overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"},
|
"overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"},
|
||||||
|
|
@ -16,8 +14,8 @@
|
||||||
"progressBottom": {"dp": 15, "px": 30, "source": "index.html:217"},
|
"progressBottom": {"dp": 15, "px": 30, "source": "index.html:217"},
|
||||||
"progressGap": {"dp": 9, "px": 18, "source": "index.html:217"},
|
"progressGap": {"dp": 9, "px": 18, "source": "index.html:217"},
|
||||||
"progressLift": {"dp": -7, "px": -14, "source": "index.html:217"},
|
"progressLift": {"dp": -7, "px": -14, "source": "index.html:217"},
|
||||||
"castHeight": {"dp": 231, "px": 462, "source": "index.html:1018"},
|
"castHeight": {"dp": 197, "px": 394, "source": "index.html:1018"},
|
||||||
"castNameTop": {"dp": 118, "px": 236, "source": "index.html:289"},
|
"castNameTop": {"dp": 108, "px": 216, "source": "index.html:289"},
|
||||||
"castNameSide": {"dp": 6, "px": 12, "source": "index.html:289"},
|
"castNameSide": {"dp": 6, "px": 12, "source": "index.html:289"},
|
||||||
"personTop": {"dp": 85, "px": 170, "source": "index.html:293"},
|
"personTop": {"dp": 85, "px": 170, "source": "index.html:293"},
|
||||||
"personGap": {"dp": 24, "px": 48, "source": "index.html:293"},
|
"personGap": {"dp": 24, "px": 48, "source": "index.html:293"},
|
||||||
|
|
@ -43,7 +41,7 @@
|
||||||
"infoRowGap": {"dp": 3, "px": 6, "source": "index.html:318"},
|
"infoRowGap": {"dp": 3, "px": 6, "source": "index.html:318"},
|
||||||
"infoColumnGap": {"dp": 10, "px": 20, "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"},
|
"infoKey": {"$type": "typography", "sp": 12, "px": 24, "weight": 650, "$value": {"fontWeight": 650}, "source": "index.html:319"},
|
||||||
"gridHeight": {"dp": 262, "px": 524, "source": "index.html:1019"},
|
"gridHeight": {"dp": 244, "px": 488, "source": "index.html:1019"},
|
||||||
"minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"},
|
"minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"},
|
||||||
"ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"},
|
"ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"},
|
||||||
"ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"},
|
"ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"},
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,9 @@
|
||||||
--layout-navSolidAfter: 40.0px;
|
--layout-navSolidAfter: 40.0px;
|
||||||
--layout-pinnedLine: 168.0px;
|
--layout-pinnedLine: 168.0px;
|
||||||
--layout-windowAhead: 700.0px;
|
--layout-windowAhead: 700.0px;
|
||||||
--layout-rowHeadingBand: 84.0px;
|
--layout-rowHeadingBand: 60.0px;
|
||||||
--layout-rowGapAfter: 68.0px;
|
--layout-rowGapAfter: 44.0px;
|
||||||
--layout-gridRowGap: 44.0px;
|
--layout-gridRowGap: 28.0px;
|
||||||
--layout-row_hero: 820.0px;
|
--layout-row_hero: 820.0px;
|
||||||
--layout-row_phero: 740.0px;
|
--layout-row_phero: 740.0px;
|
||||||
--layout-row_section: 150.0px;
|
--layout-row_section: 150.0px;
|
||||||
|
|
@ -111,7 +111,7 @@
|
||||||
--layout-sheetWidth: 800.0px;
|
--layout-sheetWidth: 800.0px;
|
||||||
--layout-sheetRow: 100.0px;
|
--layout-sheetRow: 100.0px;
|
||||||
--layout-stateWidth: 1100.0px;
|
--layout-stateWidth: 1100.0px;
|
||||||
--layout-captionOffset: 38.0px;
|
--layout-captionOffset: 18.0px;
|
||||||
--layout-badge: 48.0px;
|
--layout-badge: 48.0px;
|
||||||
--layout-badgeInset: 14.0px;
|
--layout-badgeInset: 14.0px;
|
||||||
--layout-progressBar: 8.0px;
|
--layout-progressBar: 8.0px;
|
||||||
|
|
@ -122,7 +122,6 @@
|
||||||
--layout-navAvatar: 64.0px;
|
--layout-navAvatar: 64.0px;
|
||||||
--layout-castCircle: 200.0px;
|
--layout-castCircle: 200.0px;
|
||||||
--layout-personPortrait: 240.0px;
|
--layout-personPortrait: 240.0px;
|
||||||
--layout-row_chipsGrid: 136.0px;
|
|
||||||
--motion-fast: 140ms;
|
--motion-fast: 140ms;
|
||||||
--motion-base: 160ms;
|
--motion-base: 160ms;
|
||||||
--motion-scroll: 180ms;
|
--motion-scroll: 180ms;
|
||||||
|
|
@ -260,8 +259,6 @@
|
||||||
--lift-heroY: 30.0px;
|
--lift-heroY: 30.0px;
|
||||||
--lift-sheetBlur: 80.0px;
|
--lift-sheetBlur: 80.0px;
|
||||||
--lift-sheetX: -30.0px;
|
--lift-sheetX: -30.0px;
|
||||||
--title-episodeCardHeight: 280.0px;
|
|
||||||
--title-seriesHeaderGap: 40.0px;
|
|
||||||
--title-lineGap: 22.0px;
|
--title-lineGap: 22.0px;
|
||||||
--title-lineLift: -4.0px;
|
--title-lineLift: -4.0px;
|
||||||
--title-overviewGap: 34.0px;
|
--title-overviewGap: 34.0px;
|
||||||
|
|
@ -276,8 +273,8 @@
|
||||||
--title-progressBottom: 30.0px;
|
--title-progressBottom: 30.0px;
|
||||||
--title-progressGap: 18.0px;
|
--title-progressGap: 18.0px;
|
||||||
--title-progressLift: -14.0px;
|
--title-progressLift: -14.0px;
|
||||||
--title-castHeight: 462.0px;
|
--title-castHeight: 394.0px;
|
||||||
--title-castNameTop: 236.0px;
|
--title-castNameTop: 216.0px;
|
||||||
--title-castNameSide: 12.0px;
|
--title-castNameSide: 12.0px;
|
||||||
--title-personTop: 170.0px;
|
--title-personTop: 170.0px;
|
||||||
--title-personGap: 48.0px;
|
--title-personGap: 48.0px;
|
||||||
|
|
@ -303,7 +300,7 @@
|
||||||
--title-infoRowGap: 6.0px;
|
--title-infoRowGap: 6.0px;
|
||||||
--title-infoColumnGap: 20.0px;
|
--title-infoColumnGap: 20.0px;
|
||||||
--title-infoKey: 24.0px;
|
--title-infoKey: 24.0px;
|
||||||
--title-gridHeight: 524.0px;
|
--title-gridHeight: 488.0px;
|
||||||
--title-minimumEndPadding: 80.0px;
|
--title-minimumEndPadding: 80.0px;
|
||||||
--title-ratingGap: 20.0px;
|
--title-ratingGap: 20.0px;
|
||||||
--title-ratingMarkGap: 8.0px;
|
--title-ratingMarkGap: 8.0px;
|
||||||
|
|
@ -438,7 +435,6 @@
|
||||||
--home-savedIcon: 28.0px;
|
--home-savedIcon: 28.0px;
|
||||||
--home-savedRadius: 28.0px;
|
--home-savedRadius: 28.0px;
|
||||||
--home-posterProgressBottom: 12.0px;
|
--home-posterProgressBottom: 12.0px;
|
||||||
--discover-shelfHeadingBand: 104.0px;
|
|
||||||
--discover-sheetPaddingX: 64.0px;
|
--discover-sheetPaddingX: 64.0px;
|
||||||
--discover-sheetPaddingY: 80.0px;
|
--discover-sheetPaddingY: 80.0px;
|
||||||
--discover-sheetGap: 18.0px;
|
--discover-sheetGap: 18.0px;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"$description": "Wolfskin TV design tokens, spec revision 5: approved focus and Level 2 spacing, taller episode cards, roomier Discover headings and panel-resolution hero artwork. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.",
|
"$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).",
|
||||||
"meta": {
|
"meta": {
|
||||||
"density": 2.0,
|
"density": 2.0,
|
||||||
"stagePx": [
|
"stagePx": [
|
||||||
|
|
@ -10,10 +10,9 @@
|
||||||
960,
|
960,
|
||||||
540
|
540
|
||||||
],
|
],
|
||||||
"source": "data/tv-v2/focus-2026-11-06/index.html",
|
"source": "data/tv-v2/opus-final-2026-11-05/index.html",
|
||||||
"sourceSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d",
|
"sourceSha256": "ee8844fc6d0237a3727de0d9454db8e5cf3bb14083fb7877d7bb0f4233c6e23d",
|
||||||
"generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py",
|
"generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py"
|
||||||
"feedbackDelta": "data/tv-v2/feedback-2026-11-06/delta.json"
|
|
||||||
},
|
},
|
||||||
"color": {
|
"color": {
|
||||||
"bg": {
|
"bg": {
|
||||||
|
|
@ -21,7 +20,7 @@
|
||||||
"$value": "#07070a",
|
"$value": "#07070a",
|
||||||
"css": "#07070a",
|
"css": "#07070a",
|
||||||
"source": "index.html:10",
|
"source": "index.html:10",
|
||||||
"usedBy": "app background; stage; hero/detail scrim end; nav band when solid"
|
"usedBy": "app background; stage; focus-ring gap colour; hero/detail scrim end; nav band when solid"
|
||||||
},
|
},
|
||||||
"surface": {
|
"surface": {
|
||||||
"$type": "color",
|
"$type": "color",
|
||||||
|
|
@ -774,27 +773,27 @@
|
||||||
},
|
},
|
||||||
"rowHeadingBand": {
|
"rowHeadingBand": {
|
||||||
"$type": "dimension",
|
"$type": "dimension",
|
||||||
"$value": "42dp",
|
"$value": "30dp",
|
||||||
"px": 84,
|
"px": 60,
|
||||||
"dp": 42.0,
|
"dp": 30.0,
|
||||||
"source": "index.html:1018",
|
"source": "index.html:1018",
|
||||||
"usedBy": "shelf row: heading band (44 px heading at top 0, then 40 px) before the cards start at y + 84 (HEAD_OFF)"
|
"usedBy": "shelf row: heading band (44 px heading at top 0) before the cards start at y + 60"
|
||||||
},
|
},
|
||||||
"rowGapAfter": {
|
"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",
|
"$type": "dimension",
|
||||||
"$value": "22dp",
|
"$value": "22dp",
|
||||||
"px": 44,
|
"px": 44,
|
||||||
"dp": 22.0,
|
"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",
|
"source": "index.html:1019",
|
||||||
"usedBy": "grid row height = 384 + 96 + 44 = 524 (library, See all, person)"
|
"usedBy": "grid row height = 384 + 76 + 28 = 488 (library, See all, person)"
|
||||||
},
|
},
|
||||||
"row_hero": {
|
"row_hero": {
|
||||||
"$type": "dimension",
|
"$type": "dimension",
|
||||||
|
|
@ -826,7 +825,7 @@
|
||||||
"px": 104,
|
"px": 104,
|
||||||
"dp": 52.0,
|
"dp": 52.0,
|
||||||
"source": "index.html:1021",
|
"source": "index.html:1021",
|
||||||
"usedBy": "chip row above shelves (Discover filters, service type chips, seasons); 104 px"
|
"usedBy": "chip row (Discover filters, library toolbar, See all/service type chips, seasons)"
|
||||||
},
|
},
|
||||||
"row_note": {
|
"row_note": {
|
||||||
"$type": "dimension",
|
"$type": "dimension",
|
||||||
|
|
@ -1042,10 +1041,10 @@
|
||||||
},
|
},
|
||||||
"captionOffset": {
|
"captionOffset": {
|
||||||
"$type": "dimension",
|
"$type": "dimension",
|
||||||
"px": 38,
|
"px": 18,
|
||||||
"dp": 19,
|
"dp": 9,
|
||||||
"source": "index.html:1242",
|
"source": "index.html:1242",
|
||||||
"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"
|
"usedBy": "caption under a focused poster starts 18 px below the card bottom (capHtml top = headH + h + 18); width 640"
|
||||||
},
|
},
|
||||||
"plateInset": {
|
"plateInset": {
|
||||||
"$type": "dimension",
|
"$type": "dimension",
|
||||||
|
|
@ -1172,14 +1171,6 @@
|
||||||
},
|
},
|
||||||
"source": "index.html:204",
|
"source": "index.html:204",
|
||||||
"usedBy": "letter rail (only for destinations with >= 200 titles)"
|
"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": {
|
"card": {
|
||||||
|
|
@ -1188,16 +1179,16 @@
|
||||||
"px": {
|
"px": {
|
||||||
"w": 256,
|
"w": 256,
|
||||||
"h": 384,
|
"h": 384,
|
||||||
"gap": 40,
|
"gap": 24,
|
||||||
"pitch": 296,
|
"pitch": 280,
|
||||||
"captionBand": 96
|
"captionBand": 76
|
||||||
},
|
},
|
||||||
"dp": {
|
"dp": {
|
||||||
"w": 128.0,
|
"w": 128.0,
|
||||||
"h": 192.0,
|
"h": 192.0,
|
||||||
"gap": 20.0,
|
"gap": 12.0,
|
||||||
"pitch": 148.0,
|
"pitch": 140.0,
|
||||||
"captionBand": 48.0
|
"captionBand": 38.0
|
||||||
},
|
},
|
||||||
"ratio": "2:3",
|
"ratio": "2:3",
|
||||||
"fullSlotsPerRow": 6,
|
"fullSlotsPerRow": 6,
|
||||||
|
|
@ -1207,16 +1198,16 @@
|
||||||
"tile": {
|
"tile": {
|
||||||
"$type": "card",
|
"$type": "card",
|
||||||
"px": {
|
"px": {
|
||||||
"w": 400,
|
"w": 416,
|
||||||
"h": 225,
|
"h": 234,
|
||||||
"gap": 40,
|
"gap": 24,
|
||||||
"pitch": 440,
|
"pitch": 440,
|
||||||
"captionBand": 0
|
"captionBand": 0
|
||||||
},
|
},
|
||||||
"dp": {
|
"dp": {
|
||||||
"w": 200.0,
|
"w": 208.0,
|
||||||
"h": 112.5,
|
"h": 117.0,
|
||||||
"gap": 20.0,
|
"gap": 12.0,
|
||||||
"pitch": 220.0,
|
"pitch": 220.0,
|
||||||
"captionBand": 0.0
|
"captionBand": 0.0
|
||||||
},
|
},
|
||||||
|
|
@ -1228,16 +1219,16 @@
|
||||||
"wide": {
|
"wide": {
|
||||||
"$type": "card",
|
"$type": "card",
|
||||||
"px": {
|
"px": {
|
||||||
"w": 544,
|
"w": 560,
|
||||||
"h": 306,
|
"h": 315,
|
||||||
"gap": 40,
|
"gap": 24,
|
||||||
"pitch": 584,
|
"pitch": 584,
|
||||||
"captionBand": 0
|
"captionBand": 0
|
||||||
},
|
},
|
||||||
"dp": {
|
"dp": {
|
||||||
"w": 272.0,
|
"w": 280.0,
|
||||||
"h": 153.0,
|
"h": 157.5,
|
||||||
"gap": 20.0,
|
"gap": 12.0,
|
||||||
"pitch": 292.0,
|
"pitch": 292.0,
|
||||||
"captionBand": 0.0
|
"captionBand": 0.0
|
||||||
},
|
},
|
||||||
|
|
@ -1251,15 +1242,15 @@
|
||||||
"px": {
|
"px": {
|
||||||
"w": 256,
|
"w": 256,
|
||||||
"h": 144,
|
"h": 144,
|
||||||
"gap": 40,
|
"gap": 24,
|
||||||
"pitch": 296,
|
"pitch": 280,
|
||||||
"captionBand": 0
|
"captionBand": 0
|
||||||
},
|
},
|
||||||
"dp": {
|
"dp": {
|
||||||
"w": 128.0,
|
"w": 128.0,
|
||||||
"h": 72.0,
|
"h": 72.0,
|
||||||
"gap": 20.0,
|
"gap": 12.0,
|
||||||
"pitch": 148.0,
|
"pitch": 140.0,
|
||||||
"captionBand": 0.0
|
"captionBand": 0.0
|
||||||
},
|
},
|
||||||
"ratio": "16:9",
|
"ratio": "16:9",
|
||||||
|
|
@ -1271,22 +1262,22 @@
|
||||||
"$type": "card",
|
"$type": "card",
|
||||||
"px": {
|
"px": {
|
||||||
"w": 200,
|
"w": 200,
|
||||||
"h": 310,
|
"h": 290,
|
||||||
"gap": 44,
|
"gap": 28,
|
||||||
"pitch": 244,
|
"pitch": 228,
|
||||||
"captionBand": 0
|
"captionBand": 0
|
||||||
},
|
},
|
||||||
"dp": {
|
"dp": {
|
||||||
"w": 100.0,
|
"w": 100.0,
|
||||||
"h": 155.0,
|
"h": 145.0,
|
||||||
"gap": 22.0,
|
"gap": 14.0,
|
||||||
"pitch": 122.0,
|
"pitch": 114.0,
|
||||||
"captionBand": 0.0
|
"captionBand": 0.0
|
||||||
},
|
},
|
||||||
"ratio": "1:1 circle",
|
"ratio": "1:1 circle",
|
||||||
"fullSlotsPerRow": 7,
|
"fullSlotsPerRow": 7,
|
||||||
"source": "index.html:660",
|
"source": "index.html:660",
|
||||||
"usedBy": "Cast row: 200 px circle + name + character, 310 tall (name starts 236 below the top)"
|
"usedBy": "Cast row: 200 px circle + name + character, 290 px tall"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"focus": {
|
"focus": {
|
||||||
|
|
@ -1327,60 +1318,44 @@
|
||||||
},
|
},
|
||||||
"ringCard": {
|
"ringCard": {
|
||||||
"$type": "shadow",
|
"$type": "shadow",
|
||||||
"$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)",
|
"$value": "0 0 0 4px var(--bg),0 0 0 8px var(--accent),0 18px 50px rgb(0 0 0 / .65)",
|
||||||
"gapPx": 0,
|
"gapPx": 4,
|
||||||
"ringPx": 4,
|
"ringPx": 4,
|
||||||
"gapDp": 0,
|
"gapDp": 2,
|
||||||
"ringDp": 2,
|
"ringDp": 2,
|
||||||
|
"gapColor": "color.bg",
|
||||||
"ringColor": "color.accent",
|
"ringColor": "color.accent",
|
||||||
"liftShadow": "0 18px 50px rgb(0 0 0 / .65)",
|
"liftShadow": "0 18px 50px rgb(0 0 0 / .65)",
|
||||||
"source": "index.html:134",
|
"source": "index.html:134",
|
||||||
"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\")",
|
"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"
|
||||||
"outsidePx": 2,
|
|
||||||
"insidePx": 2
|
|
||||||
},
|
},
|
||||||
"ringControl": {
|
"ringButton": {
|
||||||
"$type": "shadow",
|
"$type": "shadow",
|
||||||
"$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent),0 12px 40px rgb(0 0 0 / .5)",
|
"$value": "0 0 0 4px var(--bg),0 0 0 8px var(--accent),0 12px 40px rgb(0 0 0 / .5)",
|
||||||
"gapPx": 0,
|
"gapPx": 4,
|
||||||
"ringPx": 4,
|
"ringPx": 4,
|
||||||
"gapDp": 0,
|
|
||||||
"ringDp": 2,
|
|
||||||
"outsidePx": 2,
|
|
||||||
"insidePx": 2,
|
|
||||||
"alignment": "straddle",
|
|
||||||
"ringColor": "color.accent",
|
"ringColor": "color.accent",
|
||||||
"liftShadow": "0 12px 40px rgb(0 0 0 / .5)",
|
"liftShadow": "0 12px 40px rgb(0 0 0 / .5)",
|
||||||
"source": "index.html:111",
|
"source": "index.html:110",
|
||||||
"usedBy": "focused pill and icon buttons, including hero, title, recovery and sheet actions; existing fills, text colours, scale and lift unchanged"
|
"usedBy": "focused pill and icon buttons (primary stays white, secondary lightens to buttonSecondaryFocused)"
|
||||||
},
|
},
|
||||||
"ringAvatar": {
|
"ringAvatar": {
|
||||||
"$type": "shadow",
|
"$type": "shadow",
|
||||||
"$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)",
|
"$value": "0 0 0 4px var(--bg),0 0 0 8px var(--fg)",
|
||||||
"gapPx": 0,
|
"gapPx": 4,
|
||||||
"ringPx": 4,
|
"ringPx": 4,
|
||||||
"gapDp": 0,
|
"ringColor": "color.fg",
|
||||||
"ringDp": 2,
|
"source": "index.html:92",
|
||||||
"outsidePx": 2,
|
"usedBy": "focused nav avatar and focused gear (white ring, not lilac)"
|
||||||
"insidePx": 2,
|
|
||||||
"alignment": "straddle",
|
|
||||||
"ringColor": "color.accent",
|
|
||||||
"source": "index.html:92,312",
|
|
||||||
"usedBy": "focused nav avatar and gear; existing scale unchanged, no lift"
|
|
||||||
},
|
},
|
||||||
"ringPicker": {
|
"ringPicker": {
|
||||||
"$type": "shadow",
|
"$type": "shadow",
|
||||||
"$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)",
|
"$value": "0 0 0 6px var(--bg),0 0 0 12px var(--fg)",
|
||||||
"gapPx": 0,
|
"gapPx": 6,
|
||||||
"ringPx": 4,
|
"ringPx": 6,
|
||||||
"gapDp": 0,
|
"ringColor": "color.fg",
|
||||||
"ringDp": 2,
|
"source": "index.html:263",
|
||||||
"outsidePx": 2,
|
"usedBy": "focused person in Who's watching (white 6 + 6 ring; name turns fg)"
|
||||||
"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": {
|
"fillInverse": {
|
||||||
"$type": "rule",
|
"$type": "rule",
|
||||||
|
|
@ -1510,23 +1485,13 @@
|
||||||
},
|
},
|
||||||
"heroRequest": {
|
"heroRequest": {
|
||||||
"px": [
|
"px": [
|
||||||
1920,
|
1280,
|
||||||
1080
|
720
|
||||||
],
|
],
|
||||||
"decode": "ARGB_8888",
|
"decode": "ARGB_8888",
|
||||||
"bytes": 8294400,
|
"bytes": 3686400,
|
||||||
"source": "feedback-2026-11-06: panel-resolution artwork",
|
"source": "index.html:549",
|
||||||
"usedBy": "Home hero, Discover hero and title backdrop; bounded 1920x1080 decode"
|
"usedBy": "Home hero, Discover hero, title backdrop: the 1280x720 source, never upscaled; released when scrolled past"
|
||||||
},
|
|
||||||
"heroPosterRequest": {
|
|
||||||
"px": [420, 630],
|
|
||||||
"decode": "RGB565",
|
|
||||||
"source": "feedback-2026-11-06: largest upright hero poster slot"
|
|
||||||
},
|
|
||||||
"episodeRequest": {
|
|
||||||
"px": [400, 280],
|
|
||||||
"decode": "ARGB_8888",
|
|
||||||
"source": "feedback-2026-11-06: taller episode cards"
|
|
||||||
},
|
},
|
||||||
"castRequest": {
|
"castRequest": {
|
||||||
"px": [
|
"px": [
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json
|
||||||
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
|
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
|
||||||
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
|
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
|
||||||
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
|
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
|
||||||
bb64e45654fce2cd3ba595b505fb8b5a540526cef696dfa974be25f028d3e119 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
1c805ae8eacbca1c63fbf91e19d7f9fea6c27b54e709e91f6bd46720d5048a41 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json
|
7ed093cc63b0fd6f47fe951da5df26ee7258865b8d0a51345a408ab64f037de0 title-tokens.json
|
||||||
36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css
|
3b14b60264e1f97c02be3e57d0c0bd9477e982044ee944c8c8a5b71e939fa8fb tokens.css
|
||||||
3226c6fb9c7e35c5bde90aaf01630b238cfcb9a768ff60c49461e19a200c9125 tokens.json
|
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue