Round 2 release candidate 3: polish lanes A-E, season reel and trailer return #82
20 changed files with 974 additions and 83 deletions
|
|
@ -1,6 +1,9 @@
|
|||
package com.github.damontecres.wholphin
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -66,6 +69,9 @@ fun MainContent(
|
|||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
transitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
|
||||
popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
|
||||
predictivePopTransitionSpec = { _ -> (EnterTransition.None togetherWith ExitTransition.None).using(null) },
|
||||
entryDecorators =
|
||||
listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class ConductarrHomeSurfaceTest {
|
|||
coEvery { home.readLibrary(user.id) } returns HomeLibrary()
|
||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
||||
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession())
|
||||
try {
|
||||
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false }
|
||||
rule.setContent { WolfskinTheme {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.model.*
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import io.mockk.*
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Real Home actions keep button geometry, reject duplicate starts and launch only after the black handoff. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class HomeHeroStartingTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var model: WolfskinHomeViewModel
|
||||
private val conductarr = ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())
|
||||
private val session = WolfskinSession()
|
||||
private val navigation = NavigationManager()
|
||||
private val facade = mockk<HomeReadFacade>()
|
||||
private val release = CompletableDeferred<BaseItemDto?>()
|
||||
private val visible = mutableStateOf(true)
|
||||
private var previousFixtures = false
|
||||
private val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
|
||||
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null)
|
||||
private val shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false; pickerLaunch = false }
|
||||
|
||||
@After fun dispose() {
|
||||
release.complete(null)
|
||||
if (::model.isInitialized) model.viewModelScope.cancel()
|
||||
conductarr.close()
|
||||
ConductarrPreference.updateFixtures(previousFixtures)
|
||||
}
|
||||
|
||||
private fun mount(reduced: Boolean = false) {
|
||||
ConductarrPreference.initialize(rule.activity)
|
||||
previousFixtures = ConductarrPreference.fixtures
|
||||
ConductarrPreference.updateFixtures(true)
|
||||
coEvery { facade.preparePlayback(user.id, any()) } coAnswers { release.await() }
|
||||
every { facade.audiences } returns null
|
||||
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), conductarr, session)
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced, LocalHomePlaybackStart provides session.playback) { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
|
||||
if (visible.value && navigation.backStack.last() !is Destination.Playback)
|
||||
ShellSurface(shell) { modifier -> HomeDestination(shell, server, user, modifier, model) }
|
||||
PlaybackHandoffVeil(session.playback)
|
||||
}
|
||||
} } }
|
||||
await { model.snapshot.rows.firstOrNull()?.let { !it.skeleton && it.watchReady } == true }
|
||||
rule.mainClock.advanceTimeBy(1200)
|
||||
rule.mainClock.autoAdvance = false
|
||||
}
|
||||
|
||||
@Test fun playSaysStartingThenFadesToBlackBeforeThePlayer() = successfulStart(false)
|
||||
@Test fun reducedMotionCapsBothFadesAndStillStartsOnce() = successfulStart(true)
|
||||
|
||||
private fun successfulStart(reduced: Boolean) {
|
||||
mount(reduced)
|
||||
val width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width
|
||||
press()
|
||||
rule.mainClock.advanceTimeBy(32)
|
||||
rule.onNodeWithText("Starting…").assertIsDisplayed()
|
||||
assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f)
|
||||
press()
|
||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
|
||||
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 pixel = try { image.getPixel(600, 500) } finally { image.recycle() }
|
||||
samples.add(phase + "," + index * 16 + "," + pixel + "," + navigation.backStack.size)
|
||||
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||
}
|
||||
repeat(13) { frame("home-start", it); rule.mainClock.advanceTimeByFrame() }
|
||||
val id = UUID.randomUUID()
|
||||
release.complete(BaseItemDto(id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE))
|
||||
await { session.playback.launch != null }
|
||||
val duration = motionDuration(WolfskinTokens.Motion.base, reduced)
|
||||
var elapsed = 0
|
||||
val fading = mutableListOf<Int>()
|
||||
while (navigation.backStack.last() !is Destination.Playback && elapsed <= duration + 48) {
|
||||
fading.add(frame("home-black", elapsed / 16))
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
elapsed += 16
|
||||
}
|
||||
assertTrue("The player cannot open before the token-bounded fade", elapsed >= duration)
|
||||
assertTrue("The page must visibly fade rather than wait and cut", fading.first() > fading.last())
|
||||
assertTrue("The black handoff cannot flicker", fading.zipWithNext().all { (before, after) -> after <= before })
|
||||
assertTrue("The final painted frame must be near black", fading.last() <= fading.first() / 10)
|
||||
assertEquals(id, (navigation.backStack.last() as Destination.Playback).itemId)
|
||||
assertEquals(2, navigation.backStack.size)
|
||||
assertNull(session.playback.starting)
|
||||
assertNull(session.playback.launch)
|
||||
coVerify(exactly = 1) { facade.preparePlayback(user.id, any()) }
|
||||
File(directory, "samples.csv").writeText("phase,timeMs,pixel,stackSize\n" + samples.joinToString("\n"))
|
||||
rule.runOnIdle { navigation.goBack() }
|
||||
rule.mainClock.advanceTimeBy(100)
|
||||
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
|
||||
rule.onNodeWithText("Starting…").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test fun failedPreparationRestoresResumeAndShowsTheExistingRecoveryToast() {
|
||||
mount()
|
||||
press()
|
||||
rule.mainClock.advanceTimeBy(200)
|
||||
rule.onNodeWithText("Starting…").assertIsDisplayed()
|
||||
release.completeExceptionally(IOException("offline"))
|
||||
await { model.toast != null }
|
||||
rule.mainClock.advanceTimeBy(200)
|
||||
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
|
||||
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
|
||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||
}
|
||||
|
||||
@Test fun leavingHomeCancelsPreparationAndCannotLaunchALateResult() {
|
||||
mount()
|
||||
press()
|
||||
rule.mainClock.advanceTimeBy(32)
|
||||
rule.runOnIdle { visible.value = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeBy(32)
|
||||
release.complete(BaseItemDto(id = UUID.randomUUID(), type = org.jellyfin.sdk.model.api.BaseItemKind.MOVIE))
|
||||
await { session.playback.starting == null }
|
||||
rule.mainClock.advanceTimeBy(400)
|
||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||
assertNull(session.playback.launch)
|
||||
}
|
||||
|
||||
private fun press() = rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) }
|
||||
private fun await(condition: () -> Boolean) {
|
||||
rule.waitUntil(5000) {
|
||||
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame()
|
||||
condition()
|
||||
}
|
||||
rule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ class HomeRecoveryTest {
|
|||
val facade = mockk<HomeReadFacade>()
|
||||
coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
|
||||
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
|
||||
started.await()
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
|
|
@ -67,7 +67,7 @@ class HomeRecoveryTest {
|
|||
coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||
coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline")
|
||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
|
||||
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
|
||||
startedA.await()
|
||||
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||
|
|
@ -88,7 +88,7 @@ class HomeRecoveryTest {
|
|||
val cache = mockk<HomeLibraryCache>()
|
||||
every { cache.read(any()) } returns null
|
||||
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
|
||||
model.loadLibrary(server, personA, viewerA)
|
||||
assertTrue(model.snapshot.jellyfinAvailable)
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
|
|
@ -101,7 +101,7 @@ class HomeRecoveryTest {
|
|||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
|
||||
model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name"))
|
||||
assertTrue(model.snapshot.jellyfinAvailable)
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest {
|
|||
}
|
||||
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
||||
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
|
||||
val facade = mockk<LibraryReadFacade>()
|
||||
rule.setContent {
|
||||
val destination = navigation.backStack.last()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.ext.junit.rules.ActivityScenarioRule
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.*
|
||||
import com.github.damontecres.wholphin.preferences.*
|
||||
import com.github.damontecres.wholphin.services.*
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import io.mockk.*
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Each navigation change retires the outgoing shell in the first rendered frame. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class NavigationCutTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var host: PolishAppHost
|
||||
@After fun dispose() { if (::host.isInitialized) host.close() }
|
||||
|
||||
@Test fun titleBackAndTabReplaceTheOutgoingScreenInOneFrame() = cuts(false)
|
||||
@Test fun reducedMotionKeepsTheSameSingleFrameCuts() = cuts(true)
|
||||
|
||||
private fun cuts(reduced: Boolean) {
|
||||
host = PolishAppHost(rule)
|
||||
host.mount(picker = false, reduced = reduced)
|
||||
rule.mainClock.autoAdvance = false
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
|
||||
val samples = mutableListOf<String>()
|
||||
val homeCard = SemanticsMatcher("Home card") {
|
||||
it.config.contains(androidx.compose.ui.semantics.SemanticsProperties.TestTag) &&
|
||||
it.config[androidx.compose.ui.semantics.SemanticsProperties.TestTag].startsWith("home-card:")
|
||||
}
|
||||
fun strip(phase: String) {
|
||||
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
repeat(21) { index ->
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
val clock = rule.mainClock.currentTime
|
||||
rule.onRoot().captureRoboImage(File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png").absolutePath)
|
||||
assertEquals("Native drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime)
|
||||
val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size
|
||||
val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size
|
||||
val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size
|
||||
assertEquals("Only the live destination may own a nav bar", 1, shells)
|
||||
assertEquals(if (phase == "open-title") 1 else 0, titles)
|
||||
if (phase != "back") assertEquals("Outgoing Home cards cannot survive the cut", 0, cards)
|
||||
else assertTrue("Back immediately paints Home's shelves", cards > 0)
|
||||
samples.add(phase + "," + index * 16 + "," + shells + "," + titles + "," + cards)
|
||||
}
|
||||
}
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L); pressKey(Key.DirectionCenter, 0L) }
|
||||
strip("open-title")
|
||||
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
|
||||
strip("back")
|
||||
rule.runOnIdle { host.session.state!!.focusNav(2); host.session.state!!.key(ShellKey.Ok) }
|
||||
strip("tab")
|
||||
File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards\n" + samples.joinToString("\n"))
|
||||
}
|
||||
|
||||
@Test fun playbackVeilIgnoresKeysAndBackThenReturnsBackOwnershipToHome() {
|
||||
host = PolishAppHost(rule)
|
||||
host.mount(picker = false)
|
||||
rule.mainClock.autoAdvance = false
|
||||
var launches = 0
|
||||
rule.runOnIdle { host.session.playback.begin("hero-play"); host.session.playback.ready { launches++ } }
|
||||
rule.mainClock.advanceTimeBy(32)
|
||||
val revision = host.session.state!!.inputRevision
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter); pressKey(Key.Escape) }
|
||||
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
|
||||
assertEquals(revision, host.session.state!!.inputRevision)
|
||||
assertEquals(0, launches)
|
||||
rule.mainClock.advanceTimeBy(200)
|
||||
assertEquals(1, launches)
|
||||
assertNull(host.session.playback.starting)
|
||||
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
|
||||
assertTrue(host.session.state!!.navFocused)
|
||||
assertFalse(rule.activity.isFinishing)
|
||||
}
|
||||
|
||||
@Test fun backBeforeTheBlackVeilCancelsThePendingStartWithoutExitingHome() {
|
||||
host = PolishAppHost(rule)
|
||||
host.mount(picker = false)
|
||||
rule.runOnIdle { host.session.playback.begin("hero-play") }
|
||||
rule.mainClock.advanceTimeBy(32)
|
||||
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
|
||||
assertNull(host.session.playback.starting)
|
||||
assertTrue(host.session.state!!.navFocused)
|
||||
assertFalse(rule.activity.isFinishing)
|
||||
}
|
||||
|
||||
@Test fun setupHostReplacesServerAndUserScreensInOneFrame() = setupCuts(false)
|
||||
@Test fun reducedMotionSetupHostAlsoCutsInOneFrame() = setupCuts(true)
|
||||
|
||||
private fun setupCuts(reduced: Boolean) {
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
|
||||
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null)
|
||||
val stack = mutableStateListOf<SetupDestination>(SetupDestination.ServerList)
|
||||
val screensaver = mockk<ScreensaverService>()
|
||||
every { screensaver.state } returns kotlinx.coroutines.flow.MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { androidx.tv.material3.MaterialTheme {
|
||||
com.github.damontecres.wholphin.MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
mockk(relaxed = true), screensaver,
|
||||
Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
|
||||
setupInterceptor = { destination, modifier ->
|
||||
androidx.tv.material3.Text(destination.javaClass.simpleName, modifier)
|
||||
true
|
||||
})
|
||||
} } }
|
||||
rule.mainClock.autoAdvance = false
|
||||
for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) {
|
||||
val previous = stack.single().javaClass.simpleName
|
||||
rule.runOnIdle { stack[0] = destination; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
val clock = rule.mainClock.currentTime
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, destination.javaClass.simpleName + ".png").absolutePath)
|
||||
assertEquals("Native setup drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime)
|
||||
rule.onNodeWithText(previous).assertDoesNotExist()
|
||||
rule.onAllNodesWithText(destination.javaClass.simpleName).assertCountEquals(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Real shell integration with fixture data and an injectable saved-user switch. */
|
||||
internal class PolishAppHost(private val rule: AndroidComposeTestRule<ActivityScenarioRule<ComponentActivity>, ComponentActivity>) : AutoCloseable {
|
||||
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||
var users = listOf("Justin", "Mindel", "Shared").mapIndexed { index, name ->
|
||||
JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-00000000000${index + 2}"), serverId = server.id, name = name, accessToken = null)
|
||||
}
|
||||
val session = WolfskinSession()
|
||||
val navigation = NavigationManager()
|
||||
val repository = mockk<ServerRepository>(relaxed = true)
|
||||
val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||
private val conductarr = ConductarrSession(MemoryCacheStore())
|
||||
private val models = mutableListOf<WolfskinShellViewModel>()
|
||||
private var oldFixtures = false
|
||||
private var oldScreens = true
|
||||
private var current by mutableStateOf(users.first())
|
||||
val model get() = models.last()
|
||||
var reduceMotion by mutableStateOf(false)
|
||||
|
||||
fun showUser(user: JellyfinUser) { current = user }
|
||||
|
||||
fun mount(picker: Boolean = true, reduced: Boolean = false, fixtures: Boolean = true) {
|
||||
reduceMotion = reduced
|
||||
ConductarrPreference.initialize(rule.activity)
|
||||
WolfskinScreenPreference.initialize(rule.activity)
|
||||
oldFixtures = ConductarrPreference.fixtures
|
||||
oldScreens = WolfskinScreenPreference.enabled
|
||||
ConductarrPreference.updateFixtures(fixtures)
|
||||
WolfskinScreenPreference.enabled = true
|
||||
coEvery { repository.serverDao.getServer(server.id) } answers { JellyfinServerUsers(server, users) }
|
||||
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
|
||||
every { setup.navigateTo(any()) } answers {
|
||||
val destination = firstArg<SetupDestination>()
|
||||
if (destination is SetupDestination.AppContent) current = destination.current.user
|
||||
}
|
||||
rule.setContent {
|
||||
CompositionLocalProvider(LocalReducedMotion provides reduceMotion) {
|
||||
key(current.id) {
|
||||
val model = remember {
|
||||
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
|
||||
mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true),
|
||||
org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
|
||||
}
|
||||
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
|
||||
WolfskinApp(server, current, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), model,
|
||||
homeContent = { shell, _, user, modifier ->
|
||||
val snapshot = remember(user.id) { HomeFixture(rule.activity).snapshot(user.name!!.lowercase()) }
|
||||
HomeScreen(shell, snapshot, modifier) { action ->
|
||||
if (action is HomeAction.Details || action is HomeAction.Play)
|
||||
navigation.navigateTo(Destination.Wolfskin(titleRoute("Locke")))
|
||||
}
|
||||
},
|
||||
titleContent = { route, shell, _ ->
|
||||
val title = remember(route) {
|
||||
val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { org.json.JSONObject(it.readText()) })
|
||||
TitleState(fixture.title("Locke", shell.person.key), shell.person) {
|
||||
if (it == TitleAction.Pop) navigation.goBack()
|
||||
}
|
||||
}
|
||||
androidx.activity.compose.BackHandler { title.back(shell) }
|
||||
TitleSurface(title, shell)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
await { session.state?.people?.size == users.size && model.signedInPerson == "justin" }
|
||||
rule.runOnIdle { session.state!!.pickerVisible = picker; session.state!!.pickerLaunch = picker }
|
||||
rule.mainClock.advanceTimeBy(1200)
|
||||
}
|
||||
|
||||
fun await(condition: () -> Boolean) {
|
||||
rule.waitUntil(10_000) {
|
||||
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame()
|
||||
condition()
|
||||
}
|
||||
rule.waitForIdle()
|
||||
}
|
||||
override fun close() {
|
||||
models.forEach { it.viewModelScope.cancel() }
|
||||
conductarr.close()
|
||||
ConductarrPreference.updateFixtures(oldFixtures)
|
||||
WolfskinScreenPreference.enabled = oldScreens
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import io.mockk.*
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** A frozen, inert picker protects the selected identity across an asynchronous app replacement. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class PersonCommitTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var host: PolishAppHost
|
||||
private val release = CompletableDeferred<Unit>()
|
||||
@After fun dispose() { release.complete(Unit); if (::host.isInitialized) host.close() }
|
||||
|
||||
@Test fun choosingMindelCoversTheSwitchUntilTheirHomeCanPaint() = heldSwitch(false)
|
||||
@Test fun reducedMotionUsesOnlyTheShortFullPickerFade() = heldSwitch(true)
|
||||
|
||||
private fun heldSwitch(reduced: Boolean) {
|
||||
host = PolishAppHost(rule)
|
||||
host.mount(reduced = reduced)
|
||||
coEvery { host.repository.changeUser(host.server, any()) } coAnswers {
|
||||
release.await()
|
||||
CurrentUser(host.server, secondArg())
|
||||
}
|
||||
var pendingUser: JellyfinUser? = null
|
||||
every { host.setup.navigateTo(any()) } answers { pendingUser = (firstArg() as SetupDestination.AppContent).current.user }
|
||||
rule.mainClock.autoAdvance = false
|
||||
press(listOf(Key.DirectionRight))
|
||||
rule.mainClock.advanceTimeBy(400)
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/P08/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "picker.png").absolutePath)
|
||||
val commit = choose(listOf(Key.DirectionCenter))
|
||||
val origin = commit.startFrameNanos
|
||||
val clockOrigin = rule.mainClock.currentTime
|
||||
val samples = mutableListOf<String>()
|
||||
val others = mutableListOf<Int>()
|
||||
val chosen = mutableListOf<Int>()
|
||||
val bg = WolfskinTokens.Color.bg.toArgb()
|
||||
repeat(31) { index ->
|
||||
val timeMs = rule.mainClock.currentTime - clockOrigin
|
||||
assertEquals("Frame strips advance only in 16 ms steps", index * 16L, timeMs)
|
||||
rule.onNodeWithText("Sign in as", substring = true).assertDoesNotExist()
|
||||
val file = File(directory, "commit-" + index.toString().padStart(2, '0') + ".png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
assertEquals("Capturing a frame cannot advance animation time", timeMs, rule.mainClock.currentTime - clockOrigin)
|
||||
val image = BitmapFactory.decodeFile(file.absolutePath)
|
||||
try {
|
||||
val other = image.getPixel(600, 500)
|
||||
val selected = image.getPixel(900, 500)
|
||||
others.add(android.graphics.Color.red(other))
|
||||
chosen.add(selected)
|
||||
if (timeMs >= if (reduced) 80 else 320) {
|
||||
val pixels = IntArray(image.width * image.height)
|
||||
image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height)
|
||||
assertTrue("Only opaque background may remain while sign-in is pending", pixels.all { it == bg })
|
||||
}
|
||||
samples.add("$timeMs,$other,$selected")
|
||||
} finally { image.recycle() }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
assertTrue("The nonchosen person must fade monotonically to background", others.zipWithNext().all { (before, after) -> after <= before })
|
||||
assertNotEquals(others.first(), others.last())
|
||||
if (!reduced) assertTrue("The chosen avatar stays intact throughout phase one", chosen.take(10).all { it == chosen.first() })
|
||||
File(directory, "samples.csv").writeText("timeMs,other,chosen\n" + samples.joinToString("\n"))
|
||||
val shell = host.session.state!!
|
||||
val revision = shell.inputRevision
|
||||
val stack = host.navigation.backStack.toList()
|
||||
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.Escape))
|
||||
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
|
||||
rule.onRoot().performTouchInput { click(androidx.compose.ui.geometry.Offset(600f, 500f)) }
|
||||
assertEquals(revision, shell.inputRevision)
|
||||
assertEquals("mindel", shell.person.key)
|
||||
assertEquals(stack, host.navigation.backStack.toList())
|
||||
if (reduced) {
|
||||
rule.runOnIdle { host.reduceMotion = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
val file = File(directory, "motion-toggle.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val image = BitmapFactory.decodeFile(file.absolutePath)
|
||||
try {
|
||||
assertEquals("A completed veil cannot uncover the chosen person when motion changes", bg, image.getPixel(900, 500))
|
||||
assertEquals(bg, image.getPixel(100, 80))
|
||||
} finally { image.recycle() }
|
||||
}
|
||||
release.complete(Unit)
|
||||
host.await { pendingUser != null }
|
||||
rule.mainClock.advanceTimeBy(160)
|
||||
assertNotNull("Completing switch work cannot uncover an unauthenticated replacement app", host.session.personCommit)
|
||||
rule.runOnIdle { host.showUser(pendingUser!!) }
|
||||
host.await { host.model.signedInPerson == "mindel" && host.session.personCommit == null }
|
||||
assertEquals(origin, commit.startFrameNanos)
|
||||
rule.onAllNodesWithText("Sex and the City")[0].assertIsDisplayed()
|
||||
rule.onRoot().captureRoboImage(File(directory, "home-first-frame.png").absolutePath)
|
||||
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.heroFade.toLong() + 32)
|
||||
rule.onRoot().captureRoboImage(File(directory, "home.png").absolutePath)
|
||||
coVerify(exactly = 1) { host.repository.changeUser(host.server, any()) }
|
||||
}
|
||||
|
||||
@Test fun samePersonRunsTheChoreographyWithoutSwitchingCredentials() {
|
||||
host = PolishAppHost(rule)
|
||||
host.mount()
|
||||
rule.mainClock.autoAdvance = false
|
||||
choose(listOf(Key.DirectionCenter))
|
||||
assertNotNull(host.session.personCommit)
|
||||
finishTimeline()
|
||||
assertNull(host.session.personCommit)
|
||||
rule.onAllNodesWithText("Yu Yu Hakusho")[0].assertIsDisplayed()
|
||||
coVerify(exactly = 0) { host.repository.changeUser(any(), any()) }
|
||||
}
|
||||
|
||||
@Test fun pinProtectedPersonReturnsToSetupWithoutChangingCredentials() = protectedPerson(pin = "protected")
|
||||
@Test fun requireLoginPersonReturnsToSetupWithoutChangingCredentials() = protectedPerson(requireLogin = true)
|
||||
|
||||
private fun protectedPerson(pin: String? = null, requireLogin: Boolean = false) {
|
||||
host = PolishAppHost(rule)
|
||||
host.users = host.users.mapIndexed { index, user -> if (index == 1) user.copy(pin = pin, requireLogin = requireLogin) else user }
|
||||
host.mount(fixtures = false)
|
||||
rule.mainClock.autoAdvance = false
|
||||
choose(listOf(Key.DirectionRight, Key.DirectionCenter))
|
||||
assertNotNull(host.session.personCommit)
|
||||
awaitSwitchWork()
|
||||
verify(exactly = 0) { host.setup.navigateTo(any()) }
|
||||
finishTimeline()
|
||||
assertNull(host.session.personCommit)
|
||||
coVerify(exactly = 0) { host.repository.changeUser(any(), any()) }
|
||||
verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) }
|
||||
}
|
||||
|
||||
@Test fun failedCredentialSwitchRevealsSetupOnlyAfterThePickerIsCovered() {
|
||||
host = PolishAppHost(rule)
|
||||
host.mount(fixtures = false)
|
||||
coEvery { host.repository.changeUser(any(), any()) } throws IOException("switch failed")
|
||||
rule.mainClock.autoAdvance = false
|
||||
choose(listOf(Key.DirectionRight, Key.DirectionCenter))
|
||||
awaitSwitchWork()
|
||||
finishTimeline()
|
||||
assertNull(host.session.personCommit)
|
||||
verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) }
|
||||
}
|
||||
|
||||
private fun choose(keys: List<Key>): PersonCommit {
|
||||
press(keys)
|
||||
val commit = requireNotNull(host.session.personCommit)
|
||||
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeUntil(1000) { commit.startFrameNanos != null }
|
||||
return commit
|
||||
}
|
||||
|
||||
private fun finishTimeline() {
|
||||
repeat(WolfskinTokens.Motion.base * 2 / 16 + 2) {
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitSwitchWork() {
|
||||
rule.waitUntil(5000) {
|
||||
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
host.session.personCommit?.switchEnded == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun press(keys: List<Key>) = rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it, 0L) } }
|
||||
}
|
||||
|
|
@ -219,7 +219,7 @@ class PersonSwitchThreadingTest {
|
|||
coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary()
|
||||
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
|
||||
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, WolfskinSession())
|
||||
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true))
|
||||
try {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class PlayerHandoffTest {
|
|||
@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
|
||||
shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false }
|
||||
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
|
||||
}
|
||||
|
||||
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
|
||||
|
|
@ -78,15 +78,30 @@ class PlayerHandoffTest {
|
|||
}
|
||||
|
||||
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||
val snapshot = HomeFixture(rule.activity).snapshot("mindel")
|
||||
val title = snapshot.rows.first().cards.first()
|
||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||
coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto(
|
||||
id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE,
|
||||
userData = playbackUserData(id, 11_362_740_000L, playCount = 2))
|
||||
val view = shell.view
|
||||
view.cards["hero"] = 0
|
||||
rule.setContent { androidx.compose.runtime.CompositionLocalProvider(LocalHomePlaybackStart provides model.session.playback) { WolfskinTheme {
|
||||
androidx.compose.foundation.layout.Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
|
||||
ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } }
|
||||
PlaybackHandoffVeil(model.session.playback)
|
||||
}
|
||||
} } }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.mainClock.autoAdvance = false
|
||||
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) }
|
||||
awaitModel { navigation.backStack.last() is Destination.Playback }
|
||||
awaitModel { model.session.playback.launch != null }
|
||||
assertTrue("Preparation must not navigate before the veil", navigation.backStack.single() is Destination.Home)
|
||||
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeBy(80)
|
||||
assertTrue("The first half of the fade must still belong to Home", navigation.backStack.single() is Destination.Home)
|
||||
rule.mainClock.advanceTimeBy(192)
|
||||
assertEquals(2, navigation.backStack.size)
|
||||
val destination = navigation.backStack.last() as Destination.Playback
|
||||
assertEquals(id, destination.itemId)
|
||||
assertEquals(1_136_274L, destination.positionMs)
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ class WatchReadTest {
|
|||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L)
|
||||
val session = ConductarrSession(MemoryCacheStore())
|
||||
val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
||||
val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession())
|
||||
try {
|
||||
val loading = launch { model.loadLibrary(server, user, person) }
|
||||
runCurrent()
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class WatchReturnTest {
|
|||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val session = ConductarrSession(MemoryCacheStore())
|
||||
val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
||||
val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession())
|
||||
val oldFixtures = ConductarrPreference.fixtures
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||
|
|
@ -37,6 +38,7 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
private val navigation: NavigationManager,
|
||||
private val seeAll: WolfskinSeeAllLists,
|
||||
private val conductarr: ConductarrSession,
|
||||
val session: WolfskinSession,
|
||||
) : ViewModel() {
|
||||
val conductarrState get() = conductarr.identity
|
||||
val audiences get() = home.audiences
|
||||
|
|
@ -49,6 +51,15 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
private var loadRevision = 0L
|
||||
private var catalogueSnapshot = DiscoverSnapshot()
|
||||
private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null
|
||||
private var playbackJob: kotlinx.coroutines.Job? = null
|
||||
private var playbackRevision = 0L
|
||||
|
||||
/** Leaving Home invalidates late preparation results as well as a pending black handoff. */
|
||||
fun cancelPlayback() {
|
||||
playbackRevision++
|
||||
playbackJob?.cancel()
|
||||
session.playback.fail()
|
||||
}
|
||||
|
||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
|
||||
catalogueJob?.cancel()
|
||||
|
|
@ -164,23 +175,45 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) }
|
||||
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell)
|
||||
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
|
||||
is HomeAction.Play -> viewModelScope.launch {
|
||||
val personKey = shell.person.key
|
||||
val selectedId = shell.person.jellyfinUserId
|
||||
val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
|
||||
shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester)
|
||||
if (!snapshot.jellyfinAvailable || !matchesUser) {
|
||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) }
|
||||
if (item == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs()))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
is HomeAction.Play -> startPlayback(action.title, shell, server, user)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPlayback(title: HomeTitle, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
|
||||
val start = session.playback
|
||||
if (start.starting != null) return
|
||||
playbackJob?.cancel()
|
||||
val revision = ++playbackRevision
|
||||
val personKey = shell.person.key
|
||||
start.begin("hero-play")
|
||||
playbackJob = viewModelScope.launch {
|
||||
if (revision != playbackRevision || start.starting == null) return@launch
|
||||
val selectedId = shell.person.jellyfinUserId
|
||||
val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
|
||||
personKey == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && personKey == conductarr.identity.value?.operator?.requester)
|
||||
if (!snapshot.jellyfinAvailable || !matchesUser) {
|
||||
start.fail()
|
||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, title) }
|
||||
coroutineContext.ensureActive()
|
||||
if (revision != playbackRevision || start.starting == null) return@launch
|
||||
if (item == null) {
|
||||
start.fail()
|
||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
} else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) {
|
||||
start.ready {
|
||||
if (revision == playbackRevision && shell.person.key == personKey) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs()))
|
||||
}
|
||||
} else start.fail()
|
||||
} catch (exception: Exception) {
|
||||
if (revision == playbackRevision && start.starting != null) {
|
||||
start.fail()
|
||||
if (exception !is CancellationException) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
}
|
||||
if (exception is CancellationException) throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -234,6 +267,7 @@ fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUse
|
|||
val identity by model.conductarrState.collectAsState()
|
||||
val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}")
|
||||
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { model.load(server, user, shell.person) }
|
||||
DisposableEffect(shell.person.key, server.id, user.id) { onDispose { model.cancelPlayback() } }
|
||||
val view = shell.rememberedView("home")
|
||||
LaunchedEffect(view.row, model.snapshot.rows) {
|
||||
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -12,6 +16,10 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
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.input.pointer.pointerInput
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
|
@ -64,6 +72,8 @@ class WolfskinSession @Inject constructor() {
|
|||
var serverId: String? = null
|
||||
internal var fixtureMode: Boolean? = null
|
||||
var onAction: (ShellAction) -> Unit = {}
|
||||
var personCommit by mutableStateOf<org.justinvisser.wolfskin.design.PersonCommit?>(null)
|
||||
val playback = org.justinvisser.wolfskin.design.PlaybackStart()
|
||||
}
|
||||
|
||||
/** The shell routes only through the existing NavigationManager and real settings pages. */
|
||||
|
|
@ -214,32 +224,48 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun selectPerson(person: ShellPerson, server: JellyfinServer) {
|
||||
val commit = session.state?.takeIf { it.peopleStatus == PeopleStatus.Ready }?.let {
|
||||
org.justinvisser.wolfskin.design.PersonCommit(it.people.toList(), it.people.indexOfFirst { candidate -> candidate.key == person.key }, person.key)
|
||||
}
|
||||
session.personCommit = commit
|
||||
session.playback.fail()
|
||||
navigation.replace(Destination.Home())
|
||||
if (person.key == signedInPerson) return
|
||||
if (person.key == signedInPerson) {
|
||||
commit?.switchEnded = true
|
||||
return
|
||||
}
|
||||
identityJob?.cancel()
|
||||
personSwitchJob?.cancel()
|
||||
signedInPerson = ""
|
||||
conductarr.close()
|
||||
libraries.audiences = null
|
||||
personSwitchJob = viewModelScope.launch {
|
||||
personSwitchMutex.withLock {
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
||||
}
|
||||
if (saved == null || saved.isProtected) {
|
||||
if (ConductarrPreference.fixtures) return@launch
|
||||
setupNavigation.navigateTo(SetupDestination.UserList(server))
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val current = repository.changeUser(server, saved)
|
||||
coroutineContext.ensureActive()
|
||||
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
coroutineContext.ensureActive()
|
||||
setupNavigation.navigateTo(SetupDestination.UserList(server))
|
||||
try {
|
||||
personSwitchMutex.withLock {
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
||||
}
|
||||
if (saved == null || saved.isProtected) {
|
||||
if (!ConductarrPreference.fixtures) {
|
||||
val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) }
|
||||
if (commit == null) open() else commit.fallback = open
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val current = repository.changeUser(server, saved)
|
||||
coroutineContext.ensureActive()
|
||||
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
||||
commit?.appReplacementPending = true
|
||||
} catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
coroutineContext.ensureActive()
|
||||
val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) }
|
||||
if (commit == null) open() else commit.fallback = open
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
commit?.switchEnded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -265,6 +291,8 @@ fun WolfskinApp(
|
|||
viewModel: WolfskinShellViewModel = hiltViewModel(),
|
||||
homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit =
|
||||
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
|
||||
titleContent: @Composable (String, ShellState, UserPreferences) -> Unit =
|
||||
{ route, shell, userPreferences -> TitleDestination(route, shell, userPreferences) },
|
||||
) {
|
||||
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||
val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle()
|
||||
|
|
@ -294,11 +322,26 @@ fun WolfskinApp(
|
|||
)
|
||||
RequestsRefresh(state, viewModel::refreshRequests)
|
||||
val onBack = rememberWolfskinBackHandler(state, navigationManager)
|
||||
val session = viewModel.session
|
||||
val commit = session.personCommit
|
||||
LaunchedEffect(commit, commit?.timelineComplete, commit?.switchEnded, commit?.appReplacementPending, viewModel.signedInPerson) {
|
||||
if (commit?.timelineComplete == true && (state.person.key == viewModel.signedInPerson || (commit.switchEnded && !commit.appReplacementPending))) {
|
||||
session.personCommit = null
|
||||
commit.fallback?.invoke()
|
||||
}
|
||||
}
|
||||
WolfskinTheme {
|
||||
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
|
||||
org.justinvisser.wolfskin.design.LocalHomePlaybackStart provides session.playback,
|
||||
org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) {
|
||||
Box(modifier) {
|
||||
Box(modifier.onPreviewKeyEvent { event ->
|
||||
if (session.personCommit != null || session.playback.launch != null) true else {
|
||||
if (event.type == KeyEventType.KeyDown && org.justinvisser.wolfskin.design.readShellKey(event) == org.justinvisser.wolfskin.design.ShellKey.Back) session.playback.fail()
|
||||
false
|
||||
}
|
||||
}) {
|
||||
val current = navigationManager.backStack.last()
|
||||
LaunchedEffect(current, state.person.key) { session.playback.fail() }
|
||||
if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
|
||||
val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle()
|
||||
Backdrop(
|
||||
|
|
@ -309,15 +352,15 @@ fun WolfskinApp(
|
|||
}
|
||||
NavDisplay(
|
||||
backStack = navigationManager.backStack,
|
||||
onBack = onBack,
|
||||
onBack = { if (session.personCommit == null && session.playback.launch == null) { session.playback.fail(); onBack() } },
|
||||
transitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
|
||||
popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
|
||||
predictivePopTransitionSpec = { _ -> (EnterTransition.None togetherWith ExitTransition.None).using(null) },
|
||||
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||
entryProvider = { destination ->
|
||||
NavEntry(
|
||||
destination,
|
||||
contentKey = "${destination}_${server.id}_${user.id}",
|
||||
metadata = if (destination is Destination.Wolfskin && destination.route == "updates") {
|
||||
org.justinvisser.wolfskin.tv.update.updatesRouteTransitions
|
||||
} else emptyMap(),
|
||||
) {
|
||||
// Cached entries must not retain the launch shell after sign-in completes.
|
||||
val state = LocalShellState.current
|
||||
|
|
@ -335,14 +378,17 @@ fun WolfskinApp(
|
|||
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
||||
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
||||
if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) {
|
||||
ShellSurface(state, handlesContentKeys = false) { ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) }
|
||||
} else TitleDestination(route, state, preferences)
|
||||
ShellSurface(state, handlesContentKeys = false) {
|
||||
if (session.personCommit == null) ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it)
|
||||
}
|
||||
} else titleContent(route, state, preferences)
|
||||
} else {
|
||||
ShellSurface(state, handlesContentKeys = upstream == null,
|
||||
settingsInstalledVersion = installedVersion.versionName,
|
||||
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
||||
when {
|
||||
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson ->
|
||||
if (session.personCommit == null) ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
|
||||
route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates,
|
||||
|
|
@ -360,6 +406,16 @@ fun WolfskinApp(
|
|||
}
|
||||
},
|
||||
)
|
||||
org.justinvisser.wolfskin.design.PlaybackHandoffVeil(session.playback,
|
||||
Modifier.pointerInput(session.playback.launch) {
|
||||
awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } }
|
||||
})
|
||||
if (commit != null) org.justinvisser.wolfskin.design.PersonCommitVeil(commit)
|
||||
BackHandler(enabled = session.playback.starting != null && session.playback.launch == null) {
|
||||
session.playback.fail()
|
||||
onBack()
|
||||
}
|
||||
BackHandler(enabled = commit != null || session.playback.launch != null) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
|
||||
/** Updates animates bounded content only; the retained nav never crossfades as a full scene. */
|
||||
internal val updatesRouteTransitions =
|
||||
NavDisplay.transitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
|
||||
NavDisplay.popTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
|
||||
NavDisplay.predictivePopTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) }
|
||||
|
|
@ -148,10 +148,51 @@ The on state has no unfocused ring: Justin (2026-11-07) read it as active/select
|
|||
|
||||
Source — Justin (2026-11-07): “the watches icon on details pages has a border, making it look like it's active/selected.”
|
||||
|
||||
## UI polish — revision 7 (2026-11-07)
|
||||
|
||||
Justin approved all twelve ideas on the [UI polish page](https://pages.justinvisser.org/wolfskin/ui-polish) and E01–E07 on the [everyday polish page](https://pages.justinvisser.org/wolfskin/ui-polish-everyday): "Do now". He approved the looks and copy exactly as the mocks show. Where this section and an earlier revision disagree, this section wins.
|
||||
|
||||
| Idea | Behaviour | Tokens |
|
||||
|---|---|---|
|
||||
| P01 chips | Discover and Library chip fill, text and selected border cross-fade between rest, selected and focused; resting pixels unchanged | `focusMove` 180 ms, `easeOut` |
|
||||
| P02 toasts | Every recovery toast fades in, holds 3200 ms, then fades out; a new message replaces the text without a second toast | `base` 160 ms, `ease` |
|
||||
| P03 Sending | The request button keeps its width and reads "Sending…" from OK until the service answers | `base` 160 ms crossfade |
|
||||
| P04 receipt | The outcome block fades in once when the service answers; the form goes in the same frame | `lateLineFade` 200 ms, `ease` |
|
||||
| P05 + E03 rows | A row that already entered during this screen visit (including after Back from a pushed page, and after scrolling back) does not enter again; a new visit (tab switch, person switch) and first appearances are unchanged | `rowEnter` / `rowRise` |
|
||||
| P06 sheets | Requests, Request, Version, Audio and subtitles, Media info and the other right-hand sheets fade with their scrim; one scrim at a time; Menu ↔ sheet hand-offs keep the scrim constant | new `sheetIn` 160 ms / `sheetOut` 100 ms, `easeOut` |
|
||||
| P07 loading | Requests and picker placeholders breathe on the shared skeleton clock; buttons never pulse | `skeletonCycle`, `breatheEase` |
|
||||
| P08 person | After OK on a person, the others fade to background, then the picker does, and their Home follows with no sign-in page between | `base` ×2, `ease` |
|
||||
| P09 readiness | Rows below an expanding or collapsing request slide; the readiness content appears at once and is uncovered by the sliding rows; window shifts still jump | `lateLineFade` 200 ms, `easeOut` |
|
||||
| P10 empty Requests | "Nothing coming in." / "New requests show up here." / Close (focused) | — |
|
||||
| P11 Library results | A changed sort or filter result fades up as one: all visible rows together | `rowEnter`, `rowRise` |
|
||||
| P12 thumbnails | A request without a poster (none, or failed) shows a centred 40 px film or TV glyph in `dim` | new `requests.thumbGlyph` 40 px |
|
||||
| E01 screen changes | No whole-screen transition anywhere in the shell or the setup host; the incoming screen's own entrance carries the motion | — |
|
||||
| E02 focus ring | One card ring per shell surface moves between cards, changing x, y, width, height and radius together; card scale and lift unchanged; settled pixels unchanged | `navBlob` spring; `focusMove` alpha |
|
||||
| E04 Play | Play/Resume reads "Starting…" while the stream is prepared; the page fades to black before the player opens | `base` 160 ms, `ease` |
|
||||
| E05 scrolling | Every key-driven scroll is a critically damped spring that carries velocity across key repeats and never overshoots | `navBlob` spring |
|
||||
| E06 progress | Title and card progress fill to the new point after playback; first paint never animates | `heroFade` 320 ms, `easeInOut` |
|
||||
| E07 seasons | The selection capsule slides between season chips; a season seen before shows its episodes at once | `navBlob` spring |
|
||||
|
||||
Reduced motion (platform animator scale 0 or `LocalReducedMotion`): fades ≤ 80 ms; movement, springs, height and scroll snap; pulses stop. Motion never moves focus ownership; retained exit copies are inert and swallow pointer input. The layer budget is unchanged: one animated layer per shelf; overlays add one bounded panel layer; scrims, veils and the ring are draw-only.
|
||||
|
||||
Decisions taken while specifying, and why:
|
||||
- P08: the mock's 90 ms pause between the two fades has no token, so the second fade starts as the first ends.
|
||||
- P09: the request that leaves the bounded 5 → 4 window disappears at the start, as before.
|
||||
- P10: Close is the approved loading-state pill; the second line uses the `receiptGap` spacing.
|
||||
- P12: the glyph size needed a new look token.
|
||||
- By Justin's standing rule ("if we do things similarly elsewhere, assume my instructions likely apply there too"), these also apply:
|
||||
- E02 covers title-page shelves, cast circles and the person filmography;
|
||||
- P05/E03 cover Home, Discover and Library alike;
|
||||
- E07's season chips use P01's colour fade.
|
||||
|
||||
Native proofs: the `ui-polish/<ID>/` captures listed under Sources and verification.
|
||||
|
||||
## Sources and verification
|
||||
|
||||
Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`.
|
||||
|
||||
Revision-7 polish proofs are listed in `docker/README.md` under UI polish.
|
||||
|
||||
`MotionSurfaceTest`, `HeroMotionTest`, `NetworkArtTransitionTest` and `DiscoverSkeletonKeysTest` cover late-line geometry, loading/ready row eligibility, Back/nav ownership, rendered pulse suppression under both motion controls, strict main-thread I/O policy, network-versus-cache reveal, bounded hero blending, delayed art and interrupted hand-offs. Native follow-up cases compare the pulse pixels of a late-joining shelf, hold caption translation through both settled and interrupted exits, and observe late ratings/genres/overview fading without moving their slots or actions. Manually clocked captures explicitly deliver snapshot changes before advancing animation time; interrupted-exit assertions sample after the hide has committed. The frozen prototype includes `index.html`, its local media, the original CSS/JS/builder and a complete SHA-256 manifest; replay the HTML, not the preserved builder's live-publishing entry point.
|
||||
|
||||
The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation.
|
||||
|
|
|
|||
|
|
@ -1039,6 +1039,23 @@ 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.
|
||||
|
||||
**UI polish (revision 7).** The tests below write 16 ms frame strips and `samples.csv` to `build/wolfskin-captures/ui-polish/<ID>/`:
|
||||
- `RequestsPolishTest` (P06 Requests, P07, P09, P10, P12)
|
||||
- `DiscoverPolishTest` (P01, P03, P04, P06 Request sheet, E03 lanes)
|
||||
- `LibraryPolishTest` (P01 toolbar, P05, P11)
|
||||
- `HomePolishRowsTest` (E03)
|
||||
- `RecoveryToastMotionTest` (P02)
|
||||
- `FocusGlideTest` (E02)
|
||||
- `ScrollMomentumTest` (E05)
|
||||
- `PickerPolishTest` (P07 picker)
|
||||
- app `TitlePolishTest` (P06 title sheets, E04 title, E06, E07)
|
||||
- app `PersonCommitTest` (P08)
|
||||
- app `NavigationCutTest` (E01)
|
||||
- app `HomeHeroStartingTest` (E04 Home)
|
||||
|
||||
`TitleRound2Test.menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim` now asserts one constant scrim and a monotonic panel fade. `RequestsKeysTest` asserts that OK on the empty Requests Close closes the sheet.
|
||||
|
||||
`TitleAsyncFocusTest` delays title and person replies through the real destination
|
||||
and view-model, then exercises content focus and hardware Back. Subtitles Off
|
||||
maps to the player's explicit disabled sentinel, not its unspecified default.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.border
|
|||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
|
|
@ -17,9 +18,13 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
|||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.HomeType as T
|
||||
|
||||
/** Home's pending playback belongs to the app session rather than a transient hero composition. */
|
||||
val LocalHomePlaybackStart = compositionLocalOf<PlaybackStart?> { null }
|
||||
|
||||
/** The hero occupies one row while its backdrop extends beneath the first shelves. */
|
||||
@Composable
|
||||
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
|
||||
val start = LocalHomePlaybackStart.current
|
||||
HeroMotionScope {
|
||||
Box(Modifier.fillMaxWidth().height(L.row_hero)) {
|
||||
if (!row.skeleton) {
|
||||
|
|
@ -57,7 +62,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
|||
content = {
|
||||
val primary = (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
||||
PickerButton(primary, focused && row.watchReady && column == 0, true, "playback",
|
||||
onFocus = { if (row.watchReady) onHover(0) }, textStyle = T.button) { if (row.watchReady) onButton(0) }
|
||||
onFocus = { if (row.watchReady) onHover(0) }, textStyle = T.button,
|
||||
busy = start?.starting == "hero-play", busyLabel = "Starting…") { if (row.watchReady) onButton(0) }
|
||||
},
|
||||
) { measurables, constraints ->
|
||||
val button = measurables.single().measure(constraints)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||
|
||||
/** Frozen public identity data and a frame-clock origin survive replacement of the signed-in app. */
|
||||
@Stable
|
||||
class PersonCommit(val people: List<ShellPerson>, val chosenIndex: Int, val chosenKey: String) {
|
||||
var startFrameNanos: Long? = null
|
||||
internal set
|
||||
internal var elapsedMillis by mutableFloatStateOf(0f)
|
||||
var timelineComplete by mutableStateOf(false)
|
||||
internal set
|
||||
var switchEnded by mutableStateOf(false)
|
||||
var appReplacementPending by mutableStateOf(false)
|
||||
var fallback: (() -> Unit)? = null
|
||||
}
|
||||
|
||||
/** The frozen picker is opaque and inert; only the covering rectangles change while switching identity. */
|
||||
@Composable
|
||||
fun PersonCommitVeil(commit: PersonCommit, modifier: Modifier = Modifier) {
|
||||
val reduced = reducedMotion()
|
||||
val duration = motionDuration(M.base, reduced)
|
||||
val total = if (reduced) duration else duration * 2
|
||||
LaunchedEffect(commit, total) {
|
||||
while (!commit.timelineComplete) {
|
||||
withFrameNanos { frame ->
|
||||
val start = commit.startFrameNanos ?: frame.also { commit.startFrameNanos = it }
|
||||
commit.elapsedMillis = (frame - start) / 1_000_000f
|
||||
if (commit.elapsedMillis >= total) commit.timelineComplete = true
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(modifier.fillMaxSize().background(C.bg).clearAndSetSemantics { }
|
||||
.pointerInput(commit) { awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
val elapsed = commit.elapsedMillis
|
||||
if (!reduced) {
|
||||
val alpha = M.ease.transform((elapsed / duration).coerceIn(0f, 1f))
|
||||
val width = L.pickerAvatar.toPx()
|
||||
val gap = L.pickerGap.toPx()
|
||||
val left = (size.width - width * commit.people.size - gap * (commit.people.size - 1)) / 2
|
||||
commit.people.indices.forEach { index ->
|
||||
if (index != commit.chosenIndex) drawRect(C.bg.copy(alpha = alpha),
|
||||
topLeft = Offset(left + index * (width + gap) - gap / 2, S.pickerPeopleTop.toPx() - gap / 2),
|
||||
size = Size(width + gap, S.pickerGearTop.toPx() - S.pickerPeopleTop.toPx() + gap / 2))
|
||||
}
|
||||
}
|
||||
val full = if (commit.timelineComplete) 1f else if (reduced) elapsed / duration else (elapsed - duration) / duration
|
||||
drawRect(C.bg.copy(alpha = M.ease.transform(full.coerceIn(0f, 1f))))
|
||||
}) {
|
||||
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
|
||||
PickerPeople(commit.people, commit.chosenIndex, false, onPerson = null, onGear = null)
|
||||
}
|
||||
}
|
||||
|
|
@ -37,15 +37,23 @@ fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) {
|
|||
|
||||
@Composable
|
||||
private fun PeoplePicker(state: ShellState) {
|
||||
PickerPeople(state.people, state.pickerIndex, state.gearFocused,
|
||||
onPerson = { index -> state.focusPerson(index); state.key(ShellKey.Ok) },
|
||||
onGear = { state.gearFocused = true; state.key(ShellKey.Ok) })
|
||||
}
|
||||
|
||||
/** Live and frozen pickers share geometry; a frozen picker has no interactive modifiers. */
|
||||
@Composable
|
||||
internal fun PickerPeople(people: List<ShellPerson>, focusedIndex: Int, gearFocused: Boolean,
|
||||
onPerson: ((Int) -> Unit)?, onGear: (() -> Unit)?) {
|
||||
Box(Modifier.fillMaxWidth().offset(y = S.pickerTitleTop), contentAlignment = Alignment.TopCenter) {
|
||||
Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
|
||||
state.people.forEachIndexed { index, person ->
|
||||
val focused = !state.gearFocused && state.pickerIndex == index
|
||||
Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable {
|
||||
state.focusPerson(index); state.key(ShellKey.Ok)
|
||||
}, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
people.forEachIndexed { index, person ->
|
||||
val focused = !gearFocused && focusedIndex == index
|
||||
Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.then(if (onPerson != null) Modifier.clickable { onPerson(index) } else Modifier), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
|
||||
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing))
|
||||
Spacer(Modifier.height(S.pickerNameGap))
|
||||
|
|
@ -54,11 +62,11 @@ private fun PeoplePicker(state: ShellState) {
|
|||
}
|
||||
}
|
||||
val left = (S.stageWidth - L.gear) / 2
|
||||
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
|
||||
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
|
||||
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
|
||||
if (state.gearFocused) {
|
||||
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(gearFocused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
|
||||
.then(if (onGear != null) Modifier.clickable(onClick = onGear) else Modifier), contentAlignment = Alignment.Center) {
|
||||
Glyph("gear", if (gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
|
||||
if (gearFocused) {
|
||||
Box(Modifier.align(Alignment.TopCenter).offset(y = S.pickerGearCaptionTop).requiredWidth(L.pickerAvatar), contentAlignment = Alignment.TopCenter) {
|
||||
Text("Settings", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false)
|
||||
}
|
||||
|
|
@ -74,7 +82,7 @@ private fun RecoveryPicker(state: ShellState) {
|
|||
Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
}
|
||||
if (loading) {
|
||||
Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
|
||||
Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
|
||||
repeat(state.people.size) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(Modifier.size(L.pickerAvatar).background(C.surface2, CircleShape))
|
||||
|
|
@ -103,16 +111,20 @@ private fun RecoveryPicker(state: ShellState) {
|
|||
/** Recovery actions remain focusable while profiles cannot be read. */
|
||||
@Composable
|
||||
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null,
|
||||
textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) {
|
||||
textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button,
|
||||
busy: Boolean = false, busyLabel: String? = null, onClick: () -> Unit) {
|
||||
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
|
||||
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
|
||||
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable { if (!busy) onClick() }
|
||||
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) {
|
||||
if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
|
||||
Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle)
|
||||
val content: @Composable () -> Unit = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) {
|
||||
if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
|
||||
Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle)
|
||||
}
|
||||
}
|
||||
if (busyLabel == null) content() else BusyLabelSwap(busy, busyLabel, if (primary) C.onFocus else C.fg, textStyle, content)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
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
|
||||
|
||||
/** Loading profiles share one pulse without fading the recovery action. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class PickerPolishTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun loadingAvatarsBreatheButtonStays() = capture(false)
|
||||
@Test fun reducedMotionKeepsAvatarsAndButtonStill() = capture(true)
|
||||
|
||||
private fun capture(reduced: Boolean) {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val state = ShellState(fixturePeople(), "justin").apply { peopleStatus = PeopleStatus.Loading }
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
|
||||
} } }
|
||||
rule.mainClock.advanceTimeBy(32)
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/P07/${if (reduced) "reduced" else "normal"}").apply { mkdirs() }
|
||||
val samples = (0..14).map { index ->
|
||||
val file = File(directory, "picker-loading-${index.toString().padStart(2, '0')}.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val image = BitmapFactory.decodeFile(file.absolutePath)
|
||||
val pixels = try { image.getPixel(650, 540) to image.getPixel(950, 820) } finally { image.recycle() }
|
||||
rule.mainClock.advanceTimeBy(100, ignoreFrameDuration = true)
|
||||
"${index * 100},${pixels.first},${pixels.second}"
|
||||
}
|
||||
File(directory, "samples.csv").writeText("timeMs,avatar,button\n" + samples.joinToString("\n"))
|
||||
val avatars = samples.map { it.split(',')[1].toInt() }
|
||||
val buttons = samples.map { it.split(',')[2].toInt() }
|
||||
assertEquals("The recovery button never pulses", 1, buttons.distinct().size)
|
||||
if (reduced) assertEquals("Reduced motion stops the shared pulse", 1, avatars.distinct().size)
|
||||
else {
|
||||
assertNotEquals("The avatar must breathe by the middle of the cycle", avatars.first(), avatars[7])
|
||||
assertNotEquals("The avatar must also pass through an intermediate alpha", avatars.first(), avatars[3])
|
||||
}
|
||||
rule.runOnIdle { state.peopleStatus = PeopleStatus.Ready; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.onNodeWithTag("picker-person:justin").assertIsDisplayed()
|
||||
rule.onNodeWithText("Continue as Justin").assertDoesNotExist()
|
||||
assertTrue("Removing the last subscriber stops its infinite transition",
|
||||
androidx.compose.runtime.Recomposer.runningRecomposers.value.all { !it.hasPendingWork })
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue