Compare commits
8 commits
main
...
fm/tv-skip
| Author | SHA1 | Date | |
|---|---|---|---|
| a5b5e92017 | |||
| 2b142af8d1 | |||
| e6ddf1d7e5 | |||
| 174e1ae5c9 | |||
| ae613216d0 | |||
| 5046727b2a | |||
| a159c835c5 | |||
| 6d7513bd0e |
32 changed files with 1604 additions and 101 deletions
|
|
@ -33,6 +33,7 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.RestoredSession
|
import com.github.damontecres.wholphin.data.RestoredSession
|
||||||
|
import org.justinvisser.wolfskin.tv.recoverWolfskinSession
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||||
|
|
@ -479,11 +480,13 @@ class MainActivityViewModel
|
||||||
fun appResume() {
|
fun appResume() {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
|
val expected = serverRepository.current.value
|
||||||
try {
|
try {
|
||||||
Timber.v("Updating userDto")
|
Timber.v("Updating userDto")
|
||||||
serverRepository.updateUserDto()
|
serverRepository.updateUserDto()
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.w(ex, "Error during appResume")
|
Timber.w(ex, "Error during appResume")
|
||||||
|
recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -590,6 +593,7 @@ class MainActivityViewModel
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
Timber.w(exception, "Saved launch session revalidation failed")
|
Timber.w(exception, "Saved launch session revalidation failed")
|
||||||
|
recoverWolfskinSession(context, exception, restoredSession.currentUser, serverRepository, setupNavigationManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
||||||
import org.justinvisser.wolfskin.tv.WolfskinApp
|
import org.justinvisser.wolfskin.tv.WolfskinApp
|
||||||
|
import org.justinvisser.wolfskin.tv.WolfskinSetupContent
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||||
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
||||||
|
|
@ -48,6 +49,7 @@ fun MainContent(
|
||||||
backdropService: BackdropService,
|
backdropService: BackdropService,
|
||||||
screensaverService: ScreensaverService,
|
screensaverService: ScreensaverService,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
|
||||||
) {
|
) {
|
||||||
val preferences by rememberUpdatedState(userPreferences)
|
val preferences by rememberUpdatedState(userPreferences)
|
||||||
Surface(
|
Surface(
|
||||||
|
|
@ -72,6 +74,7 @@ fun MainContent(
|
||||||
entryProvider = { key ->
|
entryProvider = { key ->
|
||||||
key as SetupDestination
|
key as SetupDestination
|
||||||
NavEntry(key) {
|
NavEntry(key) {
|
||||||
|
if (setupInterceptor(key, Modifier.fillMaxSize())) return@NavEntry
|
||||||
when (key) {
|
when (key) {
|
||||||
SetupDestination.Loading -> {
|
SetupDestination.Loading -> {
|
||||||
Box(
|
Box(
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ class ConductarrTitlePagesTest {
|
||||||
assertEquals(TitleConnection.SignedOut, revoked.connection)
|
assertEquals(TitleConnection.SignedOut, revoked.connection)
|
||||||
assertTrue(revoked.item.ratings.isEmpty())
|
assertTrue(revoked.item.ratings.isEmpty())
|
||||||
assertTrue(revoked.playTarget!!.ratings.isEmpty())
|
assertTrue(revoked.playTarget!!.ratings.isEmpty())
|
||||||
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" })
|
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.menuOptions.any { it.id == "add" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,9 @@ class ConductarrTitleSurfaceTest {
|
||||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
||||||
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath)
|
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath)
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
|
||||||
|
rule.mainClock.advanceTimeBy(300)
|
||||||
|
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
|
||||||
rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
|
rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
|
||||||
rule.waitUntil(30_000) {
|
rule.waitUntil(30_000) {
|
||||||
|
|
@ -97,7 +100,8 @@ class ConductarrTitleSurfaceTest {
|
||||||
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
|
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
|
||||||
}
|
}
|
||||||
assertTrue(viewModel.title!!.data.item.ratings.isEmpty())
|
assertTrue(viewModel.title!!.data.item.ratings.isEmpty())
|
||||||
assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") })
|
assertFalse(viewModel.title!!.options.any { it.id == "request" })
|
||||||
|
assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" })
|
||||||
} finally { viewModel.viewModelScope.cancel() }
|
} finally { viewModel.viewModelScope.cancel() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt())) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
||||||
|
|
@ -170,7 +170,10 @@ class TitleCaptureTest {
|
||||||
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
|
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
|
||||||
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
|
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
|
||||||
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
|
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
|
||||||
@Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") }
|
@Test fun st047_mediaInfo() = capture(47) { state, shell ->
|
||||||
|
open(state, shell, "menu")
|
||||||
|
state.clickSheet(0)
|
||||||
|
}
|
||||||
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
|
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
|
||||||
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
|
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
|
||||||
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
|
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
|
||||||
|
|
@ -216,4 +219,10 @@ class TitleCaptureTest {
|
||||||
@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) }
|
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
|
||||||
|
@Test fun round2Menu() = capture(120) { state, shell -> open(state, shell, "menu") }
|
||||||
|
@Test fun round2MussoliniResume() = capture(121, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) })
|
||||||
|
@Test fun round2Episodes() = capture(122, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) { state, _ -> state.focus("episodes", 0) }
|
||||||
|
@Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days")
|
||||||
|
@Test fun round2HeldPosterFallback() = capture(124, "The Girl Who Leapt Through Time", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
|
||||||
|
@Test fun round2NotHeldShortOverview() = capture(125, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,6 @@ import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.hasClickAction
|
import androidx.compose.ui.test.hasClickAction
|
||||||
import androidx.compose.ui.test.hasText
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.performSemanticsAction
|
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
@ -36,7 +33,6 @@ import org.justinvisser.wolfskin.design.TitleSurface
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
|
||||||
|
|
||||||
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
|
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
|
@ -284,15 +280,6 @@ class TitleKeysTest {
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
assertEquals(expected, state.row)
|
assertEquals(expected, state.row)
|
||||||
}
|
}
|
||||||
@Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() {
|
|
||||||
render("Andor")
|
|
||||||
val layouts = mutableListOf<TextLayoutResult>()
|
|
||||||
rule.onNodeWithText("Add for someone", useUnmergedTree = true)
|
|
||||||
.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
|
|
||||||
val layout = layouts.single()
|
|
||||||
assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth)
|
|
||||||
assertEquals(1, layout.lineCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
|
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
|
||||||
render(transform = { it.copy(status = TitleReadStatus.Loading) })
|
render(transform = { it.copy(status = TitleReadStatus.Loading) })
|
||||||
|
|
@ -315,26 +302,6 @@ class TitleKeysTest {
|
||||||
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
|
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
||||||
@Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() {
|
|
||||||
render()
|
|
||||||
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) }
|
|
||||||
press(Key.DirectionCenter)
|
|
||||||
val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
|
||||||
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
|
||||||
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
|
|
||||||
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
|
|
||||||
val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
|
||||||
assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
||||||
@Test fun filmHeaderKeepsItsFixedCssStack() {
|
|
||||||
render()
|
|
||||||
assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f)
|
|
||||||
assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f)
|
|
||||||
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
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.compose.ui.unit.dp
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.*
|
||||||
|
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
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Native title interactions keep secondary actions, focus and section geometry intact. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class TitleRound2Test {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val effects = mutableListOf<TitleAction>()
|
||||||
|
private lateinit var state: TitleState
|
||||||
|
private lateinit var shell: ShellState
|
||||||
|
|
||||||
|
private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||||
|
private fun render(title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }) {
|
||||||
|
val fixture = fixture()
|
||||||
|
shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/test") }
|
||||||
|
state = TitleState(transform(fixture.title(title, person)), shell.person, effects::add)
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
BackHandler { state.back(shell) }
|
||||||
|
TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp))
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
private fun press(key: Key) = rule.onRoot().performKeyInput { pressKey(key) }
|
||||||
|
private fun menu() {
|
||||||
|
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "menu" }) }
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
rule.mainClock.advanceTimeBy(300)
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun menuBackRestoresItsButtonAndKeepsMediaInfoAndRequestReachable() {
|
||||||
|
render()
|
||||||
|
menu()
|
||||||
|
val opener = state.column
|
||||||
|
press(Key.Back)
|
||||||
|
assertNull(state.sheet)
|
||||||
|
assertEquals(opener, state.column)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals(TitleSheet.Menu, state.sheet)
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals(TitleSheet.MediaInfo, state.sheet)
|
||||||
|
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
press(Key.Back)
|
||||||
|
assertEquals(TitleSheet.Menu, state.sheet)
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertNull(state.sheet)
|
||||||
|
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
|
||||||
|
assertEquals(opener, state.column)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun notHeldMovieKeepsRequestAndHideOutsideTheMenu() {
|
||||||
|
render("The Amazing Spider-Man")
|
||||||
|
rule.onNodeWithTag("title-option-request").assertIsDisplayed()
|
||||||
|
rule.onNodeWithTag("title-option-hide").assertIsDisplayed()
|
||||||
|
menu()
|
||||||
|
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun movieCastStacksAfterActionsInsteadOfAReservedHeaderHeight() {
|
||||||
|
render("The Amazing Spider-Man")
|
||||||
|
val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||||
|
val cast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
|
||||||
|
val gap = cast.top - actions.bottom
|
||||||
|
assertTrue("Cast follows the actions without the old fixed header reservation", gap > 0 && cast.top < WolfskinTokens.Layout.row_dhead.value * 2)
|
||||||
|
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Searching", requestActor = "Justin")) }
|
||||||
|
val requestedActions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||||
|
val requestedCast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue(requestedActions.bottom < requestedCast.top)
|
||||||
|
assertTrue("A readiness line cannot move Cast backwards or recreate the old reservation", requestedCast.top >= cast.top && requestedCast.top < WolfskinTokens.Layout.row_dhead.value * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun mussoliniResumeHasOnlyFactsRatingsAndNoRedundantSeasonsAction() {
|
||||||
|
render("episode-episode-1-2025-s1e1", "shared") { data -> data.copy(episodes = data.episodes.distinctBy { it.id }) }
|
||||||
|
assertTrue(state.data.playTarget!!.resumeSeconds > 0)
|
||||||
|
rule.onNodeWithTag("title-progress").assertIsDisplayed()
|
||||||
|
rule.onAllNodesWithContentDescription("rtCritics", useUnmergedTree = true).assertCountEquals(1)
|
||||||
|
rule.onNodeWithText("Ep", useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
rule.onAllNodesWithText("7.4", useUnmergedTree = true).assertCountEquals(1)
|
||||||
|
rule.onNode(hasText("7.4") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertEquals("seasons", state.row)
|
||||||
|
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun seriesUsesDownToReachSeasonsWithoutARedundantShortcut() {
|
||||||
|
render("Andor")
|
||||||
|
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
|
||||||
|
menu()
|
||||||
|
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||||
|
press(Key.Back)
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertEquals("seasons", state.row)
|
||||||
|
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun episodeFactsUseTheEpisodeSetRatherThanTheSeriesSet() {
|
||||||
|
render("episode-episode-1-2025-s1e1", "shared") { data ->
|
||||||
|
data.copy(item = data.item.copy(ratings = listOf(LibraryRating("rtCritics", 20.0, "Rotten"))),
|
||||||
|
watchTarget = data.watchTarget.copy(ratings = listOf(LibraryRating("rtCritics", 84.0, "Fresh"), LibraryRating("tmdb", 8.7, episode = true))),
|
||||||
|
episodes = data.episodes.distinctBy { it.id })
|
||||||
|
}
|
||||||
|
rule.onAllNodesWithText("84%", useUnmergedTree = true).assertCountEquals(1)
|
||||||
|
rule.onAllNodesWithText("8.7", useUnmergedTree = true).assertCountEquals(1)
|
||||||
|
rule.onNodeWithText("20%", useUnmergedTree = true).assertDoesNotExist()
|
||||||
|
rule.onNode(hasText("8.7") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun episodeSkeletonAndReadySlotsMatchWhileMetadataFitsOneLine() {
|
||||||
|
render("Andor") { it.copy(episodesStatus = TitleReadStatus.Loading) }
|
||||||
|
rule.runOnIdle { shell.view.listState.requestScrollToItem(2) }
|
||||||
|
rule.waitForIdle()
|
||||||
|
val skeleton = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
|
||||||
|
rule.runOnIdle { state.update(state.data.copy(episodesStatus = TitleReadStatus.Ready)) }
|
||||||
|
val ready = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
|
||||||
|
assertEquals(skeleton, ready)
|
||||||
|
assertEquals(448f, ready.width, 0.1f)
|
||||||
|
assertEquals("Wider cards retain the approved taller episode art", 280f, ready.height, 0.1f)
|
||||||
|
rule.runOnIdle { state.focus("episodes", 0) }
|
||||||
|
val episode = state.data.episodes.first()
|
||||||
|
rule.onNodeWithText(episode.episodeLabel + " · " + episode.runtime).assertIsDisplayed()
|
||||||
|
rule.onNode(hasText(episode.seriesName!!) and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun revokedRequestsDisappearFromMenuWithoutRemovingMediaInfo() {
|
||||||
|
render()
|
||||||
|
menu()
|
||||||
|
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
|
||||||
|
rule.onNodeWithText("Add for someone").assertDoesNotExist()
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals(TitleSheet.MediaInfo, state.sheet)
|
||||||
|
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun shortOverviewMoviesKeepCastBelowThePosterForHeldAndNotAddedTitles() {
|
||||||
|
render("The Amazing Spider-Man") { it.copy(item = it.item.copy(overview = "Short overview.", backdrop = null, isPosterBackdrop = true)) }
|
||||||
|
for (held in listOf(false, true)) {
|
||||||
|
rule.runOnIdle { state.update(state.data.copy(item = state.data.item.copy(held = held))) }
|
||||||
|
val poster = rule.onNodeWithTag("title-poster-backdrop").fetchSemanticsNode().boundsInRoot
|
||||||
|
val firstActor = rule.onNodeWithTag("title-cast-${state.data.cast.first().id}").fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue("Cast clears the poster on held=$held: $firstActor vs $poster", firstActor.top >= poster.bottom + 20f - 0.5f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun revokingTheOnlyMenuActionClosesAnOtherwiseEmptyMenu() {
|
||||||
|
render("The Amazing Spider-Man")
|
||||||
|
menu()
|
||||||
|
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
|
||||||
|
assertNull(state.sheet)
|
||||||
|
rule.mainClock.advanceTimeBy(300)
|
||||||
|
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun mediaInfoKeepsTableTextSeparatedInsteadOfAccumulatingOverlap() {
|
||||||
|
render()
|
||||||
|
menu()
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
rule.onNodeWithText("Dutch", useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
val text = rule.onAllNodes(SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsActions.GetTextLayoutResult) and hasAnyAncestor(hasTestTag("title-sheet")), useUnmergedTree = true)
|
||||||
|
.fetchSemanticsNodes().map { it.boundsInRoot }.filter { it.width > 0 && it.height > 0 }
|
||||||
|
text.forEachIndexed { index, first -> text.drop(index + 1).forEach { second ->
|
||||||
|
if (minOf(first.right, second.right) > maxOf(first.left, second.left)) {
|
||||||
|
assertTrue("Media info text must never collide vertically: $first vs $second", minOf(first.bottom, second.bottom) <= maxOf(first.top, second.top))
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim() {
|
||||||
|
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
|
||||||
|
menu()
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-info-motion").apply { mkdirs() }
|
||||||
|
rule.runOnIdle { state.clickSheet(0) }
|
||||||
|
var waited = 0
|
||||||
|
while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
rule.waitForIdle()
|
||||||
|
waited += 16
|
||||||
|
}
|
||||||
|
val records = mutableListOf<String>()
|
||||||
|
fun frame(phase: String, index: Int): Pair<Int, Int> {
|
||||||
|
val path = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||||
|
rule.onRoot().captureRoboImage(path.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
|
||||||
|
fun brightness(x: Int, y: Int): Int {
|
||||||
|
val pixel = bitmap.getPixel(x, y)
|
||||||
|
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
(brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") }
|
||||||
|
} finally { bitmap.recycle() }
|
||||||
|
}
|
||||||
|
val info = List(14) { index ->
|
||||||
|
rule.waitForIdle()
|
||||||
|
frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
|
||||||
|
}
|
||||||
|
info.forEach { assertEquals("No extra scrim or disappearing overlay may darken or pop over Media info", info.last(), it) }
|
||||||
|
rule.runOnIdle { state.back(shell) }
|
||||||
|
repeat(14) { index ->
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals("Returning to Menu keeps exactly one scrim", info.last().second, frame("back", index).second)
|
||||||
|
}
|
||||||
|
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertExists()
|
||||||
|
File(directory, "samples.csv").writeText("phase,timeMs,sheetBrightness,leftBrightness\n" + records.joinToString("\n"))
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun menuFadeFramesStayMonotonicAndNeverTranslate() {
|
||||||
|
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-motion").apply { mkdirs() }
|
||||||
|
rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "menu" }) }
|
||||||
|
var waited = 0
|
||||||
|
while (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
rule.waitForIdle()
|
||||||
|
waited += 16
|
||||||
|
}
|
||||||
|
val bounds = rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||||
|
val samples = mutableListOf<String>()
|
||||||
|
fun frame(phase: String, index: Int): Int {
|
||||||
|
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val image = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
val brightness = try {
|
||||||
|
val pixel = image.getPixel((bounds.left + 40).toInt(), (bounds.top + 6).toInt())
|
||||||
|
android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||||
|
} finally { image.recycle() }
|
||||||
|
samples.add("$phase,${index * 16},$brightness,${bounds.left},${bounds.top}")
|
||||||
|
return brightness
|
||||||
|
}
|
||||||
|
fun assertMonotonic(values: List<Int>) {
|
||||||
|
assertNotEquals("Fade must visibly change the menu pixel", values.first(), values.last())
|
||||||
|
val direction = if (values.last() > values.first()) 1 else -1
|
||||||
|
values.zipWithNext().forEach { (before, after) ->
|
||||||
|
assertTrue("Menu alpha must not flicker or snap back: $values", (after - before) * direction >= -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val entering = List(14) { index ->
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals(bounds, rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot)
|
||||||
|
frame("open", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
|
||||||
|
}
|
||||||
|
assertMonotonic(entering)
|
||||||
|
rule.runOnIdle { state.back(shell) }
|
||||||
|
val leaving = mutableListOf<Int>()
|
||||||
|
repeat(9) { index ->
|
||||||
|
rule.waitForIdle()
|
||||||
|
if (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) leaving.add(frame("close", index))
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
}
|
||||||
|
assertMonotonic(leaving)
|
||||||
|
rule.onNodeWithTag("title-menu").assertDoesNotExist()
|
||||||
|
File(directory, "samples.csv").writeText("phase,timeMs,brightness,leftPx,topPx\n" + samples.joinToString("\n"))
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals(TitleSheet.Menu, state.sheet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
|
import org.robolectric.Shadows
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import com.github.damontecres.wholphin.MainActivityViewModel
|
||||||
|
import com.github.damontecres.wholphin.MainContent
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
|
import com.github.damontecres.wholphin.data.RestoredSession
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.*
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
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.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Exercise the actual MainContent boundary, not a parallel copy of its destination mapping. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
class WolfskinSetupNavigationTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
|
||||||
|
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token")
|
||||||
|
private val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
private val navigation = NavigationManager()
|
||||||
|
private val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||||
|
private val stack = mutableStateListOf<SetupDestination>(SetupDestination.Loading)
|
||||||
|
private val conductarr = ConductarrSession(MemoryCacheStore())
|
||||||
|
private val model = WolfskinSetupViewModel(repository, navigation, setup, WolfskinSession(), conductarr)
|
||||||
|
private val preferences = mockk<DataStore<AppPreferences>>()
|
||||||
|
private val intentService = mockk<IntentService>()
|
||||||
|
private var previousScreens = true
|
||||||
|
private var previousFixtures = false
|
||||||
|
|
||||||
|
@Before fun configure() {
|
||||||
|
WolfskinScreenPreference.initialize(rule.activity)
|
||||||
|
previousScreens = WolfskinScreenPreference.enabled
|
||||||
|
WolfskinScreenPreference.enabled = true
|
||||||
|
ConductarrPreference.initialize(rule.activity)
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
every { repository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
|
coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(server)
|
||||||
|
every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
|
||||||
|
every { repository.serverDao.getServers() } returns listOf(JellyfinServerUsers(server, listOf(user)))
|
||||||
|
every { setup.navigateTo(any()) } answers { stack[0] = firstArg() }
|
||||||
|
every { preferences.data } returns MutableStateFlow(AppPreferences.getDefaultInstance())
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun close() {
|
||||||
|
conductarr.close()
|
||||||
|
WolfskinScreenPreference.enabled = previousScreens
|
||||||
|
ConductarrPreference.updateFixtures(previousFixtures)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mount() {
|
||||||
|
val screensaver = mockk<ScreensaverService>(relaxed = true)
|
||||||
|
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||||
|
rule.setContent {
|
||||||
|
MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
|
mockk(relaxed = true), screensaver, Modifier,
|
||||||
|
setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appModel() = MainActivityViewModel(rule.activity, preferences, repository, setup, navigation,
|
||||||
|
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intentService)
|
||||||
|
|
||||||
|
private fun awaitStage(stage: WolfskinSetupViewModel.Stage) {
|
||||||
|
rule.waitUntil(5_000) {
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
model.stage == stage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertOwnPicker() {
|
||||||
|
awaitStage(WolfskinSetupViewModel.Stage.Picker)
|
||||||
|
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
|
||||||
|
rule.onNodeWithText("Justin").assertIsDisplayed()
|
||||||
|
rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist()
|
||||||
|
rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun coldStartLegacySignOutDrawerSwitchAndIntentFailureNeverComposeStockSelectors() {
|
||||||
|
runBlocking { appModel().handleAppStart() }
|
||||||
|
mount()
|
||||||
|
assertOwnPicker()
|
||||||
|
// The legacy sign-out-equivalent erased the recent preference, but Room still has the household.
|
||||||
|
coEvery { repository.getMostRecentServer() } returns RestoredSession.None
|
||||||
|
rule.runOnIdle { setup.navigateTo(SetupDestination.ServerList) }
|
||||||
|
assertOwnPicker()
|
||||||
|
rule.runOnIdle { setup.navigateTo(SetupDestination.UserList(server)) }
|
||||||
|
assertOwnPicker()
|
||||||
|
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Cannot restore sign-in")
|
||||||
|
rule.runOnIdle { rule.activity.lifecycleScope.launch { appModel().handleIntent(Intent()) } }
|
||||||
|
rule.waitUntil(5_000) { stack.last() == SetupDestination.ServerList }
|
||||||
|
assertOwnPicker()
|
||||||
|
coVerify(exactly = 0) { repository.switchServerOrUser() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Test fun unreachableSelectionRendersWolfskinRecoveryFrame() {
|
||||||
|
stack[0] = SetupDestination.UserList(server)
|
||||||
|
coEvery { repository.changeUser(server, user) } throws IOException("offline")
|
||||||
|
mount()
|
||||||
|
assertOwnPicker()
|
||||||
|
rule.onNodeWithText("Justin").performClick()
|
||||||
|
awaitStage(WolfskinSetupViewModel.Stage.Unavailable)
|
||||||
|
rule.onNodeWithText("Try again").assertIsDisplayed()
|
||||||
|
rule.onNodeWithText("Sign in").assertIsDisplayed()
|
||||||
|
rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist()
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "setup").apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "household-unreachable.png"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun unconfiguredFirstRunOffersUrlInsteadOfStockServerSelector() {
|
||||||
|
coEvery { repository.getMostRecentServer() } returns RestoredSession.None
|
||||||
|
every { repository.serverDao.getServers() } returns emptyList()
|
||||||
|
stack[0] = SetupDestination.ServerList
|
||||||
|
mount()
|
||||||
|
awaitStage(WolfskinSetupViewModel.Stage.Unconfigured)
|
||||||
|
rule.onNodeWithText("Connect your household server").assertIsDisplayed()
|
||||||
|
rule.onNodeWithTag("household-server-url").assertIsDisplayed().performTextInput("https://jellyfin.test")
|
||||||
|
rule.onNodeWithText("Try again").assertIsEnabled()
|
||||||
|
rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.MainContent
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
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.ScreensaverService
|
||||||
|
import com.github.damontecres.wholphin.services.ScreensaverState
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.test.TestActivity
|
||||||
|
import com.github.damontecres.wholphin.ui.TestModule
|
||||||
|
import com.github.damontecres.wholphin.ui.successResponse
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
|
import com.github.damontecres.wholphin.util.configure
|
||||||
|
import com.github.damontecres.wholphin.util.reset
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import dagger.hilt.android.testing.HiltTestApplication
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
|
import org.jellyfin.sdk.api.operations.QuickConnectApi
|
||||||
|
import org.jellyfin.sdk.api.operations.SystemApi
|
||||||
|
import org.jellyfin.sdk.api.operations.UserApi
|
||||||
|
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TestWatcher
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows.shadowOf
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** The rollout switch must still expose both real upstream selectors, not a second forked copy. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltAndroidTest
|
||||||
|
@Config(application = HiltTestApplication::class, sdk = [34])
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class WolfskinSetupRollbackTest {
|
||||||
|
@get:Rule(order = 0) val hilt = HiltAndroidRule(this)
|
||||||
|
@get:Rule(order = 1) val registerActivity = object : TestWatcher() {
|
||||||
|
override fun starting(description: Description?) {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
shadowOf(context.packageManager).addOrUpdateActivity(ActivityInfo().apply {
|
||||||
|
name = TestActivity::class.java.name
|
||||||
|
packageName = context.packageName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@get:Rule(order = 2) val rule = createAndroidComposeRule<TestActivity>()
|
||||||
|
@Inject lateinit var api: ApiClient
|
||||||
|
@Inject lateinit var jellyfin: Jellyfin
|
||||||
|
private var previousScreens = true
|
||||||
|
|
||||||
|
@Before fun setup() {
|
||||||
|
hilt.inject()
|
||||||
|
WholphinDispatchers.configure(TestModule.testDispatcher)
|
||||||
|
WolfskinScreenPreference.initialize(rule.activity)
|
||||||
|
previousScreens = WolfskinScreenPreference.enabled
|
||||||
|
WolfskinScreenPreference.enabled = false
|
||||||
|
val users = mockk<UserApi>()
|
||||||
|
val system = mockk<SystemApi>()
|
||||||
|
val quickConnect = mockk<QuickConnectApi>()
|
||||||
|
every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api
|
||||||
|
every { api.userApi } returns users
|
||||||
|
every { api.systemApi } returns system
|
||||||
|
every { api.quickConnectApi } returns quickConnect
|
||||||
|
coEvery { users.getPublicUsers() } returns successResponse(emptyList())
|
||||||
|
coEvery { system.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(version = "10.11.11"))
|
||||||
|
coEvery { quickConnect.getQuickConnectEnabled() } returns successResponse(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun restore() {
|
||||||
|
WolfskinScreenPreference.enabled = previousScreens
|
||||||
|
WholphinDispatchers.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun rollbackRendersBothStockSelectors() {
|
||||||
|
val stack = mutableStateListOf<SetupDestination>(SetupDestination.ServerList)
|
||||||
|
val screensaver = mockk<ScreensaverService>()
|
||||||
|
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||||
|
rule.setContent {
|
||||||
|
MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
|
mockk(relaxed = true), screensaver)
|
||||||
|
}
|
||||||
|
fun awaitHeading(resource: Int) {
|
||||||
|
rule.waitUntil(5_000) {
|
||||||
|
TestModule.testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
rule.onAllNodes(androidx.compose.ui.test.hasText(rule.activity.getString(resource))).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
rule.onNodeWithText(rule.activity.getString(resource)).assertIsDisplayed()
|
||||||
|
rule.onNodeWithText("Who’s watching?").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
awaitHeading(R.string.select_server)
|
||||||
|
rule.runOnIdle { stack[0] = SetupDestination.UserList(JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)) }
|
||||||
|
awaitHeading(R.string.select_user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.data.AppDatabase
|
||||||
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
|
import com.github.damontecres.wholphin.data.MostRecentServer
|
||||||
|
import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences
|
||||||
|
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.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.successResponse
|
||||||
|
import com.github.damontecres.wholphin.ui.TestModule
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
|
import org.jellyfin.sdk.api.operations.SystemApi
|
||||||
|
import org.jellyfin.sdk.api.operations.UserApi
|
||||||
|
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||||
|
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||||
|
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||||
|
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||||
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
|
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.tv.WolfskinSetupViewModel.Stage
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Setup preserves saved credentials, rejects protected-profile bypasses and recovers real auth failures. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class WolfskinSetupTest {
|
||||||
|
private val context get() = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
private val database by lazy { Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).allowMainThreadQueries().build() }
|
||||||
|
private val recent by lazy { MostRecentServerSharedPreferences(context) }
|
||||||
|
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
|
||||||
|
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "saved-test-token")
|
||||||
|
private val api = mockk<ApiClient>(relaxed = true)
|
||||||
|
private val usersApi = mockk<UserApi>()
|
||||||
|
private val systemApi = mockk<SystemApi>()
|
||||||
|
private val jellyfin by lazy { TestModule.jellyfin(mockk(), context, ClientInfo("Wolfskin test", "test"), DeviceInfo("setup-test", "Setup test")) }
|
||||||
|
private val navigation = NavigationManager()
|
||||||
|
private val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||||
|
private val session = WolfskinSession()
|
||||||
|
private val conductarr = ConductarrSession(MemoryCacheStore())
|
||||||
|
private val repository by lazy { ServerRepository(context, jellyfin, database.serverDao(), api, Dispatchers.IO, recent) }
|
||||||
|
private val model by lazy { WolfskinSetupViewModel(repository, navigation, setup, session, conductarr) }
|
||||||
|
private val dto get() = UserDto(id = user.id, name = user.name, hasPassword = true, hasConfiguredPassword = true, hasConfiguredEasyPassword = false)
|
||||||
|
private var previousFixtures = false
|
||||||
|
|
||||||
|
@Before fun saveHousehold(): Unit = runBlocking {
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
recent.clear()
|
||||||
|
database.serverDao().addOrUpdateServer(server)
|
||||||
|
user = database.serverDao().addOrUpdateUser(user)
|
||||||
|
recent.save(CurrentUser(server, user))
|
||||||
|
val client = jellyfin
|
||||||
|
every { api.userApi } returns usersApi
|
||||||
|
every { api.systemApi } returns systemApi
|
||||||
|
every { client.createApi(any(), any(), any(), any(), any()) } returns api
|
||||||
|
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
|
||||||
|
coEvery { systemApi.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(id = server.id.toString(), serverName = server.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun close() {
|
||||||
|
conductarr.close()
|
||||||
|
database.close()
|
||||||
|
ConductarrPreference.updateFixtures(previousFixtures)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun enteringPickerPreservesServerAndUserAndRecoversErasedPreferenceFromRoom() = runBlocking {
|
||||||
|
val saved = recent.get()
|
||||||
|
model.load(null)
|
||||||
|
assertEquals(Stage.Picker, model.stage)
|
||||||
|
assertEquals(user.id.toString(), model.picker!!.person.jellyfinUserId)
|
||||||
|
assertEquals(saved, recent.get())
|
||||||
|
recent.clear()
|
||||||
|
model.load(null)
|
||||||
|
assertEquals(server, model.server)
|
||||||
|
assertEquals(Stage.Picker, model.stage)
|
||||||
|
assertEquals(MostRecentServer.None, recent.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun expiredTokenRequiresCredentialsAndSuccessfulLoginRestoresSessionWithoutSecondPicker() = runBlocking {
|
||||||
|
model.load(server)
|
||||||
|
coEvery { usersApi.getCurrentUser() } throws InvalidStatusException(401)
|
||||||
|
model.select(model.picker!!.person)
|
||||||
|
assertEquals(Stage.Login, model.stage)
|
||||||
|
assertEquals(user, model.loginUser)
|
||||||
|
assertNull(repository.current.value)
|
||||||
|
assertTrue(recent.get() is MostRecentServer.ServerAndUser)
|
||||||
|
coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(user = dto, serverId = server.id.toString(), accessToken = "renewed-test-token"))
|
||||||
|
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
|
||||||
|
model.login(user.name!!, "test-password")
|
||||||
|
assertEquals("renewed-test-token", repository.current.value!!.user.accessToken)
|
||||||
|
assertEquals(user.id, (recent.get() as MostRecentServer.ServerAndUser).userId)
|
||||||
|
assertFalse(session.state!!.pickerVisible)
|
||||||
|
assertEquals(user.id.toString(), session.state!!.person.jellyfinUserId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun unreachableServerOffersRetryAndSavedPersonCanSignInWhenItReturns() = runTest {
|
||||||
|
model.load(server)
|
||||||
|
val saved = recent.get()
|
||||||
|
coEvery { usersApi.getCurrentUser() } throws IOException("offline")
|
||||||
|
model.select(model.picker!!.person)
|
||||||
|
assertEquals(Stage.Unavailable, model.stage)
|
||||||
|
assertEquals(saved, recent.get())
|
||||||
|
val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() }
|
||||||
|
assertEquals(Stage.Loading, model.stage)
|
||||||
|
retry.await()
|
||||||
|
assertEquals(Stage.Picker, model.stage)
|
||||||
|
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
|
||||||
|
model.select(model.picker!!.person)
|
||||||
|
assertEquals(user.id, repository.current.value!!.user.id)
|
||||||
|
assertFalse(session.state!!.pickerVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun retryCanRemainUnavailableWithoutErasingHousehold() = runTest {
|
||||||
|
database.serverDao().deleteUser(server.id, user.id)
|
||||||
|
val saved = recent.get()
|
||||||
|
coEvery { usersApi.getPublicUsers() } throws IOException("offline")
|
||||||
|
model.load(null)
|
||||||
|
assertEquals(Stage.Unavailable, model.stage)
|
||||||
|
val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() }
|
||||||
|
assertEquals(Stage.Loading, model.stage)
|
||||||
|
retry.await()
|
||||||
|
assertEquals(Stage.Unavailable, model.stage)
|
||||||
|
assertEquals(server, model.server)
|
||||||
|
assertEquals(saved, recent.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun pinAndRequireLoginCannotReuseSavedTokenWithoutUnlocking() = runBlocking {
|
||||||
|
user = user.copy(pin = "URDL")
|
||||||
|
database.serverDao().updateUser(user)
|
||||||
|
model.load(server)
|
||||||
|
model.select(model.picker!!.person)
|
||||||
|
assertEquals(user, model.pinUser)
|
||||||
|
assertTrue(model.picker!!.pickerVisible)
|
||||||
|
model.enterPin("WRONG")
|
||||||
|
assertNull(repository.current.value)
|
||||||
|
model.enterPin("URDL")
|
||||||
|
assertEquals(user.id, repository.current.value!!.user.id)
|
||||||
|
user = user.copy(pin = null, requireLogin = true)
|
||||||
|
database.serverDao().updateUser(user)
|
||||||
|
model.load(server)
|
||||||
|
model.select(model.picker!!.person)
|
||||||
|
assertEquals(Stage.Login, model.stage)
|
||||||
|
assertEquals(user, model.loginUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun unavailablePublicProfilesKeepHouseholdAndAllowCredentialRecovery() = runBlocking {
|
||||||
|
database.serverDao().deleteUser(server.id, user.id)
|
||||||
|
coEvery { usersApi.getPublicUsers() } throws IOException("offline")
|
||||||
|
model.load(null)
|
||||||
|
assertEquals(Stage.Unavailable, model.stage)
|
||||||
|
assertEquals(server, model.server)
|
||||||
|
model.showLogin(null)
|
||||||
|
coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(
|
||||||
|
user = dto, serverId = server.id.toString(), accessToken = "new-test-token"))
|
||||||
|
model.login(user.name!!, "test-password")
|
||||||
|
assertEquals(user.id, repository.current.value!!.user.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun expiredLaunchRecoveryIgnoresOutagesLateIdentitiesAndRollbackMode() = runBlocking {
|
||||||
|
WolfskinScreenPreference.initialize(context)
|
||||||
|
val previous = WolfskinScreenPreference.enabled
|
||||||
|
try {
|
||||||
|
WolfskinScreenPreference.enabled = true
|
||||||
|
val expected = (repository.restoreSavedSession() as com.github.damontecres.wholphin.data.RestoredSession.Success).currentUser
|
||||||
|
var destination: SetupDestination = SetupDestination.AppContent(expected)
|
||||||
|
every { setup.navigateTo(any()) } answers { destination = firstArg() }
|
||||||
|
recoverWolfskinSession(context, IOException("offline"), expected, repository, setup)
|
||||||
|
assertTrue(destination is SetupDestination.AppContent)
|
||||||
|
WolfskinScreenPreference.enabled = false
|
||||||
|
recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup)
|
||||||
|
assertTrue(destination is SetupDestination.AppContent)
|
||||||
|
WolfskinScreenPreference.enabled = true
|
||||||
|
val other = database.serverDao().addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel"))
|
||||||
|
recent.save(CurrentUser(server, other))
|
||||||
|
repository.restoreSavedSession()
|
||||||
|
recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup)
|
||||||
|
assertTrue(destination is SetupDestination.AppContent)
|
||||||
|
recoverWolfskinSession(context, InvalidStatusException(401), repository.current.value, repository, setup)
|
||||||
|
model.load((destination as SetupDestination.UserList).server)
|
||||||
|
assertEquals(Stage.Picker, model.stage)
|
||||||
|
assertTrue(model.picker!!.people.any { it.jellyfinUserId == other.id.toString() })
|
||||||
|
assertEquals(other.id, (recent.get() as MostRecentServer.ServerAndUser).userId)
|
||||||
|
} finally { WolfskinScreenPreference.enabled = previous }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun firstRunDiscoversAndPersistsHouseholdBeforeFetchingPublicProfiles() = runBlocking {
|
||||||
|
database.serverDao().deleteUser(server.id, user.id)
|
||||||
|
database.serverDao().deleteServer(server.id)
|
||||||
|
recent.clear()
|
||||||
|
model.load(null)
|
||||||
|
assertEquals(Stage.Unconfigured, model.stage)
|
||||||
|
coEvery { jellyfin.discovery.getRecommendedServers("bad-url") } returns emptyList()
|
||||||
|
model.connect("bad-url")
|
||||||
|
assertEquals(Stage.Unconfigured, model.stage)
|
||||||
|
assertTrue(database.serverDao().getServers().isEmpty())
|
||||||
|
coEvery { jellyfin.discovery.getRecommendedServers(server.url) } returns listOf(RecommendedServerInfo(
|
||||||
|
address = server.url, responseTime = 1, score = RecommendedServerInfoScore.GREAT, issues = emptyList(),
|
||||||
|
systemInfo = Result.success(PublicSystemInfo(id = server.id.toString(), serverName = server.name, startupWizardCompleted = true))))
|
||||||
|
model.connect(server.url)
|
||||||
|
assertEquals(server.id, database.serverDao().getServers().single().server.id)
|
||||||
|
coEvery { usersApi.getPublicUsers() } returns successResponse(listOf(dto))
|
||||||
|
model.load(model.server)
|
||||||
|
assertEquals(Stage.Picker, model.stage)
|
||||||
|
model.select(model.picker!!.person)
|
||||||
|
assertEquals(Stage.Login, model.stage)
|
||||||
|
assertNull(repository.current.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -225,7 +225,11 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
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) return@launch
|
if (saved == null || saved.isProtected) {
|
||||||
|
if (ConductarrPreference.fixtures) return@launch
|
||||||
|
setupNavigation.navigateTo(SetupDestination.UserList(server))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
val current = repository.changeUser(server, saved)
|
val current = repository.changeUser(server, saved)
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
|
|
@ -233,7 +237,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
setupNavigation.navigateTo(SetupDestination.UserList(server))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
|
|
||||||
|
/** Only an expired current login leaves saved content; outages and late failures keep it usable. */
|
||||||
|
fun recoverWolfskinSession(
|
||||||
|
context: Context,
|
||||||
|
exception: Exception,
|
||||||
|
expected: CurrentUser?,
|
||||||
|
repository: ServerRepository,
|
||||||
|
setup: SetupNavigationManager,
|
||||||
|
) {
|
||||||
|
if (exception !is InvalidStatusException || exception.status != 401 || expected == null || repository.current.value !== expected) return
|
||||||
|
WolfskinScreenPreference.initialize(context)
|
||||||
|
if (WolfskinScreenPreference.enabled) setup.navigateTo(SetupDestination.UserList(expected.server))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.ButtonDefaults
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.ui.components.EditTextBox
|
||||||
|
import com.github.damontecres.wholphin.ui.setup.PinEntryDialog
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.justinvisser.wolfskin.design.ShellAction
|
||||||
|
import org.justinvisser.wolfskin.design.ShellSurface
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||||
|
import org.justinvisser.wolfskin.design.Wordmark
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||||
|
import org.justinvisser.wolfskin.tv.WolfskinSetupViewModel.Stage
|
||||||
|
|
||||||
|
/** Intercept both stock setup destinations before either upstream selector can be composed. */
|
||||||
|
@Composable
|
||||||
|
fun WolfskinSetupContent(destination: SetupDestination, modifier: Modifier = Modifier, model: WolfskinSetupViewModel? = null): Boolean {
|
||||||
|
if (destination != SetupDestination.ServerList && destination !is SetupDestination.UserList) return false
|
||||||
|
val context = LocalContext.current
|
||||||
|
remember(context) {
|
||||||
|
WolfskinScreenPreference.initialize(context)
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
if (!WolfskinScreenPreference.enabled) return false
|
||||||
|
WolfskinSetupScreen(destination, modifier, model ?: hiltViewModel())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saved people remain local; network failures expose retry and real server authentication. */
|
||||||
|
@Composable
|
||||||
|
internal fun WolfskinSetupScreen(
|
||||||
|
destination: SetupDestination,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
model: WolfskinSetupViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val hint = (destination as? SetupDestination.UserList)?.server
|
||||||
|
LaunchedEffect(destination) { model.load(hint) }
|
||||||
|
val retrySetup: () -> Unit = { scope.launch { model.retry() } }
|
||||||
|
model.onAction = { action ->
|
||||||
|
when (action) {
|
||||||
|
is ShellAction.Person -> scope.launch { model.select(action.person) }
|
||||||
|
is ShellAction.Destination -> model.showSettings()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BackHandler {
|
||||||
|
if (model.pinUser != null) model.dismissPin()
|
||||||
|
else model.returnToPicker()
|
||||||
|
}
|
||||||
|
WolfskinTheme {
|
||||||
|
val picker = model.picker
|
||||||
|
if (model.stage == Stage.Picker && picker != null) {
|
||||||
|
ShellSurface(picker, modifier.testTag("wolfskin-setup-picker"))
|
||||||
|
} else {
|
||||||
|
SetupPanel(model.stage, modifier) {
|
||||||
|
when (model.stage) {
|
||||||
|
Stage.Loading -> Text("Reading saved profiles…", color = C.muted, style = WolfskinTokens.Type.body)
|
||||||
|
Stage.Unconfigured -> ServerForm(model)
|
||||||
|
Stage.Login -> LoginForm(model)
|
||||||
|
Stage.Settings -> {
|
||||||
|
SetupButton("Sign in to another profile", !model.busy, initialFocus = true) { model.showLogin(null) }
|
||||||
|
SetupButton("Back to profiles", !model.busy, onClick = model::returnToPicker)
|
||||||
|
}
|
||||||
|
Stage.Unavailable -> {
|
||||||
|
Text(model.message.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
|
SetupButton("Try again", !model.busy, initialFocus = true, onClick = retrySetup)
|
||||||
|
if (model.server != null) SetupButton("Sign in", !model.busy) { model.showLogin(null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Stage.Picker -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.pinUser?.let { user ->
|
||||||
|
PinEntryDialog(
|
||||||
|
onDismissRequest = model::dismissPin,
|
||||||
|
onClickServerAuth = { model.showLogin(user) },
|
||||||
|
onTextChange = { value -> scope.launch { model.enterPin(value) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SetupPanel(stage: Stage, modifier: Modifier, content: @Composable () -> Unit) {
|
||||||
|
Box(modifier.fillMaxSize().background(C.bg).testTag("wolfskin-setup-${stage.name.lowercase()}")) {
|
||||||
|
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
|
||||||
|
Column(Modifier.align(Alignment.Center).width(S.pickerErrorPanelWidth),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(S.panelGap)) {
|
||||||
|
Text(when (stage) {
|
||||||
|
Stage.Unconfigured -> "Connect your household server"
|
||||||
|
Stage.Login -> "Sign in to your profile"
|
||||||
|
Stage.Settings -> "Settings"
|
||||||
|
else -> "Who’s watching?"
|
||||||
|
}, color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerForm(model: WolfskinSetupViewModel) {
|
||||||
|
var url by remember { mutableStateOf("") }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||||
|
Text("Enter your Jellyfin server URL once. Wolfskin will remember it on this TV.", color = C.muted, style = WolfskinTokens.Type.body)
|
||||||
|
EditTextBox(value = url, onValueChange = { url = it }, modifier = Modifier.fillMaxWidth().focusRequester(focus).testTag("household-server-url"),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
|
||||||
|
model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) }
|
||||||
|
SetupButton(if (model.busy) "Connecting…" else "Try again", !model.busy && url.isNotBlank()) {
|
||||||
|
scope.launch { model.connect(url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoginForm(model: WolfskinSetupViewModel) {
|
||||||
|
var username by remember(model.loginUser) { mutableStateOf(model.loginUser?.name.orEmpty()) }
|
||||||
|
var password by remember(model.loginUser) { mutableStateOf("") }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
val initialUsername = remember(model.loginUser) { username.isBlank() }
|
||||||
|
LaunchedEffect(model.loginUser) { focus.requestFocus() }
|
||||||
|
Text(model.server?.name ?: model.server?.url.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body)
|
||||||
|
Text("Username", color = C.fg, style = WolfskinTokens.Type.body)
|
||||||
|
EditTextBox(value = username, onValueChange = { username = it }, modifier = Modifier.fillMaxWidth()
|
||||||
|
.then(if (initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-username"))
|
||||||
|
Text("Password", color = C.fg, style = WolfskinTokens.Type.body)
|
||||||
|
EditTextBox(value = password, onValueChange = { password = it }, modifier = Modifier.fillMaxWidth()
|
||||||
|
.then(if (!initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-password"),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
||||||
|
model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) }
|
||||||
|
SetupButton(if (model.busy) "Signing in…" else "Sign in", !model.busy && username.isNotBlank()) {
|
||||||
|
val submitted = password
|
||||||
|
password = ""
|
||||||
|
scope.launch { model.login(username, submitted) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SetupButton(label: String, enabled: Boolean, initialFocus: Boolean = false, onClick: () -> Unit) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { if (initialFocus && enabled) focus.requestFocus() }
|
||||||
|
Button(onClick = onClick, enabled = enabled, modifier = Modifier.focusRequester(focus),
|
||||||
|
shape = ButtonDefaults.shape(shape = RoundedCornerShape(WolfskinTokens.Radius.button)),
|
||||||
|
colors = ButtonDefaults.colors(containerColor = C.buttonSecondary, contentColor = C.fg,
|
||||||
|
focusedContainerColor = C.fg, focusedContentColor = C.onFocus)) {
|
||||||
|
Text(label, style = WolfskinTokens.Type.button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.github.damontecres.wholphin.data.RestoredSession
|
||||||
|
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.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
|
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import org.justinvisser.wolfskin.design.ShellAction
|
||||||
|
import org.justinvisser.wolfskin.design.ShellPerson
|
||||||
|
import org.justinvisser.wolfskin.design.ShellState
|
||||||
|
|
||||||
|
/** Setup never clears the remembered household endpoint or login merely to show the picker. */
|
||||||
|
@HiltViewModel
|
||||||
|
class WolfskinSetupViewModel @Inject constructor(
|
||||||
|
private val repository: ServerRepository,
|
||||||
|
private val navigation: NavigationManager,
|
||||||
|
private val setup: SetupNavigationManager,
|
||||||
|
private val session: WolfskinSession,
|
||||||
|
private val conductarr: ConductarrSession,
|
||||||
|
) : ViewModel() {
|
||||||
|
internal enum class Stage { Loading, Unconfigured, Picker, Login, Settings, Unavailable }
|
||||||
|
|
||||||
|
internal var stage by mutableStateOf(Stage.Loading)
|
||||||
|
private set
|
||||||
|
internal var server by mutableStateOf<JellyfinServer?>(null)
|
||||||
|
private set
|
||||||
|
internal var picker by mutableStateOf<ShellState?>(null)
|
||||||
|
private set
|
||||||
|
internal var message by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
internal var busy by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
internal var pinUser by mutableStateOf<JellyfinUser?>(null)
|
||||||
|
private set
|
||||||
|
internal var loginUser by mutableStateOf<JellyfinUser?>(null)
|
||||||
|
private set
|
||||||
|
private var users: List<JellyfinUser> = emptyList()
|
||||||
|
internal var onAction: (ShellAction) -> Unit = {}
|
||||||
|
|
||||||
|
/** Recover Room's saved household server even if the old selector erased its preference. */
|
||||||
|
internal suspend fun load(hint: JellyfinServer?) = perform {
|
||||||
|
conductarr.close()
|
||||||
|
server = hint ?: repository.current.value?.server ?: when (val recent = repository.getMostRecentServer()) {
|
||||||
|
is RestoredSession.ServerOnly -> recent.server
|
||||||
|
is RestoredSession.Success -> recent.currentUser.server
|
||||||
|
RestoredSession.None -> withContext(Dispatchers.IO) {
|
||||||
|
repository.serverDao.getServers().maxWithOrNull(compareBy { it.server.lastUsed })?.server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val household = server
|
||||||
|
if (household == null) {
|
||||||
|
stage = Stage.Unconfigured
|
||||||
|
return@perform
|
||||||
|
}
|
||||||
|
users = withContext(Dispatchers.IO) { repository.serverDao.getServer(household.id)?.users.orEmpty() }
|
||||||
|
if (users.isEmpty()) {
|
||||||
|
users = withContext(Dispatchers.IO) {
|
||||||
|
repository.jellyfin.createApi(household.url).userApi.getPublicUsers().content.map {
|
||||||
|
JellyfinUser(id = it.id, name = it.name, serverId = household.id, accessToken = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (users.isEmpty()) {
|
||||||
|
showLogin(null)
|
||||||
|
} else {
|
||||||
|
val people = users.map { ShellPerson(it.id.toString(), it.name.orEmpty(), it.name.orEmpty().take(1).uppercase(), false, it.id.toString()) }
|
||||||
|
picker = ShellState(people, people.first().key) { onAction(it) }
|
||||||
|
stage = Stage.Picker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retry the startup server/profile recovery, never the failed saved-token request. */
|
||||||
|
internal suspend fun retry() {
|
||||||
|
if (busy) return
|
||||||
|
stage = Stage.Loading
|
||||||
|
load(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discovery and persistence use the same Jellyfin contract as upstream's add-server action. */
|
||||||
|
internal suspend fun connect(url: String) = perform {
|
||||||
|
val household = withContext(Dispatchers.IO) {
|
||||||
|
val candidate = repository.jellyfin.discovery.getRecommendedServers(url.trim())
|
||||||
|
.sortedBy { it.score }.firstOrNull { it.score != RecommendedServerInfoScore.BAD }
|
||||||
|
val info = candidate?.systemInfo?.getOrNull()
|
||||||
|
val id = info?.id?.toUUIDOrNull()
|
||||||
|
require(candidate != null && info != null && id != null && info.startupWizardCompleted == true) { "Could not connect to the household server. Check the URL and try again." }
|
||||||
|
JellyfinServer(id, info.serverName, candidate.address, info.version).also {
|
||||||
|
repository.addAndChangeServer(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server = household
|
||||||
|
setup.navigateTo(SetupDestination.UserList(household))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Protected saved users must authenticate before changeUser is allowed to run. */
|
||||||
|
internal suspend fun select(person: ShellPerson) {
|
||||||
|
picker?.pickerVisible = true
|
||||||
|
if (busy) return
|
||||||
|
val selected = users.firstOrNull { it.id.toString() == person.jellyfinUserId } ?: return
|
||||||
|
when {
|
||||||
|
selected.accessToken.isNullOrBlank() || selected.requireLogin -> showLogin(selected)
|
||||||
|
selected.hasPin -> pinUser = selected
|
||||||
|
else -> changeUser(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun showLogin(user: JellyfinUser?) {
|
||||||
|
pinUser = null
|
||||||
|
loginUser = user
|
||||||
|
message = null
|
||||||
|
stage = Stage.Login
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun dismissPin() { pinUser = null }
|
||||||
|
|
||||||
|
internal fun showSettings() {
|
||||||
|
picker?.pickerVisible = true
|
||||||
|
if (!busy) stage = Stage.Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun returnToPicker() {
|
||||||
|
if (busy || picker == null) return
|
||||||
|
picker?.pickerVisible = true
|
||||||
|
stage = Stage.Picker
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun enterPin(value: String) {
|
||||||
|
val selected = pinUser ?: return
|
||||||
|
if (value == selected.pin) {
|
||||||
|
pinUser = null
|
||||||
|
changeUser(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun changeUser(user: JellyfinUser) = perform {
|
||||||
|
try {
|
||||||
|
complete(repository.changeUser(requireNotNull(server), user))
|
||||||
|
} catch (exception: InvalidStatusException) {
|
||||||
|
if (exception.status != 401) throw exception
|
||||||
|
showLogin(user)
|
||||||
|
message = "Your saved sign-in expired. Sign in again to continue."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A password is sent only to the saved household server and never retained by this model. */
|
||||||
|
internal suspend fun login(username: String, password: String) = perform {
|
||||||
|
val household = requireNotNull(server)
|
||||||
|
val current = withContext(Dispatchers.IO) {
|
||||||
|
val result = repository.jellyfin.createApi(household.url).userApi
|
||||||
|
.authenticateUserByName(username = username, password = password).content
|
||||||
|
repository.changeUser(household.url, result, loginUser)
|
||||||
|
}
|
||||||
|
complete(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun complete(current: com.github.damontecres.wholphin.data.CurrentUser) {
|
||||||
|
if (ConductarrPreference.fixtures) {
|
||||||
|
session.state = null
|
||||||
|
} else {
|
||||||
|
val saved = users.filterNot { it.id == current.user.id } + current.user
|
||||||
|
val people = saved.map { ShellPerson(it.name.orEmpty().lowercase(), it.name.orEmpty(), it.name.orEmpty().take(1).uppercase(), false, it.id.toString()) }
|
||||||
|
session.state = ShellState(people, current.user.name.orEmpty().lowercase()) { session.onAction(it) }.apply {
|
||||||
|
pickerVisible = false
|
||||||
|
pickerLaunch = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.serverId = current.server.id.toString()
|
||||||
|
session.fixtureMode = ConductarrPreference.fixtures
|
||||||
|
navigation.reloadHome()
|
||||||
|
setup.navigateTo(SetupDestination.AppContent(current))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun perform(action: suspend () -> Unit) {
|
||||||
|
if (busy) return
|
||||||
|
busy = true
|
||||||
|
message = null
|
||||||
|
try {
|
||||||
|
action()
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is CancellationException) throw exception
|
||||||
|
message = if (exception is InvalidStatusException && exception.status == 401) "Invalid username or password. Try again."
|
||||||
|
else if (exception is InvalidStatusException && exception.status == 403) "This profile is not authorized. Choose another profile or sign in again."
|
||||||
|
else if (server == null && stage == Stage.Unconfigured) "Could not connect to the household server. Check the URL and try again."
|
||||||
|
else if (server == null) "Could not read the saved household server. Try again."
|
||||||
|
else "The household server didn’t answer. Try again or sign in with your server credentials."
|
||||||
|
if (stage != Stage.Login && stage != Stage.Unconfigured) stage = Stage.Unavailable
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,7 +74,7 @@ Row reveals are grouped rather than the browser prototype's per-card staging: th
|
||||||
|
|
||||||
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen.
|
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen.
|
||||||
|
|
||||||
The series readiness band reserves **56 px (28 dp)** from its first content paint, including when empty or when readiness later disappears. Ratings remain inside the fixed-height facts band. The series header's measured height therefore cannot change when late ratings/readiness arrive; overview, actions, season and episode anchors retain their coordinates. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route.
|
The approved title follow-up (2026-11-07) supersedes the fixed readiness reservation: an absent readiness line occupies **0 px**, while a present line occupies **56 px (28 dp)**. Late arrival/removal animates that height over the **200 ms late-line duration with token ease-out**; reduced motion snaps. Overview, actions and following rows move monotonically with that band; an already-pinned focused row stays pinned through same-remeasure scroll compensation. Ratings remain inside the fixed-height facts band and appear once: an explicit episode page uses its opened episode's own set, including the episode score, rather than the series-only set. Series and episode pages have no redundant Seasons shortcut; Down reaches the season chips. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route.
|
||||||
|
|
||||||
Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range.
|
Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,33 @@ 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.
|
||||||
|
|
||||||
|
### Household setup and sign-in recovery
|
||||||
|
|
||||||
|
With **Wolfskin screens** enabled, `MainContent` intercepts both upstream setup
|
||||||
|
destinations before their stock server/user selectors can render. Cold start,
|
||||||
|
restart with automatic sign-in disabled, the old drawer's user action, legacy
|
||||||
|
sign-out-equivalent state, setup fallbacks and intent failures all use Wolfskin's
|
||||||
|
own picker. Entering it does not call `SwitchUserViewModel.init` or clear the
|
||||||
|
remembered server/user preference.
|
||||||
|
|
||||||
|
Setup reuses the supplied/current/recent household server, recovering the most
|
||||||
|
recently used Room server if the legacy picker erased the preference. Saved people
|
||||||
|
render without a network request. Only an install with no saved server gets the
|
||||||
|
one-time Jellyfin URL field; it uses existing Jellyfin discovery and Room
|
||||||
|
persistence, not a new compiled endpoint. Conductarr remains on its existing
|
||||||
|
configured URL and enriches the signed-in shell as before.
|
||||||
|
|
||||||
|
Selecting a protected profile still requires its PIN or server credentials.
|
||||||
|
Expired credentials during saved-session revalidation or foreground refresh
|
||||||
|
return to the picker without allowing a late failure to replace another person's
|
||||||
|
session. A failed selection offers Wolfskin **Try again** and **Sign in** actions;
|
||||||
|
hidden/unsaved profiles can use username/password sign-in through the setup
|
||||||
|
picker's Settings gear. An outage never replaces the saved household endpoint.
|
||||||
|
|
||||||
|
Turning **Wolfskin screens** off retains the unchanged upstream
|
||||||
|
`SwitchServerContent` and `SwitchUserContent` setup screens for rollback. Debug
|
||||||
|
fixtures still seed the existing fixture picker after authentication.
|
||||||
|
|
||||||
Person selection immediately clears the old Conductarr identity and Jellyfin
|
Person selection immediately clears the old Conductarr identity and Jellyfin
|
||||||
read gate before saved-login I/O. Detached clients are disposed exactly once on
|
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
|
the application's IO scope, after the replacement (or signed-out) identity is
|
||||||
|
|
@ -643,18 +670,24 @@ retain the approved frames except for the requested feedback changes:
|
||||||
that may still be cancelled; first mount restores the intended shelf item.
|
that may still be cancelled; first mount restores the intended shelf item.
|
||||||
Discover uses all six approved poster slots. Person keeps the final column
|
Discover uses all six approved poster slots. Person keeps the final column
|
||||||
at full poster width.
|
at full poster width.
|
||||||
- Episode images choose episode Primary, then Thumb, then the already-read
|
- Episode images choose episode Primary, then Thumb, then the already-read series Backdrop.
|
||||||
series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance.
|
Title episode cards now use 448×280 px, preserving the taller approved crop.
|
||||||
Episode context uses the episode overview, with two summary lines on the
|
Focused summaries ellipsise to one line, the show-name overlay is absent, and
|
||||||
focused card. Held-series headers measure their copy/actions and leave 40 px
|
`S1 E3 · duration` plus the watched check share one line.
|
||||||
before seasons instead of reserving a fixed empty 900 px header.
|
Ready title headers measure their copy/actions and leave 40 px before the
|
||||||
|
following section. Missing readiness lines collapse completely on every title
|
||||||
|
kind; late lines expand/collapse over the 200 ms late-line token with ease-out,
|
||||||
|
or snap with reduced motion. Already-pinned rows compensate in the same
|
||||||
|
remeasure rather than bouncing through a second focus-follow animation.
|
||||||
|
Poster-only movies floor that stack so Cast circles clear the poster by at
|
||||||
|
least 20 px, including held movie fallbacks and short overviews.
|
||||||
- Every Discover heading uses a 104 px band, including a 60 px title-to-card
|
- Every Discover heading uses a 104 px band, including a 60 px title-to-card
|
||||||
gap. Home heading geometry is unchanged.
|
gap. Home heading geometry is unchanged.
|
||||||
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
|
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
|
||||||
420×630. The shared request builder replaces conflicting Jellyfin size/encoding
|
420×630. The shared request builder replaces conflicting Jellyfin size/encoding
|
||||||
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG
|
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG
|
||||||
quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and
|
quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and
|
||||||
400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
|
448×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
|
||||||
authentication query parameters remain intact. TMDB uses w342 for posters and
|
authentication query parameters remain intact. TMDB uses w342 for posters and
|
||||||
portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
|
portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
|
||||||
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
|
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
|
||||||
|
|
@ -706,6 +739,28 @@ pop a pushed title/person from content **or** nav. Initial loading has no conten
|
||||||
target: Down stays in nav, while Back can still leave the title. A missing rating
|
target: Down stays in nav, while Back can still leave the title. A missing rating
|
||||||
set emits no layout node, so it cannot reserve an invisible extra facts gap.
|
set emits no layout node, so it cannot reserve an invisible extra facts gap.
|
||||||
|
|
||||||
|
The title Menu replaces the old More/media-info icon action and contains Media
|
||||||
|
info plus Add for someone when requests are permitted. It is shared by held
|
||||||
|
movie, series and episode pages; non-held pages expose Add for someone in the
|
||||||
|
same menu while Request/status and Not interested/Show again stay in the row.
|
||||||
|
Version, tracks, watched and trailer controls keep their existing positions.
|
||||||
|
Back from Media info returns to the menu; Back from the menu restores its
|
||||||
|
opening button. The compact menu fades in for 160 ms and out for 100 ms using
|
||||||
|
the motion ease-out token, with reduced-motion fades capped at 80 ms. Only the
|
||||||
|
bounded menu panel owns an alpha layer; the scrim is static. Opening another
|
||||||
|
sheet immediately removes the menu panel and scrim, preventing double darkening.
|
||||||
|
An empty menu closes if its last permitted action disappears. Controls
|
||||||
|
use the shared 4 px straddle focus ring. Neither series nor episode details have
|
||||||
|
a redundant Seasons shortcut; Down reaches the season chips directly. Ratings
|
||||||
|
appear only in the header facts line, not again beside progress. Explicit episode
|
||||||
|
pages use the opened episode's own set, including its `Ep` score; movie and series
|
||||||
|
pages keep their own sets.
|
||||||
|
|
||||||
|
Episode shelf skeletons use the same width, height and pitch as ready cards.
|
||||||
|
Person filmography and related-title shelves remain posters; Home continuation
|
||||||
|
tiles are a separate component and retain their series-name context.
|
||||||
|
|
||||||
|
|
||||||
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
|
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
|
||||||
person credits and playback mutations to one boundary. `ConductarrTitlePages`
|
person credits and playback mutations to one boundary. `ConductarrTitlePages`
|
||||||
adds real detail, cast, related titles, performer/filmography, readiness/receipts
|
adds real detail, cast, related titles, performer/filmography, readiness/receipts
|
||||||
|
|
@ -730,7 +785,10 @@ headers, sheets, cast and poster captions without changing shared type tokens.
|
||||||
Fractional stack placement rounds only the accumulated position, preventing a
|
Fractional stack placement rounds only the accumulated position, preventing a
|
||||||
one-pixel-per-row drift. Focused controls share the canonical lift shadow.
|
one-pixel-per-row drift. Focused controls share the canonical lift shadow.
|
||||||
`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row
|
`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row
|
||||||
entry, remembered columns, long-sheet visibility and full media-table geometry.
|
entry, remembered columns and long-sheet visibility. `TitleRound2Test` covers
|
||||||
|
menu Back/focus, preserved media info and household requests, movie stacking,
|
||||||
|
the Mussolini resume example, request revocation, episode skeleton geometry,
|
||||||
|
and native menu fade frames sampled in 16 ms steps.
|
||||||
`TitleAsyncFocusTest` delays title and person replies through the real destination
|
`TitleAsyncFocusTest` delays title and person replies through the real destination
|
||||||
and view-model, then exercises content focus and hardware Back. Subtitles Off
|
and view-model, then exercises content focus and hardware Back. Subtitles Off
|
||||||
maps to the player's explicit disabled sentinel, not its unspecified default.
|
maps to the player's explicit disabled sentinel, not its unspecified default.
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
@ -110,7 +111,7 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
|
||||||
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).testTag("title-poster-backdrop").dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxSize().background(horizontal))
|
Box(Modifier.fillMaxSize().background(horizontal))
|
||||||
Box(Modifier.fillMaxSize().background(vertical))
|
Box(Modifier.fillMaxSize().background(vertical))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
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.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
|
@ -35,6 +39,11 @@ 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 factsItem = state.data.watchTarget.takeIf { it.kind == "episode" } ?: item
|
||||||
|
val readinessHeight by animateDpAsState(
|
||||||
|
if (state.data.requestedStage == null) WolfskinTokens.Shell.zero else WolfskinTokens.Motion.readinessSlot,
|
||||||
|
tween(if (reducedMotion()) 0 else WolfskinTokens.Motion.lateLineFade, easing = WolfskinTokens.Motion.easeOut),
|
||||||
|
label = "title-readiness-height")
|
||||||
val episode = when {
|
val episode = when {
|
||||||
state.row == "episodes" -> state.data.episodes.getOrNull(state.column)
|
state.row == "episodes" -> state.data.episodes.getOrNull(state.column)
|
||||||
state.data.watchTarget.kind == "episode" -> state.data.watchTarget
|
state.data.watchTarget.kind == "episode" -> state.data.watchTarget
|
||||||
|
|
@ -56,10 +65,10 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) }
|
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) }
|
||||||
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||||
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
|
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(factsItem.ratings)
|
||||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres))
|
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres))
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) {
|
Box(Modifier.fillMaxWidth().height(readinessHeight).clipToBounds().testTag("title-readiness-slot")) {
|
||||||
state.data.requestedStage?.let { stage ->
|
state.data.requestedStage?.let { stage ->
|
||||||
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
||||||
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
@ -74,7 +83,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
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))
|
||||||
}
|
}
|
||||||
state.data.playTarget?.let { target ->
|
state.data.playTarget?.takeIf { it.resumeSeconds > 0 || it.kind == "episode" }?.let { target ->
|
||||||
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (target.resumeSeconds > 0) {
|
if (target.resumeSeconds > 0) {
|
||||||
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
|
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
|
||||||
|
|
@ -83,7 +92,6 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
}
|
}
|
||||||
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress)
|
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress)
|
||||||
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress)
|
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress)
|
||||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = TitleTextStyles.progress)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
|
|
||||||
|
/** A compact modal keeps secondary actions reachable without growing the header row. */
|
||||||
|
@Composable
|
||||||
|
internal fun TitleMenu(state: TitleState, shell: ShellState) {
|
||||||
|
val visible = state.sheet == TitleSheet.Menu && !shell.pickerVisible
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
val opacity = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(visible, reduced) {
|
||||||
|
if (visible) focus.requestFocus()
|
||||||
|
opacity.animateTo(if (visible) 1f else 0f,
|
||||||
|
tween(motionDuration(if (visible) WolfskinTokens.Motion.base else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||||
|
}
|
||||||
|
if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) {
|
||||||
|
Box(Modifier.fillMaxSize().background(C.sheetScrim)
|
||||||
|
.clickable { if (visible) state.back(shell) }
|
||||||
|
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||||
|
val key = readShellKey(event)
|
||||||
|
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||||
|
}.focusable()) {
|
||||||
|
Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth)
|
||||||
|
.graphicsLayer { alpha = opacity.value }
|
||||||
|
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||||
|
.padding(D.receiptSide).testTag("title-menu"), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
|
||||||
|
Text("Menu", color = C.fg, style = WolfskinTokens.Type.rowHeading)
|
||||||
|
state.menuOptions.forEachIndexed { index, option ->
|
||||||
|
val focused = visible && state.sheetIndex == index
|
||||||
|
TitleControl(option, focused,
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
onFocus = { if (visible) state.sheetIndex = index }, overArtwork = false) {
|
||||||
|
if (visible) state.clickSheet(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -63,7 +63,7 @@ data class TitleVersion(
|
||||||
|
|
||||||
enum class TitleReadStatus { Ready, Loading, Unavailable }
|
enum class TitleReadStatus { Ready, Loading, Unavailable }
|
||||||
enum class TitleConnection { SignedIn, SignedOut, Offline }
|
enum class TitleConnection { SignedIn, SignedOut, Offline }
|
||||||
enum class TitleSheet { Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt }
|
enum class TitleSheet { Menu, Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt }
|
||||||
|
|
||||||
/** A title read includes related titles and the selected season; reads happen outside composition. */
|
/** A title read includes related titles and the selected season; reads happen outside composition. */
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
||||||
TitleSheet.MediaInfo -> MediaInformation(state)
|
TitleSheet.MediaInfo -> MediaInformation(state)
|
||||||
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
|
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
|
||||||
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
|
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
|
||||||
null -> Unit
|
TitleSheet.Menu, null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,8 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
||||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
||||||
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
||||||
}
|
}
|
||||||
if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell)
|
if (!shell.pickerVisible && state.sheet != null && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell)
|
||||||
|
TitleMenu(state, shell)
|
||||||
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,9 +92,11 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
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) }
|
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
||||||
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
|
val compactHeader = state.data.status == TitleReadStatus.Ready
|
||||||
state.data.item.kind == "tv" && state.data.item.seasons > 0
|
val item = state.data.item
|
||||||
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
|
val posterFloor = if (item.kind == "movie" && item.poster != null && (item.backdrop == null || item.isPosterBackdrop))
|
||||||
|
D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero
|
||||||
|
val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) 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()
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +120,18 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
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
|
||||||
} }
|
} }
|
||||||
|
val headerPixels = with(density) { headerHeight.roundToPx() }
|
||||||
|
val previousHeaderPixels = remember(list, item.id, density) { intArrayOf(headerPixels) }
|
||||||
|
val compensateHeader = previousHeaderPixels[0] != headerPixels && !shell.navFocused && !list.isScrollInProgress &&
|
||||||
|
list.firstVisibleItemIndex == 0 && list.firstVisibleItemScrollOffset > 0 &&
|
||||||
|
list.layoutInfo.visibleItemsInfo.any { it.key == state.row && kotlin.math.abs(it.offset - with(density) { L.pinnedLine.roundToPx() }) <= 1 }
|
||||||
|
// Compensate in the same remeasure; an asynchronous scroll would chase the animated header.
|
||||||
|
androidx.compose.runtime.SideEffect {
|
||||||
|
if (compensateHeader) list.requestScrollToItem(0, (list.firstVisibleItemScrollOffset + headerPixels - previousHeaderPixels[0]).coerceAtLeast(0))
|
||||||
|
previousHeaderPixels[0] = headerPixels
|
||||||
|
}
|
||||||
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) {
|
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) {
|
||||||
|
if (compensateHeader) return@LaunchedEffect
|
||||||
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]
|
||||||
|
|
@ -154,7 +168,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
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(headerHeight)) {
|
||||||
TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size ->
|
TitleHeader(state, active, if (compactHeader) Modifier.onSizeChanged { size ->
|
||||||
measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap
|
measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap
|
||||||
} else Modifier)
|
} else Modifier)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
||||||
LaunchedEffect(state.column, active, state.data.cast.size) {
|
LaunchedEffect(state.column, active, state.data.cast.size) {
|
||||||
if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size)
|
if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size)
|
||||||
}
|
}
|
||||||
Box(Modifier.height(D.castHeight)) {
|
Box(Modifier.height(D.castHeight).testTag("title-cast")) {
|
||||||
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).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading),
|
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading),
|
||||||
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
||||||
|
|
@ -80,11 +80,11 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
||||||
fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
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) D.episodeCardWidth else WolfskinTokens.Card.posterW
|
||||||
val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH
|
val height = if (episodes) D.episodeCardHeight 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.Shell.stageWidth - L.safeSide * 2 + WolfskinTokens.Card.tileGap) / (D.episodeCardWidth + WolfskinTokens.Card.tileGap)).toInt() 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) D.episodeCardWidth + WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterPitch).toPx() }
|
||||||
LaunchedEffect(state.column, active, titles.size) {
|
LaunchedEffect(state.column, active, titles.size) {
|
||||||
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
|
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
|
||||||
}
|
}
|
||||||
|
|
@ -92,13 +92,13 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
||||||
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, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
|
||||||
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading),
|
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading),
|
||||||
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(if (episodes) WolfskinTokens.Card.tileGap else 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" }) { index ->
|
||||||
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
Box(Modifier.size(width, height).testTag("title-episode-slot-$index").background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||||
}
|
}
|
||||||
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
|
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
|
||||||
TitlePoster(item, active && state.column == index, Modifier.width(width)
|
TitlePoster(item, active && state.column == index, Modifier.width(width).then(if (episodes) Modifier.testTag("title-episode-slot-$index") else Modifier)
|
||||||
.onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) },
|
.onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) },
|
||||||
episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
|
episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
|
||||||
}
|
}
|
||||||
|
|
@ -117,17 +117,16 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
||||||
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true,
|
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true,
|
||||||
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
||||||
} else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() },
|
} else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() },
|
||||||
WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched,
|
D.episodeCardWidth, D.episodeCardHeight, watched = item.watched,
|
||||||
progress = item.progress.takeIf { it < 1f } ?: 0f) {
|
progress = item.progress.takeIf { it < 1f } ?: 0f) {
|
||||||
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.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)
|
if (focused && item.overview.isNotBlank()) Text(item.overview, 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)
|
CardMetadata(null, item.episodeLabel + if (item.runtimeSeconds > 0) " · ${item.runtime}" else "", item.watched && item.resumeSeconds <= 0)
|
||||||
if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0)
|
|
||||||
}
|
}
|
||||||
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) {
|
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut)), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut))) {
|
||||||
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,13 @@ class TitleState(
|
||||||
var sheet by mutableStateOf<TitleSheet?>(null)
|
var sheet by mutableStateOf<TitleSheet?>(null)
|
||||||
private set
|
private set
|
||||||
var sheetIndex by mutableStateOf(0)
|
var sheetIndex by mutableStateOf(0)
|
||||||
|
private var mediaInfoFromMenu = false
|
||||||
|
val menuOptions: List<TitleOption> by derivedStateOf { buildList {
|
||||||
|
if (target != null) add(TitleOption("info", "Media info", "", icon = false))
|
||||||
|
if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) {
|
||||||
|
add(TitleOption("add", "Add for someone", "users", icon = false))
|
||||||
|
}
|
||||||
|
} }
|
||||||
var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
|
var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
|
||||||
var versionId by mutableStateOf(initial.selectedVersion)
|
var versionId by mutableStateOf(initial.selectedVersion)
|
||||||
var audioIndex by mutableStateOf(initial.selectedAudio)
|
var audioIndex by mutableStateOf(initial.selectedAudio)
|
||||||
|
|
@ -65,6 +72,7 @@ class TitleState(
|
||||||
if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
|
if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
|
||||||
if (row !in rows) { row = "header"; column = 0 }
|
if (row !in rows) { row = "header"; column = 0 }
|
||||||
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
|
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
|
||||||
|
if (sheet == TitleSheet.Menu && menuOptions.isEmpty()) sheet = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pointer focus and D-pad use the same row memory. */
|
/** Pointer focus and D-pad use the same row memory. */
|
||||||
|
|
@ -140,7 +148,7 @@ class TitleState(
|
||||||
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
||||||
fun back(shell: ShellState) {
|
fun back(shell: ShellState) {
|
||||||
when {
|
when {
|
||||||
sheet != null -> sheet = null
|
sheet != null -> closeSheet()
|
||||||
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
||||||
else -> onAction(TitleAction.Pop)
|
else -> onAction(TitleAction.Pop)
|
||||||
}
|
}
|
||||||
|
|
@ -182,13 +190,12 @@ class TitleState(
|
||||||
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
||||||
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
||||||
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
||||||
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo }
|
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
|
||||||
"watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched()
|
"watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched()
|
||||||
"trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) }
|
"trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) }
|
||||||
"seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0))
|
|
||||||
"retry" -> onAction(TitleAction.Retry)
|
"retry" -> onAction(TitleAction.Retry)
|
||||||
"back" -> onAction(TitleAction.Pop)
|
"back" -> onAction(TitleAction.Pop)
|
||||||
"request", "other", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
|
"request", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
|
||||||
"status" -> onAction(TitleAction.Requests(data.item))
|
"status" -> onAction(TitleAction.Requests(data.item))
|
||||||
"hide", "unhide" -> {
|
"hide", "unhide" -> {
|
||||||
val hidden = id == "hide"
|
val hidden = id == "hide"
|
||||||
|
|
@ -205,9 +212,10 @@ class TitleState(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sheetKey(key: ShellKey) {
|
private fun sheetKey(key: ShellKey) {
|
||||||
if (key == ShellKey.Back) { sheet = null; return }
|
if (key == ShellKey.Back) { closeSheet(); return }
|
||||||
val current = version
|
val current = version
|
||||||
val size = when (sheet) {
|
val size = when (sheet) {
|
||||||
|
TitleSheet.Menu -> menuOptions.size
|
||||||
TitleSheet.Version -> data.versions.size
|
TitleSheet.Version -> data.versions.size
|
||||||
TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2
|
TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2
|
||||||
TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2
|
TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2
|
||||||
|
|
@ -217,6 +225,16 @@ class TitleState(
|
||||||
ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0)
|
ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0)
|
||||||
ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1)
|
ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1)
|
||||||
ShellKey.Ok -> when (sheet) {
|
ShellKey.Ok -> when (sheet) {
|
||||||
|
TitleSheet.Menu -> menuOptions.getOrNull(sheetIndex)?.let { option ->
|
||||||
|
if (option.id == "info") {
|
||||||
|
mediaInfoFromMenu = true
|
||||||
|
sheetIndex = 0
|
||||||
|
sheet = TitleSheet.MediaInfo
|
||||||
|
} else {
|
||||||
|
sheet = null
|
||||||
|
activateOption(option.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
TitleSheet.Version -> {
|
TitleSheet.Version -> {
|
||||||
val selected = data.versions[sheetIndex]
|
val selected = data.versions[sheetIndex]
|
||||||
versionId = selected.id
|
versionId = selected.id
|
||||||
|
|
@ -234,11 +252,19 @@ class TitleState(
|
||||||
onAction(TitleAction.Hide(data.item, false))
|
onAction(TitleAction.Hide(data.item, false))
|
||||||
} else onAction(TitleAction.Pop)
|
} else onAction(TitleAction.Pop)
|
||||||
}
|
}
|
||||||
else -> sheet = null
|
else -> closeSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun closeSheet() {
|
||||||
|
if (sheet == TitleSheet.MediaInfo && mediaInfoFromMenu) {
|
||||||
|
mediaInfoFromMenu = false
|
||||||
|
sheetIndex = 0
|
||||||
|
sheet = TitleSheet.Menu
|
||||||
|
} else sheet = null
|
||||||
|
}
|
||||||
|
|
||||||
private fun selectTrack(current: TitleVersion) {
|
private fun selectTrack(current: TitleVersion) {
|
||||||
when {
|
when {
|
||||||
sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index
|
sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index
|
||||||
|
|
@ -274,14 +300,12 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
|
||||||
val series = data.watchTarget.kind == "tv"
|
val series = data.watchTarget.kind == "tv"
|
||||||
add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched))
|
add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched))
|
||||||
if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer"))
|
if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer"))
|
||||||
if (target != null) add(TitleOption("more", "More", "more", "Media info"))
|
if (target != null || (data.connection == TitleConnection.SignedIn && data.requestsAllowed)) add(TitleOption("menu", "Menu", "more"))
|
||||||
if (item.kind == "tv" && item.seasons > 0) add(TitleOption("seasons", "Seasons", "tv", icon = false))
|
|
||||||
if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) add(TitleOption("add", "Add for someone", "users", icon = false))
|
|
||||||
} else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) {
|
} else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) {
|
||||||
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
||||||
} else {
|
} else {
|
||||||
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false))
|
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false))
|
||||||
add(TitleOption("other", "For someone else", "users", icon = false))
|
add(TitleOption("menu", "Menu", "more"))
|
||||||
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false))
|
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false))
|
||||||
else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false))
|
else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,7 @@ object WolfskinTokens {
|
||||||
const val heroRequestHeight = 1080
|
const val heroRequestHeight = 1080
|
||||||
const val heroPosterRequestWidth = 420
|
const val heroPosterRequestWidth = 420
|
||||||
const val heroPosterRequestHeight = 630
|
const val heroPosterRequestHeight = 630
|
||||||
const val episodeRequestWidth = 400
|
const val episodeRequestWidth = 448
|
||||||
const val episodeRequestHeight = 280
|
const val episodeRequestHeight = 280
|
||||||
const val castRequestWidth = 200
|
const val castRequestWidth = 200
|
||||||
const val castRequestHeight = 200
|
const val castRequestHeight = 200
|
||||||
|
|
@ -451,8 +451,11 @@ object WolfskinTokens {
|
||||||
const val sheetOpacity = 0.5f
|
const val sheetOpacity = 0.5f
|
||||||
}
|
}
|
||||||
object Title {
|
object Title {
|
||||||
|
val episodeCardWidth = 224.0.dp
|
||||||
val episodeCardHeight = 140.0.dp
|
val episodeCardHeight = 140.0.dp
|
||||||
|
val actionMenuWidth = 260.0.dp
|
||||||
val seriesHeaderGap = 20.0.dp
|
val seriesHeaderGap = 20.0.dp
|
||||||
|
val posterCastGap = 10.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
|
||||||
|
|
|
||||||
|
|
@ -44,20 +44,19 @@ class MotionSurfaceTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
|
||||||
private val viewer = ShellPerson("justin", "Justin", "J", false)
|
private val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
|
|
||||||
@Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() {
|
@Test fun reducedMotionReadinessSnapsAndRemovingItRestoresTheCollapsedStack() {
|
||||||
val shell = shell()
|
val shell = shell()
|
||||||
val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview")
|
val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview")
|
||||||
val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview")
|
val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview")
|
||||||
val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
|
val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
|
||||||
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides true) { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } }
|
||||||
rule.waitForIdle()
|
|
||||||
val before = bounds()
|
val before = bounds()
|
||||||
for (stage in listOf("Ready", "Downloading", null)) {
|
assertEquals(0f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
|
||||||
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Mindel", item = series.copy(ratings = listOf(LibraryRating("rt", 88.0))))) }
|
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Ready", requestActor = "Mindel")) }
|
||||||
rule.mainClock.advanceTimeBy(3400)
|
assertEquals(56f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
|
||||||
rule.waitForIdle()
|
bounds().zip(before).forEach { (after, initial) -> assertEquals(56f, after.top - initial.top, 1f) }
|
||||||
assertEquals("Late metadata cannot change reserved header geometry", before, bounds())
|
rule.runOnIdle { state.update(state.data.copy(requestedStage = null)) }
|
||||||
}
|
assertEquals(before, bounds())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {
|
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.MotionDurationScale
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import org.junit.Assert.*
|
||||||
|
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
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Readiness changes move real title content continuously, without reserving an empty band. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class TitleReadinessMotionTest {
|
||||||
|
private val animatorScale = object : MotionDurationScale { override var scaleFactor by mutableFloatStateOf(1f) }
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
|
||||||
|
private lateinit var state: TitleState
|
||||||
|
|
||||||
|
private fun render() {
|
||||||
|
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
|
val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") }
|
||||||
|
val series = TitleItem("series", "A series", "tv", seasons = 1, held = true, overview = "A series overview")
|
||||||
|
val episode = TitleItem("episode", "Pilot", "episode", held = true, season = 1, episode = 1, overview = "Episode overview")
|
||||||
|
state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
|
||||||
|
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
||||||
|
rule.mainClock.advanceTimeBy(500)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun positions(): List<Float> = listOf("title-readiness-slot", "title-overview", "title-actions", "title-shelf:episodes").mapIndexed { index, tag ->
|
||||||
|
val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
|
||||||
|
if (index == 0) bounds.height else bounds.top
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun frames(stage: String?, name: String, count: Int = 22): List<List<Float>> {
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-readiness-motion/$name").apply { mkdirs() }
|
||||||
|
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Justin")); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
|
val values = List(count) { index ->
|
||||||
|
rule.waitForIdle()
|
||||||
|
positions().also {
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "${index.toString().padStart(2, '0')}.png").absolutePath)
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
File(directory, "samples.csv").writeText("timeMs,slotHeightPx,overviewTopPx,actionsTopPx,episodesTopPx\n" + values.mapIndexed { index, row -> "${index * 16},${row.joinToString(",")}" }.joinToString("\n"))
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun lateReadinessExpansionAndRemovalStayMonotonicAndMoveTheWholeStack() {
|
||||||
|
render()
|
||||||
|
val collapsed = positions()
|
||||||
|
assertEquals(0f, collapsed[0], 0.1f)
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val opening = frames("Ready", "expand")
|
||||||
|
val expanded = opening.last()
|
||||||
|
assertEquals(56f, expanded[0], 0.1f)
|
||||||
|
expanded.drop(1).zip(collapsed.drop(1)).forEach { (after, before) -> assertEquals(56f, after - before, 1f) }
|
||||||
|
val closing = frames(null, "collapse")
|
||||||
|
assertEquals(collapsed, closing.last())
|
||||||
|
for ((samples, direction) in listOf(opening to 1f, closing to -1f)) {
|
||||||
|
samples.zipWithNext().forEach { (before, after) -> before.zip(after).forEach { (first, second) ->
|
||||||
|
assertTrue("Readiness content must not reverse or overshoot: $samples", (second - first) * direction >= -0.1f)
|
||||||
|
assertTrue("Height change must not jump in one frame", kotlin.math.abs(second - first) < 56f)
|
||||||
|
} }
|
||||||
|
samples.forEach { assertTrue(it[0] in 0f..56f) }
|
||||||
|
}
|
||||||
|
assertFalse(state.options.any { it.id == "seasons" })
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aPinnedEpisodeShelfDoesNotBounceWhenReadinessChangesAboveIt() {
|
||||||
|
render()
|
||||||
|
rule.runOnIdle { state.focus("episodes", 0) }
|
||||||
|
rule.mainClock.advanceTimeBy(500)
|
||||||
|
rule.waitForIdle()
|
||||||
|
val pinned = positions().last()
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
for (samples in listOf(frames("Ready", "pinned", count = 34), frames(null, "pinned-collapse", count = 34))) {
|
||||||
|
samples.forEach { assertEquals("A header change cannot bounce a pinned focused shelf", pinned, it.last(), 1f) }
|
||||||
|
}
|
||||||
|
assertEquals("episodes", state.row)
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ class WolfskinImagesTest {
|
||||||
Surface("grid/Home/Discover/person poster", 256, 384, "w342"),
|
Surface("grid/Home/Discover/person poster", 256, 384, "w342"),
|
||||||
Surface("Home tile", 400, 225, "w780"),
|
Surface("Home tile", 400, 225, "w780"),
|
||||||
Surface("Home wide", 544, 306, "w780"),
|
Surface("Home wide", 544, 306, "w780"),
|
||||||
Surface("episode still", 400, 280, "w780"),
|
Surface("episode still", 448, 280, "w780"),
|
||||||
Surface("cast portrait", 200, 200, "w342"),
|
Surface("cast portrait", 200, 200, "w342"),
|
||||||
Surface("person portrait", 240, 240, "w342"),
|
Surface("person portrait", 240, 240, "w342"),
|
||||||
Surface("hero poster", 420, 630, "w342", true),
|
Surface("hero poster", 420, 630, "w342", true),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
{
|
{
|
||||||
"title": {
|
"title": {
|
||||||
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: episode art height +24.4%"},
|
"episodeCardWidth": {"dp": 224, "px": 448, "source": "tv-round2: episode width +12%"},
|
||||||
|
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: preserve taller episode cards"},
|
||||||
|
"actionMenuWidth": {"dp": 260, "px": 520, "source": "tv-round2: compact title action menu"},
|
||||||
"seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"},
|
"seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"},
|
||||||
|
"posterCastGap": {"dp": 10, "px": 20, "source": "tv-round2: minimum poster-to-cast clearance"},
|
||||||
"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"},
|
||||||
|
|
|
||||||
|
|
@ -290,8 +290,11 @@
|
||||||
--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-episodeCardWidth: 448.0px;
|
||||||
--title-episodeCardHeight: 280.0px;
|
--title-episodeCardHeight: 280.0px;
|
||||||
|
--title-actionMenuWidth: 520.0px;
|
||||||
--title-seriesHeaderGap: 40.0px;
|
--title-seriesHeaderGap: 40.0px;
|
||||||
|
--title-posterCastGap: 20.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;
|
||||||
|
|
|
||||||
|
|
@ -1543,9 +1543,9 @@
|
||||||
"source": "feedback-2026-11-06: largest upright hero poster slot"
|
"source": "feedback-2026-11-06: largest upright hero poster slot"
|
||||||
},
|
},
|
||||||
"episodeRequest": {
|
"episodeRequest": {
|
||||||
"px": [400, 280],
|
"px": [448, 280],
|
||||||
"decode": "ARGB_8888",
|
"decode": "ARGB_8888",
|
||||||
"source": "feedback-2026-11-06: taller episode cards"
|
"source": "tv-round2: wider episode cards at their preserved height"
|
||||||
},
|
},
|
||||||
"castRequest": {
|
"castRequest": {
|
||||||
"px": [
|
"px": [
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token
|
||||||
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
|
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
|
||||||
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
|
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
|
||||||
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
|
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
|
||||||
3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
0d69fccfbfada103268bfc38186205cf5f94daeb345a00a6aec585609e7ddb1e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
|
98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json
|
||||||
d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css
|
a68f336e8b4373e97a7acaeb02383b4a40dc9f7cc6f6e5413341f14fb2d308eb tokens.css
|
||||||
ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json
|
b1cf8f8caea9bfacb7994e0ba39e71ffab33452f6f5227a524ba71874b045b4d tokens.json
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue