Merge pull request 'Round 2 release candidate 3: polish lanes A-E, season reel and trailer return' (#82) from fm/tv-round2-candidate3 into fm/tv-round2

This commit is contained in:
justinzeus 2026-11-08 13:58:41 +01:00
commit c21d17dfdf
97 changed files with 5795 additions and 549 deletions

View file

@ -495,6 +495,8 @@ tasks.withType<Test>().configureEach {
dependsOn(":wolfskin-design:testDebugUnitTest")
// Android 16 shared-memory setup needs Robolectric's JDK FileDescriptor bridge.
jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED")
// The full suite keeps every Robolectric capture in one worker; the 512 MB default runs out near the end.
maxHeapSize = "2g"
systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath)
systemProperty("robolectric.offline", "true")
systemProperty("roborazzi.test.record", "true")

View file

@ -53,9 +53,11 @@ class InstrumentedBasicUiTests {
MainContent(
backStack = mutableListOf(SetupDestination.ServerList),
navigationManager = navigationManager,
appPreferences = mockk(relaxed = true),
userPreferences = mockk(relaxed = true),
backdropService = mockk(relaxed = true),
screensaverService = screensaverService,
liveSession = MutableStateFlow(null),
trailerReturn = mockk(relaxed = true),
modifier = Modifier,
)
}

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
@ -9,7 +8,6 @@ import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@ -24,24 +22,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavBackStack
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository
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.PlayerBackend
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.IntentResult
import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
import com.github.damontecres.wholphin.services.NavigationManager
@ -66,7 +56,6 @@ import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
@ -74,20 +63,17 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.requestSerializersModule
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import timber.log.Timber
import org.justinvisser.wolfskin.tv.TrailerReturnState
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@ -156,6 +142,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var intentService: IntentService
@Inject
lateinit var trailerReturn: TrailerReturnState
private var signInAuto = true
private var playerBackend: PlayerBackend? = null
@ -169,6 +158,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
instance = this
if (savedInstanceState == null) trailerReturn.clear()
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver)
@ -233,7 +223,7 @@ class MainActivity : AppCompatActivity() {
playerBackend = prefs.playbackPreferences.playerBackend
}
viewModel.appStart(intent)
viewModel.appStart(intent, resuming = savedInstanceState != null, savedState = savedInstanceState)
setContent {
MaterialTheme {
Surface(Modifier.fillMaxSize()) {
@ -283,6 +273,8 @@ class MainActivity : AppCompatActivity() {
userPreferences = userPreferences,
backdropService = backdropService,
screensaverService = screensaverService,
liveSession = viewModel.serverRepository.current,
trailerReturn = trailerReturn,
modifier = Modifier.fillMaxSize(),
)
}
@ -309,6 +301,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
Timber.d("onResume")
trailerReturn.finishHandoff()
viewModel.appResume()
lifecycleScope.launchDefault {
screensaverService.pulse()
@ -318,7 +311,7 @@ class MainActivity : AppCompatActivity() {
override fun onRestart() {
super.onRestart()
Timber.d("onRestart")
viewModel.appStart(null)
viewModel.appStart(null, resuming = true)
if (!playExternalViewModel.launched.value) {
// If restarting during playback that is not external, go back a page
val lastDest = navigationManager.backStack.lastOrNull()
@ -374,6 +367,7 @@ class MainActivity : AppCompatActivity() {
Timber.w(ex, "Could not persist back stack; skipping")
}
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
trailerReturn.save(outState, viewModel.serverRepository.current.value, navigationManager.backStack.lastOrNull())
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@ -424,182 +418,6 @@ class MainActivity : AppCompatActivity() {
}
}
@HiltViewModel
class MainActivityViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val preferences: DataStore<AppPreferences>,
val serverRepository: ServerRepository,
private val setupNavigationManager: SetupNavigationManager,
private val navigationManager: NavigationManager,
private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler,
private val intentService: IntentService,
) : ViewModel() {
private val mutex = Mutex()
fun appStart(intent: Intent?) {
viewModelScope.launchDefault {
mutex.withLock {
try {
handleAppUpgrade()
} catch (ex: Exception) {
Timber.e(ex, "Error during app upgrade!")
showToast(
context,
"Error during app upgrade: ${ex.localizedMessage}",
)
}
try {
val result = handleIntent(intent)
if (result) {
Timber.i("appStart was handled by intent")
return@withLock
}
} catch (ex: Exception) {
Timber.e(ex, "Error parsing intent")
}
try {
handleAppStart()
} catch (ex: Exception) {
Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
}
}
viewModelScope.launchDefault {
// Create the mediaCodecCapabilitiesTest if needed
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
}
}
fun appResume() {
viewModelScope.launchDefault {
mutex.withLock {
val expected = serverRepository.current.value
try {
Timber.v("Updating userDto")
serverRepository.updateUserDto()
} catch (ex: Exception) {
Timber.w(ex, "Error during appResume")
recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager)
}
}
}
}
/**
* Handle an [Intent], navigating if needed
*
* @return true if the intent was handled, false if normal app start should proceed
*/
@VisibleForTesting
internal suspend fun handleIntent(intent: Intent?): Boolean {
val result = intent?.let { intentService.parseIntent(intent) }
when (result) {
is IntentResult.Error -> {
val current = serverRepository.current.value
val destination =
if (current != null) {
SetupDestination.UserList(current.server)
} else {
SetupDestination.ServerList
}
setupNavigationManager.navigateTo(destination)
Timber.e("Error parsing intent: %s", result.message)
showToast(context, result.message)
return true
}
is IntentResult.Target -> {
val current = serverRepository.current.value
if (current != null) {
Timber.i("Received valid intent, switching to AppContent")
if (result.addHomeToBackStack) {
navigationManager.reloadHome()
} else {
navigationManager.backStack.clear()
}
navigationManager.backStack.addAll(result.destinations)
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
} else {
// This should never happen, but just reset app state if it does
setupNavigationManager.navigateTo(SetupDestination.ServerList)
Timber.e("Error parsing intent, no user is active")
showToast(context, "An error occurred parsing the intent")
}
return true
}
IntentResult.NoOp,
null,
-> {
// Not handled
return false
}
}
}
private suspend fun handleAppUpgrade() {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
showToast(
context,
context.getString(
R.string.updated_toast,
appUpgradeHandler.currentVersion.toString(),
),
)
appUpgradeHandler.run()
}
appUpgradeHandler.copySubfont(false)
}
/**
* Handle starting the app including auto sign in and navigating to the right starting page
*/
@VisibleForTesting
internal suspend fun handleAppStart() {
val signInAutomatically = preferences.data.first().signInAutomatically
val currentUser = serverRepository.current.value
if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) {
// Hot reload
Timber.v("Hot reload restoring state")
setupNavigationManager.navigateTo(
SetupDestination.AppContent(currentUser),
)
} else {
val restoredSession =
if (signInAutomatically) {
Timber.v("Sign in automatically")
serverRepository.restoreSavedSession()
} else {
serverRepository.getMostRecentServer()
}
Timber.d("Restore last session result=%s", restoredSession)
setupNavigationManager.navigateTo(restoredSession.destination)
if (signInAutomatically && restoredSession is RestoredSession.Success) {
viewModelScope.launchDefault {
try {
serverRepository.revalidateSession(restoredSession.currentUser)
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
Timber.w(exception, "Saved launch session revalidation failed")
recoverWolfskinSession(context, exception, restoredSession.currentUser, serverRepository, setupNavigationManager)
}
}
}
}
}
}
private val Destination?.isPlayback: Boolean
get() =

View file

@ -0,0 +1,263 @@
package com.github.damontecres.wholphin
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.IntentResult
import com.github.damontecres.wholphin.services.IntentService
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.launchDefault
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.tv.TrailerReturnRecord
import org.justinvisser.wolfskin.tv.TrailerReturnState
import org.justinvisser.wolfskin.tv.recoverWolfskinSession
import timber.log.Timber
@HiltViewModel
class MainActivityViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val preferences: DataStore<AppPreferences>,
val serverRepository: ServerRepository,
private val setupNavigationManager: SetupNavigationManager,
private val navigationManager: NavigationManager,
private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler,
private val intentService: IntentService,
private val trailerReturn: TrailerReturnState,
) : ViewModel() {
private val mutex = Mutex()
private var startupJob: kotlinx.coroutines.Job? = null
fun appStart(intent: Intent?, resuming: Boolean = false, savedState: Bundle? = null) {
// onResume clears the pending handoff before asynchronous startup necessarily runs.
val handoff = if (resuming && savedState == null) trailerReturn.pendingHandoff() else null
if (!resuming) trailerReturn.clear()
startupJob?.cancel()
startupJob = viewModelScope.launchDefault {
mutex.withLock {
try {
handleAppUpgrade()
} catch (ex: Exception) {
if (ex is kotlinx.coroutines.CancellationException) throw ex
Timber.e(ex, "Error during app upgrade!")
showToast(
context,
"Error during app upgrade: ${ex.localizedMessage}",
)
}
try {
// A saved trailer task must not replay its original deep link or authenticate its old intent's person.
val restoringTrailer = resuming && savedState?.containsKey(TrailerReturnState.SAVED_STATE_KEY) == true
val result = if (restoringTrailer) false else handleIntent(intent)
if (result) {
Timber.i("appStart was handled by intent")
return@withLock
}
} catch (ex: Exception) {
if (ex is kotlinx.coroutines.CancellationException) throw ex
Timber.e(ex, "Error parsing intent")
}
try {
handleAppStart(resuming, savedState, handoff)
} catch (ex: Exception) {
if (ex is kotlinx.coroutines.CancellationException) throw ex
Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
}
}
viewModelScope.launchDefault {
// Create the mediaCodecCapabilitiesTest if needed
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
}
}
fun appResume() {
viewModelScope.launchDefault {
mutex.withLock {
val expected = serverRepository.current.value
try {
Timber.v("Updating userDto")
serverRepository.updateUserDto()
} catch (ex: Exception) {
Timber.w(ex, "Error during appResume")
recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager)
}
}
}
}
/**
* Handle an [Intent], navigating if needed
*
* @return true if the intent was handled, false if normal app start should proceed
*/
@VisibleForTesting
internal suspend fun handleIntent(intent: Intent?): Boolean {
val result = intent?.let { intentService.parseIntent(intent) }
when (result) {
is IntentResult.Error -> {
val current = serverRepository.current.value
val destination =
if (current != null) {
SetupDestination.UserList(current.server)
} else {
SetupDestination.ServerList
}
setupNavigationManager.navigateTo(destination)
Timber.e("Error parsing intent: %s", result.message)
showToast(context, result.message)
return true
}
is IntentResult.Target -> {
val current = serverRepository.current.value
if (current != null) {
Timber.i("Received valid intent, switching to AppContent")
if (result.addHomeToBackStack) {
navigationManager.reloadHome()
} else {
navigationManager.backStack.clear()
}
navigationManager.backStack.addAll(result.destinations)
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
} else {
// This should never happen, but just reset app state if it does
setupNavigationManager.navigateTo(SetupDestination.ServerList)
Timber.e("Error parsing intent, no user is active")
showToast(context, "An error occurred parsing the intent")
}
return true
}
IntentResult.NoOp,
null,
-> {
// Not handled
return false
}
}
}
private suspend fun handleAppUpgrade() {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
showToast(
context,
context.getString(
R.string.updated_toast,
appUpgradeHandler.currentVersion.toString(),
),
)
appUpgradeHandler.run()
}
appUpgradeHandler.copySubfont(false)
}
/**
* Handle starting the app including auto sign in and navigating to the right starting page
*/
@VisibleForTesting
internal suspend fun handleAppStart(
resuming: Boolean = false,
savedState: Bundle? = null,
handoff: TrailerReturnRecord? = trailerReturn.pendingHandoff(),
) {
val signInAutomatically = preferences.data.first().signInAutomatically
val currentUser = serverRepository.current.value
val owner = setupNavigationManager.backStack.lastOrNull()
val expected = if (resuming && savedState != null) {
trailerReturn.restore(savedState, navigationManager.backStack.lastOrNull())
} else {
trailerReturn.clear()
null
}
val shown = (owner as? SetupDestination.AppContent)?.current
val canContinue = currentUser != null && shown != null &&
currentUser.server.id == shown.server.id && currentUser.user.id == shown.user.id
if (expected != null) {
val restored = when {
currentUser == null && owner == SetupDestination.Loading ->
serverRepository.restoreSavedSession(expected) {
trailerReturn.isRestoring(expected) && serverRepository.current.value == null &&
setupNavigationManager.backStack.lastOrNull() === owner
}
currentUser != null && (canContinue || owner == SetupDestination.Loading) && !currentUser.user.isProtected &&
currentUser.server.id == expected.serverId && currentUser.user.id == expected.userId &&
serverRepository.isSavedIdentity(expected) -> RestoredSession.Success(currentUser)
else -> serverRepository.getMostRecentServer()
}
finishRestore(restored, owner, fromTrailer = true, revalidate = currentUser == null)
return
}
// A picker may be an explicit switch or expired login, even while a user remains cached.
val continuingTrailer = resuming && savedState == null && canContinue &&
trailerReturn.canContinue(handoff, currentUser, navigationManager.backStack.lastOrNull())
if ((signInAutomatically || continuingTrailer) && currentUser != null && !currentUser.user.isProtected) {
withContext(WholphinDispatchers.Main.immediate) {
if (setupNavigationManager.backStack.lastOrNull() === owner && serverRepository.current.value === currentUser) {
setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser))
}
}
} else {
val restored = if (signInAutomatically) serverRepository.restoreSavedSession() else serverRepository.getMostRecentServer()
finishRestore(restored, owner, fromTrailer = false, revalidate = true)
}
}
private suspend fun finishRestore(
restored: RestoredSession,
owner: SetupDestination?,
fromTrailer: Boolean,
revalidate: Boolean,
) {
val shown = withContext(WholphinDispatchers.Main.immediate) {
if (setupNavigationManager.backStack.lastOrNull() !== owner) return@withContext false
if (restored is RestoredSession.Success && serverRepository.current.value !== restored.currentUser) return@withContext false
if (fromTrailer && restored is RestoredSession.Success) trailerReturn.confirmRestore(restored.currentUser)
else trailerReturn.clear()
setupNavigationManager.navigateTo(restored.destination)
true
}
if (shown && revalidate && restored is RestoredSession.Success) {
viewModelScope.launchDefault {
try {
serverRepository.revalidateSession(restored.currentUser)
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
Timber.w(exception, "Saved launch session revalidation failed")
recoverWolfskinSession(context, exception, restored.currentUser, serverRepository, setupNavigationManager)
}
}
}
}
}

View file

@ -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
@ -11,17 +14,13 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
@ -29,6 +28,7 @@ import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ScreensaverService
@ -40,7 +40,10 @@ import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
import kotlinx.coroutines.flow.StateFlow
import org.justinvisser.wolfskin.tv.TrailerReturnState
/** Setup entries carry display snapshots; lifecycle privacy checks use the live repository identity. */
@Composable
fun MainContent(
backStack: MutableList<SetupDestination>,
@ -48,6 +51,8 @@ fun MainContent(
userPreferences: UserPreferences,
backdropService: BackdropService,
screensaverService: ScreensaverService,
liveSession: StateFlow<CurrentUser?>,
trailerReturn: TrailerReturnState,
modifier: Modifier = Modifier,
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
) {
@ -66,6 +71,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(),
@ -104,16 +112,8 @@ fun MainContent(
backdropService.clearBackdrop()
}
val current = key.current
var showContent by remember {
mutableStateOf(true)
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
if (!userPreferences.appPreferences.signInAutomatically) {
showContent = false
}
}
if (showContent) {
SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically,
trailerReturn, { navigationManager.backStack.lastOrNull() }) {
WolfskinApp(
user = current.user,
server = current.server,
@ -121,16 +121,6 @@ fun MainContent(
preferences = preferences,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
}

View file

@ -0,0 +1,63 @@
package com.github.damontecres.wholphin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.tv.material3.MaterialTheme
import androidx.lifecycle.compose.LifecycleEventEffect
import com.github.damontecres.wholphin.data.CurrentUser
import kotlinx.coroutines.flow.StateFlow
import com.github.damontecres.wholphin.ui.nav.Destination
import org.justinvisser.wolfskin.tv.TrailerReturnRecord
import org.justinvisser.wolfskin.tv.TrailerReturnState
/** Only a pending trailer handoff keeps an auto-off session mounted across a stopped activity. */
@Composable
internal fun SignedInContent(
current: CurrentUser,
liveSession: StateFlow<CurrentUser?>,
signInAutomatically: Boolean,
trailerReturn: TrailerReturnState,
destination: () -> Destination?,
content: @Composable () -> Unit,
) {
var showContent by remember(current.server.id, current.user.id) { mutableStateOf(true) }
var hasStopped by remember(current.server.id, current.user.id) { mutableStateOf(false) }
var handoff by remember(current.server.id, current.user.id) { mutableStateOf<TrailerReturnRecord?>(null) }
fun hideInvalidSession() {
if (!signInAutomatically) {
val live = liveSession.value
val canContinue = live != null && live.server.id == current.server.id &&
live.user.id == current.user.id && trailerReturn.canContinue(handoff, live, destination())
if (!canContinue) showContent = false
}
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
hasStopped = true
handoff = trailerReturn.pendingHandoff()
hideInvalidSession()
}
LifecycleEventEffect(Lifecycle.Event.ON_START) {
// A newly unlocked entry may paint; a returning entry must recheck protection changed while away.
if (hasStopped) {
hideInvalidSession()
hasStopped = false
}
}
if (showContent) {
content()
} else {
Box(Modifier.size(200.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.border, modifier = Modifier.align(Alignment.Center))
}
}
}

View file

@ -10,6 +10,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -120,12 +122,35 @@ class ServerRepository
}
/** Restore only an unprotected saved login; the launch picker must not wait for the network. */
suspend fun restoreSavedSession(): RestoredSession = withContext(ioDispatcher) {
suspend fun restoreSavedSession(): RestoredSession = restoreSavedSessionInternal(null, null)
/** A task-bound restore cannot select another saved person or outlive its caller's authorization. */
suspend fun restoreSavedSession(
expected: SavedSessionIdentity,
isStillAuthorized: () -> Boolean,
): RestoredSession = restoreSavedSessionInternal(expected, isStillAuthorized)
/** Clearing/changing the saved login also invalidates a surviving trailer activity's handoff. */
fun isSavedIdentity(expected: SavedSessionIdentity): Boolean =
matchesSavedIdentity(mostRecentServerProvider.get(), expected)
private fun matchesSavedIdentity(recent: MostRecentServer, expected: SavedSessionIdentity): Boolean =
recent is MostRecentServer.ServerAndUser && recent.serverId == expected.serverId && recent.userId == expected.userId
private suspend fun restoreSavedSessionInternal(
expected: SavedSessionIdentity?,
isStillAuthorized: (() -> Boolean)?,
): RestoredSession = withContext(ioDispatcher) {
val recent = mostRecentServerProvider.get()
if (expected != null && !matchesSavedIdentity(recent, expected)) return@withContext getMostRecentServerInternal(recent)
if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent)
val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None
val user = saved.users.firstOrNull { it.id == recent.userId }
if (user == null || user.isProtected) return@withContext RestoredSession.ServerOnly(saved.server)
if (user == null || user.isProtected || (expected != null && user.accessToken.isNullOrBlank())) {
return@withContext RestoredSession.ServerOnly(saved.server)
}
currentCoroutineContext().ensureActive()
if (isStillAuthorized?.invoke() == false) return@withContext RestoredSession.ServerOnly(saved.server)
apiClient.update(baseUrl = saved.server.url, accessToken = user.accessToken)
val current = CurrentUser(saved.server, user)
_currentUserDto.value = null
@ -384,3 +409,6 @@ data class CurrentUser(
val server: JellyfinServer,
val user: JellyfinUser,
)
/** A previously authenticated server/person pair, without credentials or protection state. */
data class SavedSessionIdentity(val serverId: UUID, val userId: UUID)

View file

@ -0,0 +1,199 @@
package com.github.damontecres.wholphin
import androidx.datastore.core.DataStore
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.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
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.nav.Destination
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
import io.mockk.*
import java.util.UUID
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.justinvisser.wolfskin.tv.TrailerReturnClock
import org.justinvisser.wolfskin.tv.TrailerReturnState
/** Only a valid trailer handoff continues an auto-off live identity; cold and locked entries stay gated. */
@OptIn(ExperimentalCoroutinesApi::class)
class MainActivityResumeTest {
private val dispatcher = StandardTestDispatcher()
private val repository = mockk<ServerRepository>()
private val setup = mockk<SetupNavigationManager>(relaxed = true)
private val navigation = NavigationManager()
private val preferences = mockk<DataStore<AppPreferences>>()
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
private val current = CurrentUser(server,
JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token"))
private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false })
private var elapsed = 1_000L
private var boot = 7
private val clock = mockk<TrailerReturnClock>().also {
every { it.readElapsedMillis() } answers { elapsed }
every { it.readBootCount() } answers { boot }
}
private val trailerReturn = TrailerReturnState(clock)
private val title = Destination.Wolfskin("title/${UUID.randomUUID()}")
private val model = MainActivityViewModel(mockk(), preferences, repository, setup, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), trailerReturn)
@Before fun configure() {
WholphinDispatchers.configure(dispatcher)
every { preferences.data } returns automatic
every { repository.current } returns MutableStateFlow(current)
every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(current))
coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(current.server)
coEvery { repository.restoreSavedSession() } returns RestoredSession.ServerOnly(current.server)
}
@After fun reset() { WholphinDispatchers.reset() }
@Test fun ordinaryWarmReturnShowsThePickerWithAutomaticSignInOff() = runTest(dispatcher) {
val title = Destination.Wolfskin("title/${UUID.randomUUID()}")
navigation.backStack.add(title)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
coVerify(exactly = 0) { repository.restoreSavedSession() }
}
@Test fun trailerReturnKeepsTheSamePersonAndTitleButTheNextOrdinaryReturnShowsThePicker() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
model.handleAppStart(resuming = true)
verify(exactly = 1) { setup.navigateTo(SetupDestination.AppContent(current)) }
assertEquals(listOf(Destination.Home(), title), navigation.backStack.toList())
clearMocks(setup, answers = false)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun onResumeCleanupCannotEraseTheHandoffCapturedBeforeAsynchronousStartup() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
model.appStart(null, resuming = true)
trailerReturn.finishHandoff()
advanceUntilIdle()
verify { setup.navigateTo(SetupDestination.AppContent(current)) }
coVerify(exactly = 0) { repository.getMostRecentServer() }
}
@Test fun expiredOrDifferentBootHandoffsStillShowThePicker() = runTest(dispatcher) {
navigation.backStack.add(title)
for ((age, returningBoot) in listOf(600_000L to 7, 1L to 8, 1L to -1)) {
elapsed = 1_000L
boot = 7
trailerReturn.prepare(current, title)
elapsed += age
boot = returningBoot
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
}
@Test fun protectionAddedDuringAPendingHandoffStillRequiresUnlocking() = runTest(dispatcher) {
navigation.backStack.add(title)
for (user in listOf(current.user.copy(pin = "1234"), current.user.copy(requireLogin = true))) {
trailerReturn.prepare(current, title)
val protected = current.copy(user = user)
every { repository.current } returns MutableStateFlow(protected)
every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(protected))
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(protected)) }
}
}
@Test fun anotherPersonOrTitleCannotReuseAPendingHandoff() = runTest(dispatcher) {
val other = current.copy(user = current.user.copy(id = UUID.randomUUID()))
navigation.backStack.add(title)
trailerReturn.prepare(other, title)
model.handleAppStart(resuming = true)
trailerReturn.prepare(current, Destination.Wolfskin("title/${UUID.randomUUID()}"))
model.handleAppStart(resuming = true)
verify(exactly = 2) { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun ordinaryWarmReturnWithAutomaticSignInOnStillContinuesTheLivePerson() = runTest(dispatcher) {
automatic.value = automatic.value.update { signInAutomatically = true }
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.AppContent(current)) }
coVerify(exactly = 0) { repository.restoreSavedSession() }
}
@Test fun coldLaunchCannotUseAPendingLiveHandoff() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
model.handleAppStart(resuming = false)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun anExplicitPickerCannotBeDismissedByAPendingLiveHandoff() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
every { setup.backStack } returns mutableListOf(SetupDestination.UserList(current.server))
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun theHandoffDeadlineIsRecheckedAfterAsynchronousStartupHasWaited() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
model.appStart(null, resuming = true)
elapsed += 600_000L
advanceUntilIdle()
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun warmReturnCannotUnlockPinOrRequireLoginProfiles() = runTest(dispatcher) {
for (enabled in listOf(false, true)) {
for (user in listOf(current.user.copy(pin = "1234"), current.user.copy(requireLogin = true))) {
automatic.value = automatic.value.update { signInAutomatically = enabled }
val protected = current.copy(user = user)
every { repository.current } returns MutableStateFlow(protected)
every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(protected))
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(protected)) }
}
}
}
@Test fun recreatedActivityWithoutALiveIdentityStillUsesTheColdStartPicker() = runTest(dispatcher) {
every { repository.current } returns MutableStateFlow<CurrentUser?>(null)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
coVerify(exactly = 0) { repository.restoreSavedSession() }
}
@Test fun warmReturnDoesNotDismissAnExplicitPickerOrAnotherPersonsEntry() = runTest(dispatcher) {
val other = current.copy(user = current.user.copy(id = UUID.randomUUID()))
for (shown in listOf(SetupDestination.UserList(current.server), SetupDestination.AppContent(other))) {
every { setup.backStack } returns mutableListOf(shown)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
}
}

View file

@ -0,0 +1,376 @@
package com.github.damontecres.wholphin
import android.app.Application
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import android.net.Uri
import android.os.Bundle
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.datastore.core.DataStore
import androidx.lifecycle.viewModelScope
import androidx.room.Room
import com.github.damontecres.wholphin.data.*
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.services.*
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.successResponse
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.*
import java.io.File
import java.util.UUID
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.sdk.api.client.ApiClient
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.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.UserDto
import org.json.JSONObject
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.justinvisser.wolfskin.tv.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.justinvisser.wolfskin.tv.update.FakeUpdateController
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** New repository and shell instances reproduce process death, not merely activity recreation. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class MainActivityTrailerRestoreTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", "12.1.0")
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "saved-token")
private val users = mockk<UserApi>()
private val system = mockk<SystemApi>()
private val api = mockk<ApiClient>(relaxed = true)
private val recent = mockk<MostRecentServerProvider>()
private var recentValue: MostRecentServer = MostRecentServer.None
private val preferences = mockk<DataStore<AppPreferences>>()
private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false })
private var elapsed = 1_000L
private val clock = mockk<TrailerReturnClock>().also {
every { it.readElapsedMillis() } answers { elapsed }
every { it.readBootCount() } returns 7
}
private val restoredReturn = TrailerReturnState(clock)
private val navigation = NavigationManager()
private val setup = mockk<SetupNavigationManager>(relaxed = true)
private val title = Destination.Wolfskin(titleRoute("Locke"))
private val database by lazy {
Room.inMemoryDatabaseBuilder(rule.activity, AppDatabase::class.java).allowMainThreadQueries().build()
}
private val dao by lazy { spyk(database.serverDao()) }
private val repository by lazy { ServerRepository(rule.activity, mockk(), dao, api, Dispatchers.Unconfined, recent) }
private val intents = mockk<IntentService>(relaxed = true)
private val model by lazy { MainActivityViewModel(rule.activity, preferences, repository, setup, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intents, restoredReturn) }
@Volatile private var destination: SetupDestination = SetupDestination.Loading
private val conductarr = ConductarrSession(MemoryCacheStore())
private var shellModel: WolfskinShellViewModel? = null
private var previousFixtures = false
private var previousScreens = true
@Before fun savedIdentityButNoLiveSession() {
ConductarrPreference.initialize(rule.activity)
WolfskinScreenPreference.initialize(rule.activity)
previousFixtures = ConductarrPreference.fixtures
previousScreens = WolfskinScreenPreference.enabled
ConductarrPreference.updateFixtures(true)
WolfskinScreenPreference.enabled = true
dao.addOrUpdateServer(server)
user = dao.addOrUpdateUser(user)
recentValue = MostRecentServer.from(CurrentUser(server, user))
every { recent.get() } answers { recentValue }
every { api.userApi } returns users
every { api.systemApi } returns system
coEvery { users.getCurrentUser() } returns successResponse(UserDto(id = user.id, name = "Justin",
hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false))
coEvery { system.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(serverName = server.name, version = server.version))
every { preferences.data } returns automatic
every { setup.backStack } answers { mutableListOf(destination) }
every { setup.navigateTo(any()) } answers { destination = firstArg() }
navigation.backStack.add(title)
val handler = ResolveInfo().apply {
activityInfo = ActivityInfo().apply { packageName = "test.youtube"; name = "test.youtube.Player"; exported = true }
}
shadowOf(rule.activity.packageManager).addResolveInfoForIntent(
Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")), handler)
assertNull(repository.current.value)
}
@After fun closeSessions() {
val jobs = listOfNotNull(model.viewModelScope.coroutineContext[Job], shellModel?.viewModelScope?.coroutineContext?.get(Job))
jobs.forEach { it.cancel() }
shadowOf(Looper.getMainLooper()).idle()
runBlocking { jobs.forEach { it.join() } }
conductarr.close()
database.close()
ConductarrPreference.updateFixtures(previousFixtures)
WolfskinScreenPreference.enabled = previousScreens
}
private fun snapshot(): Bundle {
val oldProcess = TrailerReturnState(clock)
val alreadySignedIn = CurrentUser(server, user)
check(TitleTrailer(rule.activity).open("https://www.youtube.com/watch?v=ab_-defghij") {
oldProcess.prepare(alreadySignedIn, title)
})
return Bundle().also { oldProcess.save(it, alreadySignedIn, title) }
}
private fun restore(saved: Bundle? = snapshot(), resuming: Boolean = true) {
val result = model.viewModelScope.async(Dispatchers.Default) { model.handleAppStart(resuming = resuming, savedState = saved) }
rule.waitUntil(15_000) {
shadowOf(Looper.getMainLooper()).idle()
result.isCompleted
}
runBlocking { result.await() }
}
private fun assertGated() {
assertEquals(SetupDestination.UserList(server), destination)
assertNull(repository.current.value)
verify(exactly = 0) { api.update(any(), any()) }
}
private fun loadFreshShell(): ShellState {
val session = WolfskinSession()
val fresh = WolfskinShellViewModel(rule.activity, session, repository, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk<LibraryReadFacade>(relaxed = true),
WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), FakeUpdateController(), restoredReturn)
shellModel = fresh
runBlocking { fresh.load(server, requireNotNull(repository.current.value).user) }
return requireNotNull(session.state)
}
private fun render(shell: ShellState) {
shell.enterDestination(titleRoute("Locke"))
val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
val scene = TitleState(fixture.title("Locke", shell.person.key), shell.person) {}
rule.setContent { WolfskinTheme { TitleSurface(scene, shell) } }
}
private fun sameTitleAfterProcessDeath(automaticSignIn: Boolean, capture: String = automaticSignIn.toString()) {
automatic.value = automatic.value.update { signInAutomatically = automaticSignIn }
restore()
val current = requireNotNull(repository.current.value)
assertEquals(SetupDestination.AppContent(current), destination)
assertEquals(server.id, current.server.id)
assertEquals(user.id, current.user.id)
assertEquals(title, navigation.backStack.last())
val shell = loadFreshShell()
render(shell)
rule.onNodeWithTag("title-heading").assertIsDisplayed().assertTextEquals("Locke")
rule.onNodeWithText("Who’s watching?").assertDoesNotExist()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "trailer-return").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "process-restored-title-$capture.png"))
}
@Test fun processDeathReturnsToTheSamePersonAndTitleWithAutomaticSignInOff() = sameTitleAfterProcessDeath(false)
@Test fun processDeathDoesNotReopenTheFreshShellChooserWithAutomaticSignInOn() = sameTitleAfterProcessDeath(true)
@Test fun protectionAddedWhileTheProcessWasDeadStillRequiresUnlocking() {
val saved = snapshot()
for (protected in listOf(user.copy(pin = "URDL"), user.copy(requireLogin = true))) {
destination = SetupDestination.Loading
dao.updateUser(protected)
restore(saved)
assertGated()
}
}
@Test fun aDifferentMostRecentPersonCannotReplaceTheTrailerIdentityEvenWithAutomaticSignInOn() {
val saved = snapshot()
val other = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel", accessToken = "another-token"))
recentValue = MostRecentServer.from(CurrentUser(server, other))
for (enabled in listOf(false, true)) {
destination = SetupDestination.Loading
automatic.value = automatic.value.update { signInAutomatically = enabled }
restore(saved)
assertGated()
}
}
@Test fun anExistingPickerCannotBeBypassedByATrailerSnapshot() {
destination = SetupDestination.UserList(server)
automatic.value = automatic.value.update { signInAutomatically = true }
restore()
assertGated()
}
@Test fun missingTokenCannotBeRecoveredFromTheSavedTask() {
val saved = snapshot()
dao.updateUser(user.copy(accessToken = null))
restore(saved)
assertGated()
}
@Test fun removedPeopleAreNotRecreatedByTheSavedTask() {
val saved = snapshot()
dao.deleteUser(server.id, user.id)
restore(saved)
assertGated()
}
@Test fun aFreshLaunchCannotUseAPreviousTrailerSnapshot() {
restore(snapshot(), resuming = false)
assertGated()
}
@Test fun aFreshColdLaunchWithoutSavedTaskStateKeepsThePicker() {
restore(saved = null)
assertGated()
}
@Test fun aReturnHoursLaterCannotRestoreThePerson() {
val saved = snapshot()
elapsed += 4L * 60L * 60L * 1000L
restore(saved)
assertGated()
}
@Test fun aDifferentRestoredDestinationCannotReuseTheIdentityRecord() {
val saved = snapshot()
navigation.backStack[navigation.backStack.lastIndex] = Destination.Wolfskin(titleRoute("another-title"))
restore(saved)
assertGated()
}
@Test fun automaticSignInWithoutATrailerRecordStillShowsTheColdChooser() {
automatic.value = automatic.value.update { signInAutomatically = true }
restore(saved = null)
render(loadFreshShell())
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun expiryDuringProfileLookupCannotInstallTheSavedToken() {
val saved = snapshot()
every { dao.getServer(server.id) } answers {
elapsed += 600_000L
callOriginal()
}
restore(saved)
assertGated()
}
@Test fun restoringATrailerTaskDoesNotReplayItsOriginalLaunchIntent() {
coEvery { intents.parseIntent(any()) } returns IntentResult.Error("Old launch target expired")
model.appStart(Intent("old.launch.target"), resuming = true, savedState = snapshot())
rule.waitUntil(15_000) {
shadowOf(Looper.getMainLooper()).idle()
destination != SetupDestination.Loading
}
assertEquals(SetupDestination.AppContent(requireNotNull(repository.current.value)), destination)
assertEquals(user.id, repository.current.value!!.user.id)
assertEquals(title, navigation.backStack.last())
}
@Test fun aNewExplicitLaunchCancelsAnOlderPendingTaskRestore() {
val started = CompletableDeferred<Unit>()
val release = CompletableDeferred<Unit>()
var firstRead = true
every { preferences.data } returns kotlinx.coroutines.flow.flow {
if (firstRead) {
firstRead = false
started.complete(Unit)
release.await()
}
emit(automatic.value)
}
model.appStart(null, resuming = true, savedState = snapshot())
rule.waitUntil(15_000) {
shadowOf(Looper.getMainLooper()).idle()
started.isCompleted
}
model.appStart(null)
release.complete(Unit)
rule.waitUntil(15_000) {
shadowOf(Looper.getMainLooper()).idle()
destination != SetupDestination.Loading
}
assertGated()
}
private fun authenticateSavedPerson(person: JellyfinUser = user) {
recentValue = MostRecentServer.from(CurrentUser(server, person))
coEvery { recent.save(any()) } coAnswers {
recentValue = MostRecentServer.from(firstArg())
}
coEvery { users.getCurrentUser() } returns successResponse(UserDto(id = person.id, name = person.name,
hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false))
runBlocking { check(repository.restoreLastSession() is RestoredSession.Success) }
clearMocks(api, answers = false, recordedCalls = true)
}
@Test fun aRecommendationsWorkerRestoredPersonReturnsToTheSameTitleWithoutSigningInAgain() {
authenticateSavedPerson()
sameTitleAfterProcessDeath(false, capture = "worker")
verify(exactly = 0) { api.update(any(), any()) }
}
@Test fun aWorkerRestoredPersonWithoutTrailerMetadataCannotSkipTheColdPicker() {
authenticateSavedPerson()
restore(saved = null)
assertEquals(SetupDestination.UserList(server), destination)
assertEquals(user.id, repository.current.value!!.user.id)
verify(exactly = 0) { api.update(any(), any()) }
}
@Test fun aDifferentCurrentWorkerIdentityCannotBeReplacedByTheCapturedPerson() {
val saved = snapshot()
val other = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel", accessToken = "another-token"))
authenticateSavedPerson(other)
recentValue = MostRecentServer.from(CurrentUser(server, user))
restore(saved)
assertEquals(SetupDestination.UserList(server), destination)
assertEquals(other.id, repository.current.value!!.user.id)
verify(exactly = 0) { api.update(any(), any()) }
}
@Test fun aProtectedCurrentWorkerIdentityStillRequiresUnlocking() {
authenticateSavedPerson()
val saved = snapshot()
for ((pin, login) in listOf("URDL" to false, null to true)) {
destination = SetupDestination.Loading
runBlocking { repository.updateUserAuth(user, pin, login) }
restore(saved)
assertEquals(SetupDestination.UserList(server), destination)
assertTrue(repository.current.value!!.user.isProtected)
verify(exactly = 0) { api.update(any(), any()) }
}
}
@Test fun clearingTheSavedLoginInvalidatesAnOtherwiseEligibleTaskSnapshot() {
val saved = snapshot()
recentValue = MostRecentServer.Server(server.id, server.url)
restore(saved)
assertGated()
}
@Test fun aCachedPersonCannotDismissAnExplicitPickerWithSavedTrailerMetadata() {
authenticateSavedPerson()
val saved = snapshot()
destination = SetupDestination.UserList(server)
automatic.value = automatic.value.update { signInAutomatically = true }
restore(saved)
assertEquals(SetupDestination.UserList(server), destination)
assertEquals(user.id, repository.current.value!!.user.id)
verify(exactly = 0) { api.update(any(), any()) }
}
}

View file

@ -0,0 +1,180 @@
package com.github.damontecres.wholphin
import android.app.Application
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.Lifecycle
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.every
import io.mockk.mockk
import java.io.File
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
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.justinvisser.wolfskin.tv.TitleFixture
import org.justinvisser.wolfskin.tv.TitleTrailer
import org.justinvisser.wolfskin.tv.TrailerReturnClock
import org.justinvisser.wolfskin.tv.TrailerReturnState
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Only the first valid trailer return keeps an auto-off title visible; other returns re-enter setup. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SignedInContentTest {
@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 liveSession = MutableStateFlow<CurrentUser?>(CurrentUser(server, user))
private var elapsed = 1_000L
private var boot = 7
private val clock = mockk<TrailerReturnClock>().also {
every { it.readElapsedMillis() } answers { elapsed }
every { it.readBootCount() } answers { boot }
}
private val trailerReturn = TrailerReturnState(clock)
private lateinit var destination: Destination
private fun mount(person: JellyfinUser = user, automatic: Boolean = false) {
liveSession.value = CurrentUser(server, person)
val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
val data = fixture.title("Locke", "justin").copy(trailerUrl = "https://www.youtube.com/watch?v=ab_-defghij")
destination = Destination.Wolfskin("title/${data.item.id}")
val shell = ShellState(fixture.people, "justin").apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/${data.item.id}") }
val title = TitleState(data, shell.person) { action ->
if (action is TitleAction.Trailer) assertTrue(TitleTrailer(rule.activity).open(action.url) {
trailerReturn.prepare(liveSession.value, destination)
})
}
rule.setContent {
WolfskinTheme {
SignedInContent(CurrentUser(server, person), liveSession, signInAutomatically = automatic,
trailerReturn, { destination }) {
TitleSurface(title, shell)
}
}
}
rule.onNodeWithTag("title-heading").assertTextEquals("Locke")
}
private fun stopAndReturn() {
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
// Startup or onResume may clear the marker before the returning composition catches up.
trailerReturn.finishHandoff()
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
}
@Test fun closingTrailerKeepsTheSameTitleMountedWithAutomaticSignInOff() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij"))
val handler = ResolveInfo().apply {
activityInfo = ActivityInfo().apply { packageName = "test.youtube"; name = "test.youtube.Player"; exported = true }
}
shadowOf(rule.activity.packageManager).addResolveInfoForIntent(intent, handler)
mount()
rule.onNodeWithTag("title-option-trailer").performClick()
assertEquals("test.youtube", shadowOf(rule.activity).nextStartedActivity.`package`)
stopAndReturn()
rule.onNodeWithTag("title-heading").assertIsDisplayed().assertTextEquals("Locke")
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "trailer-return").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "returned-title.png"))
}
@Test fun aSecondOrdinaryReturnAfterTheTrailerHidesTheTitle() {
mount()
trailerReturn.prepare(liveSession.value, destination)
stopAndReturn()
rule.onNodeWithTag("title-heading").assertIsDisplayed()
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun handoffExpiringWhileAwayDoesNotRevealTheTitle() {
mount()
trailerReturn.prepare(liveSession.value, destination)
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
elapsed += 600_000L
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun changedBootWhileAwayDoesNotRevealTheTitle() {
mount()
trailerReturn.prepare(liveSession.value, destination)
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
boot += 1
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun requireLoginAddedDuringAHandoffKeepsTheTitleHidden() {
mount()
trailerReturn.prepare(liveSession.value, destination)
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
liveSession.value = CurrentUser(server, user.copy(requireLogin = true))
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun ordinaryWarmReturnWithAutomaticSignInOnKeepsTheTitleVisible() {
mount(automatic = true)
stopAndReturn()
rule.onNodeWithTag("title-heading").assertIsDisplayed()
}
@Test fun ordinaryWarmReturnHidesTheTitleWithAutomaticSignInOff() {
mount()
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun stoppedPinProtectedTitleRemainsHiddenUntilThePickerUnlocksIt() {
mount(user.copy(pin = "1234"))
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun stoppedRequireLoginTitleRemainsHiddenUntilAuthentication() {
mount(user.copy(requireLogin = true))
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun protectionAddedAfterOpeningTheTitleIsReadAtStopNotFromTheOldEntry() {
mount()
trailerReturn.prepare(liveSession.value, destination)
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun profileProtectedWhileAwayDoesNotReappearOnReturn() {
mount()
trailerReturn.prepare(liveSession.value, destination)
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
}

View file

@ -72,6 +72,7 @@ class TestMainActivityViewModel {
backdropService,
appUpgradeHandler,
intentService,
trailerReturn = mockk(relaxed = true),
)
private val serverId = UUID.randomUUID()
@ -88,6 +89,7 @@ class TestMainActivityViewModel {
@Before
fun setUp() {
WholphinDispatchers.configure(testDispatcher)
every { setupNavigationManager.backStack } returns mutableListOf(SetupDestination.Loading)
every { appUpgradeHandler.needUpgrade() } returns false
every { appUpgradeHandler.copySubfont(any()) } returns Unit
mockkStatic(Toast::class)
@ -167,14 +169,22 @@ class TestMainActivityViewModel {
@Test
fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) {
setupPreferences { signInAutomatically = true }
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser)
val canonical = MutableStateFlow<CurrentUser?>(null)
every { serverRepository.current } returns canonical
coEvery { serverRepository.restoreSavedSession() } coAnswers {
canonical.value = currentUser
RestoredSession.Success(currentUser)
}
val release = CompletableDeferred<Unit>()
coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() }
val validationStarted = CompletableDeferred<Unit>()
coEvery { serverRepository.revalidateSession(currentUser) } coAnswers {
validationStarted.complete(Unit)
release.await()
}
viewModel.handleAppStart()
runCurrent()
verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) }
assertFalse(release.isCompleted)
Assert.assertTrue(validationStarted.isCompleted)
release.complete(Unit)
}

View file

@ -88,7 +88,7 @@ class AnimeAvailabilityTest {
}
}
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try {
requireNotNull(model.onForeground(server, user)).join()
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)

View file

@ -69,7 +69,7 @@ class AnimeRootRemovalTest {
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try {
model.load(server, user)
requireNotNull(model.onForeground(server, user)).join()

View file

@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest {
val client = session.identity.value!!.client
val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try {
requireNotNull(model.onForeground(server, user)).join()
assertNull(libraries.audiences)
@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest {
session.authenticate()
val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try {
val identity = session.identity.value!!
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(

View file

@ -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 {

View file

@ -50,7 +50,7 @@ class ConductarrTitleSurfaceTest {
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val revokedLookup = CompletableDeferred<java.util.UUID?>()
try {
val route = titleRoute("movie:42")

View file

@ -60,7 +60,7 @@ class DiscoverRefreshMotionTest {
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", false)
val route = titleRoute("tv:44")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
@ -112,7 +112,7 @@ class DiscoverRefreshMotionTest {
@Test fun loadedFixtureRefreshKeepsTheSameReadyHeaderAndCatalogueIdentity() {
ConductarrSession(MemoryCacheStore()).use { session ->
val model = WolfskinTitleViewModel(rule.activity, mockk(relaxed = true), TitleFixtureProvider(rule.activity),
NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("justin", "Justin", "J", true)
val route = titleRoute("movie-the-amazing-spider-man-2012")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
@ -166,7 +166,7 @@ class DiscoverRefreshMotionTest {
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(any(), any()) } returns null
coEvery { facade.read(any(), any(), any()) } returns held
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", false)
val route = titleRoute(id.toString())
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination(route) }

View file

@ -71,7 +71,7 @@ class DiscoverTitleTimingTest {
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) }
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true))
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
}

View file

@ -91,7 +91,7 @@ class FirstSignInNavigationTest {
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf(
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
try {

View file

@ -0,0 +1,192 @@
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 directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath)
val width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width
val samples = mutableListOf<String>()
fun frame(phase: String, index: Int): Int {
val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png")
val clock = rule.mainClock.currentTime
rule.onRoot().captureRoboImage(file.absolutePath)
rule.waitForIdle()
assertEquals("Native capture cannot warm the animation clock", clock, rule.mainClock.currentTime)
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)
}
press()
val origin = rule.mainClock.currentTime
repeat(14) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
frame("home-start", index)
assertEquals("Every frame after OK is observed, including frame zero", origin + index * 16L, rule.mainClock.currentTime)
if (index >= 2) {
rule.onNodeWithText("Starting…").assertIsDisplayed()
assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f)
}
if (index == 3) press()
assertTrue(navigation.backStack.single() is Destination.Home)
}
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()) }
// Returning from the player requires a painted handoff first, not a pop in the same unrendered turn.
rule.mainClock.advanceTimeByFrame()
rule.onRoot().captureRoboImage(File(directory, "home-disposed.png").absolutePath)
rule.onAllNodesWithTag("wolfskin-shell").assertCountEquals(0)
File(directory, "samples.csv").writeText("phase,timeMs,pixel,stackSize\n" + samples.joinToString("\n"))
rule.runOnIdle { navigation.goBack(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
rule.onRoot().captureRoboImage(File(directory, "home-return-first.png").absolutePath)
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
rule.onNodeWithText("Starting…").assertDoesNotExist()
}
@Test fun failedPreparationRestoresResumeAndShowsTheExistingRecoveryToast() {
mount()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/failure").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath)
press()
repeat(14) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
rule.onRoot().captureRoboImage(File(directory, "preparing-" + index.toString().padStart(2, '0') + ".png").absolutePath)
if (index >= 2) rule.onNodeWithText("Starting…").assertIsDisplayed()
}
release.completeExceptionally(IOException("offline"))
await { model.toast != null }
repeat(14) { index ->
rule.mainClock.advanceTimeByFrame()
rule.onRoot().captureRoboImage(File(directory, "restored-" + index.toString().padStart(2, '0') + ".png").absolutePath)
}
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
assertTrue(navigation.backStack.single() is Destination.Home)
}
@Test fun leavingHomeCancelsPreparationAndCannotLaunchALateResult() {
mount()
press()
assertEquals("Leaving Home must cancel an actual pending start", "hero-play", session.playback.starting)
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) }
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
}
private fun await(condition: () -> Boolean) {
rule.waitUntil(5000) {
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
shadowOf(Looper.getMainLooper()).idle()
condition()
}
rule.waitForIdle()
}
}

View file

@ -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)

View file

@ -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()

View file

@ -0,0 +1,413 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import android.os.Looper
import androidx.compose.foundation.background
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.platform.testTag
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
private const val PLAYER_PAINT = 0xff204060
/** Native forward cuts paint by 32 ms, Back by 64 ms; all outgoing semantics retire by 64 ms. */
@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 titleAndTabPaintWithinTwoFramesBackWithinFour() = cuts(false)
@Test fun reducedMotionKeepsOpaqueCutsAndRetiresOutgoingByFourFrames() = cuts(true)
private fun cuts(reduced: Boolean) {
host = PolishAppHost(rule)
host.mount(picker = false, reduced = reduced)
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:")
}
// Establish a painted Home and a focused shelf card before starting the navigation clock.
rule.onRoot().captureRoboImage(File(directory, "home-precondition.png").absolutePath)
rule.onNodeWithText("Who’s watching?").assertDoesNotExist()
rule.runOnIdle { host.session.state!!.focusNav(0); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
repeat(2) {
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L) }
rule.waitForIdle()
}
assertFalse("Home card activation cannot start from a picker", host.session.state!!.pickerVisible)
assertNotNull("The OK precondition is a shelf card, not the nav or hero", host.session.state!!.contentFocus?.cardKey)
assertTrue(host.navigation.backStack.single() is Destination.Home)
rule.onRoot().captureRoboImage(File(directory, "home-card-ready.png").absolutePath)
rule.mainClock.autoAdvance = false
fun strip(phase: String, previousFrame: File) {
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
val origin = rule.mainClock.currentTime
var firstIncoming: Int? = null
// These fixture-art probes are outside the entering title copy. Composition alone cannot prove paint.
val x = intArrayOf(1500, 1700, 1900, 1500, 1700, 1900)
val y = intArrayOf(300, 500, 700, 700, 900, 300)
val outgoingPaint = IntArray(x.size)
val paint = IntArray(x.size)
val previous = BitmapFactory.decodeFile(previousFrame.absolutePath)
val opaqueAvatar = try {
for (point in x.indices) outgoingPaint[point] = previous.getPixel(x[point], y[point])
previous.getPixel(1774, 90)
} finally { previous.recycle() }
try {
repeat(21) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
rule.waitForIdle()
val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size
val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size
val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size
val libraries = rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().size
val image = BitmapFactory.decodeFile(file.absolutePath)
val avatar = try {
for (point in x.indices) paint[point] = image.getPixel(x[point], y[point])
image.getPixel(1774, 90)
} finally { image.recycle() }
samples.add("$phase,${index * 16},$shells,$titles,$cards,$libraries,$avatar,${paint.joinToString(";")}")
assertEquals("Observing a native frame cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime)
assertEquals("The persistent avatar cannot acquire whole-screen transition alpha or a second blended nav", opaqueAvatar, avatar)
val composed = when (phase) {
"open-title" -> titles == 1
"back" -> cards > 0
else -> libraries == 1
}
val incoming = composed && !paint.contentEquals(outgoingPaint)
if (incoming && firstIncoming == null) {
firstIncoming = index
val limit = if (phase == "back") 4 else 2
assertTrue("$phase first painted incoming after $index frames (${index * 16} ms), exceeding $limit frames", index <= limit)
}
if (firstIncoming != null) assertTrue("Native paint cannot revert to the outgoing screen", incoming)
if (index >= 4) {
assertEquals("By 64 ms exactly one destination owns the nav semantics", 1, shells)
assertEquals(if (phase == "open-title") 1 else 0, titles)
assertEquals(if (phase == "tab") 1 else 0, libraries)
if (phase != "back") assertEquals("Outgoing Home cards must retire by 64 ms", 0, cards)
else assertTrue("Back must restore Home, not leave a blank destination", cards > 0)
}
}
assertNotNull("Incoming destination did not appear within the 320 ms strip", firstIncoming)
} finally {
File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards,libraries,avatarPixel,backdropPixels\n" + samples.joinToString("\n"))
}
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) }
assertEquals("OK must change the back stack before the first observed frame", 2, host.navigation.backStack.size)
strip("open-title", File(directory, "home-card-ready.png"))
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
strip("back", File(directory, "open-title-20.png"))
// Settle focus before OK; that focus animation is not a screen transition.
rule.mainClock.autoAdvance = true
rule.runOnIdle { host.session.state!!.focusNav(2); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.onRoot().captureRoboImage(File(directory, "tab-ready.png").absolutePath)
assertTrue(host.navigation.backStack.single() is Destination.Home)
rule.mainClock.autoAdvance = false
rule.runOnIdle { host.session.state!!.key(ShellKey.Ok) }
strip("tab", File(directory, "tab-ready.png"))
}
@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 fullRootPlaybackHandoffNeverRepaintsHome() = rootHandoff(false)
@Test fun reducedMotionFullRootHandoffNeverRepaintsHome() = rootHandoff(true)
private fun rootHandoff(reduced: Boolean) {
host = PolishAppHost(rule)
host.playerReady = false
host.mount(picker = false, reduced = reduced)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/root-" + if (reduced) "reduced" else "normal").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath)
rule.mainClock.autoAdvance = false
val destination = Destination.Playback(UUID.randomUUID(), 0L)
rule.runOnIdle {
host.session.playback.begin("hero-play")
host.session.playback.ready { host.navigation.navigateTo(destination) }
}
val origin = rule.mainClock.currentTime
val samples = mutableListOf<String>()
var navigationTime: Long? = null
var playerPainted = false
try {
repeat(21) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
val file = File(directory, "handoff-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
val logicalPlayer = host.navigation.backStack.last() == destination
val time = rule.mainClock.currentTime - origin
val image = BitmapFactory.decodeFile(file.absolutePath)
try {
val pixel = image.getPixel(600, 500)
val avatar = image.getPixel(1774, 90)
samples.add("$time,$logicalPlayer,$pixel,$avatar")
if (logicalPlayer) {
if (navigationTime == null) navigationTime = time
assertTrue("After handoff only opaque black or the incoming player may paint; never old Home",
pixel == android.graphics.Color.BLACK || pixel == PLAYER_PAINT.toInt())
assertEquals("The old Home nav cannot flash through the completed veil", pixel, avatar)
assertEquals(pixel, image.getPixel(100, 900))
assertEquals(pixel, image.getPixel(1800, 900))
if (pixel == PLAYER_PAINT.toInt()) playerPainted = true
if (pixel == android.graphics.Color.BLACK && time == navigationTime + 16) {
val revision = host.session.state!!.inputRevision
rule.onRoot().performKeyInput {
pressKey(Key.DirectionRight, 0L); pressKey(Key.DirectionCenter, 0L); pressKey(Key.Escape, 0L)
}
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertEquals("Keys cannot reach old Home during the pending native player scene", revision, host.session.state!!.inputRevision)
assertEquals(destination, host.navigation.backStack.last())
}
if (time == navigationTime + 48) rule.runOnIdle {
host.playerReady = true
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
}
}
} finally { image.recycle() }
assertEquals("The full-root probe cannot warm the manual clock", origin + index * 16L, rule.mainClock.currentTime)
}
assertTrue("The incoming player must eventually be uncovered", playerPainted)
assertEquals(2, host.navigation.backStack.size)
rule.onAllNodesWithTag("wolfskin-shell").assertCountEquals(0)
assertNull(host.session.playback.starting)
assertNull(host.session.playback.launch)
} finally {
File(directory, "samples.csv").writeText("timeMs,logicalPlayer,bodyPixel,avatarPixel\n" + samples.joinToString("\n"))
}
}
@Test fun setupHostKeepsOpaquePaintAndRetiresOutgoingWithinFourFrames() = setupCuts(false)
@Test fun reducedMotionSetupHostKeepsTheSameCutDeadlines() = 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 colors = mapOf("ServerList" to 0xff304060.toInt(), "UserList" to 0xff406030.toInt(), "AppContent" to 0xff603040.toInt())
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, kotlinx.coroutines.flow.MutableStateFlow<com.github.damontecres.wholphin.data.CurrentUser?>(null), mockk(relaxed = true),
Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
setupInterceptor = { destination, modifier ->
androidx.compose.foundation.layout.Box(modifier.then(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
.then(Modifier.background(androidx.compose.ui.graphics.Color(colors.getValue(destination.javaClass.simpleName))))) {
androidx.tv.material3.Text(destination.javaClass.simpleName)
}
true
})
} } }
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() }
val samples = mutableListOf<String>()
rule.onRoot().captureRoboImage(File(directory, "setup-precondition.png").absolutePath)
rule.onAllNodesWithText("ServerList").assertCountEquals(1)
rule.mainClock.autoAdvance = false
for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) {
val previous = stack.single().javaClass.simpleName
val next = destination.javaClass.simpleName
rule.runOnIdle { stack[0] = destination; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
val origin = rule.mainClock.currentTime
var firstIncoming: Int? = null
try {
repeat(21) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
val file = File(directory, next + "-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
rule.waitForIdle()
val counts = colors.keys.associateWith { rule.onAllNodesWithText(it).fetchSemanticsNodes().size }
val image = BitmapFactory.decodeFile(file.absolutePath)
val pixel = try {
val sample = image.getPixel(600, 500)
assertEquals("The entire setup scene must share its opaque paint", sample, image.getPixel(100, 500))
assertEquals(sample, image.getPixel(1500, 500))
assertEquals(sample, image.getPixel(600, 900))
sample
} finally { image.recycle() }
samples.add("$next,${index * 16},${counts.getValue(previous)},${counts.getValue(next)},$pixel")
assertEquals("Native setup observation cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime)
val incoming = pixel == colors.getValue(next)
assertTrue("Setup native paint is exactly the outgoing or incoming scene, never an alpha composite",
incoming || pixel == colors.getValue(previous))
if (incoming && firstIncoming == null) {
firstIncoming = index
assertTrue("$next first visible after $index frames (${index * 16} ms), not within two frames", index <= 2)
}
if (firstIncoming != null) assertTrue("Incoming native paint cannot revert to the outgoing scene", incoming)
if (index >= 4) {
assertEquals("By 64 ms only the incoming setup screen may remain in semantics", 1, counts.values.sum())
rule.onNodeWithText(previous).assertDoesNotExist()
rule.onAllNodesWithText(next).assertCountEquals(1)
}
}
assertNotNull("Setup screen did not appear within 320 ms", firstIncoming)
} finally {
File(directory, "samples.csv").writeText("destination,timeMs,previous,incoming,pixel\n" + samples.joinToString("\n"))
}
}
}
}
/** 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)
var playerReady by mutableStateOf(true)
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
val backdrop = mockk<BackdropService>(relaxed = true)
every { backdrop.backdropFlow } returns kotlinx.coroutines.flow.MutableStateFlow(BackdropResult.NONE)
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, backdrop,
mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true),
org.justinvisser.wolfskin.tv.update.FakeUpdateController(), mockk(relaxed = true)).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)
},
fullScreenContent = { destination, modifier ->
assertTrue(destination is Destination.Playback)
androidx.compose.foundation.layout.Box(modifier.then(if (playerReady) Modifier.background(androidx.compose.ui.graphics.Color(PLAYER_PAINT)) else Modifier)
.testTag("polish-player"))
})
}
}
}
await { session.state?.people?.size == users.size && model.signedInPerson == "justin" }
rule.runOnIdle {
session.state!!.pickerVisible = picker
session.state!!.pickerLaunch = picker
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
}
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
}
}

View file

@ -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) } }
}

View file

@ -85,7 +85,7 @@ class PersonPickerSessionTest {
val navigation = remember { NavigationManager() }
val model = remember {
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)).also { models.add(it) }
}
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),

View file

@ -87,7 +87,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
val clients = mutableListOf<ConductarrClient>()
try {
model.load(server, justin)
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try {
model.load(server, justin)
requireNotNull(model.onForeground(server, justin)).join()
@ -219,8 +219,8 @@ 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 titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true))
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), trailerReturn = mockk(relaxed = true))
try {
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)
conductarr.authenticate()

View file

@ -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)

View file

@ -63,7 +63,7 @@ class SavedPeoplePickerTest {
val session = WolfskinSession()
val navigation = NavigationManager()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true),
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try {
rule.setContent {
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),

View file

@ -66,7 +66,7 @@ class ShellStartupTimingTest {
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
val load = launch { model.load(server, user) }
try {
withTimeout(5_000) { savedPeopleStarted.await() }
@ -121,7 +121,7 @@ class ShellStartupTimingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
val pending = CompletableDeferred<Unit>()
val release = java.util.concurrent.CountDownLatch(1)
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)

View file

@ -60,7 +60,7 @@ class TitleAsyncFocusTest {
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(id, any()) } returns null
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true))
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("justin", "Justin", "J", false)
val route = titleRoute(id.toString())
navigation.navigateTo(Destination.Wolfskin(route))
@ -103,7 +103,7 @@ class TitleAsyncFocusTest {
val facade = mockk<TitleReadFacade>()
coEvery { facade.readPersonSaved(id) } returns null
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true))
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val viewer = ShellPerson("justin", "Justin", "J", false)
val route = personRoute(id.toString())
navigation.navigateTo(Destination.Wolfskin(route))
@ -150,7 +150,7 @@ class TitleAsyncFocusTest {
reads.incrementAndGet()
throw java.io.IOException("offline")
}
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true))
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val viewer = ShellPerson("justin", "Justin", "J", false)
val route = titleRoute(id.toString())
navigation.navigateTo(Destination.Wolfskin(route))

View file

@ -80,10 +80,10 @@ class TitleEntryBackTest {
ConductarrPreference.updateFixtures(true)
val server = com.github.damontecres.wholphin.data.model.JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
val user = com.github.damontecres.wholphin.data.model.JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
val source = WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr)
val source = WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, WolfskinSession())
homeModel = source
viewModel = WolfskinTitleViewModel(rule.activity, mockk(), TitleFixtureProvider(rule.activity), navigation, conductarr,
mockk(relaxed = true), mockk(relaxed = true))
mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
val onBack = rememberWolfskinBackHandler(shell, navigation)
@ -183,7 +183,7 @@ class TitleEntryBackTest {
}
conductarr = ConductarrSession(MemoryCacheStore())
viewModel = WolfskinTitleViewModel(activity, facade, mockk<TitleFixtureProvider>(), navigation, conductarr,
mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
shell.enterDestination(route)
navigation.navigateTo(destination)
val home = HomeSnapshot(listOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(HomeTitle("home-film", "Home film", "movie")))))

View file

@ -1,6 +1,9 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import org.junit.Rule
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
@ -35,6 +38,7 @@ import java.util.UUID
@Config(sdk = [34], application = Application::class)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class TitlePlaybackNavigationTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001")
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002")
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003")
@ -55,7 +59,7 @@ class TitlePlaybackNavigationTest {
episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor")))
}
val session = ConductarrSession(MemoryCacheStore())
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true)
try {
navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString())))
@ -109,25 +113,37 @@ class TitlePlaybackNavigationTest {
coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE,
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
val session = ConductarrSession(MemoryCacheStore())
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
rule.mainClock.autoAdvance = false
rule.setContent { model.title?.let { PlaybackHandoffVeil(it.playbackStart) } }
fun finishHandoff() {
rule.waitForIdle()
assertFalse("Preparation alone cannot navigate before black", navigation.backStack.last() is Destination.Playback)
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.base.toLong() + 48)
rule.waitForIdle()
}
try {
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
advanceUntilIdle()
model.title!!.click("header", 0)
advanceUntilIdle()
finishHandoff()
assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last())
navigation.goBack()
model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" })
advanceUntilIdle()
finishHandoff()
assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last())
navigation.goBack()
model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false)
advanceUntilIdle()
model.title!!.click("header", 0)
advanceUntilIdle()
finishHandoff()
assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last())
coVerify(exactly = 1) { facade.preparePlayback(seriesId) }
} finally {
rule.mainClock.autoAdvance = true
model.viewModelScope.cancel()
session.close()
Dispatchers.resetMain()

View file

@ -0,0 +1,481 @@
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.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
import java.util.UUID
/** Native title transitions preserve focus, stream identity and immediate cached content. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitlePolishTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private var model: WolfskinTitleViewModel? = null
private var session: ConductarrSession? = null
private lateinit var state: TitleState
private lateinit var shell: ShellState
private val reduced = mutableStateOf(false)
private val entryActive = mutableStateOf(true)
private val samples = mutableListOf<String>()
private var bitmaps: Map<String, ImageBitmap> = emptyMap()
private val person = ShellPerson("justin", "Justin", "J", false)
private val id = UUID.fromString("00000000-0000-0000-0000-000000000091")
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
@After fun dispose() { model?.viewModelScope?.cancel(); session?.close() }
private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
private fun prepareShell() {
shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination(titleRoute(id.toString())); navFocused = false }
}
private fun render(data: TitlePageData = fixture().title("Locke", "justin"), reducedMotion: Boolean = false) {
reduced.value = reducedMotion
prepareShell()
state = TitleState(data, person) { }
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp))
} } }
rule.waitForIdle()
rule.mainClock.advanceTimeBy(600)
rule.mainClock.autoAdvance = false
}
private fun mountModel(facade: TitleReadFacade, data: TitlePageData, reducedMotion: Boolean) : NavigationManager {
reduced.value = reducedMotion
prepareShell()
coEvery { facade.readSaved(id, any()) } returns null
coEvery { facade.read(id, any(), any()) } returns data
val navigation = NavigationManager().apply { navigateTo(Destination.Wolfskin(titleRoute(id.toString()))) }
session = ConductarrSession(MemoryCacheStore())
model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session!!,
mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalTitleBitmaps provides bitmaps) { WolfskinTheme {
val route = titleRoute(id.toString())
ScreenEntry(shell, route, entryActive.value && navigation.backStack.lastOrNull() == Destination.Wolfskin(route)) {
TitleDestination(route, shell, preferences, Modifier.requiredSize(960.dp, 540.dp), model!!, fixtures = false)
}
} } }
rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); model!!.title?.data?.status == TitleReadStatus.Ready }
state = model!!.title!!
rule.waitForIdle()
rule.mainClock.advanceTimeBy(600)
rule.mainClock.autoAdvance = false
return navigation
}
private fun tick() { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
private fun path(idea: String, name: String, index: Int): File = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")),
"ui-polish/$idea/${if (reduced.value) "reduced-" else ""}$name-${index.toString().padStart(2, '0')}.png").also { requireNotNull(it.parentFile).mkdirs() }
private fun frame(idea: String, name: String, index: Int, sample: (android.graphics.Bitmap) -> Int): Int {
val file = path(idea, name, index)
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
return try { sample(bitmap).also { samples.add("$name,${index * 16},$it") } } finally { bitmap.recycle() }
}
private fun brightness(bitmap: android.graphics.Bitmap, x: Int, y: Int): Int = bitmap.getPixel(x, y).let {
android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it)
}
private fun saveSamples(idea: String) {
File(path(idea, "samples", 0).parentFile, "${if (reduced.value) "reduced-" else ""}${samples.first().substringBefore(',')}-samples.csv")
.writeText("phase,timeMs,value\n" + samples.joinToString("\n"))
}
private fun monotonic(values: List<Int>) {
assertNotEquals("Motion changes visible pixels", values.first(), values.last())
val direction = if (values.last() > values.first()) 1 else -1
values.zipWithNext().forEach { (before, after) -> assertTrue("No reversal: $values", (after - before) * direction >= -1) }
}
@Test fun versionSheetFadesWithScrimAndReturnsFocusOnce() = versionSheet(false)
@Test fun reducedVersionSheetFadesWithScrimAndReturnsFocusOnce() = versionSheet(true)
private fun versionSheet(reducedMotion: Boolean) {
render(fixture().title("Locke", "justin"), reducedMotion)
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "version" }) }
rule.onRoot().performKeyInput { pressKey(androidx.compose.ui.input.key.Key.DirectionCenter) }
val duration = if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.sheetIn
val scrim = mutableListOf<Int>()
val panel = mutableListOf<Int>()
repeat(duration / 16 + 3) { index ->
frame("P06", "title-version", index) { bitmap ->
scrim.add(brightness(bitmap, 400, 900)); brightness(bitmap, 1900, 900).also { panel.add(it) }
}
tick()
}
monotonic(scrim); monotonic(panel)
val opener = state.column
rule.onRoot().performKeyInput { pressKey(androidx.compose.ui.input.key.Key.Escape) }
val owner = hasTestTag("title-page") or hasTestTag("title-option-version")
val closing = mutableListOf<Int>()
repeat((if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.sheetOut) / 16 + 3) { index ->
closing.add(frame("P06", "title-version-out", index) { brightness(it, 1900, 900) })
if (index == 2) {
rule.waitForIdle()
rule.runOnIdle { }
rule.onNode(owner and isFocused(), useUnmergedTree = true).assertExists()
rule.onNodeWithTag("title-sheet", useUnmergedTree = true)
.assert(SemanticsMatcher.keyNotDefined(androidx.compose.ui.semantics.SemanticsProperties.Focused))
rule.onAllNodes(hasAnyAncestor(hasTestTag("title-sheet")) and
SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsProperties.Focused), useUnmergedTree = true).assertCountEquals(0)
}
assertNull(state.sheet); assertEquals(opener, state.column); assertFalse(shell.navFocused)
tick()
}
monotonic(closing)
rule.onNodeWithTag("title-sheet").assertDoesNotExist()
rule.onNode(owner and isFocused(), useUnmergedTree = true).assertExists()
saveSamples("P06")
}
private fun pendingPlayback(reducedMotion: Boolean): Triple<TitleReadFacade, CompletableDeferred<BaseItemDto?>, NavigationManager> {
val facade = mockk<TitleReadFacade>()
val prepared = CompletableDeferred<BaseItemDto?>()
coEvery { facade.preparePlayback(id) } coAnswers { prepared.await() }
val data = fixture().title("Locke", "justin")
val target = data.item.copy(id = id.toString())
val navigation = mountModel(facade, data.copy(item = target, playTarget = target, watchTarget = target,
related = listOf(TitleItem("other", "Other title", "movie"))), reducedMotion)
return Triple(facade, prepared, navigation)
}
@Test fun playSaysStartingThenFadesToBlackBeforeThePlayer() = playback(false)
@Test fun reducedPlaySaysStartingThenFadesToBlackBeforeThePlayer() = playback(true)
private fun playback(reducedMotion: Boolean) {
val (facade, prepared, navigation) = pendingPlayback(reducedMotion)
val resting = rule.onNodeWithTag("title-option-play").fetchSemanticsNode().boundsInRoot
rule.runOnIdle { state.click("header", 0) }
repeat(13) { index -> frame("E04", "title-start", index) { brightness(it, 100, 900) }; tick() }
rule.onNodeWithText("Starting…").assertIsDisplayed()
assertEquals(resting.width, rule.onNodeWithTag("title-option-play").fetchSemanticsNode().boundsInRoot.width, .1f)
rule.runOnIdle { state.click("header", 0) }
coVerify(exactly = 1) { facade.preparePlayback(id) }
rule.runOnIdle { prepared.complete(BaseItemDto(id = id, type = BaseItemKind.MOVIE)) }
rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.playbackStart.launch != null }
assertFalse(navigation.backStack.last() is Destination.Playback)
val values = mutableListOf<Int>()
val duration = if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.base
repeat(duration / 16 + 3) { index ->
values.add(frame("E04", "title-veil", index) { brightness(it, 100, 900) })
if (index * 16 < duration) assertFalse(navigation.backStack.last() is Destination.Playback)
tick()
}
assertEquals(Destination.Playback(id, 0L), navigation.backStack.last())
assertEquals(1, navigation.backStack.count { it is Destination.Playback })
assertTrue("The handoff reaches black before navigation", values.contains(0))
assertNull(state.playbackStart.starting)
rule.runOnIdle { navigation.goBack() }
coEvery { facade.preparePlayback(id) } throws IllegalStateException("offline")
rule.runOnIdle { state.click("header", 0) }
rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.toast == RecoveryMessage.PlaybackUnavailable }
repeat(14) { tick() }
rule.onNodeWithText("Starting…").assertDoesNotExist()
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
assertFalse(navigation.backStack.last() is Destination.Playback)
saveSamples("E04")
}
@Test fun progressFillsToTheNewPointAfterPlayback() = progress(false)
@Test fun reducedProgressSnapsToTheNewPointAfterPlayback() = progress(true)
@Test fun lateRuntimeMetadataAnimatesAnExistingResumeTrack() = progress(false, runtimeKnown = false)
@Test fun reducedLateRuntimeMetadataSnapsAnExistingResumeTrack() = progress(true, runtimeKnown = false)
private fun progress(reducedMotion: Boolean, runtimeKnown: Boolean = true) {
val data = fixture().title("Locke", "justin")
val target = data.item.copy(runtimeSeconds = if (runtimeKnown) 1000 else 0, resumeSeconds = 134)
val from = if (runtimeKnown) .134f else 0f
val phase = if (runtimeKnown) "title-progress" else "title-progress-late-runtime"
render(data.copy(playTarget = target), reducedMotion)
val bounds = rule.onNodeWithTag("title-progress").fetchSemanticsNode().boundsInRoot
val width = (WolfskinTokens.Title.progressWidth.value * 2).toInt()
fun edge(bitmap: android.graphics.Bitmap): Int = (0 until width).lastOrNull { x ->
bitmap.getPixel(bounds.left.toInt() + x, bounds.center.y.toInt()) == WolfskinTokens.Color.accent.toArgb()
}?.plus(1) ?: 0
val initial = frame("E06", "$phase-initial", 0, ::edge)
assertEquals(width * from, initial.toFloat(), 1f)
rule.runOnIdle { state.update(state.data.copy(playTarget = target.copy(runtimeSeconds = 1000, resumeSeconds = 419))) }
val values = List(23) { index ->
frame("E06", phase, index, ::edge).also {
if (index == 1) rule.onNodeWithText("10 min left").assertIsDisplayed()
tick()
}
}
assertEquals(width * .419f, values.last().toFloat(), 1f)
if (reducedMotion) assertEquals(values.last(), values[1]) else {
monotonic(values)
assertEquals(width * (from + .419f) / 2f, values[11].toFloat(), width * .06f)
}
rule.onNodeWithText("10 min left").assertIsDisplayed()
rule.runOnIdle { state.update(state.data.copy(playTarget = target.copy(id = "other", runtimeSeconds = 1000, resumeSeconds = 800))) }
tick()
assertEquals(width * .8f, frame("E06", "title-new-target", 0, ::edge).toFloat(), 1f)
saveSamples("E06")
}
@Test fun cardProgressFillsAfterPlaybackWithoutAnimatingItsFirstPaint() = cardProgress(false)
@Test fun reducedCardProgressSnapsAfterPlayback() = cardProgress(true)
private fun cardProgress(reducedMotion: Boolean) {
reduced.value = reducedMotion
val title = HomeTitle("card", "Card", "movie", runtimeSeconds = 1000, resumeSeconds = 134.0, held = true)
val row = mutableStateOf(HomeRow("resume", HomeRowKind.Shelf, cards = listOf(title)))
val memory = HomeViewMemory()
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { HomeShelf(row.value, memory, false, 0, emptyList(), {}, {}) }
} } }
rule.waitForIdle(); rule.mainClock.advanceTimeBy(600); rule.mainClock.autoAdvance = false
val bounds = rule.onNodeWithTag("home-card:resume:0").fetchSemanticsNode().boundsInRoot
val y = (bounds.bottom - (WolfskinTokens.CardMetadata.bottom.value + WolfskinTokens.CardMetadata.resumeLine.value / 2) * 2).toInt()
fun edge(bitmap: android.graphics.Bitmap): Int = (0 until bounds.width.toInt()).lastOrNull { x ->
bitmap.getPixel(bounds.left.toInt() + x, y) == WolfskinTokens.Color.accent.toArgb()
}?.plus(1) ?: 0
assertEquals(bounds.width * .134f, frame("E06", "card-progress-initial", 0, ::edge).toFloat(), 1f)
rule.runOnIdle { row.value = row.value.copy(cards = listOf(title.copy(resumeSeconds = 419.0))) }
val values = List(23) { index -> frame("E06", "card-progress", index, ::edge).also { tick() } }
assertEquals(bounds.width * .419f, values.last().toFloat(), 1f)
if (reducedMotion) assertEquals(values.last(), values[1]) else monotonic(values)
saveSamples("E06")
}
@Test fun seasonCapsuleGlidesAndCachedSeasonSkipsSkeleton() = seasons(false)
@Test fun reducedSeasonCapsuleSnapsAndCachedSeasonSkipsSkeleton() = seasons(true)
private fun seasons(reducedMotion: Boolean) {
val facade = mockk<TitleReadFacade>()
val series = TitleItem(id.toString(), "Series", "tv", held = true, seasons = 6)
fun image(color: Int) = android.graphics.Bitmap.createBitmap(2, 2, android.graphics.Bitmap.Config.ARGB_8888)
.apply { eraseColor(color) }.asImageBitmap()
bitmaps = mapOf("cache:fifth" to image(android.graphics.Color.RED), "cache:sixth" to image(android.graphics.Color.GREEN))
val fifth = TitleItem("fifth", "Cached episode", "episode", season = 5, episode = 1, backdrop = "cache:fifth")
val sixth = fifth.copy(id = "sixth", name = "New episode", season = 6, backdrop = "cache:sixth")
val refresh = CompletableDeferred<TitlePageData>()
val data = TitlePageData(series, selectedSeason = 5, episodes = listOf(fifth))
val navigation = mountModel(facade, data, reducedMotion)
coEvery { facade.read(id, 6, any()) } returns data.copy(selectedSeason = 6, episodes = listOf(sixth))
coEvery { facade.read(id, 5, any()) } coAnswers { refresh.await() }
rule.runOnIdle { state.focus("seasons", 4) }
repeat(25) { tick() }
rule.runOnIdle { state.click("seasons", 5); state.focus("seasons", 0) }
val row = rule.onNode(hasText("Season 6") and hasClickAction()).fetchSemanticsNode().boundsInRoot
fun left(bitmap: android.graphics.Bitmap): Int = (0 until bitmap.width).firstOrNull { x ->
val pixel = bitmap.getPixel(x, (row.top + 4).toInt())
android.graphics.Color.red(pixel) > 180 && android.graphics.Color.green(pixel) < 210 && android.graphics.Color.blue(pixel) > 230
} ?: -1
val values = List(23) { index -> frame("E07", "season", index, ::left).also { tick() } }
assertTrue(values.all { it >= 0 })
if (reducedMotion) assertEquals(values.last(), values[1]) else monotonic(values)
values.forEach { assertTrue("No capsule overshoot", it <= values.last() + 1) }
rule.runOnIdle { state.click("seasons", 4) }
assertEquals("Cached episodes are installed synchronously", listOf(fifth), state.data.episodes)
assertEquals(TitleReadStatus.Ready, state.data.episodesStatus)
tick()
// Frame 0 may retain the outgoing real cards; the first incoming subtree is due by frame 1.
repeat(4) { index ->
assertEquals(listOf(fifth), state.data.episodes)
assertEquals(TitleReadStatus.Ready, state.data.episodesStatus)
val slot = hasTestTag("title-episode-slot-0")
rule.onAllNodes(slot, useUnmergedTree = true).assertCountEquals(1)
rule.onNode(slot and hasAnyDescendant(hasClickAction()), useUnmergedTree = true).assertExists()
rule.onAllNodes(hasText("Cached episode") or hasText("New episode"), useUnmergedTree = true).assertCountEquals(1)
val bounds = rule.onNode(slot, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val pixel = frame("E07", "cached-return", index) { bitmap ->
val pixels = (1..3).map { part ->
bitmap.getPixel((bounds.left + bounds.width * part / 4).toInt(), (bounds.top + 24).toInt())
}
assertTrue("Every frame has opaque real episode art, never a skeleton, blank row or partial alpha",
pixels.all { it == android.graphics.Color.RED || it == android.graphics.Color.GREEN })
assertEquals("The episode artwork stays uniformly opaque", 1, pixels.distinct().size)
pixels.first()
}
if (index >= 1) {
assertEquals("The first incoming frame paints cached Season 5", android.graphics.Color.RED, pixel)
rule.onNode(hasText("Cached episode") and hasAnyAncestor(slot), useUnmergedTree = true).assertExists()
}
tick()
}
rule.onNode(hasClickAction() and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true)
.performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() }
assertEquals(Destination.Wolfskin(titleRoute(fifth.id)), navigation.backStack.last())
rule.runOnIdle { navigation.goBack() }
rule.runOnIdle { refresh.completeExceptionally(IllegalStateException("offline")) }
rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.toast == RecoveryMessage.StillUnreachable }
assertEquals(listOf(fifth), state.data.episodes)
saveSamples("E07")
}
@Test fun titleScrollKeepsTheCastPinnedWithoutChangingFocus() = titleScroll(false)
@Test fun reducedTitleScrollSnapsWithoutChangingFocus() = titleScroll(true)
private fun titleScroll(reducedMotion: Boolean) {
val data = fixture().title("Andor", "justin")
render(data.copy(episodes = data.episodes.distinctBy { it.id }), reducedMotion)
rule.runOnIdle { state.focus("cast", 0) }
val positions = mutableListOf<Int>()
repeat(26) { index ->
frame("E05", "title-scroll", index) { brightness(it, 400, 900) }
val offset = shell.view.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "cast" }?.offset
if (offset != null) positions.add(offset)
tick()
}
rule.mainClock.advanceTimeUntil(2000) { !shell.view.listState.isScrollInProgress }
rule.waitForIdle()
val settled = shell.view.listState.layoutInfo.visibleItemsInfo.first { it.key == "cast" }.offset
assertEquals((WolfskinTokens.Layout.pinnedLine.value * 2).toInt(), settled)
if (reducedMotion) assertEquals(positions.last(), positions.first())
else positions.zipWithNext().forEach { (before, after) -> assertTrue(after <= before + 1) }
assertEquals("cast", state.row); assertEquals(0, state.column); assertFalse(shell.navFocused)
saveSamples("E05")
}
@Test fun personScrollKeepsTheSelectedCreditRowPinned() = personScroll(false)
@Test fun reducedPersonScrollSnapsToTheSelectedCreditRow() = personScroll(true)
private fun personScroll(reducedMotion: Boolean) {
reduced.value = reducedMotion
prepareShell()
val person = PersonState(TitlePerson("actor", "Actor"), List(30) { TitleItem("credit-$it", "Credit $it", "movie") }, onAction = { })
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
PersonSurface(person, shell, Modifier.requiredSize(960.dp, 540.dp))
} } }
rule.waitForIdle(); rule.mainClock.advanceTimeBy(600); rule.mainClock.autoAdvance = false
rule.runOnIdle { person.index = 24 }
val positions = mutableListOf<Int>()
repeat(26) { index ->
frame("E05", "person-scroll", index) { brightness(it, 400, 900) }
val offset = shell.view.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "credit-24" }?.offset
if (offset != null) positions.add(offset)
tick()
}
rule.mainClock.advanceTimeUntil(2000) { !shell.view.listState.isScrollInProgress }
rule.waitForIdle()
val settled = shell.view.listState.layoutInfo.visibleItemsInfo.first { it.key == "credit-24" }.offset
assertEquals((WolfskinTokens.Layout.pinnedLine.value * 2).toInt(), settled)
if (reducedMotion) assertEquals(positions.last(), positions.first())
else positions.zipWithNext().forEach { (before, after) -> assertTrue(after <= before + 1) }
assertEquals(24, person.index); assertFalse(shell.navFocused)
saveSamples("E05")
}
@Test fun cardFirstPaintUsesTheCurrentFractionBeforeAnyAnimationFrame() {
rule.mainClock.autoAdvance = false
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
TitleCard(false, Modifier.testTag("first-card"), progress = .134f) { }
}
} }
rule.waitForIdle()
val bounds = rule.onNodeWithTag("first-card").fetchSemanticsNode().boundsInRoot
val y = (bounds.bottom - (WolfskinTokens.CardMetadata.bottom.value + WolfskinTokens.CardMetadata.resumeLine.value / 2) * 2).toInt()
val edge = frame("E06", "card-first-paint", 0) { bitmap ->
(0 until bounds.width.toInt()).lastOrNull { bitmap.getPixel(bounds.left.toInt() + it, y) == WolfskinTokens.Color.accent.toArgb() }?.plus(1) ?: 0
}
assertEquals(bounds.width * .134f, edge.toFloat(), 1f)
}
@Test fun seasonCapsuleRetargetsWithoutStoppingBetweenChoices() = retargetSeason(false)
@Test fun reducedSeasonCapsuleRetargetsInOneFrame() = retargetSeason(true)
private fun retargetSeason(reducedMotion: Boolean) {
val series = TitleItem("repeat", "Series", "tv", held = true, seasons = 8)
render(TitlePageData(series, selectedSeason = 3), reducedMotion)
rule.runOnIdle { state.focus("seasons", 0) }
repeat(25) { tick() }
val row = rule.onNode(hasText("Season 5") and hasClickAction()).fetchSemanticsNode().boundsInRoot
fun left(bitmap: android.graphics.Bitmap): Int = (0 until bitmap.width).firstOrNull { x ->
val pixel = bitmap.getPixel(x, (row.top + 4).toInt())
android.graphics.Color.red(pixel) > 180 && android.graphics.Color.green(pixel) < 210 && android.graphics.Color.blue(pixel) > 230
} ?: -1
rule.runOnIdle { state.click("seasons", 3); state.focus("seasons", 0) }
val first = List(6) { index -> frame("E07", "season-repeat-first", index, ::left).also { tick() } }
rule.runOnIdle { state.click("seasons", 4); state.focus("seasons", 0) }
val second = List(23) { index -> frame("E07", "season-repeat-second", index, ::left).also { tick() } }
assertTrue(first.all { it >= 0 } && second.all { it >= 0 })
if (reducedMotion) assertEquals(second.last(), second[1]) else {
val before = first.last() - first[first.lastIndex - 1]
val after = second[2] - second[1]
assertTrue("A moving capsule cannot restart from zero velocity: before=$before, after=$after", before > 0 && after >= before * .75f)
second.zipWithNext().forEach { (previous, next) -> assertTrue(next >= previous - 1) }
}
saveSamples("E07")
}
@Test fun backCancelsPreparationWithoutALateFailureToast() = cancelPreparation(false)
@Test fun reducedBackCancelsPreparationWithoutALateFailureToast() = cancelPreparation(true)
private fun cancelPreparation(reducedMotion: Boolean) {
val (_, prepared, navigation) = pendingPlayback(reducedMotion)
rule.runOnIdle { state.click("header", 0) }
tick()
rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "version" }) }
tick()
rule.runOnIdle { state.back(shell) }
assertNull(state.playbackStart.starting)
assertNull(state.sheet)
rule.runOnIdle { prepared.completeExceptionally(IllegalStateException("late failure")) }
repeat(24) { tick() }
assertNull(state.toast)
assertFalse(navigation.backStack.last() is Destination.Playback)
rule.onNodeWithText("Starting…").assertDoesNotExist()
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertDoesNotExist()
}
@Test fun leavingTheTitleEntryCancelsPreparationWithoutALatePlayer() = cancelOwnership(false)
@Test fun reducedLeavingTheTitleEntryCancelsPreparationWithoutALatePlayer() = cancelOwnership(true)
private fun cancelOwnership(reducedMotion: Boolean) {
val (facade, initial, navigation) = pendingPlayback(reducedMotion)
var prepared = initial
coEvery { facade.preparePlayback(id) } coAnswers { prepared.await() }
for (inactive in listOf(true, false)) {
rule.runOnIdle { state.click("header", 0) }
tick()
rule.runOnIdle { if (inactive) entryActive.value = false else state.click("related", 0) }
tick()
rule.mainClock.advanceTimeUntil(timeoutMillis = 80) {
!rule.onNodeWithTag("title-page").fetchSemanticsNode().config
.contains(androidx.compose.ui.semantics.SemanticsProperties.Focused)
}
val destination = navigation.backStack.last()
assertNull("The rendered entry has lost ownership (inactive=$inactive)", state.playbackStart.starting)
rule.runOnIdle { prepared.complete(BaseItemDto(id = id, type = BaseItemKind.MOVIE)) }
repeat(24) { tick() }
assertEquals("A late prepared stream cannot take over the new page", destination, navigation.backStack.last())
assertFalse(destination is Destination.Playback)
assertNull(state.toast)
rule.onNodeWithText("Starting…").assertDoesNotExist()
rule.runOnIdle {
if (inactive) { entryActive.value = true; prepared = CompletableDeferred() } else navigation.goBack()
}
tick()
}
}
}

View file

@ -46,7 +46,7 @@ class TitleRequestSafetyTest {
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", true)
val route = titleRoute("movie:42")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }

View file

@ -197,10 +197,10 @@ class TitleRound2Test {
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() }
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P06/menu-to-info").apply { mkdirs() }
rule.runOnIdle { state.clickSheet(0) }
var waited = 0
while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.sheetIn + 16) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
waited += 16
@ -212,7 +212,7 @@ class TitleRound2Test {
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 (android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)) / 3
}
return try {
(brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") }
@ -222,7 +222,10 @@ class TitleRound2Test {
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) }
info.forEach { assertEquals("Media info keeps one full scrim on every frame", info.last().second, it.second) }
info.zipWithNext().forEach { (before, after) -> assertTrue("The panel fades monotonically instead of popping", after.first >= before.first - 1) }
assertNotEquals("The Media info panel visibly fades", info.first().first, info.last().first)
assertEquals("The panel settles by sheetIn plus one frame", info.last().first, info[(WolfskinTokens.Motion.sheetIn / 16 + 1).coerceAtMost(info.lastIndex)].first)
rule.runOnIdle { state.back(shell) }
repeat(14) { index ->
rule.mainClock.advanceTimeBy(16)

View file

@ -0,0 +1,169 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.ui.successQueryResult
import com.github.damontecres.wholphin.ui.successResponse
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.runBlocking
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.TvShowsApi
import org.jellyfin.sdk.api.operations.UserLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleAction
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitleState
import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.util.UUID
/** The actual SDK projection and native reel must agree on chronology and the activated episode. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleSeasonReelTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@get:Rule val directory = TemporaryFolder()
private val seriesId = UUID(0, 1)
private val series = BaseItemDto(id = seriesId, name = "Desperate Housewives", type = BaseItemKind.SERIES,
providerIds = mapOf("Tmdb" to "693"), people = emptyList())
private val episodes = (1..24).map { number ->
BaseItemDto(id = UUID(0, 100L + number), name = "Episode $number", type = BaseItemKind.EPISODE,
seriesId = seriesId, parentIndexNumber = 5, indexNumber = number,
providerIds = mapOf("Tmdb" to number.toString()), people = emptyList(),
userData = playbackUserData(UUID(0, 100L + number), 0, played = number < 24))
}
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
private lateinit var facade: TitleReadFacade
private lateinit var state: TitleState
private val actions = mutableListOf<TitleAction>()
@Before fun setup() {
mockkObject(WholphinApplication.Companion)
every { WholphinApplication.instance } returns mockk {
every { resources } returns rule.activity.resources
}
val library = mockk<UserLibraryApi>()
val items = mockk<ItemsApi>()
val shows = mockk<TvShowsApi>()
val api = mockk<ApiClient>()
every { api.userLibraryApi } returns library
every { api.itemsApi } returns items
every { api.tvShowsApi } returns shows
coEvery { library.getItem(seriesId) } returns successResponse(series)
episodes.forEach { episode -> coEvery { library.getItem(episode.id) } returns successResponse(episode) }
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult((1..8).map { number ->
BaseItemDto(id = UUID(0, 10L + number), type = BaseItemKind.SEASON, indexNumber = number)
})
coEvery { shows.getEpisodes(any<GetEpisodesRequest>()) } returns successQueryResult(episodes)
coEvery { shows.getNextUp(any<GetNextUpRequest>()) } returns successQueryResult(listOf(episodes.last()))
val playback = mockk<ItemPlaybackRepository>()
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
val repository = mockk<ServerRepository> {
every { currentServer } returns null
every { currentUser } returns null
}
val context = mockk<Context> { every { filesDir } returns directory.root }
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk(), context)
}
@After fun teardown() { unmockkObject(WholphinApplication.Companion) }
private fun render(page: TitlePageData) {
val viewer = ShellPerson("justin", "Justin", "J", false)
val shell = ShellState(listOf(viewer), viewer.key).apply {
pickerVisible = false; pickerLaunch = false; enterDestination("title/series")
}
state = TitleState(page, viewer, actions::add)
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
press(Key.DirectionDown)
press(Key.DirectionDown)
repeat(4) { press(Key.DirectionLeft) }
assertEquals("episodes", state.row)
assertEquals(0, state.column)
rule.onNodeWithTag("title-page").assertIsFocused()
}
@Test fun nextUpAtSeasonEndCannotMoveTheFinalEpisodeToTheStartOfTheReel() = runBlocking<Unit> {
val page = facade.read(seriesId, 5, preferences)
render(page)
press(Key.DirectionCenter)
assertEquals("The first tile must open E1, not next-up E24", episodes.first().id.toString(), openedEpisode())
assertEquals("Play remains next-up even though the reel is chronological", episodes.last().id.toString(), page.playTarget?.id)
assertEquals((1..24).toList(), page.episodes.map { it.episode })
val saved = requireNotNull(facade.readSaved(seriesId, preferences))
assertEquals(page.episodes.map { it.id }, saved.episodes.map { it.id })
repeat(7) { press(Key.DirectionRight) }
repeat(2) { press(Key.DirectionLeft) }
press(Key.DirectionUp)
press(Key.DirectionDown)
press(Key.DirectionCenter)
assertEquals("Row return and Left/Right preserve E6", episodes[5].id.toString(), openedEpisode())
rule.onNodeWithTag("title-episode-slot-5").assertIsDisplayed()
}
@Test fun openingAnotherEpisodeAndRefreshingCannotReorderTheRememberedReel() = runBlocking<Unit> {
val opened = facade.read(episodes[11].id, 5, preferences)
render(opened)
repeat(12) { press(Key.DirectionRight) }
assertEquals(episodes[12].id.toString(), state.data.episodes[state.column].id)
val refreshed = facade.read(seriesId, 5, preferences)
rule.runOnIdle { state.update(refreshed) }
press(Key.DirectionCenter)
assertEquals("Refresh must not change E13 into another episode", episodes[12].id.toString(), openedEpisode())
assertEquals(episodes[11].id.toString(), opened.playTarget?.id)
assertEquals(episodes[11].id.toString(), opened.watchTarget.id)
assertEquals(opened.episodes.map { it.id }, refreshed.episodes.map { it.id })
rule.onNodeWithTag("title-episode-slot-12").assertIsDisplayed()
}
private fun openedEpisode() = (actions.last() as TitleAction.OpenTitle).item.id
private fun press(key: Key) {
rule.onRoot().performKeyInput { pressKey(key) }
rule.mainClock.advanceTimeBy(400)
rule.waitForIdle()
}
}

View file

@ -35,6 +35,7 @@ import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleItem
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@ -162,7 +163,7 @@ class TitleThemeMusicTest {
coEvery { facade.preparePlayback(episodeId) } returns BaseItemDto(id = episodeId, type = BaseItemKind.EPISODE)
val conductarr = ConductarrSession(MemoryCacheStore()).also { session = it }
val titleModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, conductarr,
mockk(relaxed = true), playback.player).also { model = it }
mockk(relaxed = true), playback.player, trailerReturn = mockk(relaxed = true)).also { model = it }
rule.runOnIdle { navigation.navigateTo(destination) }
rule.setContent {
WolfskinTheme {
@ -175,6 +176,8 @@ class TitleThemeMusicTest {
assertEquals(listOf("play:$seriesId"), playback.events)
playback.onStop = { if (playback.playing != null) assertEquals(destination, navigation.backStack.last()) }
rule.runOnIdle { titleModel.title!!.click("header", 0) }
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.base.toLong() + 48)
rule.waitForIdle()
rule.waitUntil(5_000) { navigation.backStack.last() is Destination.Playback }
playback.onStop = {}
rule.waitForIdle()

View file

@ -23,13 +23,13 @@ class TitleTrailerTest {
@Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() {
register(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/watch?v=ab_-defghij")), "test.browser")
assertFalse(TitleTrailer(context).open(url))
assertFalse(TitleTrailer(context).open(url) {})
assertNull(shadowOf(context as Application).nextStartedActivity)
}
@Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() {
register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")), "test.youtube")
assertTrue(TitleTrailer(context).open(url))
assertTrue(TitleTrailer(context).open(url) {})
val launched = shadowOf(context as Application).nextStartedActivity
assertEquals(Intent.ACTION_VIEW, launched.action)
assertEquals("test.youtube", launched.`package`)
@ -40,7 +40,7 @@ class TitleTrailerTest {
@Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() {
register(Intent(Intent.ACTION_VIEW, Uri.parse("https://trailers.example/video")), "test.browser")
assertFalse(TitleTrailer(context).open("https://trailers.example/video"))
assertFalse(TitleTrailer(context).open("https://trailers.example/video") {})
assertNull(shadowOf(context as Application).nextStartedActivity)
}

View file

@ -0,0 +1,150 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Bundle
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.SavedSessionIdentity
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.every
import io.mockk.mockk
import java.util.UUID
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** A saved task may carry only a short-lived, exact, unprotected trailer identity; not a generic login. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class TrailerReturnStateTest {
private var elapsed = 1_000L
private var boot = 7
private val clock = mockk<TrailerReturnClock>().also {
every { it.readElapsedMillis() } answers { elapsed }
every { it.readBootCount() } answers { boot }
}
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "never-serialize-this-token")
private val current = CurrentUser(server, user)
private val title = Destination.Wolfskin(titleRoute("Locke"))
private val identity = SavedSessionIdentity(server.id, user.id)
private fun snapshot(person: CurrentUser = current): Bundle {
val originalProcess = TrailerReturnState(clock)
originalProcess.prepare(person, title)
return Bundle().also { originalProcess.save(it, person, title) }
}
@Test fun aNewProcessCanRecoverTheExactTaskIdentityWithoutCopyingCredentials() {
val saved = snapshot()
assertFalse(saved.getString(TrailerReturnState.SAVED_STATE_KEY)!!.contains(user.accessToken!!))
elapsed += 3_000L
assertEquals(identity, TrailerReturnState(clock).restore(saved, title))
}
@Test fun freshLaunchAndCancelledHandoffHaveNoRestorationCapability() {
val state = TrailerReturnState(clock)
state.prepare(current, title)
assertNull(state.restore(null, title))
state.prepare(current, title)
state.clear()
val saved = Bundle().also { state.save(it, current, title) }
assertNull(TrailerReturnState(clock).restore(saved, title))
}
@Test fun theReturnWindowExpiresAtTenMinutesAndRejectsFutureTimestamps() {
for ((age, permitted) in listOf(599_999L to true, 600_000L to false, 14_400_000L to false, -1L to false)) {
elapsed = 1_000L
val saved = snapshot()
elapsed += age
assertEquals("age=$age", if (permitted) identity else null, TrailerReturnState(clock).restore(saved, title))
}
}
@Test fun savingAnOlderHandoffDoesNotRenewItsAuthorizationTime() {
val state = TrailerReturnState(clock)
state.prepare(current, title)
elapsed += 599_999L
val saved = Bundle().also { state.save(it, current, title) }
elapsed += 1L
assertNull(TrailerReturnState(clock).restore(saved, title))
}
@Test fun differentAndUnknownBootsCannotRestoreATask() {
for ((initial, returning) in listOf(7 to 8, 7 to -1, -1 to 7)) {
boot = initial
val saved = snapshot()
boot = returning
assertNull(TrailerReturnState(clock).restore(saved, title))
}
}
@Test fun protectedAndTokenlessPeopleCannotCreateAHandoffRecord() {
for (person in listOf(user.copy(pin = "URDL"), user.copy(requireLogin = true), user.copy(accessToken = null))) {
val saved = snapshot(current.copy(user = person))
assertNull(TrailerReturnState(clock).restore(saved, title))
}
}
@Test fun aPersonProtectionOrDestinationChangeInvalidatesTheTaskSnapshot() {
val otherServer = server.copy(id = UUID.randomUUID())
val changes = listOf(
current.copy(user = user.copy(id = UUID.randomUUID())) to title,
CurrentUser(otherServer, user.copy(serverId = otherServer.id)) to title,
current.copy(user = user.copy(pin = "URDL")) to title,
current to Destination.Wolfskin(titleRoute("another-title")),
)
for ((person, destination) in changes) {
val state = TrailerReturnState(clock)
state.prepare(current, title)
val saved = Bundle().also { state.save(it, person, destination) }
assertNull(TrailerReturnState(clock).restore(saved, destination))
}
}
@Test fun anotherRecoveredTitleAndMalformedStateDoNotAuthorizeRestoration() {
assertNull(TrailerReturnState(clock).restore(snapshot(), Destination.Wolfskin(titleRoute("another-title"))))
val malformed = Bundle().apply { putString(TrailerReturnState.SAVED_STATE_KEY, "not JSON") }
assertNull(TrailerReturnState(clock).restore(malformed, title))
}
@Test fun skippingTheColdChooserRequiresConfirmedIdentityAndIsOneUse() {
val state = TrailerReturnState(clock)
val saved = snapshot()
assertEquals(identity, state.restore(saved, title))
assertFalse(state.consumeRestoredSession(current))
state.confirmRestore(current)
assertTrue(state.consumeRestoredSession(current))
assertFalse(state.consumeRestoredSession(current))
}
@Test fun confirmationCannotDismissTheChooserForAnotherOrNowProtectedPerson() {
for (person in listOf(user.copy(id = UUID.randomUUID()), user.copy(pin = "URDL"), user.copy(requireLogin = true))) {
val state = TrailerReturnState(clock)
state.restore(snapshot(), title)
val changed = current.copy(user = person)
state.confirmRestore(changed)
assertFalse(state.consumeRestoredSession(changed))
}
}
@Test fun aNormalResumeStopsLaterUnrelatedSavesFromReusingTheHandoff() {
val state = TrailerReturnState(clock)
state.prepare(current, title)
state.finishHandoff()
val saved = Bundle().also { state.save(it, current, title) }
assertNull(TrailerReturnState(clock).restore(saved, title))
}
@Test fun aRecreatedActivityResumeDoesNotDiscardItsValidatedRestoration() {
val state = TrailerReturnState(clock)
state.restore(snapshot(), title)
state.finishHandoff()
assertTrue(state.isRestoring(identity))
state.confirmRestore(current)
assertTrue(state.consumeRestoredSession(current))
}
}

View file

@ -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()

View file

@ -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 {

View file

@ -171,7 +171,7 @@ class WolfskinRootExitTest {
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
conductarr = ConductarrSession(MemoryCacheStore())
model = WolfskinShellViewModel(activity, session, repository, navigation, mockk(relaxed = true),
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
val home = HomeFixture(activity).snapshot("justin")
rule.setContent {
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model,

View file

@ -18,6 +18,8 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.datastore.core.DataStore
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import kotlinx.coroutines.Dispatchers
import com.github.damontecres.wholphin.MainActivityViewModel
import com.github.damontecres.wholphin.MainContent
import com.github.damontecres.wholphin.R
@ -99,13 +101,13 @@ class WolfskinSetupNavigationTest {
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
rule.setContent {
MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
mockk(relaxed = true), screensaver, Modifier,
mockk(relaxed = true), screensaver, liveSession = repository.current, trailerReturn = mockk(relaxed = true), modifier = 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)
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intentService, trailerReturn = mockk(relaxed = true))
private fun awaitStage(stage: WolfskinSetupViewModel.Stage) {
rule.waitUntil(5_000) {
@ -115,6 +117,16 @@ class WolfskinSetupNavigationTest {
}
}
private fun startApp() {
val startupModel = appModel()
val result = rule.activity.lifecycleScope.async(Dispatchers.Default) { startupModel.handleAppStart() }
rule.waitUntil(15_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
result.isCompleted
}
runBlocking { result.await() }
}
private fun assertOwnPicker() {
awaitStage(WolfskinSetupViewModel.Stage.Picker)
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
@ -124,7 +136,7 @@ class WolfskinSetupNavigationTest {
}
@Test fun coldStartLegacySignOutDrawerSwitchAndIntentFailureNeverComposeStockSelectors() {
runBlocking { appModel().handleAppStart() }
startApp()
mount()
assertOwnPicker()
// The legacy sign-out-equivalent erased the recent preference, but Room still has the household.
@ -145,7 +157,7 @@ class WolfskinSetupNavigationTest {
val saved = listOf(user, user.copy(id = UUID.randomUUID(), name = "Mindel"),
user.copy(id = UUID.randomUUID(), name = "Shared"))
every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, saved)
runBlocking { appModel().handleAppStart() }
startApp()
mount()
assertOwnPicker()
saved.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() }

View file

@ -104,7 +104,7 @@ class WolfskinSetupRollbackTest {
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
rule.setContent {
MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
mockk(relaxed = true), screensaver)
mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null), trailerReturn = mockk(relaxed = true))
}
fun awaitHeading(resource: Int) {
rule.waitUntil(5_000) {

View file

@ -92,6 +92,8 @@ class UpdatesKeysTest {
launch(UpdateUiState.Checking, settings = true)
repeat(9) { press(listOf(Key.DirectionDown)) }
assertEquals(9, shell.settingsRow)
// Visual settling precedes the spring's idle state and final row boundary.
rule.mainClock.advanceTimeUntil(timeoutMillis = 320) { !shell.view.listState.isScrollInProgress }
assertEquals(4, shell.view.listState.firstVisibleItemIndex)
capture("classic")
press(listOf(Key.DirectionCenter))

View file

@ -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

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@ -71,6 +72,7 @@ class WolfskinTitleViewModel @Inject constructor(
val conductarr: ConductarrSession,
private val repository: ServerRepository,
val themeSongPlayer: ThemeSongPlayer,
private val trailerReturn: TrailerReturnState,
) : ViewModel() {
var title by mutableStateOf<TitleState?>(null)
private set
@ -83,19 +85,38 @@ class WolfskinTitleViewModel @Inject constructor(
private var personReadPending = false
private var personReadFinished = false
private var loadJob: Job? = null
private var playbackJob: Job? = null
private var playbackGeneration = 0
private val seasonEpisodes = mutableMapOf<Int, List<TitleItem>>()
private val saveMutex = Mutex()
private var performerPage = 1
var conductarrAction: ((TitleAction) -> Unit)? = null
val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}"
fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route))
fun popRoute() = navigation.goBack()
fun openRoute(route: String) {
cancelPlayback()
navigation.navigateTo(Destination.Wolfskin(route))
}
fun popRoute() {
cancelPlayback()
navigation.goBack()
}
/** Preparation belongs to the active entry, including when Back closes an overlay. */
internal fun cancelPlayback() {
playbackJob?.cancel()
playbackJob = null
title?.playbackStart?.fail()
}
private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) }
/** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
val sameTitle = route == destination && ::viewer.isInitialized && viewer.key == selected.key && !destination.startsWith("person/")
if (!sameTitle) {
cancelPlayback()
seasonEpisodes.clear()
}
val readyTitle = sameTitle && title?.data?.status == TitleReadStatus.Ready
val fixtureRefresh = title?.takeIf { force && readyTitle && fixture != null && fixtures && BuildConfig.DEBUG }
if (force && readyTitle && fixture == null &&
@ -117,6 +138,7 @@ class WolfskinTitleViewModel @Inject constructor(
preferences = userPreferences
val id = Uri.decode(destination.substringAfter('/'))
if (fixtureRefresh == null) {
cancelPlayback()
// Losing authorization clears the old data, but is not another first-load skeleton.
val status = if (sameTitle && title?.data?.status != TitleReadStatus.Loading) TitleReadStatus.Unavailable else TitleReadStatus.Loading
title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = status), viewer, ::handle)
@ -140,13 +162,26 @@ class WolfskinTitleViewModel @Inject constructor(
hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden),
)
if (fixtureRefresh != null) {
if (title === fixtureRefresh) fixtureRefresh.update(restored)
} else title = TitleState(restored, viewer, ::handle)
if (title === fixtureRefresh) updateTitle(fixtureRefresh, restored)
} else {
title = TitleState(restored, viewer, ::handle)
rememberSeason(restored)
}
}
} else if (destination.startsWith("person/")) readPerson(id) else readTitle(id)
}
}
private fun rememberSeason(data: TitlePageData) {
if (data.episodesStatus == TitleReadStatus.Ready) data.selectedSeason?.let { seasonEpisodes[it] = data.episodes }
}
private fun updateTitle(current: TitleState, data: TitlePageData) {
if (title !== current) return
current.update(data)
rememberSeason(data)
}
private suspend fun readTitle(id: String, retry: Boolean = false) {
val current = title ?: return
val uuid = id.toUuidOrNull()
@ -157,9 +192,9 @@ class WolfskinTitleViewModel @Inject constructor(
val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) }
if (title !== current) return
if (heldId != null) {
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) }
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { updateTitle(current, it) }
held = facade.read(heldId, current.requestedSeason, preferences)
current.update(held)
updateTitle(current, held)
}
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
@ -174,7 +209,7 @@ class WolfskinTitleViewModel @Inject constructor(
if (identity != null && tmdb != null) {
val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV
ConductarrTitlePages(identity).title(tmdb, media, held).collect { data ->
if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) current.update(data)
if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) updateTitle(current, data)
}
} else if (uuid == null) current.update(current.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut, requestsAllowed = false))
}
@ -253,9 +288,13 @@ class WolfskinTitleViewModel @Inject constructor(
private fun handle(action: TitleAction) {
when (action) {
TitleAction.Pop -> navigation.goBack()
is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id)))
TitleAction.Pop -> popRoute()
is TitleAction.Person -> {
cancelPlayback()
navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id)))
}
is TitleAction.OpenTitle -> {
cancelPlayback()
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
@ -267,25 +306,36 @@ class WolfskinTitleViewModel @Inject constructor(
is TitleAction.Play -> {
val opened = title?.data?.watchTarget
play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(),
restart = action.restart)
restart = action.restart, control = if (action.restart) "restart" else "play")
}
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true)
else {
themeSongPlayer.stop()
if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer."
val launched = TitleTrailer(context).open(action.url) {
trailerReturn.prepare(repository.current.value, navigation.backStack.lastOrNull())
}
if (!launched) {
trailerReturn.clear()
title?.toast = "No YouTube app is available to play this trailer."
}
}
is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
is TitleAction.Season -> {
val current = title ?: return
loadJob?.cancel()
val cached = seasonEpisodes[action.number]
if (cached == null) current.showSeasonLoading() else current.showSeason(action.number, cached)
loadJob = viewModelScope.launch {
val current = title ?: return@launch
current.showSeasonLoading()
val source = fixture
if (source == null) readTitle(Uri.decode(route.substringAfter('/')))
else {
else try {
val data = withContext(Dispatchers.IO) { source.title(Uri.decode(route.substringAfter('/')), viewer.key, action.number) }
current.update(data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex,
updateTitle(current, data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex,
selectedSubtitle = current.subtitleIndex, watchTarget = current.data.watchTarget.copy(watched = current.watched), hidden = current.data.hidden))
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason()
current.toast = RecoveryMessage.StillUnreachable
}
}
}
@ -304,19 +354,33 @@ class WolfskinTitleViewModel @Inject constructor(
}
}
private fun play(id: UUID?, restart: Boolean) {
val current = title
private fun play(id: UUID?, restart: Boolean, control: String = "restart") {
val current = title ?: return
if (current.playbackStart.starting != null) return
val generation = ++playbackGeneration
playbackJob?.cancel()
current.playbackStart.begin(control)
val playbackScope = watchScope
viewModelScope.launch {
playbackJob = viewModelScope.launch {
try {
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
if (title !== current || watchScope != playbackScope) return@launch
themeSongPlayer.stop()
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
if (generation != playbackGeneration) return@launch
if (title !== current || watchScope != playbackScope || current.playbackStart.starting == null) {
current.playbackStart.fail()
return@launch
}
current.playbackStart.ready {
if (generation == playbackGeneration && title === current && watchScope == playbackScope) {
themeSongPlayer.stop()
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
}
}
} catch (exception: Exception) {
val active = generation == playbackGeneration && current.playbackStart.starting != null
if (active) current.playbackStart.fail()
if (exception is CancellationException) throw exception
title?.toast = RecoveryMessage.PlaybackUnavailable
if (active && title === current && watchScope == playbackScope) current.toast = RecoveryMessage.PlaybackUnavailable
}
}
}
@ -358,20 +422,30 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen
val watchRefresh = rememberWatchRefresh(viewModel.watchScope)
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
val active = isScreenEntryActive()
DisposableEffect(viewModel, title, active) {
if (!active) viewModel.cancelPlayback()
onDispose { viewModel.cancelPlayback() }
}
TitleThemeMusic(title?.data?.item, preferences, viewModel.themeSongPlayer,
active = isScreenEntryActive() && route.startsWith("title/") && title?.data?.status == TitleReadStatus.Ready,
active = active && route.startsWith("title/") && title?.data?.status == TitleReadStatus.Ready,
owner = "${shell.person.key}:${viewModel.watchScope}")
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
LaunchedEffect(title?.data?.status) {
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
}
val person = viewModel.person.takeIf { title != null }
BackHandler(enabled = isScreenEntryActive() && title != null) {
viewModel.handleBack(route, shell) {
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
BackHandler(enabled = active && title != null) {
if (title?.playbackStart?.launch == null) {
viewModel.cancelPlayback()
viewModel.handleBack(route, shell) {
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
}
}
}
Box(modifier.onPreviewKeyEvent { event ->
if (title?.playbackStart?.launch != null) return@onPreviewKeyEvent true
if (readShellKey(event) == ShellKey.Back && event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) viewModel.cancelPlayback()
if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let {
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it)
true

View file

@ -9,7 +9,7 @@ import android.net.Uri
/** Only an installed app that handles the YouTube scheme may receive a remote trailer. */
class TitleTrailer(private val context: Context) {
/** A missing handler leaves the title in place rather than falling back to a browser. */
fun open(url: String): Boolean {
fun open(url: String, beforeLaunch: () -> Unit): Boolean {
val source = Uri.parse(url)
val video = when (source.host?.lowercase()) {
"youtu.be" -> source.pathSegments.firstOrNull()
@ -23,6 +23,7 @@ class TitleTrailer(private val context: Context) {
.firstOrNull { it.activityInfo.exported } ?: return false
intent.setPackage(handler.activityInfo.packageName)
return try {
beforeLaunch()
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {

View file

@ -0,0 +1,154 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import android.os.Bundle
import android.os.SystemClock
import android.provider.Settings
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.SavedSessionIdentity
import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
/** Elapsed time includes sleep; a missing boot counter cannot authorize task restoration. */
class TrailerReturnClock @Inject constructor(@param:ApplicationContext private val context: Context) {
/** A wall-clock adjustment must not extend the return window. */
fun readElapsedMillis(): Long = SystemClock.elapsedRealtime()
/** Older or restricted systems retain the picker when same-boot evidence is unavailable. */
fun readBootCount(): Int = try {
Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, -1)
} catch (_: SecurityException) {
-1
}
}
/** Only an authenticated, unprotected trailer hand-off may supply a short-lived saved-task identity. */
@Singleton
class TrailerReturnState @Inject constructor(private val clock: TrailerReturnClock) {
private var pending: TrailerReturnRecord? = null
private var restoring: TrailerReturnRecord? = null
private var confirmed: TrailerReturnRecord? = null
/** Capture authorization before handing the activity to the external app; never capture a PIN login. */
@Synchronized
fun prepare(current: CurrentUser?, destination: Destination?) {
clear()
val route = titleDestination(destination) ?: return
if (current == null || current.user.isProtected || current.user.accessToken.isNullOrBlank()) return
val elapsed = clock.readElapsedMillis()
val boot = clock.readBootCount()
if (elapsed < 0 || boot < 0) return
pending = TrailerReturnRecord(current.server.id.toString(), current.user.id.toString(), route, elapsed, boot)
}
/** Snapshot before lifecycle cleanup; asynchronous startup must still recheck identity and expiry. */
@Synchronized
internal fun pendingHandoff(): TrailerReturnRecord? = pending
/** A live return has the same identity, title and deadline constraints as its saved task. */
@Synchronized
internal fun canContinue(record: TrailerReturnRecord?, current: CurrentUser?, destination: Destination?): Boolean =
record != null && matches(record, current) && record.route == titleDestination(destination) && isWithinWindow(record)
/** A task snapshot cannot carry an old identity after a person/protection/destination change. */
@Synchronized
fun save(outState: Bundle, current: CurrentUser?, destination: Destination?) {
outState.remove(SAVED_STATE_KEY)
val record = pending ?: return
if (!matches(record, current) || titleDestination(destination) != record.route || !isWithinWindow(record)) {
pending = null
return
}
outState.putString(SAVED_STATE_KEY, Json.encodeToString(record))
}
/** Saved navigation is insufficient without the matching, unexpired trailer hand-off record. */
@Synchronized
fun restore(savedState: Bundle?, destination: Destination?): SavedSessionIdentity? {
clear()
val encoded = savedState?.getString(SAVED_STATE_KEY) ?: return null
val record = try {
Json.decodeFromString<TrailerReturnRecord>(encoded)
} catch (_: SerializationException) {
return null
}
if (record.route != titleDestination(destination) || !isWithinWindow(record)) return null
val server = record.serverId.toUUIDOrNull() ?: return null
val user = record.userId.toUUIDOrNull() ?: return null
restoring = record
return SavedSessionIdentity(server, user)
}
/** The shell may skip its cold chooser only after the repository has accepted the exact identity. */
@Synchronized
fun confirmRestore(current: CurrentUser) {
val record = restoring
restoring = null
confirmed = record?.takeIf { matches(it, current) && isWithinWindow(it) }
}
@Synchronized
fun hasConfirmedRestore(): Boolean = confirmed != null
/** This one-use UI hand-off never authenticates anyone and cannot select a different person. */
@Synchronized
fun consumeRestoredSession(current: CurrentUser?): Boolean {
val record = confirmed
confirmed = null
return record != null && matches(record, current) && isWithinWindow(record)
}
/** A cancelled startup or slow lookup must not install credentials after its return window closes. */
@Synchronized
fun isRestoring(expected: SavedSessionIdentity): Boolean =
restoring?.let {
it.serverId == expected.serverId.toString() && it.userId == expected.userId.toString() && isWithinWindow(it)
} == true
/** Translucent handlers can return through onResume without onRestart; do not reuse their capture. */
@Synchronized
fun finishHandoff() {
pending = null
}
/** Normal return and explicit launches must not lend their marker to unrelated later saves. */
@Synchronized
fun clear() {
pending = null
restoring = null
confirmed = null
}
private fun matches(record: TrailerReturnRecord, current: CurrentUser?): Boolean =
current != null && !current.user.isProtected && !current.user.accessToken.isNullOrBlank() &&
current.server.id.toString() == record.serverId && current.user.id.toString() == record.userId
private fun isWithinWindow(record: TrailerReturnRecord): Boolean {
if (record.startedElapsed < 0 || record.bootCount < 0) return false
val age = clock.readElapsedMillis() - record.startedElapsed
return age >= 0 && age < RETURN_WINDOW_MILLIS && clock.readBootCount() == record.bootCount
}
private fun titleDestination(destination: Destination?): String? =
destination?.toWolfskinRoute()?.takeIf { it.startsWith("title/") }
companion object {
internal const val SAVED_STATE_KEY = "wolfskin.trailer.return"
private const val RETURN_WINDOW_MILLIS = 10L * 60L * 1000L
}
}
@Serializable
internal data class TrailerReturnRecord(
val serverId: String,
val userId: String,
val route: String,
val startedElapsed: Long,
val bootCount: Int,
)

View file

@ -1,17 +1,29 @@
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.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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 +76,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. */
@ -80,6 +94,7 @@ class WolfskinShellViewModel @Inject constructor(
val conductarr: ConductarrSession,
artwork: WolfskinArtwork,
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
private val trailerReturn: TrailerReturnState,
) : ViewModel() {
init { artwork.configure(context) }
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
@ -107,10 +122,20 @@ class WolfskinShellViewModel @Inject constructor(
session.serverId = server.id.toString()
session.fixtureMode = ConductarrPreference.fixtures
val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) }
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
val continueTrailer = trailerReturn.hasConfirmedRestore() && trailerReturn.consumeRestoredSession(repository.current.value?.takeIf {
it.server.id == server.id && it.user.id == user.id
})
session.state = ShellState(people, signedInPerson) { session.onAction(it) }.apply {
if (continueTrailer) {
pickerVisible = false
pickerLaunch = false
}
}
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) }
session.state?.updatePeople(savedPeople)
} else {
trailerReturn.consumeRestoredSession(null)
}
identityJob = viewModelScope.launch {
if (!ConductarrPreference.fixtures) loadHousehold(user)
@ -214,32 +239,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 +306,10 @@ 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) },
fullScreenContent: @Composable (Destination, Modifier) -> Unit =
{ destination, contentModifier -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) },
) {
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle()
@ -294,11 +339,35 @@ 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()
}
}
var composedDestination by remember(server.id, user.id, navigationManager) { mutableStateOf<Destination?>(null) }
val playerEntryPending by remember(server.id, user.id, navigationManager) {
derivedStateOf {
val target = navigationManager.backStack.lastOrNull()
target is Destination.Playback && composedDestination != target
}
}
// Keep input blocked until the composition that removes the black cover has actually replaced it.
val playerCoverVisible = playerEntryPending
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 || playerEntryPending || playerCoverVisible) 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,40 +378,46 @@ fun WolfskinApp(
}
NavDisplay(
backStack = navigationManager.backStack,
onBack = onBack,
onBack = { if (session.personCommit == null && session.playback.launch == null && !playerEntryPending && !playerCoverVisible) { 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
val discover = LocalDiscoverState.current
val route = destination.toWolfskinRoute()
val isCurrent = navigationManager.backStack.lastOrNull() == destination
if (isCurrent) SideEffect { composedDestination = destination }
LaunchedEffect(destination, state, isCurrent) {
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
}
org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) {
if (destination.fullScreen) {
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
// Loading, error and external-player content may not paint their own background.
fullScreenContent(destination, Modifier.fillMaxSize()
.then(if (destination is Destination.Playback) Modifier.background(Color.Black) else Modifier))
} else {
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
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 +435,20 @@ fun WolfskinApp(
}
},
)
org.justinvisser.wolfskin.design.PlaybackHandoffVeil(session.playback,
Modifier.pointerInput(session.playback.launch) {
awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } }
})
if (playerCoverVisible) Box(Modifier.fillMaxSize().background(Color.Black)
.pointerInput(Unit) {
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 && !playerCoverVisible) {
session.playback.fail()
onBack()
}
BackHandler(enabled = commit != null || session.playback.launch != null || playerCoverVisible) { }
}
}
}

View file

@ -192,6 +192,9 @@ class TitleReadFacade @Inject constructor(
streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
}
// Rich playback/watch rows are prefixed in storage, not in the season's display order.
val seasonEpisodes = rows.filterTo(ArrayList()) { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }
seasonEpisodes.sortBy { it.indexNumber }
val pageItem = mapItem(item).copy(seasons = seasonNumbers.size)
return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) },
rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
@ -200,7 +203,7 @@ class TitleReadFacade @Inject constructor(
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
},
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) },
episodes = seasonEpisodes.map { mapItem(it, pageItem.backdrop) },
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
}

View file

@ -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) }

View file

@ -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.

View file

@ -449,11 +449,11 @@ 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.
destinations before their stock server/user selectors can render. Cold launches
that require sign-in, protected-profile restarts, 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
@ -469,6 +469,38 @@ session. A failed selection offers Wolfskin **Try again** and **Sign in** action
hidden/unsaved profiles can use username/password sign-in through the setup
picker's Settings gear. An outage never replaces the saved household endpoint.
With **Sign in automatically** off, ordinary returns from the background show the
profile picker. Only the first return after a trailer handoff keeps the originating
title and person: the same unprotected live identity and active signed-in entry
must match the pending handoff, exact title, same boot and under-ten-minute window.
Live protection is checked at stop and again when the stopped entry starts. The
handoff is cleared on return; a second, ordinary background return shows the picker.
Automatic sign-in on keeps its existing ordinary-return behaviour.
If Android kills Wolfskin while the trailer is in front, the same saved Android
task can continue for **strictly less than ten minutes from the handoff, on the
same boot**. Only an exported YouTube-handler launch from an authenticated,
unprotected title creates this return record. It stores server/person IDs, the
exact title route, elapsed time and boot counter in the Activity's saved Bundle:
no token, password, PIN or new last-person preference. Saving/recreating does not
renew the deadline; missing boot evidence keeps the picker.
Before installing a saved token, restoration re-reads the profile and requires
the exact most-recent server/person, a present token, no PIN and no require-login,
then rechecks the deadline. The recommendations worker may already have restored
that same unprotected canonical identity before setup leaves Loading; continuing
it does not install credentials again. A different current person, sign-out,
removed/tokenless profile or explicit picker cannot be replaced by the return
path. PIN/require-login people remain gated. A fresh task, fresh explicit intent,
wrong route or expired/missing record follows ordinary startup policy.
After identity acceptance, a one-use confirmation lets only that person skip the
new shell's launch chooser and reach the saved title. Ordinary cold automatic
sign-in still opens the chooser. `MainActivityTrailerRestoreTest` and
`TrailerReturnStateTest` cover the process-death window and identity gates;
`MainActivityResumeTest` and `SignedInContentTest` cover live return and protection
changes while away.
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.
@ -484,7 +516,7 @@ late reply cannot navigate back to an earlier person.
The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its
server and shell state, not to the user-scoped ViewModel. Switching people replaces
the upstream app entry and ViewModel but retains the same-server, same-mode shell:
the picker stays closed on Home after the first OK. A cold start still opens it,
the picker stays closed on Home after the first OK. A fresh task still opens it,
and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real
shell and user-entry replacement for launch/avatar Left and Right, the initial
person, gear and back, and fast Left-Left-OK; each selection emits one person action.
@ -1007,6 +1039,14 @@ 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.
Season episode reels use episode-number order independently of the Play/Resume
or explicitly opened episode. The shared live/saved projection keeps rich
playback metadata without promoting that target into the first tile. A refresh
therefore cannot replace the episode under a remembered reel index merely
because the playback target changed. `TitleSeasonReelTest` replays native remote
input on SDK 34 and 36 with 24 season-five episodes and next-up at E24, including
row return, exact activation, cached reads and an explicit-episode refresh.
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
person credits and playback mutations to one boundary. `ConductarrTitlePages`
@ -1046,6 +1086,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.

View file

@ -30,6 +30,7 @@
"receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" },
"receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" },
"receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" },
"thumbGlyph": { "$type": "dimension", "px": 40, "dp": 20, "source": "ui-polish P12, approved 2026-11-07: film/TV glyph centred in a request thumbnail without a poster" },
"buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" },
"skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" },
"skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" },

View file

@ -1,14 +1,24 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -16,6 +26,13 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.layout.layout
import kotlin.math.roundToInt
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
@ -29,6 +46,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import androidx.compose.runtime.mutableStateOf
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
@ -36,10 +54,19 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */
@Composable
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
val visible = shell.requestsVisible
val fade = rememberSheetFade(visible)
if (!visible && fade.value == 0f) return
val state = shell.requests
val data = state.snapshot
val current = RequestsPanelFrame(state.snapshot, state.focusedId, state.expanded, state.windowStart, state.windowSize,
state.recoveryFocused, state.ratingsVisible, shell.requestsExpansionInput)
val retained = remember { mutableStateOf(current) }
SideEffect { if (visible) retained.value = current }
val frame = if (visible) current else retained.value
val data = frame.data
val ratingsReader = LocalScreenRatings.current
LaunchedEffect(state, state.focusedId, data.signedIn, data.status) {
LaunchedEffect(visible, state, state.focusedId, data.signedIn, data.status) {
if (!visible) return@LaunchedEffect
if (ratingsReader == null) state.awaitFocusedRatings()
else if (data.signedIn) state.focused?.let { item ->
ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values ->
@ -50,8 +77,9 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
}
}
}
Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim"))
Column(modifier.fillMaxHeight().width(L.sheetWidth)
Box(modifier.fillMaxSize().sheetScrim { fade.value }.focusProperties { canFocus = false }
.clickable { if (visible) shell.closeRequests() }.testTag("coming-in-scrim"))
Column(modifier.sheetPanelFade { fade.value }.fillMaxHeight().width(L.sheetWidth)
.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
.drawWithCache {
val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
@ -61,9 +89,11 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
onDrawBehind {
drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) }
}
}.pointerInput(Unit) {
}.focusProperties { canFocus = visible }
.then(if (visible) Modifier else Modifier.clearAndSetSemantics { })
.pointerInput(visible) {
awaitPointerEventScope {
while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() }
while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() }
}
}
.padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY)
@ -71,10 +101,10 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg)
when {
data.status == ComingInStatus.Loading && data.items.isEmpty() -> {
LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) {
LazyColumn(Modifier.skeletonPulse(), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() }
}
SheetButton("Close") { shell.closeRequests() }
SheetButton("Close", interactive = visible) { shell.closeRequests() }
}
data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> {
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
@ -86,34 +116,50 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
}
Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted)
}
SheetButton("Try again", primary = true) { shell.retryRequests() }
SheetButton("Try again", primary = true, interactive = visible) { shell.retryRequests() }
}
data.items.isEmpty() -> {
Column(Modifier.padding(top = T.emptyMargin), verticalArrangement = Arrangement.spacedBy(T.receiptGap)) {
Text("Nothing coming in.", style = ComingInTypography.body, color = C.muted)
Text("New requests show up here.", style = ComingInTypography.body, color = C.muted)
}
SheetButton("Close", interactive = visible) { shell.closeRequests() }
}
data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
else -> {
if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn)
data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) }
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
val start = state.windowStart
val visibleRows = remember(data, start, state.windowSize) {
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
val start = frame.start
val listState = rememberLazyListState()
val placement = rememberRequestPlacement(frame, listState)
val visibleRows = remember(data, start, frame.windowSize) {
data.ordered.subList(start, (start + frame.windowSize).coerceAtMost(data.ordered.size))
}
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier),
state = listState, verticalArrangement = Arrangement.spacedBy(T.listGap)) {
val keys = visibleRows.map { it.id }.uniqueKeys()
itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { _, item ->
ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { index, item ->
ComingInRow(item, shell.person, !frame.recoveryFocused && item.id == frame.focusedId, frame.expanded && item.id == frame.focusedId,
frame.ratingsVisible && data.signedIn && item.id == frame.focusedId,
onFocus = { if (visible) state.focus(item.id) }, onClick = { if (visible) { state.focus(item.id); shell.activateRequest() } },
modifier = Modifier.layout { measurable, constraints ->
val row = measurable.measure(constraints)
layout(row.width, row.height) {
row.placeRelative(0, placement.offset(keys[index], listState.layoutInfo))
}
},
readinessProgress = { placement.progress.value }, interactive = visible)
}
}
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() }
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = frame.recoveryFocused, interactive = visible) { shell.retryRequests() }
}
}
}
}
@Composable
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) }
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, interactive: Boolean = true, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = { if (interactive) onClick() }) }
}
@Composable
@ -128,32 +174,95 @@ private fun ComingInSkeleton() {
}
}
private data class RequestsPanelFrame(val data: ComingInSnapshot, val focusedId: String?, val expanded: Boolean,
val start: Int, val windowSize: Int, val recoveryFocused: Boolean, val ratingsVisible: Boolean, val expansionInput: Long)
private class RequestPlacementMemory(var frame: RequestsPanelFrame, var motion: RequestsPlacement? = null)
/** Placement reads one clock; target-offset caching happens only when the list is measured. */
private class RequestsPlacement(val animate: Boolean, private val origins: Map<Any, Int>) {
val progress: Animatable<Float, AnimationVector1D> = Animatable(if (animate) 0f else 1f)
private var measured: LazyListLayoutInfo? = null
private var targets: Map<Any, Int> = emptyMap()
fun offset(key: Any, info: LazyListLayoutInfo): Int {
val progress = progress.value
if (!animate || progress == 1f) return 0
if (measured !== info) {
targets = info.visibleItemsInfo.associate { it.key to it.offset }
measured = info
}
val target = targets[key] ?: return 0
return (((origins[key] ?: target) - target) * (1f - progress)).roundToInt()
}
}
/** Only remote expansion toggles animate; interruptions start at the current visual placement. */
@Composable
private fun rememberRequestPlacement(frame: RequestsPanelFrame, listState: LazyListState): RequestsPlacement {
val previous = remember { RequestPlacementMemory(frame) }
val reduced = reducedMotion()
val motion = remember(frame, reduced) {
val animate = !reduced && previous.frame.let {
it.expansionInput != frame.expansionInput && it.data === frame.data && it.focusedId == frame.focusedId &&
it.start == frame.start && it.expanded != frame.expanded
}
val info = listState.layoutInfo
val origins = if (animate) info.visibleItemsInfo.associate {
it.key to it.offset + (previous.motion?.offset(it.key, info) ?: 0)
} else emptyMap()
RequestsPlacement(animate, origins)
}
SideEffect { previous.frame = frame; previous.motion = motion }
LaunchedEffect(motion) {
if (motion.animate) motion.progress.animateTo(1f, tween(M.lateLineFade, easing = M.easeOut))
}
return motion
}
/** Thumbnail slots do not resize when the text or the readiness block grows. */
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
@Composable
fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean,
showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier,
readinessProgress: () -> Float = { 1f }, interactive: Boolean = true) {
val foreground = if (focused) C.onFocus else C.fg
val secondary = if (focused) C.onFocus else C.muted
val context = LocalContext.current
val request = remember(item.poster, context) {
item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) }
}
val posterReady = remember(request) { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow)
val posterState = remember(request) { mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty) }
var collapsedHeight by remember { mutableIntStateOf(0) }
Row(modifier.fillMaxWidth().testTag("coming-row-${item.id}")
.onSizeChanged { if (!expanded) collapsedHeight = it.height }
.drawWithContent {
val progress = readinessProgress()
if (expanded && collapsedHeight > 0 && progress < 1f) {
val bottom = collapsedHeight + (size.height - collapsedHeight) * progress
clipRect(left = -size.width, top = -size.height, right = size.width * 2, bottom = bottom) {
this@drawWithContent.drawContent()
}
} else drawContent()
}.focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
.pointerInput(item.id) {
.then(if (interactive) Modifier.pointerInput(item.id) {
awaitPointerEventScope {
while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus()
}
}.clickable(onClick = onClick)
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"),
} else Modifier).clickable(enabled = interactive, onClick = onClick)
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
.background(if (posterReady.value) Color.Transparent else C.surface3),
onState = { posterReady.value = it is AsyncImagePainter.State.Success },
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
Box(Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
.background(if (posterState.value is AsyncImagePainter.State.Success) Color.Transparent else C.surface3)
.testTag("coming-thumb-${item.id}"), contentAlignment = Alignment.Center) {
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
modifier = Modifier.fillMaxSize(), onState = { posterState.value = it },
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
if (request == null || posterState.value is AsyncImagePainter.State.Error) {
Glyph(if (item.mediaType == "movie") "film" else "tv", C.dim, Modifier.size(T.thumbGlyph))
}
}
Column(Modifier.weight(1f)) {
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: GPL-2.0-only
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.border
import androidx.compose.foundation.clickable
@ -18,12 +20,23 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.platform.testTag
@ -37,48 +50,67 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Sheets draw after the shell nav: their scrim consumes pointers and focus stays inside. */
@Composable
fun DiscoverOverlay(state: DiscoverState) {
val overlay = state.overlay
if (overlay != null && !state.shell.pickerVisible) {
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) {
val current = state.overlay
var retained by remember { mutableStateOf(current) }
SideEffect { if (current != null) retained = current }
val visible = current != null && !state.shell.pickerVisible
val fade = rememberSheetFade(visible)
val overlay = current ?: retained
if (overlay != null && !state.shell.pickerVisible && (visible || fade.value > 0f)) {
Box(Modifier.fillMaxSize().sheetScrim { fade.value }.clickable(enabled = visible, onClick = {})
.pointerInput(visible) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
if (!visible) event.changes.forEach { it.consume() }
}
}
}.testTag("discover-overlay")) {
Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize()
.sheetPanelFade { fade.value }
.dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur,
color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity),
offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero)))
.background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
when (overlay) {
is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state)
is DiscoverOverlayState.Hidden -> {
ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.")
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) }
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) }
key(overlay) {
when (overlay) {
is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state, visible)
is DiscoverOverlayState.Hidden -> {
ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.")
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Undo", overlay.focus == 0, enabled = visible) { state.restore(overlay.title, showToast = false) }
DiscoverButton("Done", overlay.focus == 1, primary = false, enabled = visible) { overlay.focus = 1; state.key(ShellKey.Ok) }
}
}
}
is DiscoverOverlayState.Restore -> {
Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
Text("Hidden from ${state.shell.person.name}’s picks.",
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body),
color = C.muted, style = DiscoverType.body)
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) }
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null }
is DiscoverOverlayState.Restore -> {
Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
Text("Hidden from ${state.shell.person.name}’s picks.",
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body),
color = C.muted, style = DiscoverType.body)
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Show again", overlay.focus == 0, enabled = visible) { state.restore(overlay.title, showToast = true) }
DiscoverButton("Back", overlay.focus == 1, primary = false, enabled = visible) { state.overlay = null }
}
}
}
}
}
}
}
state.toast?.let { RecoveryToast(it) }
RecoveryToastHost(state.toast)
}
@Composable
private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) {
var previousPhase by remember(sheet) { mutableStateOf(sheet.phase) }
val phase = sheet.phase
SideEffect { previousPhase = phase }
Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}",
Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body)
if (state.isSignedIn) SheetRatings(sheet.title.ratings)
when (sheet.phase) {
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
when (phase) {
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet, interactive)
RequestPhase.LoadingRecipients -> {
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
repeat(3) {
@ -89,10 +121,21 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
}
}
Box(Modifier.padding(top = D.sheetButtonTop)) {
DiscoverButton("Close", true, primary = false) { state.overlay = null }
DiscoverButton("Close", true, primary = false, enabled = interactive) { state.overlay = null }
}
}
else -> RequestOutcome(state, sheet)
else -> {
val opacity = remember(sheet, phase) { Animatable(if (previousPhase == RequestPhase.Sending) 0f else 1f) }
val reduced = reducedMotion()
LaunchedEffect(opacity, reduced) {
if (opacity.value < 1f) opacity.animateTo(1f,
tween(motionDuration(WolfskinTokens.Motion.lateLineFade, reduced), easing = WolfskinTokens.Motion.ease))
}
Column(Modifier.fillMaxWidth().graphicsLayer {
alpha = opacity.value
compositingStrategy = CompositingStrategy.ModulateAlpha
}, verticalArrangement = Arrangement.spacedBy(D.sheetGap)) { RequestOutcome(state, sheet, interactive) }
}
}
}
@ -119,7 +162,7 @@ private fun SheetRatings(ratings: List<DiscoverRating>) {
}
@Composable
private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) {
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
sheet.people.forEachIndexed { index, person ->
val focused = sheet.focus == index
@ -127,7 +170,7 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f)
.focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
.clickable(enabled = !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex }
.clickable(enabled = interactive && !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex }
.padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow)
Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = DiscoverType.listTitle)
@ -141,12 +184,16 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = DiscoverType.caption)
else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = DiscoverType.caption)
Box(Modifier.padding(top = D.sheetButtonTop)) {
DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex,
glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked, enabled = interactive,
busy = sheet.phase == RequestPhase.Sending, busyLabel = if (sheet.blocked) null else "Sending…") {
sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok)
}
}
}
@Composable
private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) {
val phase = sheet.phase
val name = sheet.selectedPerson.name
val actor = sheet.result?.actor
@ -169,9 +216,9 @@ private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = DiscoverType.caption)
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) {
DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false) { state.overlay = null }
} else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true) { state.overlay = null }
DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex, enabled = interactive) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false, enabled = interactive) { state.overlay = null }
} else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true, enabled = interactive) { state.overlay = null }
}
}

View file

@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -193,11 +195,15 @@ private fun DiscoverChips(state: DiscoverState) {
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = D.chipTop), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
(if (state.service == null) listOf("all" to "All", "movie" to "Movies", "tv" to "Series") else listOf("movie" to "Movies", "tv" to "Series")).forEachIndexed { index, (value, label) ->
val focused = state.contentFocused && state.focusedRow == 1 && state.column == index
val selected = state.filter == value
val fill = animatedFocusColor(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest)
val foreground = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg)
val border = animatedFocusColor(if (selected && !focused) C.accent else C.accent.copy(alpha = 0f))
Box(Modifier.height(D.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.background(if (focused) C.fg else if (state.filter == value) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip))
.border(if (!focused && state.filter == value) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && state.filter == value) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip))
.background(fill, RoundedCornerShape(WolfskinTokens.Radius.chip))
.border(WolfskinTokens.Focus.ringCardRing / 2, border, RoundedCornerShape(WolfskinTokens.Radius.chip))
.clickable { state.focus(1, index); state.key(ShellKey.Ok) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = DiscoverType.label)
Text(label, color = foreground, style = DiscoverType.label)
}
}
}
@ -223,12 +229,21 @@ private fun DiscoverStatePanel(state: DiscoverState) {
/** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */
@Composable
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true,
busy: Boolean = false, busyLabel: String? = null, enabled: Boolean = true, onClick: () -> Unit) {
val foreground = if (primary) C.onFocus else C.fg
val content: @Composable RowScope.() -> Unit = {
if (glyph != null) Glyph(glyph, foreground, Modifier.size(L.buttonIcon))
Text(label, color = foreground, style = DiscoverType.button, maxLines = 1)
}
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button))
.clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
Text(label, color = if (primary) C.onFocus else C.fg, style = DiscoverType.button, maxLines = 1)
.clickable(enabled = enabled, onClick = onClick).padding(horizontal = L.buttonPaddingX),
horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
if (busyLabel == null) content()
else BusyLabelSwap(busy, busyLabel, color = foreground, style = DiscoverType.button) {
Row(Modifier.fillMaxHeight(), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically, content = content)
}
}
}

View file

@ -7,6 +7,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
@ -18,6 +20,9 @@ import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
@ -37,7 +42,7 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
/** Lift shadows remain specific to cards and buttons. */
enum class FocusLift { None, Card, Button }
/** Every focus ring straddles the surface edge above content, without a background gap. */
/** Hosted cards keep their lift and share the surface stroke; standalone rings straddle the surface edge. */
@Composable
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
val reduced = reducedMotion()
@ -55,6 +60,23 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
}
}
val host = LocalCardRing.current.takeIf { lift == FocusLift.Card }
if (host != null) {
val owner = remember { Any() }
val density = LocalDensity.current
val scale = WolfskinTokens.Focus.scaleCard
val corner = with(density) { radius.toPx() } * scale
val stroke = with(density) { width.toPx() } * scale
DisposableEffect(host, focused) {
onDispose { if (focused) host.release(owner) }
}
SideEffect { if (focused) host.retainFocus() }
return if (!focused) shadow else shadow.onGloballyPositioned { coordinates ->
val center = coordinates.localToRoot(Offset(coordinates.size.width / 2f, coordinates.size.height / 2f))
val size = Size(coordinates.size.width * scale, coordinates.size.height * scale)
host.update(owner, CardRingGeometry(Rect(center - Offset(size.width / 2, size.height / 2), size), corner, stroke))
}
}
return shadow.drawWithCache {
val stroke = Stroke(width.toPx())
val corner = CornerRadius(radius.toPx())

View file

@ -0,0 +1,136 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector4D
import androidx.compose.animation.core.TwoWayConverter
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInRoot
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
private val boundsConverter = TwoWayConverter<Rect, AnimationVector4D>(
{ AnimationVector4D(it.left, it.top, it.right, it.bottom) },
{ Rect(it.v1, it.v2, it.v3, it.v4) },
)
internal data class CardRingGeometry(val bounds: Rect, val radius: Float, val stroke: Float)
private data class CardRingTarget(val owner: Any, val geometry: CardRingGeometry)
internal val LocalCardRing = staticCompositionLocalOf<CardRingMotion?> { null }
/** Target geometry follows layout; only the displacement from it carries spring velocity. */
@Stable
internal class CardRingMotion(private val scope: CoroutineScope) {
var reduced = false
private var target by mutableStateOf<CardRingTarget?>(null)
private var retained by mutableStateOf<CardRingGeometry?>(null)
private val displacement = Animatable(Rect.Zero, boundsConverter)
private val radiusDisplacement = Animatable(0f)
val opacity = Animatable(0f)
private var geometryJob: Job? = null
private var fadeJob: Job? = null
private var releaseJob: Job? = null
private var stroke = Stroke(0f)
fun geometry(): CardRingGeometry? {
val destination = target?.geometry ?: return retained
if (reduced) return destination
return CardRingGeometry(destination.bounds + displacement.value,
(destination.radius + radiusDisplacement.value).coerceAtLeast(0f), destination.stroke)
}
fun update(owner: Any, geometry: CardRingGeometry) {
releaseJob?.cancel()
if (stroke.width != geometry.stroke) stroke = Stroke(geometry.stroke)
val previous = target
if (previous?.owner === owner) {
target = CardRingTarget(owner, geometry)
return
}
val current = this.geometry()
val velocity = displacement.velocity
val radiusVelocity = radiusDisplacement.velocity
geometryJob?.cancel()
target = CardRingTarget(owner, geometry)
geometryJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
displacement.snapTo(if (previous != null && current != null && !reduced) current.bounds - geometry.bounds else Rect.Zero)
radiusDisplacement.snapTo(if (previous != null && current != null && !reduced) current.radius - geometry.radius else 0f)
if (!reduced) coroutineScope {
launch { displacement.animateTo(Rect.Zero, spring(M.navBlobDampingRatio, M.navBlobStiffness), initialVelocity = velocity) }
launch { radiusDisplacement.animateTo(0f, spring(M.navBlobDampingRatio, M.navBlobStiffness), initialVelocity = radiusVelocity) }
}
}
if (previous == null) fade(true)
}
fun drawRing(scope: DrawScope, origin: Offset) {
val geometry = target?.geometry ?: retained ?: return
val alpha = opacity.value
if (alpha <= 0f) return
val delta = if (target != null && !reduced) displacement.value else Rect.Zero
val radius = geometry.radius + if (target != null && !reduced) radiusDisplacement.value else 0f
scope.drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = alpha),
topLeft = geometry.bounds.topLeft + Offset(delta.left, delta.top) - origin,
size = Size(geometry.bounds.width + delta.right - delta.left, geometry.bounds.height + delta.bottom - delta.top),
cornerRadius = CornerRadius(radius.coerceAtLeast(0f)), style = stroke)
}
fun retainFocus() { releaseJob?.cancel() }
fun release(owner: Any) {
// A replacement card registers during layout in the same composition; it must not fade the ring.
releaseJob?.cancel()
releaseJob = scope.launch {
yield()
if (target?.owner === owner) {
retained = geometry()
target = null
geometryJob?.cancel()
fade(false)
}
}
}
private fun fade(visible: Boolean) {
fadeJob?.cancel()
fadeJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
opacity.animateTo(if (visible) 1f else 0f, tween(motionDuration(M.focusMove, reduced), easing = M.easeOut))
}
}
}
private operator fun Rect.plus(other: Rect) = Rect(left + other.left, top + other.top, right + other.right, bottom + other.bottom)
private operator fun Rect.minus(other: Rect) = Rect(left - other.left, top - other.top, right - other.right, bottom - other.bottom)
/** One draw-only card ring sits above surface content without changing layout or focus ownership. */
@Composable
fun FocusRingHost(content: @Composable () -> Unit) {
val scope = rememberCoroutineScope()
val motion = remember(scope) { CardRingMotion(scope) }
val reduced = reducedMotion()
SideEffect { motion.reduced = reduced }
var origin by remember { mutableStateOf(Offset.Zero) }
Box(Modifier.onPlaced { origin = it.positionInRoot() }.drawWithContent {
drawContent()
motion.drawRing(this, origin)
}) {
CompositionLocalProvider(LocalCardRing provides motion, content = content)
}
}

View file

@ -1,19 +1,72 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.spring
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.withFrameNanos
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.WeakHashMap
import kotlin.math.abs
import kotlin.math.sign
import kotlin.math.sqrt
/** Every frame follows focus with the same bounded scroll tween. */
internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
if (distance == 0f) return
list.scroll {
var previous = 0f
animate(0f, distance, animationSpec = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll,
easing = WolfskinTokens.Motion.easeOut)) { value, _ ->
scrollBy(value - previous)
previous = value
private class ScrollMomentum {
var velocity = 0f
var frameTime = Long.MIN_VALUE
var generation = 0L
fun reset() { velocity = 0f; frameTime = Long.MIN_VALUE }
fun release(context: CoroutineContext, generation: Long) {
if (this.generation != generation) return
if (context.isActive) { reset(); return }
// A replacement must claim the carry before the next frame, without keeping the old mutation alive.
CoroutineScope(context + NonCancellable).launch(start = CoroutineStart.UNDISPATCHED) {
try { withFrameNanos { } }
finally { if (this@ScrollMomentum.generation == generation) reset() }
}
}
}
// Scroll mutations and animation callbacks run on the UI thread; values never retain their weak keys.
private val scrollMomentum = WeakHashMap<LazyListState, ScrollMomentum>()
private val scrollAngularFrequency = sqrt(WolfskinTokens.Motion.navBlobStiffness)
/** Key-driven frames and shelves share a bounded nav spring and carry same-direction velocity. */
internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
val momentum = scrollMomentum.getOrPut(list) { ScrollMomentum() }
val generation = ++momentum.generation
if (distance == 0f) { momentum.reset(); return }
try {
list.scroll {
if (NetworkArtTransition.reduced) {
momentum.reset()
scrollBy(distance)
} else {
val maximum = scrollAngularFrequency * abs(distance)
val velocity = if (momentum.velocity.sign == distance.sign) momentum.velocity.coerceIn(-maximum, maximum) else 0f
var previous = 0f
// A fresh frame-clock start would insert a stationary frame on every held-key repeat.
AnimationState(0f, velocity, lastFrameTimeNanos = momentum.frameTime).animateTo(distance,
animationSpec = spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness),
sequentialAnimation = momentum.frameTime != Long.MIN_VALUE) {
scrollBy(value - previous)
previous = value
momentum.velocity = this.velocity
momentum.frameTime = lastFrameTimeNanos
}
}
}
} finally {
val context = currentCoroutineContext()
momentum.release(context, generation)
context.ensureActive()
}
}

View file

@ -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)

View file

@ -1,7 +1,5 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
@ -69,7 +67,7 @@ fun HomeScreen(
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
}.coerceAtLeast(0f)
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
view.listState.animateScrollBy(target - current, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
animateFrameScroll(view.listState, target - current)
view.home.positionedKey = key
view.home.positionedReset = view.home.resetScroll
}

View file

@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.derivedStateOf
import java.util.Locale
@ -82,6 +83,7 @@ class LibraryGridState(
var pagination: LibraryPagination? = null
var pageRevision by mutableStateOf(0)
var refreshRevision by mutableStateOf(0)
var resultRevision by mutableIntStateOf(0)
var pointerReentry: (() -> Int?)? = null
var index by mutableStateOf(0)
var toolbar by mutableStateOf(false)
@ -147,8 +149,10 @@ class LibraryGridState(
ShellKey.Left -> tool = (tool - 1).coerceAtLeast(0)
ShellKey.Right -> tool = (tool + 1).coerceAtMost(count - 1)
ShellKey.Ok -> {
val previous = items.map { if (catalogue) it.catalogueKey else it.id }
if (catalogue) mediaType = listOf("all", "movie", "tv")[tool]
else if (tool == 0) sort = (sort + 1) % 3 else unwatched = !unwatched
if (previous != items.map { if (catalogue) it.catalogueKey else it.id }) resultRevision++
index = 0
}
else -> Unit

View file

@ -137,7 +137,7 @@ fun LibraryScreen(
}.uniqueKeys()
items(rowCount, key = { rowKeys[it] }, contentType = { "grid" }) { row ->
Box(Modifier.fillMaxWidth().height(rowHeight)) {
Row(Modifier.padding(start = L.safeSide).rowReveal("grid-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
Row(Modifier.padding(start = L.safeSide).rowReveal("grid-${grid.resultRevision}-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
for (column in 0 until grid.columns) {
val index = row * grid.columns + column
val title = items.getOrNull(index) ?: break
@ -194,12 +194,14 @@ private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) {
labels.forEachIndexed { index, label ->
val focused = active && grid.toolbar && grid.tool == index
val selected = if (grid.catalogue) listOf("all", "movie", "tv")[index] == grid.mediaType else index == 1 && grid.unwatched
val fill = animatedFocusColor(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest)
val color = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg)
val border = animatedFocusColor(if (selected && !focused) C.accent else C.accent.copy(alpha = 0f))
Row(Modifier.height(G.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, CircleShape)
.then(if (selected && !focused) Modifier.border(G.borderWidth, C.accent, CircleShape) else Modifier)
.background(fill, CircleShape)
.border(G.borderWidth, border, CircleShape)
.clickable { grid.toolbar = true; grid.tool = index; grid.key(ShellKey.Ok) }
.padding(horizontal = G.chipPadding), horizontalArrangement = Arrangement.spacedBy(G.chipContentGap), verticalAlignment = Alignment.CenterVertically) {
val color = if (focused) C.onFocus else if (selected) C.accentText else C.fg
if (!grid.catalogue) Glyph(if (index == 0) "advanced" else "eye", color, Modifier.size(G.chipIcon))
if (!grid.catalogue && index == 0) Text(buildAnnotatedString {
withStyle(SpanStyle(color = color.copy(alpha = G.chipLabelOpacity), fontWeight = FontWeight.Medium)) { append("Sort") }

View file

@ -74,14 +74,25 @@ fun Modifier.skeletonPulse(loading: Boolean = true): Modifier {
return graphicsLayer { alpha = clock.alpha.value }
}
/** Content enters on its row layer, never one animation per card or a full-screen transition. */
/**
* Content enters on its row layer, never per card or across the full screen.
* ScreenEntry remembers each enabled identity for that saved visit, so lazy disposal and Back
* do not replay its entrance. Outside an entry, each new composition retains the original reveal.
*/
@Composable
fun Modifier.rowReveal(identity: Any, enabled: Boolean = true): Modifier {
val memory = LocalRevealMemory.current
val key = identity.toString()
val shouldReveal = remember(memory, identity, enabled) { enabled && memory?.identities?.contains(key) != true }
if (!shouldReveal) return this
val reduced = reducedMotion()
val progress = remember(identity, enabled) { Animatable(if (enabled) 0f else 1f) }
LaunchedEffect(progress, reduced) { if (enabled) progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
val rise = with(LocalDensity.current) { M.rowRise.toPx() }
return if (!enabled) this else graphicsLayer {
val progress = remember(memory, identity, enabled) { Animatable(0f) }
LaunchedEffect(progress, reduced) {
memory?.identities?.add(key)
progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut))
}
val rise = if (reduced) 0f else with(LocalDensity.current) { M.rowRise.toPx() }
return graphicsLayer {
alpha = progress.value
translationY = if (reduced) 0f else rise * (1f - progress.value)
}

View file

@ -0,0 +1,71 @@
// 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
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))))
// Paint the opaque phase-one masks last: phase two must not round their background pixels upward.
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))
}
}
}) {
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
PickerPeople(commit.people, commit.chosenIndex, false, onPerson = null, onGear = null)
}
}

View file

@ -21,7 +21,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -116,7 +115,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2)
}
val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset
if (target != current) list.animateScrollBy(target - current, titleFrameScrollSpec)
if (target != current) animateFrameScroll(list, target - current)
}
Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" }
.titleWheel { wheelPending = true; shell.navFocused = false }

View file

@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
/** One pending playback start per surface; the player opens only after the page has faded to black. */
@Stable
class PlaybackStart {
/** The control that reads "Starting…" while the stream is prepared, or null. */
var starting by mutableStateOf<String?>(null)
private set
/** Set once the player is ready; the veil runs it exactly once, when the screen is black. */
var launch by mutableStateOf<(() -> Unit)?>(null)
private set
fun begin(control: String) { starting = control; launch = null }
fun fail() { starting = null; launch = null }
fun ready(open: () -> Unit) { launch = open }
internal fun finish() { starting = null; launch = null }
}
/** The hand-off is one black rectangle drawn over the page, never a second screen layer. */
@Composable
fun PlaybackHandoffVeil(start: PlaybackStart, modifier: Modifier = Modifier) {
val pending = start.launch
val reduced = reducedMotion()
val alpha = remember { Animatable(0f) }
LaunchedEffect(pending, reduced) {
if (pending == null) {
alpha.snapTo(0f)
return@LaunchedEffect
}
alpha.animateTo(1f, tween(motionDuration(M.base, reduced), easing = M.ease))
start.finish()
pending()
}
if (pending != null || alpha.value > 0f) Box(modifier.fillMaxSize().drawBehind { drawRect(Color.Black.copy(alpha = alpha.value)) })
}

View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
/** Side panels enter over sheetIn and leave over sheetOut on the shared ease-out; reduced motion caps both at the fade limit. */
@Composable
internal fun rememberSheetFade(visible: Boolean, initial: Float = 0f): State<Float> {
val reduced = reducedMotion()
val progress = remember { Animatable(initial) }
LaunchedEffect(visible, reduced) {
progress.animateTo(if (visible) 1f else 0f,
tween(motionDuration(if (visible) M.sheetIn else M.sheetOut, reduced), easing = M.easeOut))
}
return progress.asState()
}
/** The scrim stays a draw-only colour, so a fading sheet never adds a full-screen layer. */
internal fun Modifier.sheetScrim(progress: () -> Float): Modifier = drawBehind {
val scrim = WolfskinTokens.Color.sheetScrim
drawRect(scrim.copy(alpha = scrim.alpha * progress()))
}
/** The bounded panel modulates each draw, so its outside shadow fades with it and no offscreen buffer is allocated. */
internal fun Modifier.sheetPanelFade(progress: () -> Float): Modifier = graphicsLayer {
alpha = progress()
compositingStrategy = CompositingStrategy.ModulateAlpha
}
/** Focus and selection colours retarget over focusMove on the ease-out; reduced motion caps the fade. */
@Composable
internal fun animatedFocusColor(target: Color, label: String = "focus-colour"): Color {
val reduced = reducedMotion()
return animateColorAsState(target, tween(motionDuration(M.focusMove, reduced), easing = M.easeOut), label = label).value
}
/** A pending action keeps its button's resting width: the resting content fades out while the busy label fades in over base. */
@Composable
internal fun BusyLabelSwap(busy: Boolean, busyLabel: String, color: Color, style: TextStyle, content: @Composable () -> Unit) {
val reduced = reducedMotion()
val progress = remember { Animatable(if (busy) 1f else 0f) }
LaunchedEffect(busy, reduced) {
progress.animateTo(if (busy) 1f else 0f, tween(motionDuration(M.base, reduced), easing = M.ease))
}
val showLabel = busy || progress.value > 0f
Layout(content = {
Box(Modifier.graphicsLayer { alpha = 1f - progress.value }.then(if (busy) Modifier.clearAndSetSemantics { } else Modifier)) { content() }
if (showLabel) Text(busyLabel, Modifier.graphicsLayer { alpha = progress.value }, color = color, style = style, maxLines = 1, softWrap = false)
}) { measurables, constraints ->
val resting = measurables[0].measure(constraints)
val label = measurables.getOrNull(1)?.measure(Constraints())
layout(resting.width, resting.height) {
resting.place(0, 0)
if (label != null) label.place((resting.width - label.width) / 2, (resting.height - label.height) / 2)
}
}
}

View file

@ -1,14 +1,24 @@
// SPDX-License-Identifier: GPL-2.0-only
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.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.DpOffset
@ -16,14 +26,31 @@ import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
/** Callers pass their current message; a cleared message fades out with its last text instead of vanishing. */
@Composable
fun RecoveryToastHost(message: String?, modifier: Modifier = Modifier) {
val reduced = reducedMotion()
var last by remember { mutableStateOf(message) }
val opacity = remember { Animatable(0f) }
SideEffect { if (message != null) last = message }
LaunchedEffect(message != null, reduced) {
opacity.animateTo(if (message != null) 1f else 0f, tween(motionDuration(M.base, reduced), easing = M.ease))
}
val text = message ?: last ?: return
if (message == null && opacity.value == 0f) return
RecoveryToast(text, modifier) { opacity.value }
}
/** Recovery messages remain transient and never take focus from the action that failed. */
@Composable
fun RecoveryToast(message: String, modifier: Modifier = Modifier) {
fun RecoveryToast(message: String, modifier: Modifier = Modifier, opacity: () -> Float = { 1f }) {
Box(modifier.fillMaxSize()) {
val shape = androidx.compose.runtime.remember { RoundedCornerShape(WolfskinTokens.Radius.panel) }
val shape = remember { RoundedCornerShape(WolfskinTokens.Radius.panel) }
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop)
.widthIn(max = H.toastWidth)
.graphicsLayer { alpha = opacity(); compositingStrategy = CompositingStrategy.ModulateAlpha }
.dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity),
offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY)))
.background(C.surface2, shape)

View file

@ -4,6 +4,20 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
/** Entrance identities belong to one navigation entry, not the lifetime of a recycled lazy item. */
internal class RevealMemory(val identities: MutableSet<String> = mutableSetOf()) {
companion object {
val saver = Saver<RevealMemory, ArrayList<String>>(
save = { ArrayList(it.identities) },
restore = { RevealMemory(it.toMutableSet()) },
)
}
}
internal val LocalRevealMemory = compositionLocalOf<RevealMemory?> { null }
internal val LocalScreenView = compositionLocalOf<RememberedView?> { null }
internal val LocalScreenActive = compositionLocalOf { true }
@ -16,7 +30,9 @@ fun isScreenEntryActive(): Boolean = LocalScreenActive.current
@Composable
fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) {
remember(shell, shell.person.key, route) { shell.prepareDestinationView(route) }
CompositionLocalProvider(LocalScreenView provides shell.rememberedView(route), LocalScreenActive provides active, content = content)
val revealMemory = rememberSaveable(shell.person.key, route, saver = RevealMemory.saver) { RevealMemory() }
CompositionLocalProvider(LocalScreenView provides shell.rememberedView(route), LocalScreenActive provides active,
LocalRevealMemory provides revealMemory, content = content)
}
/** Direct surface previews use the current view when no navigation entry is installed. */

View file

@ -1,7 +1,5 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -57,13 +55,12 @@ val SETTINGS_GROUPS = listOf(
@Composable
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
val list = state.view.listState
val reduced = reducedMotion()
val density = LocalDensity.current
LaunchedEffect(state.settingsRow, state.navFocused) {
if (state.navFocused) return@LaunchedEffect
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset
list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
animateFrameScroll(list, distance)
}
Box(modifier.fillMaxSize()) {
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),

View file

@ -1,7 +1,5 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.lazy.LazyListState
/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */
@ -15,7 +13,6 @@ internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
if (distance != 0f) animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.base,
easing = WolfskinTokens.Motion.ease))
animateFrameScroll(this, distance)
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
}

View file

@ -82,6 +82,8 @@ class ShellState(
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
var recoveryIndex by mutableStateOf(0)
var requestsVisible by mutableStateOf(false)
internal var requestsExpansionInput = 0L
private set
var toast by mutableStateOf<String?>(null)
private val personRequests = mutableMapOf<String, ComingInState>()
val requests: ComingInState get() = personRequests.getValue(person.key)
@ -161,8 +163,12 @@ class ShellState(
when {
key == ShellKey.Back -> closeRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
requests.key(key) -> activateRequest()
key == ShellKey.Ok && requests.snapshot.items.isEmpty() && requests.snapshot.status != ComingInStatus.Unavailable -> closeRequests()
else -> {
val expanded = requests.expanded
if (requests.key(key)) activateRequest()
if ((key == ShellKey.Right || key == ShellKey.Left) && requests.expanded != expanded) requestsExpansionInput++
}
}
return true
}

View file

@ -96,12 +96,14 @@ fun ShellSurface(
if (state.pickerVisible) {
WhosWatching(state)
} else {
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
FocusRingHost {
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
}
NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
if (state.requestsVisible) ComingInSheet(state)
ComingInSheet(state)
overlay()
}
state.toast?.let { RecoveryToast(it) }
RecoveryToastHost(state.toast)
}
}

View file

@ -1,5 +1,11 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@ -33,6 +39,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs
height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f,
content: @Composable BoxScope.() -> Unit) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
val animatedProgress = rememberTitleProgress(null, progress.coerceIn(0f, 1f), hasResumeLine = progress > 0f)
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2)
@ -51,9 +58,21 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs
val top = Offset(0f, size.height - edge - fill)
if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill))
drawRect(WolfskinTokens.Color.accent, top,
Size(size.width * if (progress > 0f) progress.coerceIn(0f, 1f) else 1f, fill))
Size(size.width * if (progress > 0f) animatedProgress.value else 1f, fill))
}
}
}, content = content)
}
}
/** Target and line-kind changes cut; a held resume line interpolates even when runtime arrives late. */
@Composable
internal fun rememberTitleProgress(identity: String?, fraction: Float, hasResumeLine: Boolean): State<Float> {
val reduced = reducedMotion()
val progress = remember(identity, hasResumeLine) { Animatable(fraction) }
LaunchedEffect(progress, fraction, reduced) {
if (reduced) progress.snapTo(fraction)
else progress.animateTo(fraction, tween(WolfskinTokens.Motion.heroFade, easing = WolfskinTokens.Motion.easeInOut))
}
return if (reduced) rememberUpdatedState(fraction) else progress.asState()
}

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
@ -25,24 +26,35 @@ 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
/** Retained sheet controls are painted but cannot receive focus, hover or clicks. */
internal val LocalTitleControlsEnabled = compositionLocalOf { true }
/** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */
@Composable
fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, onClick: () -> Unit) {
fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, busy: Boolean = false, busyLabel: String? = null, onClick: () -> Unit) {
val focusOwner = LocalTitleFocusOwner.current
val enabled = LocalTitleControlsEnabled.current
val background = if (option.primary) C.fg else if (overArtwork) {
if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary
} else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
val foreground = if (option.primary) C.onFocus else C.fg
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl)
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus.takeIf { enabled }).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
.background(background, shape)
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
.clickable(enabled = enabled && !busy) { focusOwner?.invoke(); onClick() }.then(dimensions),
horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
if (busyLabel != null) BusyLabelSwap(busy, busyLabel, foreground, WolfskinTokens.Type.button) {
Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(L.buttonIcon))
Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
}
} else {
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
}
}
}

View file

@ -4,6 +4,8 @@ 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.draw.drawBehind
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.platform.testTag
@ -13,7 +15,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
@ -86,10 +87,10 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
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) {
if (target.resumeSeconds > 0) {
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f
Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(C.accent))
}
val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f
val progress = rememberTitleProgress(target.id, fraction, hasResumeLine = true)
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))
.drawBehind { drawRect(C.accent, size = Size(size.width * progress.value, size.height)) })
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)
}
@ -97,7 +98,8 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index,
Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) },
onFocus = { state.focus("header", index) }) { state.click("header", index) } }
onFocus = { state.focus("header", index) }, busy = option.id == "play" && state.playbackStart.starting == "play",
busyLabel = if (option.id == "play") "Starting…" else null) { state.click("header", index) } }
}
if (state.options.any { it.icon }) {
val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon }

View file

@ -15,6 +15,9 @@ 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.CompositionLocalProvider
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@ -41,15 +44,21 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) {
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))
tween(motionDuration(if (visible) WolfskinTokens.Motion.sheetIn else WolfskinTokens.Motion.sheetOut, 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) }
.clickable(enabled = visible) { state.back(shell) }
.pointerInput(visible) {
if (!visible) awaitPointerEventScope {
while (true) awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
}
}
.focusRequester(focus).onPreviewKeyEvent { event ->
val key = readShellKey(event)
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable()) {
}.focusable(enabled = visible)) {
CompositionLocalProvider(LocalTitleControlsEnabled provides visible) {
Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth)
.graphicsLayer { alpha = opacity.value }
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
@ -64,6 +73,7 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) {
}
}
}
}
}
}
}

View file

@ -1,5 +1,11 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
@ -44,25 +50,65 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** Sheets sit above navigation and consume pointer and key input across the entire scrim. */
@Composable
fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
val visible = state.sheet != null && state.sheet != TitleSheet.Menu
val menu = state.sheet == TitleSheet.Menu
val previous = remember { arrayOf<TitleSheet?>(null) }
val retainedSheet = remember { arrayOf<TitleSheet?>(null) }
val retained = remember(state) { TitleState(state.data, state.person) { } }
val panelScrim = remember { booleanArrayOf(false) }
val ownsScrim = visible || (panelScrim[0] && !menu)
val fromMenu = visible && previous[0] == TitleSheet.Menu
val fade = rememberSheetFade(visible)
val scrim = remember { Animatable(0f) }
val reduced = reducedMotion()
val focus = remember { FocusRequester() }
LaunchedEffect(state.sheet) { focus.requestFocus() }
SideEffect {
previous[0] = state.sheet
if (visible || menu) panelScrim[0] = visible
if (visible) {
retainedSheet[0] = state.sheet
retained.update(state.data)
retained.sheetIndex = state.sheetIndex
retained.versionId = state.versionId
retained.audioIndex = state.audioIndex
retained.subtitleIndex = state.subtitleIndex
retained.watched = state.watched
}
}
LaunchedEffect(visible, menu, reduced) {
if (visible) focus.requestFocus()
if (menu || fromMenu) scrim.snapTo(1f)
if (!menu) scrim.animateTo(if (visible) 1f else 0f,
tween(motionDuration(if (visible) WolfskinTokens.Motion.sheetIn else WolfskinTokens.Motion.sheetOut, reduced), easing = WolfskinTokens.Motion.easeOut))
}
if (!visible && fade.value == 0f && (!ownsScrim || scrim.value == 0f)) return
val sheet = if (visible) state.sheet else retainedSheet[0]
val content = if (visible) state else retained
val shadow = remember { Shadow(radius = D.sheetShadowRadius, offset = DpOffset.Zero.copy(x = D.sheetShadowX), color = Color.Black.copy(alpha = D.sheetShadowOpacity)) }
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { }
.testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" }
Box(Modifier.fillMaxSize().sheetScrim { if (!ownsScrim) 0f else if (fromMenu) 1f else scrim.value }
.pointerInput(visible) {
awaitPointerEventScope {
while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() }
}
}
.testTag("title-sheet").semantics { stateDescription = "$sheet:${content.sheetIndex}" }
.focusRequester(focus).onPreviewKeyEvent { event ->
val key = readShellKey(event)
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable()) {
CssColumn(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface)
.verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
gap = if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) {
when (state.sheet) {
TitleSheet.Version -> VersionChoices(state)
TitleSheet.Tracks -> TrackChoices(state)
TitleSheet.MediaInfo -> MediaInformation(state)
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
TitleSheet.Menu, null -> Unit
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable(enabled = visible)) {
CompositionLocalProvider(LocalTitleControlsEnabled provides visible, LocalTitleFocusOwner provides null) {
CssColumn(Modifier.sheetPanelFade { fade.value }.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight()
.dropShadow(RectangleShape, shadow).background(C.surface)
.verticalScroll(rememberScrollState(), enabled = visible).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
gap = if (sheet == TitleSheet.Tracks) D.trackGap else if (sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) {
when (sheet) {
TitleSheet.Version -> VersionChoices(content)
TitleSheet.Tracks -> TrackChoices(content)
TitleSheet.MediaInfo -> MediaInformation(content)
TitleSheet.ConfirmWatched -> WatchedConfirmation(content)
TitleSheet.HiddenReceipt -> HiddenReceipt(content)
TitleSheet.Menu, null -> Unit
}
}
}
}
@ -100,11 +146,12 @@ private fun TrackChoices(state: TitleState) {
@Composable
private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
val enabled = LocalTitleControlsEnabled.current
val foreground = if (focused) C.onFocus else C.fg
val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical)
Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleRow)
Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus.takeIf { enabled }).focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
.clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
.clickable(enabled = enabled, onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
CssColumn(Modifier.weight(1f)) {
Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle))

View file

@ -28,9 +28,23 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.Stroke
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester
@ -54,6 +68,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
val view = screenView(shell)
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
if (state.playbackStart.launch != null) return@onPreviewKeyEvent true
// No content target exists during an initial load; Down must not enter a skeleton.
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
else when (readShellKey(event)) {
@ -69,9 +84,10 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
}
if (!shell.pickerVisible && state.sheet != null && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell)
TitleMenu(state, shell)
if (!shell.pickerVisible) TitleOptionsSheet(state, shell)
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
PlaybackHandoffVeil(state.playbackStart)
}
}
@ -81,7 +97,7 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism
LaunchedEffect(message) {
if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() }
}
if (message != null) RecoveryToast(message, modifier)
RecoveryToastHost(message, modifier)
}
/** One keyed vertical frame renders the exact header, season, cast and related order. */
@ -92,6 +108,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
val entryActive = LocalScreenActive.current
var wheelPending by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() }
val sheetWasOpen = remember { booleanArrayOf(state.sheet != null) }
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
val compactHeader = state.data.status == TitleReadStatus.Ready
val item = state.data.item
@ -99,7 +116,12 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
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, entryActive) {
if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
val restoring = sheetWasOpen[0] && state.sheet == null
sheetWasOpen[0] = state.sheet != null
if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) {
if (restoring) withFrameNanos { }
focus.requestFocus()
}
}
val displayedRows = remember(state.data) {
buildList {
@ -138,7 +160,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
else y - with(density) { L.pinnedLine.toPx() }
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
if (target != position) animateFrameScroll(list, target - position)
}
val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } }
@ -197,13 +219,66 @@ private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) {
private fun TitleSeasons(state: TitleState, active: Boolean) {
val focusOwner = LocalTitleFocusOwner.current
val list = androidx.compose.foundation.lazy.rememberLazyListState()
val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season)
LaunchedEffect(visibleChip, state.data.seasonNumbers) {
val reduced = reducedMotion()
val selectedIndex = state.data.seasonNumbers.indexOf(state.season)
val visibleChip = if (active) state.column else selectedIndex
LaunchedEffect(visibleChip, state.data.seasonNumbers, reduced) {
if (visibleChip >= 0) {
if (NetworkArtTransition.reduced) list.scrollToItem(visibleChip) else list.animateScrollToItem(visibleChip)
val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == visibleChip }
if (visible == null) list.scrollToItem(visibleChip)
else animateFrameScroll(list, visible.offset.toFloat())
}
}
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
var capsuleIndex by remember { mutableIntStateOf(selectedIndex) }
val capsulePosition = remember { Animatable(0f) }
val capsuleWidth = remember { Animatable(0f) }
// A stable virtual row coordinate preserves the spring's frame time and velocity on retarget.
var capsuleTarget by remember { mutableFloatStateOf(0f) }
val selection = rememberUpdatedState(selectedIndex)
LaunchedEffect(list, reduced) {
snapshotFlow { selection.value }.collectLatest { index ->
val target = snapshotFlow { list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } }.filterNotNull().first()
val previous = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == capsuleIndex }
if (reduced || capsuleWidth.value == 0f || previous == null) {
capsulePosition.snapTo(0f)
capsuleWidth.snapTo(target.size.toFloat())
capsuleTarget = 0f
capsuleIndex = index
} else {
val targetPosition = capsuleTarget + target.offset - previous.offset
capsuleTarget = targetPosition
capsuleIndex = index
launch { capsulePosition.animateTo(targetPosition, spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness)) }
if (capsuleWidth.targetValue != target.size.toFloat()) launch {
capsuleWidth.animateTo(target.size.toFloat(), spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness))
}
}
}
}
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop).drawWithCache {
val height = D.chipHeight.toPx()
val radius = WolfskinTokens.Radius.chip.toPx()
val border = D.chipBorder.toPx()
val stroke = Stroke(border)
val inset = Offset(border / 2, border / 2)
val outerCorner = CornerRadius(radius)
val innerCorner = CornerRadius(radius - border / 2)
onDrawBehind {
val layout = list.layoutInfo
val visible = layout.visibleItemsInfo
val selected = capsuleIndex
var index = 0
while (index < visible.size) {
val chip = visible[index++]
if (chip.index != selected) continue
val topLeft = Offset(chip.offset + layout.beforeContentPadding + capsulePosition.value - capsuleTarget, 0f)
val bounds = Size(capsuleWidth.value, height)
drawRoundRect(C.accentSoft, topLeft, bounds, outerCorner)
drawRoundRect(C.accent, topLeft + inset, Size(bounds.width - border, bounds.height - border), innerCorner, style = stroke)
break
}
}
}, state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
val seasonNumbers = state.data.seasonNumbers
val keys = seasonNumbers.map { "season-$it" }.uniqueKeys()
items(seasonNumbers.size, key = { keys[it] }, contentType = { "chip" }) { index ->
@ -211,12 +286,12 @@ private fun TitleSeasons(state: TitleState, active: Boolean) {
val focused = active && state.column == index
val selected = state.season == number
val shape = RoundedCornerShape(WolfskinTokens.Radius.chip)
val label = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg)
Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl)
.onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) }
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape)
.then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier)
.background(if (focused) C.fg else if (selected) androidx.compose.ui.graphics.Color.Transparent else C.chipRest, shape)
.clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
Text("Season $number", color = if (focused) C.onFocus else if (selected) C.accentText else C.fg, style = WolfskinTokens.Type.label)
Text("Season $number", color = label, style = WolfskinTokens.Type.label)
}
}
}
@ -238,4 +313,3 @@ private fun TitleReadError(state: TitleState) {
}
}
internal val titleFrameScrollSpec get() = tween<Float>(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)

View file

@ -20,6 +20,11 @@ class TitleState(
private val centers = HashMap<String, MutableMap<Int, Float>>()
fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x }
fun showSeasonLoading() { if (data.episodesStatus != TitleReadStatus.Loading) data = data.copy(episodesStatus = TitleReadStatus.Loading) }
/** Cached episodes replace the loading state on the season-selection frame. */
fun showSeason(number: Int, episodes: List<TitleItem>) {
season = number
data = data.copy(selectedSeason = number, episodes = episodes, episodesStatus = TitleReadStatus.Ready)
}
fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) }
var sheet by mutableStateOf<TitleSheet?>(null)
private set
@ -37,6 +42,7 @@ class TitleState(
var subtitleIndex by mutableStateOf(initial.selectedSubtitle)
var watched by mutableStateOf(initial.watchTarget.watched)
var toast by mutableStateOf<String?>(null)
val playbackStart = PlaybackStart()
private var armedCard: String? = null
private var seasonChosen = false
val requestedSeason: Int? get() = season.takeIf { seasonChosen }
@ -115,6 +121,7 @@ class TitleState(
/** Key-up can only activate the same card that armed the first key-down. */
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
if (playbackStart.launch != null) return true
if (key == ShellKey.Ok) {
if (!down) {
okHeld = false
@ -147,6 +154,8 @@ class TitleState(
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
fun back(shell: ShellState) {
if (playbackStart.launch != null) return
playbackStart.fail()
when {
sheet != null -> closeSheet()
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
@ -187,7 +196,7 @@ class TitleState(
private fun activateOption(id: String) {
when (id) {
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
"play", "restart" -> if (data.watchReady && playbackStart.starting == null) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }

View file

@ -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)
}
}

View file

@ -229,6 +229,8 @@ object WolfskinTokens {
const val focusMove = 180
const val captionDelay = 60
const val captionOut = 100
const val sheetIn = 160
const val sheetOut = 100
const val artReveal = 160
const val heroFade = 320
const val heroCopyOut = 120
@ -319,6 +321,7 @@ object WolfskinTokens {
val receiptPaddingX = 15.0.dp
val receiptGap = 5.0.dp
val receiptGlyph = 18.0.dp
val thumbGlyph = 20.0.dp
val buttonMargin = 6.0.dp
val skeletonTitleWidth = 180.0.dp
val skeletonTitleHeight = 17.0.dp

View file

@ -0,0 +1,242 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
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.semantics.SemanticsActions
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
/** The shipped Discover surface preserves geometry, input and settled pixels through polish motion. */
@RunWith(ParameterizedRobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverPolishTest(private val reduced: Boolean) {
companion object {
@JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "reduced={0}")
fun motionModes() = listOf(arrayOf(false), arrayOf(true))
}
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val fixture = discoverTestFixture()
private val snapshot = fixture.snapshot("justin")
private val scope = CoroutineScope(Dispatchers.Main.immediate)
private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
private val answer = CompletableDeferred<DiscoverRequestResult>()
private var requestCount = 0
private val operations = object : DiscoverOperations by RecordingDiscoverOperations(snapshot, "justin") {
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { requestCount++; return answer.await() }
}
private val state = DiscoverState(shell, snapshot, operations, scope, {}, {}, fixture::service)
@Before fun fullscreen() = polishFullscreen(rule.activity)
@After fun cancelRequests() { scope.cancel() }
private fun mount() {
val painters = mutableMapOf<String, ImageBitmap>()
rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalReducedMotion provides reduced,
LocalDiscoverArt provides { path -> painters.getOrPut(path) { BitmapFactory.decodeFile(path).asImageBitmap() } }) {
ScreenEntry(shell, "discover", true) {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) }
}
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
}
private fun request(phase: RequestPhase = RequestPhase.Choose): RequestSheetState {
state.openRequest(true, snapshot.picks.first { it.type == "movie" }, phase)
return (state.overlay as DiscoverOverlayState.Request).state.apply {
selected = people.indexOfFirst { it.key == "mindel" }; focus = buttonIndex
}
}
@Test fun chipColoursCrossfadeOverFocusMove() {
state.focus(1, 0)
mount()
val all = rule.onNodeWithText("All", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val movies = rule.onNode(hasText("Movies") and hasAnyAncestor(hasTestTag("discover-screen")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val x = movies.center.x.toInt()
val y = (movies.top - 5).toInt()
val frames = PolishFrames(rule, "P01", "discover-chips", reduced)
val resting = frames.capture("settled-before", 0, x, y)
updatePolishState(rule) { state.key(ShellKey.Right) }
rule.mainClock.advanceTimeBy(32)
val images = frames.strip(16, x = x, y = y)
frames.finish()
assertEquals("First focus frame keeps old fill", resting.getPixel(x, y), images.first().getPixel(x, y))
assertPolishMonotonic(images.map { polishBrightness(it, x, y) })
val midway = images[if (reduced) 2 else 5].getPixel(x, y)
assertNotEquals("Focus colour must interpolate rather than pop", images.first().getPixel(x, y), midway)
assertNotEquals("Focus colour must not finish early", images.last().getPixel(x, y), midway)
assertPolishMonotonic(images.map { polishBrightness(it, all.center.x.toInt(), y) })
val settledIndex = (if (reduced) 80 else 180) / 16 + 1
assertEquals(C.fg.toArgb(), images[settledIndex].getPixel(x, y))
assertEquals("The departed selected chip keeps its selected fill", C.accentSoft.toArgb(), images.last().getPixel(all.center.x.toInt(), y))
resting.recycle(); images.forEach(Bitmap::recycle)
}
@Test fun requestButtonSaysSendingUntilTheServerAnswers() {
val sheet = request()
mount()
val label = sheet.buttonLabel
val restingBounds = rule.onNode(hasClickAction() and hasText(label)).fetchSemanticsNode().boundsInRoot
val frames = PolishFrames(rule, "P03", "sending", reduced)
frames.capture("settled-before", 0).recycle()
updatePolishState(rule) { state.key(ShellKey.Ok) }
rule.mainClock.advanceTimeByFrame()
val strip = frames.strip(12)
rule.onNodeWithText("Sending…").assertIsDisplayed()
val busyBounds = rule.onNode(hasClickAction() and hasText("Sending…")).fetchSemanticsNode().boundsInRoot
assertEquals(restingBounds.width, busyBounds.width, 0f)
rule.onNodeWithText(label).assertDoesNotExist()
rule.onNodeWithText("Requested").assertDoesNotExist()
updatePolishState(rule) { state.key(ShellKey.Ok) }
assertEquals(RequestPhase.Sending, sheet.phase)
assertEquals("Repeated OK cannot send twice", 1, requestCount)
updatePolishState(rule) { state.key(ShellKey.Back) }
rule.mainClock.advanceTimeByFrame()
updatePolishState(rule) { request() }
rule.mainClock.advanceTimeBy(32)
val reopened = frames.capture("reopened-sending", 0)
assertPolishRegionEquals(strip.last(), reopened, busyBounds, "Reopening a sending sheet shows the busy label at rest")
assertEquals(1, requestCount)
reopened.recycle(); strip.forEach(Bitmap::recycle)
frames.finish()
updatePolishState(rule) { answer.complete(DiscoverRequestResult(RequestPhase.Receipt, "Justin")) }
rule.mainClock.advanceTimeBy(240)
rule.onNodeWithText("Requested").assertIsDisplayed()
rule.onNodeWithText("Sending…").assertDoesNotExist()
}
@Test fun receiptFadesInOnceAfterSending() {
val sheet = request()
mount()
updatePolishState(rule) { state.key(ShellKey.Ok) }
rule.mainClock.advanceTimeBy(240)
updatePolishState(rule) { answer.complete(DiscoverRequestResult(RequestPhase.Receipt, "Justin")) }
rule.mainClock.advanceTimeBy(32)
val heading = rule.onNodeWithText("Requested", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val x = (heading.left + 5).toInt()
val y = (heading.bottom + 5).toInt()
val frames = PolishFrames(rule, "P04", "receipt", reduced)
assertEquals("Done owns logical focus from frame zero", sheet.buttonIndex, sheet.focus)
rule.onNodeWithText("Done").assertExists()
val images = frames.strip(15, x = x, y = y)
frames.finish()
assertPolishMonotonic(images.map { polishBrightness(it, x, y) })
val settledIndex = (if (reduced) 80 else 200) / 16 + 1
assertEquals(images.last().getPixel(x, y), images[settledIndex].getPixel(x, y))
images.forEach(Bitmap::recycle)
}
@Test fun reopenedUnconfirmedOutcomeRestsImmediately() {
request()
mount()
updatePolishState(rule) { state.key(ShellKey.Ok) }
rule.mainClock.advanceTimeBy(240)
updatePolishState(rule) { answer.complete(DiscoverRequestResult(RequestPhase.NotConfirmed, "Justin")) }
rule.mainClock.advanceTimeBy(240)
val heading = rule.onNodeWithText("Not confirmed", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val frames = PolishFrames(rule, "P04", "unconfirmed-reopen", reduced)
val settled = frames.capture("settled", 0)
updatePolishState(rule) { state.key(ShellKey.Back) }
rule.mainClock.advanceTimeByFrame()
updatePolishState(rule) { request() }
rule.mainClock.advanceTimeBy(32)
val reopened = frames.capture("reopened", 0)
frames.finish()
assertPolishRegionEquals(settled, reopened, heading, "An outcome that reopens must be fully opaque immediately")
settled.recycle(); reopened.recycle()
}
@Test fun requestSheetFadesInAndOutWithoutClickThrough() {
mount()
val frames = PolishFrames(rule, "P06", "request-sheet", reduced)
frames.capture("settled-before", 0).recycle()
val details = rule.onNode(hasClickAction() and hasText("Details")).fetchSemanticsNode().boundsInRoot.center
updatePolishState(rule) { request() }
rule.mainClock.advanceTimeBy(32)
val entering = frames.strip(12, "open", 1900, 900)
val heading = rule.onNodeWithText("Request", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
var panelX = heading.left.toInt()
var panelY = heading.top.toInt()
var brightest = -1
// Interior text avoids the one-level RGB rounding of a nearly opaque dark panel.
for (y in heading.top.toInt() until heading.bottom.toInt()) {
for (x in heading.left.toInt() until heading.right.toInt()) {
val brightness = polishBrightness(entering.last(), x, y)
if (brightness > brightest) { brightest = brightness; panelX = x; panelY = y }
}
}
val button = rule.onNode(hasClickAction() and hasText("Request for Mindel")).fetchSemanticsNode().boundsInRoot.center
val navFocused = shell.navFocused
val row = state.focusedRow
updatePolishState(rule) { state.key(ShellKey.Back) }
rule.mainClock.advanceTimeBy(32)
rule.onNodeWithTag("discover-overlay").assertExists()
val focusTargets = rule.onAllNodes(hasAnyAncestor(hasTestTag("discover-overlay")) and
SemanticsMatcher.keyIsDefined(SemanticsActions.RequestFocus), useUnmergedTree = true).fetchSemanticsNodes()
updatePolishState(rule) {
focusTargets.forEach { node ->
assertFalse("Exit copies must refuse focus", node.config[SemanticsActions.RequestFocus].action?.invoke() == true)
}
}
rule.onAllNodes(hasAnyAncestor(hasTestTag("discover-overlay")) and isFocused()).assertCountEquals(0)
rule.onRoot().performTouchInput { click(button); click(details) }
assertNull("Retained controls cannot reopen or act on the sheet", state.overlay)
assertNull("Pointers cannot fall through to Details", state.title)
val leaving = frames.strip(8, "close", 1900, 900)
entering.forEachIndexed { index, image -> frames.sample("open-panel", index, image, panelX, panelY) }
leaving.forEachIndexed { index, image -> frames.sample("close-panel", index, image, panelX, panelY) }
frames.finish()
rule.onNodeWithTag("discover-overlay").assertDoesNotExist()
assertEquals(navFocused, shell.navFocused)
assertEquals(row, state.focusedRow)
assertPolishMonotonic(entering.map { polishBrightness(it, panelX, panelY) })
assertPolishMonotonic(entering.map { polishBrightness(it, 400, 900) })
assertPolishMonotonic(leaving.map { polishBrightness(it, panelX, panelY) })
assertPolishMonotonic(leaving.map { polishBrightness(it, 400, 900) })
val middle = entering[if (reduced) 2 else 5].getPixel(panelX, panelY)
assertNotEquals(entering.first().getPixel(panelX, panelY), middle)
assertNotEquals(entering.last().getPixel(panelX, panelY), middle)
entering.forEach(Bitmap::recycle); leaving.forEach(Bitmap::recycle)
}
@Test fun scrollBackLaneDoesNotReplay() {
state.focus(state.firstShelf, 0)
mount()
val first = state.lanes.first()
val frames = PolishFrames(rule, "E03", "discover-scroll-back", reduced)
val settled = frames.capture("settled", 0)
updatePolishState(rule) { shell.view.listState.requestScrollToItem(state.lanes.lastIndex + state.firstShelf) }
rule.mainClock.advanceTimeBy(240)
rule.onNodeWithTag("discover-lane-${first.id}").assertDoesNotExist()
updatePolishState(rule) { shell.view.listState.requestScrollToItem(state.firstShelf) }
rule.mainClock.advanceTimeBy(32)
val images = frames.strip(15)
frames.finish()
val restoredBounds = rule.onNodeWithTag("poster-${first.titles[1].key}").fetchSemanticsNode().boundsInRoot
assertPolishRegionEquals(images.last(), images.first(), restoredBounds, "Seen Discover lane must not re-enter")
settled.recycle(); images.forEach(Bitmap::recycle)
}
}

View file

@ -0,0 +1,251 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Rect
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
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 java.io.File
import kotlin.math.abs
import org.junit.Assert.*
import org.junit.Before
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
/** Native ring pixels cover translation, shape morphing, scroll tracking and legacy parity. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class FocusGlideTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val directory get() = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/E02").apply { mkdirs() }
private val accentArgb = WolfskinTokens.Color.accent.toArgb()
private var pixels = IntArray(0)
@Before fun fullscreen() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
@Test fun shelfRightAndRowDownGlideAsOneShape() = shelves(false)
@Test fun reducedShelfGeometrySnapsInOneFrame() = shelves(true)
private fun shelves(reduced: Boolean) {
val row = mutableIntStateOf(0)
val column = mutableIntStateOf(0)
val memory = HomeViewMemory()
val cards = List(5) { HomeTitle("$it", "Film $it", "movie") }
val tiles = HomeRow("tiles", HomeRowKind.Shelf, cards = cards, cardKind = HomeCardKind.Tile)
val posters = tiles.copy(id = "posters", cardKind = HomeCardKind.Poster)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) {
FocusRingHost { Column(Modifier.padding(top = 40.dp)) {
HomeShelf(tiles, memory, row.intValue == 0, column.intValue, emptyList(), {}, {})
HomeShelf(posters, memory, row.intValue == 1, column.intValue, emptyList(), {}, {})
} }
}
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
val suffix = if (reduced) "reduced/" else ""
val samples = mutableListOf<String>()
fun frame(phase: String, index: Int): Rect {
val image = capture("$suffix$phase-${index.toString().padStart(2, '0')}")
val bounds = accentBounds(image)
val runs = edgeRuns(image, bounds.centerY())
samples += "$phase,${index * 16},${bounds.left},${bounds.top},${bounds.width()},${bounds.height()},$runs"
image.recycle()
return bounds
}
val start = frame("shelf-right", 0)
commit { column.intValue = 1 }
val right = List(21) { index -> step(); frame("shelf-right", index + 1) }
commit { row.intValue = 1 }
val down = List(21) { index -> step(); frame("row-down", index) }
commit { row.intValue = -1 }
val fadeBudget = if (reduced) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.focusMove
repeat((fadeBudget + 15) / 16 + 1) { step() }
val hidden = capture("${suffix}nav-exit")
assertTrue("Leaving cards fades the ring completely", accentBounds(hidden).isEmpty)
val lastRing = down.last()
val background = WolfskinTokens.Color.bg.toArgb()
for (y in lastRing.top - 3..lastRing.top + 7) for (shift in listOf(0, 8, 16)) {
val pixel = hidden.getPixel(lastRing.centerX(), y)
assertTrue("No residual ring alpha after the fade deadline",
abs((pixel ushr shift and 255) - (background ushr shift and 255)) <= 2)
}
hidden.recycle()
File(directory, "${suffix}samples.csv").writeText("phase,timeMs,left,top,width,height,edgeRuns\n" + samples.joinToString("\n"))
assertTrue("Ring must travel to the next card", right.last().left > start.left)
monotonic(listOf(start.left) + right.map { it.left })
assertTrue("Nav spring must be visually settled by 300ms", abs(right.last().left - right[18].left) <= 2)
monotonic(listOf(start.width()) + down.map { it.width() })
monotonic(listOf(start.height()) + down.map { it.height() })
if (reduced) {
assertEquals("Reduced horizontal move snaps on frame one", right.last(), right.first())
assertEquals("Reduced shape change snaps on frame one", down.last(), down.first())
} else {
assertTrue("Horizontal move must glide, not jump", right.first().left < right.last().left)
assertTrue("Width must morph", down.first().width() != down.last().width())
assertTrue("Height must morph", down.first().height() != down.last().height())
}
samples.filter { it.startsWith("shelf-right") }.forEach { assertEquals("Exactly one ring crosses its midline: $it", 2, it.substringAfterLast(',').toInt()) }
}
@Test fun circleRadiusMorphsOnTheNavSpring() = circleRadius(false)
@Test fun reducedCircleRadiusSnapsInOneFrame() = circleRadius(true)
private fun circleRadius(reduced: Boolean) {
val selected = mutableIntStateOf(0)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
Box(Modifier.requiredSize(320.dp).background(WolfskinTokens.Color.bg)) {
FocusRingHost {
for (index in 0..1) key(index) {
val radius = if (index == 0) WolfskinTokens.Radius.card else 60.dp
Box(Modifier.offset(80.dp, 80.dp).size(120.dp)
.focusScale(selected.intValue == index, WolfskinTokens.Focus.scaleCard)
.focusRing(selected.intValue == index, radius, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
.background(WolfskinTokens.Color.surface, androidx.compose.foundation.shape.RoundedCornerShape(radius)))
}
}
}
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
fun topSpan(name: String): Int {
val bitmap = capture(name)
val bounds = accentBounds(bitmap)
val pixels = (bounds.left until bounds.right).filter { accent(bitmap.getPixel(it, bounds.top + 3)) }
bitmap.recycle()
return if (pixels.isEmpty()) 0 else pixels.last() - pixels.first() + 1
}
val prefix = if (reduced) "reduced/circle" else "circle"
val start = topSpan("$prefix-start")
commit { selected.intValue = 1 }
val spans = List(22) { step(); topSpan("$prefix-$it") }
File(directory, "$prefix-samples.csv").writeText("timeMs,topEdgeSpanPx\n0,$start\n" +
spans.mapIndexed { index, span -> "${(index + 1) * 16},$span" }.joinToString("\n"))
assertTrue("Radius change must change corner pixels", start > spans.last() + 20)
monotonic(listOf(start) + spans)
if (reduced) assertEquals(spans.last(), spans.first())
else assertTrue("Radius must morph, not jump", spans.first() > spans.last())
}
@Test fun posterTileAndSeeAllSettledRingsMatchLegacyPixels() {
val hosted = mutableStateOf(false)
val kind = mutableIntStateOf(0)
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(500.dp, 320.dp).background(WolfskinTokens.Color.bg)) {
@Composable fun card() {
val width = if (kind.intValue == 1) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
val height = if (kind.intValue == 1) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
val modifier = Modifier.offset(60.dp, 40.dp)
if (kind.intValue == 2) LibrarySeeAllCard(40, true, {}, modifier, width, height, HomeType.listTitle, HomeType.label)
else TitleCard(true, modifier, width, height) { Box(Modifier.fillMaxSize().background(WolfskinTokens.Color.surface)) }
}
if (hosted.value) FocusRingHost { card() } else card()
}
} }
for (shape in 0..2) {
rule.runOnIdle { hosted.value = false; kind.intValue = shape }
rule.mainClock.advanceTimeBy(1000)
val legacy = capture("legacy-$shape")
rule.runOnIdle { hosted.value = true }
rule.mainClock.advanceTimeBy(1000)
val shared = capture("settled-$shape")
assertEquals("Settled ring bounds for shape $shape", accentBounds(legacy), accentBounds(shared))
var maximumDifference = 0
for (y in 0 until legacy.height) for (x in 0 until legacy.width) {
val expected = legacy.getPixel(x, y)
val actual = shared.getPixel(x, y)
for (channel in 0..3) {
val shift = channel * 8
maximumDifference = maxOf(maximumDifference, abs((expected ushr shift and 255) - (actual ushr shift and 255)))
}
}
assertTrue("Settled shape $shape differs by $maximumDifference channel levels", maximumDifference <= 2)
legacy.recycle(); shared.recycle()
}
}
@Test fun libraryRingTracksFrameScrollAndRapidRetargets() = library(false)
@Test fun reducedLibraryRingTracksFrameScrollAndRapidRetargets() = library(true)
private fun library(reduced: Boolean) {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("movies"); navFocused = false }
val grid = LibraryGridState("Movies", List(60) { LibraryTitle("$it", "Film $it", "movie") })
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { FocusRingHost { LibraryScreen(shell, grid, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } } } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
rule.runOnIdle { grid.key(ShellKey.Down) }
repeat(3) { step() }
rule.runOnIdle { grid.key(ShellKey.Right) }
repeat(3) { step() }
rule.runOnIdle { grid.key(ShellKey.Down) }
repeat(30) { step() }
val image = capture(if (reduced) "reduced/library-settled" else "library-settled")
val ring = accentBounds(image)
image.recycle()
assertFalse(ring.isEmpty)
assertEquals("Scroll target stays on the pinned line", WolfskinTokens.Layout.pinnedLine.value * 2 - WolfskinTokens.Card.posterH.value * (WolfskinTokens.Focus.scaleCard - 1f), ring.top.toFloat(), 5f)
}
private fun commit(action: () -> Unit) = rule.runOnIdle { action(); Snapshot.sendApplyNotifications() }
private fun step() { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
private fun capture(name: String): Bitmap {
val file = File(directory, "$name.png").apply { parentFile!!.mkdirs() }
rule.onRoot().captureRoboImage(file.absolutePath)
return BitmapFactory.decodeFile(file.absolutePath)
}
private fun accent(pixel: Int): Boolean {
for (channel in 0..2) {
val shift = channel * 8
if (abs((pixel ushr shift and 255) - (accentArgb ushr shift and 255)) > 8) return false
}
return true
}
private fun accentBounds(image: Bitmap): Rect {
val bounds = Rect(image.width, image.height, 0, 0)
val count = image.width * image.height
if (pixels.size < count) pixels = IntArray(count)
image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height)
var index = 0
for (y in 0 until image.height) for (x in 0 until image.width) if (accent(pixels[index++])) {
bounds.left = minOf(bounds.left, x); bounds.top = minOf(bounds.top, y)
bounds.right = maxOf(bounds.right, x + 1); bounds.bottom = maxOf(bounds.bottom, y + 1)
}
return bounds
}
private fun edgeRuns(image: Bitmap, y: Int): Int {
var runs = 0
var inside = false
for (x in 0 until image.width) {
val next = accent(image.getPixel(x, y))
if (next && !inside) runs++
inside = next
}
return runs
}
private fun monotonic(values: List<Int>) {
val direction = if (values.last() >= values.first()) 1 else -1
values.zipWithNext().forEach { (before, after) -> assertTrue("Geometry reverses or overshoots: $values", (after - before) * direction >= -1) }
}
}

View file

@ -0,0 +1,143 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.test.ext.junit.rules.ActivityScenarioRule
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Native frames prove that recycled shelves keep their settled pixels, including reduced motion. */
@RunWith(ParameterizedRobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HomePolishRowsTest(private val reduced: Boolean) {
companion object {
@JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "reduced={0}")
fun motionModes() = listOf(arrayOf(false), arrayOf(true))
}
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Before fun fullscreen() = polishFullscreen(rule.activity)
@Test fun scrollBackShelfDoesNotReplay() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("home"); navFocused = true }
val rows = List(8) { row -> HomeRow("shelf-$row", HomeRowKind.Shelf, title = "Shelf $row", cards = List(8) { HomeTitle("$row-$it", "Film $it", "movie") }) }
rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalReducedMotion provides reduced) {
ScreenEntry(shell, "home", true) {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { HomeScreen(shell, HomeSnapshot(rows), it) {} }
}
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
val bounds = rule.onNodeWithTag("home-card:shelf-0:0").fetchSemanticsNode().boundsInRoot
val frames = PolishFrames(rule, "E03", "home-scroll-back", reduced)
val settled = frames.capture("settled", 0)
updatePolishState(rule) { shell.view.listState.requestScrollToItem(6) }
rule.mainClock.advanceTimeBy(32)
rule.onNodeWithTag("home-card:shelf-0:0").assertDoesNotExist()
val freshBounds = rule.onNodeWithTag("home-card:shelf-6:0").fetchSemanticsNode().boundsInRoot
val fresh = frames.capture("first-appearance", 0)
rule.mainClock.advanceTimeBy(240)
val freshSettled = frames.capture("first-appearance-settled", 0)
updatePolishState(rule) { shell.view.listState.requestScrollToItem(0) }
rule.mainClock.advanceTimeBy(32)
val returning = frames.strip(15)
frames.finish()
assertPolishRegionEquals(settled, returning.first(), bounds, "Seen Home shelf must be at rest on its first frame back")
val x = (freshBounds.left + 30).toInt()
val y = (freshBounds.top + 80).toInt()
assertNotEquals("A genuinely new shelf still enters", fresh.getPixel(x, y), freshSettled.getPixel(x, y))
settled.recycle(); fresh.recycle(); freshSettled.recycle(); returning.forEach(Bitmap::recycle)
}
}
/** Flush state writes before advancing the manual animation clock. */
internal fun updatePolishState(
rule: AndroidComposeTestRule<ActivityScenarioRule<ComponentActivity>, ComponentActivity>,
update: () -> Unit,
) {
rule.runOnIdle { update(); Snapshot.sendApplyNotifications() }
}
/** Keep captures on the actual full-screen native surface used by the TV. */
internal fun polishFullscreen(activity: ComponentActivity) {
activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
/** A manual-clock strip records native pixels and sample coordinates for independent review. */
internal class PolishFrames(
private val rule: AndroidComposeTestRule<ActivityScenarioRule<ComponentActivity>, ComponentActivity>,
id: String,
private val name: String,
private val reduced: Boolean,
) {
private val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/$id").apply { mkdirs() }
private val samples = mutableListOf("phase,timeMs,x,y,red,green,blue")
fun capture(phase: String, index: Int, x: Int = 400, y: Int = 900): Bitmap {
rule.waitForIdle()
val file = File(directory, "$name${if (reduced) "-reduced" else ""}-$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
return BitmapFactory.decodeFile(file.absolutePath).also { image ->
for ((sampleX, sampleY) in listOf(x to y, 400 to 900).distinct()) {
sample(phase, index, image, sampleX, sampleY)
}
}
}
fun strip(count: Int, phase: String = "motion", x: Int = 400, y: Int = 900): List<Bitmap> = List(count) { index ->
capture(phase, index, x, y).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
}
fun sample(phase: String, index: Int, image: Bitmap, x: Int, y: Int) {
val pixel = image.getPixel(x, y)
samples += "$phase,${index * 16},$x,$y,${android.graphics.Color.red(pixel)},${android.graphics.Color.green(pixel)},${android.graphics.Color.blue(pixel)}"
}
fun finish() {
File(directory, "$name${if (reduced) "-reduced" else ""}-samples.csv").writeText(samples.joinToString("\n") + "\n")
val combined = buildString {
appendLine("strip,phase,timeMs,x,y,red,green,blue")
directory.listFiles { file -> file.name.endsWith("-samples.csv") }.orEmpty().sortedBy { it.name }.forEach { file ->
file.readLines().drop(1).forEach { appendLine("${file.nameWithoutExtension},$it") }
}
}
File(directory, "samples.csv").writeText(combined)
}
}
internal fun polishBrightness(image: Bitmap, x: Int, y: Int): Int = image.getPixel(x, y).let {
android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it)
}
internal fun assertPolishMonotonic(values: List<Int>, changing: Boolean = true) {
if (changing) assertNotEquals("Motion must change the sampled pixel: $values", values.first(), values.last())
val direction = if (values.last() >= values.first()) 1 else -1
values.zipWithNext().forEach { (before, after) -> assertTrue("Pixel must move monotonically: $values", (after - before) * direction >= -2) }
}
internal fun assertPolishRegionEquals(expected: Bitmap, actual: Bitmap, bounds: Rect, message: String) {
val inset = 12
for (y in bounds.top.toInt() + inset until bounds.bottom.toInt() - inset step 4) {
for (x in bounds.left.toInt() + inset until bounds.right.toInt() - inset step 4) {
assertEquals("$message at ($x,$y)", expected.getPixel(x, y), actual.getPixel(x, y))
}
}
}

View file

@ -0,0 +1,129 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
/** Library transitions reveal real changed results without replaying a retained navigation entry. */
@RunWith(ParameterizedRobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class LibraryPolishTest(private val reduced: Boolean) {
companion object {
@JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "reduced={0}")
fun motionModes() = listOf(arrayOf(false), arrayOf(true))
}
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("movies"); navFocused = false }
private val titles = List(18) { index ->
val score = when (index % 6) { 0 -> index + 4; 4 -> index - 4; else -> index } / 2.0
LibraryTitle("film-$index", "Film ${index.toString().padStart(2, '0')}", "movie", addedAt = "${100 + index}", ratings = listOf(LibraryRating("tmdb", score)))
}
private val grid = LibraryGridState("Movies", titles).apply { toolbar = true }
private var entry by mutableStateOf<String?>("movies-entry")
@Before fun fullscreen() = polishFullscreen(rule.activity)
private fun mount() {
rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalReducedMotion provides reduced) {
val holder = rememberSaveableStateHolder()
entry?.let { key -> holder.SaveableStateProvider(key) {
ScreenEntry(shell, "movies", true) {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { LibraryScreen(shell, grid, it) }
}
} }
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
}
@Test fun toolbarChipColoursCrossfadeOverFocusMove() {
mount()
val chip = rule.onNodeWithText("All", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val x = chip.center.x.toInt()
val y = (chip.top - 5).toInt()
val frames = PolishFrames(rule, "P01", "library-toolbar", reduced)
val resting = frames.capture("settled-before", 0, x, y)
updatePolishState(rule) { grid.key(ShellKey.Right) }
rule.mainClock.advanceTimeBy(32)
val images = frames.strip(16, x = x, y = y)
frames.finish()
assertEquals(resting.getPixel(x, y), images.first().getPixel(x, y))
assertPolishMonotonic(images.map { polishBrightness(it, x, y) })
val midway = images[if (reduced) 2 else 5].getPixel(x, y)
assertNotEquals("Focus colour must interpolate rather than pop", images.first().getPixel(x, y), midway)
assertNotEquals("Focus colour must not finish early", images.last().getPixel(x, y), midway)
assertEquals(C.fg.toArgb(), images[(if (reduced) 80 else 180) / 16 + 1].getPixel(x, y))
resting.recycle(); images.forEach(Bitmap::recycle)
}
@Test fun backFromTitleKeepsRowsAtRest() {
mount()
val bounds = rule.onNodeWithTag("poster-film-0").fetchSemanticsNode().boundsInRoot
val frames = PolishFrames(rule, "P05", "library-back", reduced)
val settled = frames.capture("settled", 0)
updatePolishState(rule) { entry = null }
rule.mainClock.advanceTimeBy(32)
rule.onNodeWithTag("library-grid").assertDoesNotExist()
updatePolishState(rule) { entry = "movies-entry" }
rule.mainClock.advanceTimeBy(32)
val restored = frames.strip(15)
updatePolishState(rule) { entry = null }
rule.mainClock.advanceTimeBy(32)
rule.onNodeWithTag("library-grid").assertDoesNotExist()
updatePolishState(rule) { entry = "fresh-movies-entry" }
rule.mainClock.advanceTimeBy(32)
val fresh = frames.strip(15, "fresh-entry")
frames.finish()
assertPolishRegionEquals(settled, restored.first(), bounds, "Back must restore the settled row at frame zero")
val x = (bounds.left + 25).toInt(); val y = (bounds.top + 25).toInt()
assertNotEquals("A fresh tab entry still reveals", fresh.last().getPixel(x, y), fresh.first().getPixel(x, y))
settled.recycle(); restored.forEach(Bitmap::recycle); fresh.forEach(Bitmap::recycle)
}
@Test fun sortChangeRevealsVisibleRowsTogether() {
grid.sort = 1
mount()
val first = rule.onNodeWithTag("poster-film-17").fetchSemanticsNode().boundsInRoot
val second = rule.onNodeWithTag("poster-film-11").fetchSemanticsNode().boundsInRoot
val x = (first.left + 100).toInt()
val y0 = (first.top + 65).toInt(); val y1 = (second.top + 65).toInt()
val frames = PolishFrames(rule, "P11", "sort-rating", reduced)
frames.capture("settled-before", 0, x, y0).recycle()
updatePolishState(rule) { grid.key(ShellKey.Ok) }
rule.mainClock.advanceTimeBy(32)
val images = frames.strip(15, x = x, y = y0)
updatePolishState(rule) { grid.tool = 1 }
rule.mainClock.advanceTimeBy(240)
val beforeUnchanged = frames.capture("unchanged-before", 0, x, y0)
updatePolishState(rule) { grid.key(ShellKey.Ok) }
rule.mainClock.advanceTimeBy(32)
val afterUnchanged = frames.capture("unchanged-after", 0, x, y0)
frames.finish()
assertPolishMonotonic(images.map { polishBrightness(it, x, y0) })
val background = C.bg.toArgb().let { android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it) }
fun ratio(y: Int): Double = (polishBrightness(images[2], x, y) - background).toDouble() / (polishBrightness(images.last(), x, y) - background)
assertEquals("Rows reveal on the same clock", ratio(y0), ratio(y1), .03)
assertTrue("Frame two is still entering", ratio(y0) in 0.01..0.99)
assertPolishRegionEquals(beforeUnchanged, afterUnchanged, first, "A filter with identical membership must not replay")
assertEquals(images.last().getPixel(x, y0), images[(if (reduced) 80 else 180) / 16 + 1].getPixel(x, y0))
images.forEach(Bitmap::recycle); beforeUnchanged.recycle(); afterUnchanged.recycle()
}
}

View file

@ -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 })
}
}

View file

@ -0,0 +1,112 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.Snapshot
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
/** Native toast pixels prove retention, interruption and bounded reduced-motion fades. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class RecoveryToastMotionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun toastFadesInHoldsAndFadesOutWithoutStacking() = exercise(false)
@Test fun reducedToastFadesInHoldsAndFadesOutWithoutStacking() = exercise(true)
private fun exercise(reduced: Boolean) {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
val message = mutableStateOf<String?>(null)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) {
RecoveryToastHost(message.value)
}
} } }
rule.mainClock.autoAdvance = false
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P02${if (reduced) "/reduced" else ""}").apply { mkdirs() }
val samples = mutableListOf<String>()
var sampleX = 0
var sampleY = 0
fun frame(phase: String, index: Int): Int {
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
val pixel = bitmap.getPixel(sampleX, sampleY)
bitmap.recycle()
val brightness = android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
samples += "$phase,${index * 16},$brightness"
return brightness
}
fun step() { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
fun count() = rule.onAllNodesWithTag("recovery-toast", useUnmergedTree = true).fetchSemanticsNodes().size
fun show(value: String?) = rule.runOnIdle { message.value = value; Snapshot.sendApplyNotifications() }
show(RecoveryMessage.StillUnreachable)
step()
var inserted = 0
while (count() == 0 && inserted++ < 3) step()
val bounds = rule.onNodeWithTag("recovery-toast", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
sampleX = (bounds.left + 40).toInt()
sampleY = (bounds.center.y - 6).toInt()
val entering = List(14) { index -> frame("toast-in", index).also { step() } }
assertTrue("Fade-in must change native toast pixels: $entering", entering.last() > entering.first())
entering.zipWithNext().forEach { (before, after) -> assertTrue("Fade-in reverses: $entering", after >= before - 1) }
val deadline = (if (reduced) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.base) / 16 + 1
assertEquals("Fade-in must settle by its token deadline", entering.last(), entering[deadline])
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.toast.toLong())
assertEquals(entering.last(), frame("hold", 0))
show(RecoveryMessage.PlaybackUnavailable)
step()
assertEquals(1, count())
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertExists()
assertEquals("Replacing a visible message must not restart its fade", entering.last(), frame("replace", 0))
show(RecoveryMessage.StillUnreachable)
step()
show(null)
step()
assertEquals("Retain the row while fading out", 1, count())
val leaving = List(14) { index -> frame("toast-out", index).also { step() } }
assertTrue("Fade-out must change native toast pixels: $leaving", leaving.first() > leaving.last())
leaving.zipWithNext().forEach { (before, after) -> assertTrue("Fade-out reverses: $leaving", after <= before + 1) }
assertEquals(leaving.last(), leaving[deadline])
rule.onNodeWithTag("recovery-toast").assertDoesNotExist()
show(RecoveryMessage.StillUnreachable)
repeat(14) { step() }
show(null)
step(); step(); step()
val interrupted = frame("interrupted", 0)
assertTrue(interrupted < entering.last())
show(RecoveryMessage.StillUnreachable)
step()
val resumed = List(14) { index -> frame("resume", index).also { step() } }
// Recomposition may consume one final outgoing frame before retargeting.
assertTrue("Re-entry retains current opacity instead of resetting or snapping",
resumed.first() >= leaving[3] - 1 && resumed.first() <= interrupted + 1)
resumed.zipWithNext().forEach { (before, after) -> assertTrue(after >= before - 1) }
assertEquals(entering.last(), resumed.last())
assertEquals(1, count())
File(directory, "samples.csv").writeText("phase,timeMs,brightness\n" + samples.joinToString("\n"))
}
}

View file

@ -100,9 +100,9 @@ class RequestsKeysTest {
assertEquals(1, retries)
assertTrue(state.requestsVisible)
rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) }
rule.onNodeWithText("New requests show up here.").assertExists()
send(Key.DirectionDown, Key.DirectionCenter)
rule.onNodeWithText("Nothing coming in.").assertExists()
assertTrue(state.requestsVisible)
assertFalse(state.requestsVisible)
}
@Test fun cachedRatingsRenderInTheTelevisionLocale() {

View file

@ -0,0 +1,310 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import coil3.intercept.Interceptor
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.awaitCancellation
import org.junit.Assert.*
import org.junit.Before
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 org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
/** Native frames exercise Requests transitions without replacing the shell's key or focus policy. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class RequestsPolishTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val reduced = mutableStateOf(false)
@Before fun fullscreen() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
rule.mainClock.autoAdvance = false
}
private fun shell(snapshot: ComingInSnapshot = requestsFixture()) = ShellState(fixturePeople(), "justin").apply {
pickerVisible = false
pickerLaunch = false
enterDestination("settings")
settingsRow = 2
requests.update(snapshot)
}
private fun mount(state: ShellState) {
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) {
WolfskinTheme { ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) }
} }
rule.mainClock.advanceTimeBy(32)
rule.waitForIdle()
}
private fun change(block: () -> Unit) {
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
private fun capture(idea: String, name: String): Bitmap {
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/$idea").apply { mkdirs() }
val file = File(directory, "$name.png")
rule.onRoot().captureRoboImage(file.absolutePath)
return BitmapFactory.decodeFile(file.absolutePath)
}
private fun csv(idea: String, rows: List<String>) {
File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/$idea/samples.csv").writeText(rows.joinToString("\n", postfix = "\n"))
}
private fun brightness(pixel: Int) = android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
private fun monotonic(values: List<Float>, increasing: Boolean) {
assertNotEquals("Transition must change the sampled value", values.first(), values.last())
values.zipWithNext().forEach { (first, second) -> assertTrue("Non-monotonic samples: $values", if (increasing) second >= first - 1f else second <= first + 1f) }
assertTrue("Transition must include an intermediate frame: $values", values.any { it > minOf(values.first(), values.last()) + 1 && it < maxOf(values.first(), values.last()) - 1 })
}
@Test fun requestsPanelFadesWithItsScrim() {
val state = shell(ComingInSnapshot(status = ComingInStatus.Ready))
mount(state)
val samples = mutableListOf("mode,phase,timeMs,scrim,panel")
for (reduce in listOf(false, true)) {
change { reduced.value = reduce; state.openRequests() }
val entering = List(13) { index ->
val image = capture("P06", "requests-${if (reduce) "reduced-" else ""}open-${index.toString().padStart(2, '0')}")
val pixels = brightness(image.getPixel(400, 900)).toFloat() to brightness(image.getPixel(1500, 900)).toFloat()
image.recycle()
samples += "$reduce,open,${index * 16},${pixels.first},${pixels.second}"
rule.mainClock.advanceTimeBy(16)
pixels
}
val row = state.settingsRow
val nav = state.navFocused
change { state.closeRequests() }
assertEquals(row, state.settingsRow)
assertEquals(nav, state.navFocused)
rule.onNodeWithTag("coming-in-scrim").performTouchInput { click(Offset(400f, 900f)) }
val leaving = List(9) { index ->
val image = capture("P06", "requests-${if (reduce) "reduced-" else ""}close-${index.toString().padStart(2, '0')}")
val pixels = brightness(image.getPixel(400, 900)).toFloat() to brightness(image.getPixel(1500, 900)).toFloat()
image.recycle()
samples += "$reduce,close,${index * 16},${pixels.first},${pixels.second}"
rule.mainClock.advanceTimeBy(16)
pixels
}
csv("P06", samples)
monotonic(entering.map { it.first }, false)
monotonic(entering.map { it.second }, true)
monotonic(leaving.map { it.first }, true)
monotonic(leaving.map { it.second }, false)
assertFalse(state.requestsVisible)
assertEquals(row, state.settingsRow)
assertEquals(nav, state.navFocused)
rule.onNodeWithTag("coming-in").assertDoesNotExist()
val end = if (reduce) 5 else 10
assertEquals(entering.last(), entering[end + 1])
assertEquals(leaving.last(), leaving[if (reduce) 6 else 8])
}
}
@Test fun loadingRowsBreatheAsOneGroupAndStopWhenLoaded() {
val state = shell(ComingInSnapshot(status = ComingInStatus.Loading)).apply { openRequests() }
mount(state)
rule.mainClock.advanceTimeBy(240)
val samples = mutableListOf("mode,timeMs,thumb1,thumb2,button")
for (reduce in listOf(false, true)) {
change { reduced.value = reduce }
val close = rule.onNodeWithText("Close").fetchSemanticsNode().boundsInRoot
val times = ((0..1400 step 100).toList() + 350).sorted()
val values = times.mapIndexed { index, time ->
val image = capture("P07", "requests-loading-${if (reduce) "reduced-" else ""}${time.toString().padStart(4, '0')}")
val pixels = listOf(image.getPixel(1240, 240), image.getPixel(1240, 418), image.getPixel(close.center.x.toInt(), close.bottom.toInt() + 8))
image.recycle()
samples += "$reduce,$time,${pixels.joinToString(",")}"
if (index < times.lastIndex) rule.mainClock.advanceTimeBy((times[index + 1] - time).toLong(), ignoreFrameDuration = true)
pixels
}
csv("P07", samples)
assertEquals(values.map { it[0] }, values.map { it[1] })
assertEquals(1, values.map { it[2] }.distinct().size)
if (reduce) assertEquals(1, values.map { it[0] }.distinct().size)
else {
assertNotEquals(values[0][0], values[4][0])
assertNotEquals(values[4][0], values[8][0])
}
}
change { reduced.value = false; state.requests.update(requestsFixture()) }
rule.mainClock.advanceTimeBy(400)
rule.onNodeWithText("Close").assertDoesNotExist()
assertTrue("Loaded panel must leave the recomposer idle", androidx.compose.runtime.Recomposer.runningRecomposers.value.all { !it.hasPendingWork })
}
@Test fun readinessExpansionPushesRowsBelowWithoutRemeasure() {
val state = shell().apply { openRequests(); requests.key(ShellKey.Down) }
mount(state)
// Rating dwell changes layout independently; toggles require settled metadata.
rule.mainClock.advanceTimeBy(M.ratingDwell.toLong())
val samples = mutableListOf("mode,phase,timeMs,focusedTop,followingTop")
for (reduce in listOf(false, true)) {
change { reduced.value = reduce }
val focusedBounds = rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot
fun focusedTop(): Float {
val bounds = rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot
val scale = WolfskinTokens.Focus.scaleRow
val scaleOffset = kotlin.math.abs(bounds.height - focusedBounds.height) * (scale - 1f) / (2f * scale)
assertEquals(focusedBounds.top, bounds.top, scaleOffset + 0.5f)
return bounds.top
}
val collapsed = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
change { state.key(ShellKey.Right) }
val expanding = List(16) { index ->
val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
val fixed = focusedTop()
capture("P09", "expand-${if (reduce) "reduced-" else ""}${index.toString().padStart(2, '0')}").recycle()
samples += "$reduce,expand,${index * 16},$fixed,$top"
rule.mainClock.advanceTimeBy(16)
top
}
change { state.key(ShellKey.Left) }
val collapsing = List(16) { index ->
val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
capture("P09", "collapse-${if (reduce) "reduced-" else ""}${index.toString().padStart(2, '0')}").recycle()
samples += "$reduce,collapse,${index * 16},${focusedTop()},$top"
rule.mainClock.advanceTimeBy(16)
top
}
csv("P09", samples)
assertEquals(collapsed, collapsing.last(), 0.1f)
if (reduce) {
assertTrue(expanding.all { kotlin.math.abs(it - expanding.last()) < 0.1f })
assertTrue(collapsing.all { kotlin.math.abs(it - collapsed) < 0.1f })
} else {
monotonic(listOf(collapsed) + expanding, true)
assertTrue("Expansion must not finish early", expanding[5] < expanding.last() - 1f)
monotonic(listOf(expanding.last()) + collapsing, false)
assertTrue("Collapse must not finish early", collapsing[5] > collapsing.last() + 1f)
}
}
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P09")
for (phase in listOf("expand", "collapse")) {
val animated = BitmapFactory.decodeFile(File(directory, "$phase-15.png").absolutePath)
val snapped = BitmapFactory.decodeFile(File(directory, "$phase-reduced-15.png").absolutePath)
try { assertTrue("Settled $phase pixels must match reduced-motion snapping", animated.sameAs(snapped)) }
finally { animated.recycle(); snapped.recycle() }
}
val collapsedTop = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
change { reduced.value = false; state.key(ShellKey.Right) }
rule.mainClock.advanceTimeBy(48)
change { state.key(ShellKey.Left) }
val retargeted = List(16) { index ->
val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
capture("P09", "retarget-${index.toString().padStart(2, '0')}").recycle()
samples += "false,retarget,${index * 16},${rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot.top},$top"
rule.mainClock.advanceTimeBy(16)
top
}
assertTrue("An interrupted expand must return from its current placement", retargeted.first() > collapsedTop + 1f)
monotonic(retargeted, false)
assertEquals(collapsedTop, retargeted.last(), 0.1f)
change { reduced.value = false; state.key(ShellKey.Right) }
rule.mainClock.advanceTimeBy(48)
change { state.key(ShellKey.Down) }
val jumped = rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top
rule.mainClock.advanceTimeBy(240)
assertEquals(jumped, rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top, 0.1f)
rule.mainClock.advanceTimeBy(M.ratingDwell.toLong())
change { state.key(ShellKey.Right) }
rule.mainClock.advanceTimeBy(48)
change { state.requests.update(state.requests.snapshot.copy(partial = true)) }
val refreshed = rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top
rule.mainClock.advanceTimeBy(240)
assertEquals(refreshed, rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top, 0.1f)
csv("P09", samples)
}
@Test fun emptyRequestsCloseReturnsToSettingsRow() {
val state = shell(ComingInSnapshot(status = ComingInStatus.Ready)).apply { openRequests() }
mount(state)
rule.mainClock.advanceTimeBy(240)
capture("P10", "requests-empty").recycle()
rule.onNodeWithText("New requests show up here.").assertExists()
val row = state.settingsRow
val nav = state.navFocused
change { state.key(ShellKey.Down); state.key(ShellKey.Ok) }
assertFalse(state.requestsVisible)
assertEquals(row, state.settingsRow)
assertEquals(nav, state.navFocused)
rule.mainClock.advanceTimeBy(160)
change { state.openRequests() }
rule.mainClock.advanceTimeBy(240)
rule.onNodeWithText("Close").performClick()
assertFalse(state.requestsVisible)
assertEquals(row, state.settingsRow)
assertEquals(nav, state.navFocused)
}
@Test fun missingPosterShowsFilmOrTvGlyph() {
val completed = AtomicBoolean(false)
WolfskinImages.configureNetwork(rule.activity) {
add(Interceptor { chain ->
if (chain.request.data == "pending-poster") awaitCancellation()
else chain.proceed().also { if (chain.request.data == "file:///missing-wolfskin-poster.png") completed.set(true) }
})
}
val source = requestsFixture().items.first()
val items = listOf(source.copy(id = "movie", mediaType = "movie", poster = null),
source.copy(id = "tv", mediaType = "tv", poster = null),
source.copy(id = "failed", mediaType = "movie", poster = "file:///missing-wolfskin-poster.png"),
source.copy(id = "pending", poster = "pending-poster"))
val state = shell(ComingInSnapshot(items, ComingInStatus.Ready)).apply { openRequests(); requests.focus("pending") }
mount(state)
rule.waitUntil(5000) { completed.get() }
rule.mainClock.advanceTimeBy(400)
val image = capture("P12", "missing-thumbs")
try {
fun glyph(id: String): List<Int> {
val thumb = rule.onNodeWithTag("coming-thumb-$id", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val left = (thumb.center.x - 20).toInt()
val top = (thumb.center.y - 20).toInt()
return (0 until 40).flatMap { y -> (0 until 40).map { x -> image.getPixel(left + x, top + y) } }
}
val movie = glyph("movie")
val tv = glyph("tv")
val failed = glyph("failed")
val stroke = C.dim.compositeOver(C.surface3).toArgb()
listOf(movie, tv, failed).forEach { pixels ->
assertTrue("The thumbnail centre column must contain the dim glyph stroke", (0 until 40).any { y -> pixels[y * 40 + 20] == stroke })
}
assertNotEquals(movie, tv)
val deltas = movie.zip(failed).map { (first, second) ->
listOf(16, 8, 0).map { shift -> kotlin.math.abs(((first ushr shift) and 255) - ((second ushr shift) and 255)) }
}
assertTrue("Absent and failed posters must render the same film glyph", deltas.flatten().average() < 2)
assertTrue("Only antialiasing differences are allowed", deltas.count { it.max() <= 8 } >= movie.size * 0.99)
assertTrue("Loading remains a plain thumbnail", glyph("pending").all { it == C.surface3.toArgb() })
} finally { image.recycle(); WolfskinImages.configureNetwork(rule.activity) {} }
}
}

View file

@ -0,0 +1,144 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
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
/** Held keys exercise real shelf/frame targets and measure velocity across interrupted scrolls. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ScrollMomentumTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun heldRightCarriesVelocityAndSettlesOnShelfFirst() = exercise(false, false)
@Test fun heldDownCarriesVelocityAndSettlesOnLibraryRow() = exercise(false, true)
@Test fun reducedShelfScrollSnapsInOneFrame() = exercise(true, false)
@Test fun reducedLibraryScrollSnapsInOneFrame() = exercise(true, true)
private fun exercise(reduced: Boolean, library: Boolean) {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
val shell = ShellState(fixturePeople(), "justin").apply {
pickerVisible = false
if (library) enterDestination("movies")
navFocused = false
}
val row = HomeRow("long", HomeRowKind.Shelf, cards = List(30) { HomeTitle("$it", "Film $it", "movie") })
shell.view.cards[row.id] = 4
val grid = LibraryGridState("Movies", List(120) { LibraryTitle("$it", "Film $it", "movie") })
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
if (library) LibraryScreen(shell, grid, modifier)
else HomeScreen(shell, HomeSnapshot(listOf(row)), modifier) {}
}
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
val track = if (library) shell.view.listState else shell.view.home.tracks.getValue(row.id)
val pitch = (WolfskinTokens.Card.posterW + WolfskinTokens.Card.posterGap).value * 2
val framePitch = (WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + WolfskinTokens.Layout.gridRowGap).value * 2
fun offset(): Float {
val first = track.firstVisibleItemIndex
if (!library) return first * pitch + track.firstVisibleItemScrollOffset
val top = if (first == 0) 0f else WolfskinTokens.Layout.row_lhead.value * 2 +
if (first < 2) 0f else WolfskinTokens.Layout.row_chipsGrid.value * 2 + framePitch * (first - 2)
return top + track.firstVisibleItemScrollOffset
}
fun target(): Float = if (!library) shell.view.home.firstCards.getValue(row.id) * pitch else
if (grid.index / grid.columns == 0) 0f else (WolfskinTokens.Layout.row_lhead.value + WolfskinTokens.Layout.row_chipsGrid.value - WolfskinTokens.Layout.pinnedLine.value) * 2 + framePitch * (grid.index / grid.columns)
fun press() = rule.runOnIdle { shell.key(if (library) ShellKey.Down else ShellKey.Right); Snapshot.sendApplyNotifications() }
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/E05${if (library) "/library" else ""}${if (reduced) "/reduced" else ""}").apply { mkdirs() }
val samples = mutableListOf<String>()
val offsets = mutableListOf<Float>()
val velocities = mutableListOf<Float>()
val repeats = mutableListOf<Int>()
press()
// Start sampling at the animation's zero-time frame, after composition commits the new focus.
repeat(2) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
val singleTarget = target()
for (index in 0..80) {
if (index >= 32 && index <= 47 && (index - 32) % 3 == 0) { repeats += index; press() }
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
val position = offset()
offsets += position
velocities += if (index == 0) position / .016f else (position - offsets[index - 1]) / .016f
samples += "${(index + 1) * 16},$position,${velocities.last()},${target()}"
val file = File(directory, "held-${if (library) "down" else "right"}-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
}
File(directory, "samples.csv").writeText("timeMs,offsetPx,velocityPxPerSecond,targetPx\n" + samples.joinToString("\n"))
offsets.zipWithNext().forEach { (before, after) -> assertTrue("Track reversed: $offsets", after >= before) }
assertTrue("No overshoot of final target", offsets.all { it <= target() + 1 })
assertEquals("Final target must reconcile exactly", target(), offsets.last(), 1f)
assertTrue("Single press must reach 95 percent by 200ms: $offsets", offsets[12] >= singleTarget * .95f)
assertEquals("Single press is visually settled by 320ms", singleTarget, offsets[19], 2f)
if (reduced) assertEquals("Reduced scroll lands in one frame", singleTarget, offsets.first(), 1f)
else repeats.drop(2).forEach { index ->
assertTrue("Repeated key must not stop the track", velocities[index] > 0f)
assertTrue("Retarget must carry velocity instead of restarting: ${velocities.subList(index - 1, index + 2)}", velocities[index + 1] >= velocities[index - 1] * .5f)
}
}
@Test fun cancelledScrollCannotReuseAnAbandonedFrameClock() = cancelledScroll(false)
@Test fun reducedCancelledScrollStillSnapsOnReentry() = cancelledScroll(true)
private fun cancelledScroll(reduced: Boolean) {
val visible = mutableStateOf(true)
val target = mutableFloatStateOf(0f)
val list = LazyListState()
fun offset() = list.firstVisibleItemIndex * 200f + list.firstVisibleItemScrollOffset
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
if (visible.value) {
LaunchedEffect(target.floatValue) { animateFrameScroll(list, target.floatValue - offset()) }
LazyRow(Modifier.requiredSize(500.dp, 100.dp), state = list) {
items(30) { Box(Modifier.size(100.dp)) }
}
} else Box(Modifier.requiredSize(500.dp, 100.dp))
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
rule.runOnIdle { target.floatValue = 600f; Snapshot.sendApplyNotifications() }
repeat(5) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
rule.runOnIdle { visible.value = false; Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
rule.mainClock.advanceTimeBy(1000)
val resumedTarget = offset() + 600f
rule.runOnIdle { target.floatValue = resumedTarget; visible.value = true; Snapshot.sendApplyNotifications() }
repeat(3) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
if (reduced) assertEquals(resumedTarget, offset(), 1f)
else assertTrue("An abandoned frame timestamp must not turn a fresh scroll into a snap", offset() < resumedTarget - 300f)
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
assertEquals(resumedTarget, offset(), 1f)
}
}

View file

@ -142,6 +142,8 @@
--motion-focusMove: 180ms;
--motion-captionDelay: 60ms;
--motion-captionOut: 100ms;
--motion-sheetIn: 160ms;
--motion-sheetOut: 100ms;
--motion-artReveal: 160ms;
--motion-heroFade: 320ms;
--motion-heroCopyOut: 120ms;
@ -192,6 +194,7 @@
--requests-receiptPaddingX: 30.0px;
--requests-receiptGap: 10.0px;
--requests-receiptGlyph: 36.0px;
--requests-thumbGlyph: 40.0px;
--requests-buttonMargin: 12.0px;
--requests-skeletonTitleWidth: 360.0px;
--requests-skeletonTitleHeight: 34.0px;

View file

@ -1408,6 +1408,8 @@
"focusMove": { "$type": "duration", "$value": "180ms" },
"captionDelay": { "$type": "duration", "$value": "60ms" },
"captionOut": { "$type": "duration", "$value": "100ms" },
"sheetIn": { "$type": "duration", "$value": "160ms", "source": "ui-polish P06, approved 2026-11-07: side panel and its scrim fade in, easeOut" },
"sheetOut": { "$type": "duration", "$value": "100ms", "source": "ui-polish P06, approved 2026-11-07: side panel and its scrim fade out, easeOut" },
"artReveal": { "$type": "duration", "$value": "160ms" },
"heroFade": { "$type": "duration", "$value": "320ms" },
"heroCopyOut": { "$type": "duration", "$value": "120ms" },

View file

@ -1,9 +1,9 @@
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py
72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
42399062a6a22566e26adb71eaa4052b28be93d28d0eb46d9d4b87f3142a2944 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
9562bd6e6d8fb4d6ef380380e621d6c32401387560437eb4f999ebecbcda160e shell-tokens.json
68a94b81bb0300aa05d241f11d88ff4843caa0b1b16e0ed09f62674b336e69fe src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json
731ff25d6ec3ba12d0840523a1ebd65a81b3474cf74040f13bbd1f54985a3053 tokens.css
12d33b7b5740443e068cbb06528d88c42ad3bb1df0d4d45dd6ee6797d3dfc401 tokens.json
e93d868d6fdf9ad939f1fd18c452732fb3afa76bbba8fe6fdd0c1007aeff3b01 tokens.css
e729efd2efd6bc52a35480038d64ff0199ce166e6e794332e9cfa7dc84ed98c9 tokens.json
19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json