Round 2 release candidate 3: polish lanes A-E, season reel and trailer return #82
97 changed files with 5795 additions and 549 deletions
|
|
@ -495,6 +495,8 @@ tasks.withType<Test>().configureEach {
|
||||||
dependsOn(":wolfskin-design:testDebugUnitTest")
|
dependsOn(":wolfskin-design:testDebugUnitTest")
|
||||||
// Android 16 shared-memory setup needs Robolectric's JDK FileDescriptor bridge.
|
// Android 16 shared-memory setup needs Robolectric's JDK FileDescriptor bridge.
|
||||||
jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED")
|
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.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath)
|
||||||
systemProperty("robolectric.offline", "true")
|
systemProperty("robolectric.offline", "true")
|
||||||
systemProperty("roborazzi.test.record", "true")
|
systemProperty("roborazzi.test.record", "true")
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,11 @@ class InstrumentedBasicUiTests {
|
||||||
MainContent(
|
MainContent(
|
||||||
backStack = mutableListOf(SetupDestination.ServerList),
|
backStack = mutableListOf(SetupDestination.ServerList),
|
||||||
navigationManager = navigationManager,
|
navigationManager = navigationManager,
|
||||||
appPreferences = mockk(relaxed = true),
|
userPreferences = mockk(relaxed = true),
|
||||||
backdropService = mockk(relaxed = true),
|
backdropService = mockk(relaxed = true),
|
||||||
screensaverService = screensaverService,
|
screensaverService = screensaverService,
|
||||||
|
liveSession = MutableStateFlow(null),
|
||||||
|
trailerReturn = mockk(relaxed = true),
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.github.damontecres.wholphin
|
package com.github.damontecres.wholphin
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
|
@ -9,7 +8,6 @@ import android.view.WindowManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -24,24 +22,16 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
|
||||||
import com.github.damontecres.wholphin.data.RestoredSession
|
|
||||||
import org.justinvisser.wolfskin.tv.recoverWolfskinSession
|
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
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.ImageUrlService
|
||||||
import com.github.damontecres.wholphin.services.IntentResult
|
|
||||||
import com.github.damontecres.wholphin.services.IntentService
|
import com.github.damontecres.wholphin.services.IntentService
|
||||||
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
|
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
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.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
|
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.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
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.WholphinDispatchers
|
||||||
import com.github.damontecres.wholphin.util.requestSerializersModule
|
import com.github.damontecres.wholphin.util.requestSerializersModule
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import org.justinvisser.wolfskin.tv.TrailerReturnState
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
|
@ -156,6 +142,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var intentService: IntentService
|
lateinit var intentService: IntentService
|
||||||
|
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var trailerReturn: TrailerReturnState
|
||||||
private var signInAuto = true
|
private var signInAuto = true
|
||||||
private var playerBackend: PlayerBackend? = null
|
private var playerBackend: PlayerBackend? = null
|
||||||
|
|
||||||
|
|
@ -169,6 +158,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
instance = this
|
instance = this
|
||||||
|
if (savedInstanceState == null) trailerReturn.clear()
|
||||||
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
||||||
lifecycle.addObserver(playbackLifecycleObserver)
|
lifecycle.addObserver(playbackLifecycleObserver)
|
||||||
|
|
||||||
|
|
@ -233,7 +223,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
playerBackend = prefs.playbackPreferences.playerBackend
|
playerBackend = prefs.playbackPreferences.playerBackend
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.appStart(intent)
|
viewModel.appStart(intent, resuming = savedInstanceState != null, savedState = savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
Surface(Modifier.fillMaxSize()) {
|
Surface(Modifier.fillMaxSize()) {
|
||||||
|
|
@ -283,6 +273,8 @@ class MainActivity : AppCompatActivity() {
|
||||||
userPreferences = userPreferences,
|
userPreferences = userPreferences,
|
||||||
backdropService = backdropService,
|
backdropService = backdropService,
|
||||||
screensaverService = screensaverService,
|
screensaverService = screensaverService,
|
||||||
|
liveSession = viewModel.serverRepository.current,
|
||||||
|
trailerReturn = trailerReturn,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -309,6 +301,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
Timber.d("onResume")
|
Timber.d("onResume")
|
||||||
|
trailerReturn.finishHandoff()
|
||||||
viewModel.appResume()
|
viewModel.appResume()
|
||||||
lifecycleScope.launchDefault {
|
lifecycleScope.launchDefault {
|
||||||
screensaverService.pulse()
|
screensaverService.pulse()
|
||||||
|
|
@ -318,7 +311,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onRestart() {
|
override fun onRestart() {
|
||||||
super.onRestart()
|
super.onRestart()
|
||||||
Timber.d("onRestart")
|
Timber.d("onRestart")
|
||||||
viewModel.appStart(null)
|
viewModel.appStart(null, resuming = true)
|
||||||
if (!playExternalViewModel.launched.value) {
|
if (!playExternalViewModel.launched.value) {
|
||||||
// If restarting during playback that is not external, go back a page
|
// If restarting during playback that is not external, go back a page
|
||||||
val lastDest = navigationManager.backStack.lastOrNull()
|
val lastDest = navigationManager.backStack.lastOrNull()
|
||||||
|
|
@ -374,6 +367,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
Timber.w(ex, "Could not persist back stack; skipping")
|
Timber.w(ex, "Could not persist back stack; skipping")
|
||||||
}
|
}
|
||||||
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
|
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
|
||||||
|
trailerReturn.save(outState, viewModel.serverRepository.current.value, navigationManager.backStack.lastOrNull())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
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
|
private val Destination?.isPlayback: Boolean
|
||||||
get() =
|
get() =
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package com.github.damontecres.wholphin
|
package com.github.damontecres.wholphin
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -11,17 +14,13 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.NavEntry
|
import androidx.navigation3.runtime.NavEntry
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
|
|
@ -29,6 +28,7 @@ import androidx.navigation3.ui.NavDisplay
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
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.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
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.setup.SwitchUserContent
|
||||||
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
||||||
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
|
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
|
@Composable
|
||||||
fun MainContent(
|
fun MainContent(
|
||||||
backStack: MutableList<SetupDestination>,
|
backStack: MutableList<SetupDestination>,
|
||||||
|
|
@ -48,6 +51,8 @@ fun MainContent(
|
||||||
userPreferences: UserPreferences,
|
userPreferences: UserPreferences,
|
||||||
backdropService: BackdropService,
|
backdropService: BackdropService,
|
||||||
screensaverService: ScreensaverService,
|
screensaverService: ScreensaverService,
|
||||||
|
liveSession: StateFlow<CurrentUser?>,
|
||||||
|
trailerReturn: TrailerReturnState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
|
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
|
||||||
) {
|
) {
|
||||||
|
|
@ -66,6 +71,9 @@ fun MainContent(
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
onBack = { backStack.removeLastOrNull() },
|
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 =
|
entryDecorators =
|
||||||
listOf(
|
listOf(
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
|
@ -104,16 +112,8 @@ fun MainContent(
|
||||||
backdropService.clearBackdrop()
|
backdropService.clearBackdrop()
|
||||||
}
|
}
|
||||||
val current = key.current
|
val current = key.current
|
||||||
var showContent by remember {
|
SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically,
|
||||||
mutableStateOf(true)
|
trailerReturn, { navigationManager.backStack.lastOrNull() }) {
|
||||||
}
|
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
|
||||||
if (!userPreferences.appPreferences.signInAutomatically) {
|
|
||||||
showContent = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showContent) {
|
|
||||||
WolfskinApp(
|
WolfskinApp(
|
||||||
user = current.user,
|
user = current.user,
|
||||||
server = current.server,
|
server = current.server,
|
||||||
|
|
@ -121,16 +121,6 @@ fun MainContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.size(200.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
color = MaterialTheme.colorScheme.border,
|
|
||||||
modifier = Modifier.align(Alignment.Center),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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. */
|
/** 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()
|
val recent = mostRecentServerProvider.get()
|
||||||
|
if (expected != null && !matchesSavedIdentity(recent, expected)) return@withContext getMostRecentServerInternal(recent)
|
||||||
if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent)
|
if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent)
|
||||||
val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None
|
val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None
|
||||||
val user = saved.users.firstOrNull { it.id == recent.userId }
|
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)
|
apiClient.update(baseUrl = saved.server.url, accessToken = user.accessToken)
|
||||||
val current = CurrentUser(saved.server, user)
|
val current = CurrentUser(saved.server, user)
|
||||||
_currentUserDto.value = null
|
_currentUserDto.value = null
|
||||||
|
|
@ -384,3 +409,6 @@ data class CurrentUser(
|
||||||
val server: JellyfinServer,
|
val server: JellyfinServer,
|
||||||
val user: JellyfinUser,
|
val user: JellyfinUser,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** A previously authenticated server/person pair, without credentials or protection state. */
|
||||||
|
data class SavedSessionIdentity(val serverId: UUID, val userId: UUID)
|
||||||
|
|
|
||||||
|
|
@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,7 @@ class TestMainActivityViewModel {
|
||||||
backdropService,
|
backdropService,
|
||||||
appUpgradeHandler,
|
appUpgradeHandler,
|
||||||
intentService,
|
intentService,
|
||||||
|
trailerReturn = mockk(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val serverId = UUID.randomUUID()
|
private val serverId = UUID.randomUUID()
|
||||||
|
|
@ -88,6 +89,7 @@ class TestMainActivityViewModel {
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
WholphinDispatchers.configure(testDispatcher)
|
WholphinDispatchers.configure(testDispatcher)
|
||||||
|
every { setupNavigationManager.backStack } returns mutableListOf(SetupDestination.Loading)
|
||||||
every { appUpgradeHandler.needUpgrade() } returns false
|
every { appUpgradeHandler.needUpgrade() } returns false
|
||||||
every { appUpgradeHandler.copySubfont(any()) } returns Unit
|
every { appUpgradeHandler.copySubfont(any()) } returns Unit
|
||||||
mockkStatic(Toast::class)
|
mockkStatic(Toast::class)
|
||||||
|
|
@ -167,14 +169,22 @@ class TestMainActivityViewModel {
|
||||||
@Test
|
@Test
|
||||||
fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) {
|
fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) {
|
||||||
setupPreferences { signInAutomatically = true }
|
setupPreferences { signInAutomatically = true }
|
||||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
val canonical = MutableStateFlow<CurrentUser?>(null)
|
||||||
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser)
|
every { serverRepository.current } returns canonical
|
||||||
|
coEvery { serverRepository.restoreSavedSession() } coAnswers {
|
||||||
|
canonical.value = currentUser
|
||||||
|
RestoredSession.Success(currentUser)
|
||||||
|
}
|
||||||
val release = CompletableDeferred<Unit>()
|
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()
|
viewModel.handleAppStart()
|
||||||
runCurrent()
|
runCurrent()
|
||||||
verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) }
|
verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) }
|
||||||
assertFalse(release.isCompleted)
|
Assert.assertTrue(validationStarted.isCompleted)
|
||||||
release.complete(Unit)
|
release.complete(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ class AnimeAvailabilityTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
|
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 {
|
try {
|
||||||
requireNotNull(model.onForeground(server, user)).join()
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
|
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AnimeRootRemovalTest {
|
||||||
val navigation = NavigationManager()
|
val navigation = NavigationManager()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
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 {
|
try {
|
||||||
model.load(server, user)
|
model.load(server, user)
|
||||||
requireNotNull(model.onForeground(server, user)).join()
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest {
|
||||||
val client = session.identity.value!!.client
|
val client = session.identity.value!!.client
|
||||||
val libraries = LibraryReadFacade(mockk())
|
val libraries = LibraryReadFacade(mockk())
|
||||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
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 {
|
try {
|
||||||
requireNotNull(model.onForeground(server, user)).join()
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
assertNull(libraries.audiences)
|
assertNull(libraries.audiences)
|
||||||
|
|
@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest {
|
||||||
session.authenticate()
|
session.authenticate()
|
||||||
val libraries = LibraryReadFacade(mockk())
|
val libraries = LibraryReadFacade(mockk())
|
||||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
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 {
|
try {
|
||||||
val identity = session.identity.value!!
|
val identity = session.identity.value!!
|
||||||
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ class ConductarrHomeSurfaceTest {
|
||||||
coEvery { home.readLibrary(user.id) } returns HomeLibrary()
|
coEvery { home.readLibrary(user.id) } returns HomeLibrary()
|
||||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||||
every { cache.read(any()) } returns null
|
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 {
|
try {
|
||||||
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false }
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false }
|
||||||
rule.setContent { WolfskinTheme {
|
rule.setContent { WolfskinTheme {
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ class ConductarrTitleSurfaceTest {
|
||||||
runBlocking { session.authenticate() }
|
runBlocking { session.authenticate() }
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.findHeld(any(), any()) } returns null
|
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?>()
|
val revokedLookup = CompletableDeferred<java.util.UUID?>()
|
||||||
try {
|
try {
|
||||||
val route = titleRoute("movie:42")
|
val route = titleRoute("movie:42")
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ class DiscoverRefreshMotionTest {
|
||||||
runBlocking { session.authenticate() }
|
runBlocking { session.authenticate() }
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.findHeld(any(), any()) } returns null
|
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 person = ShellPerson("shared", "Shared", "S", false)
|
||||||
val route = titleRoute("tv:44")
|
val route = titleRoute("tv:44")
|
||||||
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
|
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
|
||||||
|
|
@ -112,7 +112,7 @@ class DiscoverRefreshMotionTest {
|
||||||
@Test fun loadedFixtureRefreshKeepsTheSameReadyHeaderAndCatalogueIdentity() {
|
@Test fun loadedFixtureRefreshKeepsTheSameReadyHeaderAndCatalogueIdentity() {
|
||||||
ConductarrSession(MemoryCacheStore()).use { session ->
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
val model = WolfskinTitleViewModel(rule.activity, mockk(relaxed = true), TitleFixtureProvider(rule.activity),
|
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 person = ShellPerson("justin", "Justin", "J", true)
|
||||||
val route = titleRoute("movie-the-amazing-spider-man-2012")
|
val route = titleRoute("movie-the-amazing-spider-man-2012")
|
||||||
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
|
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
|
||||||
|
|
@ -166,7 +166,7 @@ class DiscoverRefreshMotionTest {
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.readSaved(any(), any()) } returns null
|
coEvery { facade.readSaved(any(), any()) } returns null
|
||||||
coEvery { facade.read(any(), any(), any()) } returns held
|
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 person = ShellPerson("shared", "Shared", "S", false)
|
||||||
val route = titleRoute(id.toString())
|
val route = titleRoute(id.toString())
|
||||||
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination(route) }
|
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination(route) }
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ class DiscoverTitleTimingTest {
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.findHeld(any(), any()) } returns null
|
coEvery { facade.findHeld(any(), any()) } returns null
|
||||||
val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) }
|
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 {
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
|
||||||
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ class FirstSignInNavigationTest {
|
||||||
val navigation = NavigationManager()
|
val navigation = NavigationManager()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
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(
|
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")))
|
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ class HomeRecoveryTest {
|
||||||
val facade = mockk<HomeReadFacade>()
|
val facade = mockk<HomeReadFacade>()
|
||||||
coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
||||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
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) }
|
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
|
||||||
started.await()
|
started.await()
|
||||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
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(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||||
coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline")
|
coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline")
|
||||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
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) }
|
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
|
||||||
startedA.await()
|
startedA.await()
|
||||||
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||||
|
|
@ -88,7 +88,7 @@ class HomeRecoveryTest {
|
||||||
val cache = mockk<HomeLibraryCache>()
|
val cache = mockk<HomeLibraryCache>()
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
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)
|
model.loadLibrary(server, personA, viewerA)
|
||||||
assertTrue(model.snapshot.jellyfinAvailable)
|
assertTrue(model.snapshot.jellyfinAvailable)
|
||||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||||
|
|
@ -101,7 +101,7 @@ class HomeRecoveryTest {
|
||||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||||
every { cache.read(any()) } returns null
|
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"))
|
model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name"))
|
||||||
assertTrue(model.snapshot.jellyfinAvailable)
|
assertTrue(model.snapshot.jellyfinAvailable)
|
||||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest {
|
||||||
}
|
}
|
||||||
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
val 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 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>()
|
val facade = mockk<LibraryReadFacade>()
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
val destination = navigation.backStack.last()
|
val destination = navigation.backStack.last()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) } }
|
||||||
|
}
|
||||||
|
|
@ -85,7 +85,7 @@ class PersonPickerSessionTest {
|
||||||
val navigation = remember { NavigationManager() }
|
val navigation = remember { NavigationManager() }
|
||||||
val model = remember {
|
val model = remember {
|
||||||
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
|
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() } }
|
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
|
||||||
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ class PersonSwitchThreadingTest {
|
||||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
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>()
|
val clients = mutableListOf<ConductarrClient>()
|
||||||
try {
|
try {
|
||||||
model.load(server, justin)
|
model.load(server, justin)
|
||||||
|
|
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
|
||||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
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 {
|
try {
|
||||||
model.load(server, justin)
|
model.load(server, justin)
|
||||||
requireNotNull(model.onForeground(server, justin)).join()
|
requireNotNull(model.onForeground(server, justin)).join()
|
||||||
|
|
@ -219,8 +219,8 @@ class PersonSwitchThreadingTest {
|
||||||
coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary()
|
coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary()
|
||||||
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
|
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
|
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, WolfskinSession())
|
||||||
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true))
|
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||||
try {
|
try {
|
||||||
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)
|
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)
|
||||||
conductarr.authenticate()
|
conductarr.authenticate()
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ class PlayerHandoffTest {
|
||||||
@Suppress("DEPRECATION")
|
@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.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 }
|
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() {
|
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
|
||||||
|
|
@ -78,15 +78,30 @@ class PlayerHandoffTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
@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")
|
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||||
coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto(
|
coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto(
|
||||||
id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE,
|
id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE,
|
||||||
userData = playbackUserData(id, 11_362_740_000L, playCount = 2))
|
userData = playbackUserData(id, 11_362_740_000L, playCount = 2))
|
||||||
val view = shell.view
|
val view = shell.view
|
||||||
view.cards["hero"] = 0
|
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) }
|
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
|
val destination = navigation.backStack.last() as Destination.Playback
|
||||||
assertEquals(id, destination.itemId)
|
assertEquals(id, destination.itemId)
|
||||||
assertEquals(1_136_274L, destination.positionMs)
|
assertEquals(1_136_274L, destination.positionMs)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ class SavedPeoplePickerTest {
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val navigation = NavigationManager()
|
val navigation = NavigationManager()
|
||||||
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true),
|
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 {
|
try {
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class ShellStartupTimingTest {
|
||||||
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
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) }
|
val load = launch { model.load(server, user) }
|
||||||
try {
|
try {
|
||||||
withTimeout(5_000) { savedPeopleStarted.await() }
|
withTimeout(5_000) { savedPeopleStarted.await() }
|
||||||
|
|
@ -121,7 +121,7 @@ class ShellStartupTimingTest {
|
||||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
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 pending = CompletableDeferred<Unit>()
|
||||||
val release = java.util.concurrent.CountDownLatch(1)
|
val release = java.util.concurrent.CountDownLatch(1)
|
||||||
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
|
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ class TitleAsyncFocusTest {
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.readSaved(id, any()) } returns null
|
coEvery { facade.readSaved(id, any()) } returns null
|
||||||
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
|
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 person = ShellPerson("justin", "Justin", "J", false)
|
||||||
val route = titleRoute(id.toString())
|
val route = titleRoute(id.toString())
|
||||||
navigation.navigateTo(Destination.Wolfskin(route))
|
navigation.navigateTo(Destination.Wolfskin(route))
|
||||||
|
|
@ -103,7 +103,7 @@ class TitleAsyncFocusTest {
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.readPersonSaved(id) } returns null
|
coEvery { facade.readPersonSaved(id) } returns null
|
||||||
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
|
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 viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
val route = personRoute(id.toString())
|
val route = personRoute(id.toString())
|
||||||
navigation.navigateTo(Destination.Wolfskin(route))
|
navigation.navigateTo(Destination.Wolfskin(route))
|
||||||
|
|
@ -150,7 +150,7 @@ class TitleAsyncFocusTest {
|
||||||
reads.incrementAndGet()
|
reads.incrementAndGet()
|
||||||
throw java.io.IOException("offline")
|
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 viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
val route = titleRoute(id.toString())
|
val route = titleRoute(id.toString())
|
||||||
navigation.navigateTo(Destination.Wolfskin(route))
|
navigation.navigateTo(Destination.Wolfskin(route))
|
||||||
|
|
|
||||||
|
|
@ -80,10 +80,10 @@ class TitleEntryBackTest {
|
||||||
ConductarrPreference.updateFixtures(true)
|
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 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 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
|
homeModel = source
|
||||||
viewModel = WolfskinTitleViewModel(rule.activity, mockk(), TitleFixtureProvider(rule.activity), navigation, conductarr,
|
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)
|
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||||
val onBack = rememberWolfskinBackHandler(shell, navigation)
|
val onBack = rememberWolfskinBackHandler(shell, navigation)
|
||||||
|
|
@ -183,7 +183,7 @@ class TitleEntryBackTest {
|
||||||
}
|
}
|
||||||
conductarr = ConductarrSession(MemoryCacheStore())
|
conductarr = ConductarrSession(MemoryCacheStore())
|
||||||
viewModel = WolfskinTitleViewModel(activity, facade, mockk<TitleFixtureProvider>(), navigation, conductarr,
|
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)
|
shell.enterDestination(route)
|
||||||
navigation.navigateTo(destination)
|
navigation.navigateTo(destination)
|
||||||
val home = HomeSnapshot(listOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(HomeTitle("home-film", "Home film", "movie")))))
|
val home = HomeSnapshot(listOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(HomeTitle("home-film", "Home film", "movie")))))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import org.junit.Rule
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -35,6 +38,7 @@ import java.util.UUID
|
||||||
@Config(sdk = [34], application = Application::class)
|
@Config(sdk = [34], application = Application::class)
|
||||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
class TitlePlaybackNavigationTest {
|
class TitlePlaybackNavigationTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001")
|
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001")
|
||||||
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002")
|
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002")
|
||||||
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
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")))
|
episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor")))
|
||||||
}
|
}
|
||||||
val session = ConductarrSession(MemoryCacheStore())
|
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)
|
fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true)
|
||||||
try {
|
try {
|
||||||
navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString())))
|
navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString())))
|
||||||
|
|
@ -109,25 +113,37 @@ class TitlePlaybackNavigationTest {
|
||||||
coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE,
|
coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE,
|
||||||
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
|
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
|
||||||
val session = ConductarrSession(MemoryCacheStore())
|
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 {
|
try {
|
||||||
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
|
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
model.title!!.click("header", 0)
|
model.title!!.click("header", 0)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
finishHandoff()
|
||||||
assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last())
|
assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last())
|
||||||
navigation.goBack()
|
navigation.goBack()
|
||||||
model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" })
|
model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" })
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
finishHandoff()
|
||||||
assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last())
|
assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last())
|
||||||
navigation.goBack()
|
navigation.goBack()
|
||||||
model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false)
|
model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
model.title!!.click("header", 0)
|
model.title!!.click("header", 0)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
finishHandoff()
|
||||||
assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last())
|
assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last())
|
||||||
coVerify(exactly = 1) { facade.preparePlayback(seriesId) }
|
coVerify(exactly = 1) { facade.preparePlayback(seriesId) }
|
||||||
} finally {
|
} finally {
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
model.viewModelScope.cancel()
|
model.viewModelScope.cancel()
|
||||||
session.close()
|
session.close()
|
||||||
Dispatchers.resetMain()
|
Dispatchers.resetMain()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,7 +46,7 @@ class TitleRequestSafetyTest {
|
||||||
runBlocking { session.authenticate() }
|
runBlocking { session.authenticate() }
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.findHeld(any(), any()) } returns null
|
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 person = ShellPerson("shared", "Shared", "S", true)
|
||||||
val route = titleRoute("movie:42")
|
val route = titleRoute("movie:42")
|
||||||
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
|
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
|
||||||
|
|
|
||||||
|
|
@ -197,10 +197,10 @@ class TitleRound2Test {
|
||||||
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
|
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
|
||||||
menu()
|
menu()
|
||||||
rule.mainClock.autoAdvance = false
|
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) }
|
rule.runOnIdle { state.clickSheet(0) }
|
||||||
var waited = 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.mainClock.advanceTimeByFrame()
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
waited += 16
|
waited += 16
|
||||||
|
|
@ -212,7 +212,7 @@ class TitleRound2Test {
|
||||||
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
|
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
|
||||||
fun brightness(x: Int, y: Int): Int {
|
fun brightness(x: Int, y: Int): Int {
|
||||||
val pixel = bitmap.getPixel(x, y)
|
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 {
|
return try {
|
||||||
(brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") }
|
(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()
|
rule.waitForIdle()
|
||||||
frame("info", index).also { rule.mainClock.advanceTimeBy(16); 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) }
|
rule.runOnIdle { state.back(shell) }
|
||||||
repeat(14) { index ->
|
repeat(14) { index ->
|
||||||
rule.mainClock.advanceTimeBy(16)
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ import org.justinvisser.wolfskin.design.ShellState
|
||||||
import org.justinvisser.wolfskin.design.TitleItem
|
import org.justinvisser.wolfskin.design.TitleItem
|
||||||
import org.justinvisser.wolfskin.design.TitlePageData
|
import org.justinvisser.wolfskin.design.TitlePageData
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
|
|
@ -162,7 +163,7 @@ class TitleThemeMusicTest {
|
||||||
coEvery { facade.preparePlayback(episodeId) } returns BaseItemDto(id = episodeId, type = BaseItemKind.EPISODE)
|
coEvery { facade.preparePlayback(episodeId) } returns BaseItemDto(id = episodeId, type = BaseItemKind.EPISODE)
|
||||||
val conductarr = ConductarrSession(MemoryCacheStore()).also { session = it }
|
val conductarr = ConductarrSession(MemoryCacheStore()).also { session = it }
|
||||||
val titleModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, conductarr,
|
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.runOnIdle { navigation.navigateTo(destination) }
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
WolfskinTheme {
|
WolfskinTheme {
|
||||||
|
|
@ -175,6 +176,8 @@ class TitleThemeMusicTest {
|
||||||
assertEquals(listOf("play:$seriesId"), playback.events)
|
assertEquals(listOf("play:$seriesId"), playback.events)
|
||||||
playback.onStop = { if (playback.playing != null) assertEquals(destination, navigation.backStack.last()) }
|
playback.onStop = { if (playback.playing != null) assertEquals(destination, navigation.backStack.last()) }
|
||||||
rule.runOnIdle { titleModel.title!!.click("header", 0) }
|
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 }
|
rule.waitUntil(5_000) { navigation.backStack.last() is Destination.Playback }
|
||||||
playback.onStop = {}
|
playback.onStop = {}
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,13 @@ class TitleTrailerTest {
|
||||||
|
|
||||||
@Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() {
|
@Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() {
|
||||||
register(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/watch?v=ab_-defghij")), "test.browser")
|
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)
|
assertNull(shadowOf(context as Application).nextStartedActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() {
|
@Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() {
|
||||||
register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")), "test.youtube")
|
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
|
val launched = shadowOf(context as Application).nextStartedActivity
|
||||||
assertEquals(Intent.ACTION_VIEW, launched.action)
|
assertEquals(Intent.ACTION_VIEW, launched.action)
|
||||||
assertEquals("test.youtube", launched.`package`)
|
assertEquals("test.youtube", launched.`package`)
|
||||||
|
|
@ -40,7 +40,7 @@ class TitleTrailerTest {
|
||||||
|
|
||||||
@Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() {
|
@Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() {
|
||||||
register(Intent(Intent.ACTION_VIEW, Uri.parse("https://trailers.example/video")), "test.browser")
|
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)
|
assertNull(shadowOf(context as Application).nextStartedActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -115,7 +115,7 @@ class WatchReadTest {
|
||||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
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)
|
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 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 {
|
try {
|
||||||
val loading = launch { model.loadLibrary(server, user, person) }
|
val loading = launch { model.loadLibrary(server, user, person) }
|
||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ class WatchReturnTest {
|
||||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
val session = ConductarrSession(MemoryCacheStore())
|
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
|
val oldFixtures = ConductarrPreference.fixtures
|
||||||
ConductarrPreference.updateFixtures(false)
|
ConductarrPreference.updateFixtures(false)
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ class WolfskinRootExitTest {
|
||||||
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||||
conductarr = ConductarrSession(MemoryCacheStore())
|
conductarr = ConductarrSession(MemoryCacheStore())
|
||||||
model = WolfskinShellViewModel(activity, session, repository, navigation, mockk(relaxed = true),
|
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")
|
val home = HomeFixture(activity).snapshot("justin")
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model,
|
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import com.github.damontecres.wholphin.MainActivityViewModel
|
import com.github.damontecres.wholphin.MainActivityViewModel
|
||||||
import com.github.damontecres.wholphin.MainContent
|
import com.github.damontecres.wholphin.MainContent
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
|
@ -99,13 +101,13 @@ class WolfskinSetupNavigationTest {
|
||||||
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
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) })
|
setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun appModel() = MainActivityViewModel(rule.activity, preferences, repository, setup, navigation,
|
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) {
|
private fun awaitStage(stage: WolfskinSetupViewModel.Stage) {
|
||||||
rule.waitUntil(5_000) {
|
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() {
|
private fun assertOwnPicker() {
|
||||||
awaitStage(WolfskinSetupViewModel.Stage.Picker)
|
awaitStage(WolfskinSetupViewModel.Stage.Picker)
|
||||||
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
|
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
|
||||||
|
|
@ -124,7 +136,7 @@ class WolfskinSetupNavigationTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun coldStartLegacySignOutDrawerSwitchAndIntentFailureNeverComposeStockSelectors() {
|
@Test fun coldStartLegacySignOutDrawerSwitchAndIntentFailureNeverComposeStockSelectors() {
|
||||||
runBlocking { appModel().handleAppStart() }
|
startApp()
|
||||||
mount()
|
mount()
|
||||||
assertOwnPicker()
|
assertOwnPicker()
|
||||||
// The legacy sign-out-equivalent erased the recent preference, but Room still has the household.
|
// 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"),
|
val saved = listOf(user, user.copy(id = UUID.randomUUID(), name = "Mindel"),
|
||||||
user.copy(id = UUID.randomUUID(), name = "Shared"))
|
user.copy(id = UUID.randomUUID(), name = "Shared"))
|
||||||
every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, saved)
|
every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, saved)
|
||||||
runBlocking { appModel().handleAppStart() }
|
startApp()
|
||||||
mount()
|
mount()
|
||||||
assertOwnPicker()
|
assertOwnPicker()
|
||||||
saved.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() }
|
saved.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() }
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ class WolfskinSetupRollbackTest {
|
||||||
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
|
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) {
|
fun awaitHeading(resource: Int) {
|
||||||
rule.waitUntil(5_000) {
|
rule.waitUntil(5_000) {
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,8 @@ class UpdatesKeysTest {
|
||||||
launch(UpdateUiState.Checking, settings = true)
|
launch(UpdateUiState.Checking, settings = true)
|
||||||
repeat(9) { press(listOf(Key.DirectionDown)) }
|
repeat(9) { press(listOf(Key.DirectionDown)) }
|
||||||
assertEquals(9, shell.settingsRow)
|
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)
|
assertEquals(4, shell.view.listState.firstVisibleItemIndex)
|
||||||
capture("classic")
|
capture("classic")
|
||||||
press(listOf(Key.DirectionCenter))
|
press(listOf(Key.DirectionCenter))
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
||||||
import org.justinvisser.wolfskin.design.*
|
import org.justinvisser.wolfskin.design.*
|
||||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||||
|
|
@ -37,6 +38,7 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
private val navigation: NavigationManager,
|
private val navigation: NavigationManager,
|
||||||
private val seeAll: WolfskinSeeAllLists,
|
private val seeAll: WolfskinSeeAllLists,
|
||||||
private val conductarr: ConductarrSession,
|
private val conductarr: ConductarrSession,
|
||||||
|
val session: WolfskinSession,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val conductarrState get() = conductarr.identity
|
val conductarrState get() = conductarr.identity
|
||||||
val audiences get() = home.audiences
|
val audiences get() = home.audiences
|
||||||
|
|
@ -49,6 +51,15 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
private var loadRevision = 0L
|
private var loadRevision = 0L
|
||||||
private var catalogueSnapshot = DiscoverSnapshot()
|
private var catalogueSnapshot = DiscoverSnapshot()
|
||||||
private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null
|
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) {
|
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
|
||||||
catalogueJob?.cancel()
|
catalogueJob?.cancel()
|
||||||
|
|
@ -164,23 +175,45 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) }
|
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.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.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
|
||||||
is HomeAction.Play -> viewModelScope.launch {
|
is HomeAction.Play -> startPlayback(action.title, shell, server, user)
|
||||||
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)
|
private fun startPlayback(title: HomeTitle, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
|
||||||
if (!snapshot.jellyfinAvailable || !matchesUser) {
|
val start = session.playback
|
||||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
if (start.starting != null) return
|
||||||
return@launch
|
playbackJob?.cancel()
|
||||||
}
|
val revision = ++playbackRevision
|
||||||
try {
|
val personKey = shell.person.key
|
||||||
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) }
|
start.begin("hero-play")
|
||||||
if (item == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
playbackJob = viewModelScope.launch {
|
||||||
else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs()))
|
if (revision != playbackRevision || start.starting == null) return@launch
|
||||||
} catch (exception: Exception) {
|
val selectedId = shell.person.jellyfinUserId
|
||||||
if (exception is CancellationException) throw exception
|
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)
|
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 identity by model.conductarrState.collectAsState()
|
||||||
val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}")
|
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) }
|
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")
|
val view = shell.rememberedView("home")
|
||||||
LaunchedEffect(view.row, model.snapshot.rows) {
|
LaunchedEffect(view.row, model.snapshot.rows) {
|
||||||
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -71,6 +72,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
val conductarr: ConductarrSession,
|
val conductarr: ConductarrSession,
|
||||||
private val repository: ServerRepository,
|
private val repository: ServerRepository,
|
||||||
val themeSongPlayer: ThemeSongPlayer,
|
val themeSongPlayer: ThemeSongPlayer,
|
||||||
|
private val trailerReturn: TrailerReturnState,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
var title by mutableStateOf<TitleState?>(null)
|
var title by mutableStateOf<TitleState?>(null)
|
||||||
private set
|
private set
|
||||||
|
|
@ -83,19 +85,38 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
private var personReadPending = false
|
private var personReadPending = false
|
||||||
private var personReadFinished = false
|
private var personReadFinished = false
|
||||||
private var loadJob: Job? = null
|
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 val saveMutex = Mutex()
|
||||||
private var performerPage = 1
|
private var performerPage = 1
|
||||||
var conductarrAction: ((TitleAction) -> Unit)? = null
|
var conductarrAction: ((TitleAction) -> Unit)? = null
|
||||||
val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}"
|
val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}"
|
||||||
|
|
||||||
fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route))
|
fun openRoute(route: String) {
|
||||||
fun popRoute() = navigation.goBack()
|
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) }
|
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. */
|
/** 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) {
|
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
|
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
||||||
val sameTitle = route == destination && ::viewer.isInitialized && viewer.key == selected.key && !destination.startsWith("person/")
|
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 readyTitle = sameTitle && title?.data?.status == TitleReadStatus.Ready
|
||||||
val fixtureRefresh = title?.takeIf { force && readyTitle && fixture != null && fixtures && BuildConfig.DEBUG }
|
val fixtureRefresh = title?.takeIf { force && readyTitle && fixture != null && fixtures && BuildConfig.DEBUG }
|
||||||
if (force && readyTitle && fixture == null &&
|
if (force && readyTitle && fixture == null &&
|
||||||
|
|
@ -117,6 +138,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
preferences = userPreferences
|
preferences = userPreferences
|
||||||
val id = Uri.decode(destination.substringAfter('/'))
|
val id = Uri.decode(destination.substringAfter('/'))
|
||||||
if (fixtureRefresh == null) {
|
if (fixtureRefresh == null) {
|
||||||
|
cancelPlayback()
|
||||||
// Losing authorization clears the old data, but is not another first-load skeleton.
|
// 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
|
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)
|
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),
|
hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden),
|
||||||
)
|
)
|
||||||
if (fixtureRefresh != null) {
|
if (fixtureRefresh != null) {
|
||||||
if (title === fixtureRefresh) fixtureRefresh.update(restored)
|
if (title === fixtureRefresh) updateTitle(fixtureRefresh, restored)
|
||||||
} else title = TitleState(restored, viewer, ::handle)
|
} else {
|
||||||
|
title = TitleState(restored, viewer, ::handle)
|
||||||
|
rememberSeason(restored)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (destination.startsWith("person/")) readPerson(id) else readTitle(id)
|
} 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) {
|
private suspend fun readTitle(id: String, retry: Boolean = false) {
|
||||||
val current = title ?: return
|
val current = title ?: return
|
||||||
val uuid = id.toUuidOrNull()
|
val uuid = id.toUuidOrNull()
|
||||||
|
|
@ -157,9 +192,9 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) }
|
val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) }
|
||||||
if (title !== current) return
|
if (title !== current) return
|
||||||
if (heldId != null) {
|
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)
|
held = facade.read(heldId, current.requestedSeason, preferences)
|
||||||
current.update(held)
|
updateTitle(current, held)
|
||||||
}
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
|
|
@ -174,7 +209,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
if (identity != null && tmdb != null) {
|
if (identity != null && tmdb != null) {
|
||||||
val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV
|
val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV
|
||||||
ConductarrTitlePages(identity).title(tmdb, media, held).collect { data ->
|
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))
|
} 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) {
|
private fun handle(action: TitleAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
TitleAction.Pop -> navigation.goBack()
|
TitleAction.Pop -> popRoute()
|
||||||
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)))
|
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 -> {
|
is TitleAction.OpenTitle -> {
|
||||||
|
cancelPlayback()
|
||||||
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
||||||
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
||||||
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
||||||
|
|
@ -267,25 +306,36 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
is TitleAction.Play -> {
|
is TitleAction.Play -> {
|
||||||
val opened = title?.data?.watchTarget
|
val opened = title?.data?.watchTarget
|
||||||
play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(),
|
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)
|
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true)
|
||||||
else {
|
else {
|
||||||
themeSongPlayer.stop()
|
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.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
||||||
is TitleAction.Season -> {
|
is TitleAction.Season -> {
|
||||||
|
val current = title ?: return
|
||||||
loadJob?.cancel()
|
loadJob?.cancel()
|
||||||
|
val cached = seasonEpisodes[action.number]
|
||||||
|
if (cached == null) current.showSeasonLoading() else current.showSeason(action.number, cached)
|
||||||
loadJob = viewModelScope.launch {
|
loadJob = viewModelScope.launch {
|
||||||
val current = title ?: return@launch
|
|
||||||
current.showSeasonLoading()
|
|
||||||
val source = fixture
|
val source = fixture
|
||||||
if (source == null) readTitle(Uri.decode(route.substringAfter('/')))
|
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) }
|
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))
|
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) {
|
private fun play(id: UUID?, restart: Boolean, control: String = "restart") {
|
||||||
val current = title
|
val current = title ?: return
|
||||||
|
if (current.playbackStart.starting != null) return
|
||||||
|
val generation = ++playbackGeneration
|
||||||
|
playbackJob?.cancel()
|
||||||
|
current.playbackStart.begin(control)
|
||||||
val playbackScope = watchScope
|
val playbackScope = watchScope
|
||||||
viewModelScope.launch {
|
playbackJob = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
||||||
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
||||||
if (title !== current || watchScope != playbackScope) return@launch
|
if (generation != playbackGeneration) return@launch
|
||||||
themeSongPlayer.stop()
|
if (title !== current || watchScope != playbackScope || current.playbackStart.starting == null) {
|
||||||
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
|
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) {
|
} catch (exception: Exception) {
|
||||||
|
val active = generation == playbackGeneration && current.playbackStart.starting != null
|
||||||
|
if (active) current.playbackStart.fail()
|
||||||
if (exception is CancellationException) throw exception
|
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)
|
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) }
|
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 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,
|
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}")
|
owner = "${shell.person.key}:${viewModel.watchScope}")
|
||||||
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
|
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
|
||||||
LaunchedEffect(title?.data?.status) {
|
LaunchedEffect(title?.data?.status) {
|
||||||
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
||||||
}
|
}
|
||||||
val person = viewModel.person.takeIf { title != null }
|
val person = viewModel.person.takeIf { title != null }
|
||||||
BackHandler(enabled = isScreenEntryActive() && title != null) {
|
BackHandler(enabled = active && title != null) {
|
||||||
viewModel.handleBack(route, shell) {
|
if (title?.playbackStart?.launch == null) {
|
||||||
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
|
viewModel.cancelPlayback()
|
||||||
|
viewModel.handleBack(route, shell) {
|
||||||
|
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(modifier.onPreviewKeyEvent { event ->
|
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 (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let {
|
||||||
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it)
|
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it)
|
||||||
true
|
true
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import android.net.Uri
|
||||||
/** Only an installed app that handles the YouTube scheme may receive a remote trailer. */
|
/** Only an installed app that handles the YouTube scheme may receive a remote trailer. */
|
||||||
class TitleTrailer(private val context: Context) {
|
class TitleTrailer(private val context: Context) {
|
||||||
/** A missing handler leaves the title in place rather than falling back to a browser. */
|
/** 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 source = Uri.parse(url)
|
||||||
val video = when (source.host?.lowercase()) {
|
val video = when (source.host?.lowercase()) {
|
||||||
"youtu.be" -> source.pathSegments.firstOrNull()
|
"youtu.be" -> source.pathSegments.firstOrNull()
|
||||||
|
|
@ -23,6 +23,7 @@ class TitleTrailer(private val context: Context) {
|
||||||
.firstOrNull { it.activityInfo.exported } ?: return false
|
.firstOrNull { it.activityInfo.exported } ?: return false
|
||||||
intent.setPackage(handler.activityInfo.packageName)
|
intent.setPackage(handler.activityInfo.packageName)
|
||||||
return try {
|
return try {
|
||||||
|
beforeLaunch()
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
true
|
true
|
||||||
} catch (_: ActivityNotFoundException) {
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -1,17 +1,29 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.content.Context
|
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.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
|
@ -64,6 +76,8 @@ class WolfskinSession @Inject constructor() {
|
||||||
var serverId: String? = null
|
var serverId: String? = null
|
||||||
internal var fixtureMode: Boolean? = null
|
internal var fixtureMode: Boolean? = null
|
||||||
var onAction: (ShellAction) -> Unit = {}
|
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. */
|
/** The shell routes only through the existing NavigationManager and real settings pages. */
|
||||||
|
|
@ -80,6 +94,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
val conductarr: ConductarrSession,
|
val conductarr: ConductarrSession,
|
||||||
artwork: WolfskinArtwork,
|
artwork: WolfskinArtwork,
|
||||||
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
|
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
|
||||||
|
private val trailerReturn: TrailerReturnState,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
init { artwork.configure(context) }
|
init { artwork.configure(context) }
|
||||||
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
||||||
|
|
@ -107,10 +122,20 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
session.serverId = server.id.toString()
|
session.serverId = server.id.toString()
|
||||||
session.fixtureMode = ConductarrPreference.fixtures
|
session.fixtureMode = ConductarrPreference.fixtures
|
||||||
val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) }
|
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 saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
|
||||||
val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) }
|
val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) }
|
||||||
session.state?.updatePeople(savedPeople)
|
session.state?.updatePeople(savedPeople)
|
||||||
|
} else {
|
||||||
|
trailerReturn.consumeRestoredSession(null)
|
||||||
}
|
}
|
||||||
identityJob = viewModelScope.launch {
|
identityJob = viewModelScope.launch {
|
||||||
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
||||||
|
|
@ -214,32 +239,48 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectPerson(person: ShellPerson, server: JellyfinServer) {
|
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())
|
navigation.replace(Destination.Home())
|
||||||
if (person.key == signedInPerson) return
|
if (person.key == signedInPerson) {
|
||||||
|
commit?.switchEnded = true
|
||||||
|
return
|
||||||
|
}
|
||||||
identityJob?.cancel()
|
identityJob?.cancel()
|
||||||
personSwitchJob?.cancel()
|
personSwitchJob?.cancel()
|
||||||
signedInPerson = ""
|
signedInPerson = ""
|
||||||
conductarr.close()
|
conductarr.close()
|
||||||
libraries.audiences = null
|
libraries.audiences = null
|
||||||
personSwitchJob = viewModelScope.launch {
|
personSwitchJob = viewModelScope.launch {
|
||||||
personSwitchMutex.withLock {
|
try {
|
||||||
val saved = withContext(Dispatchers.IO) {
|
personSwitchMutex.withLock {
|
||||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
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
|
if (saved == null || saved.isProtected) {
|
||||||
setupNavigation.navigateTo(SetupDestination.UserList(server))
|
if (!ConductarrPreference.fixtures) {
|
||||||
return@launch
|
val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) }
|
||||||
}
|
if (commit == null) open() else commit.fallback = open
|
||||||
try {
|
}
|
||||||
val current = repository.changeUser(server, saved)
|
return@launch
|
||||||
coroutineContext.ensureActive()
|
}
|
||||||
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
try {
|
||||||
} catch (exception: Exception) {
|
val current = repository.changeUser(server, saved)
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
coroutineContext.ensureActive()
|
||||||
coroutineContext.ensureActive()
|
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
||||||
setupNavigation.navigateTo(SetupDestination.UserList(server))
|
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(),
|
viewModel: WolfskinShellViewModel = hiltViewModel(),
|
||||||
homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit =
|
homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit =
|
||||||
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
|
{ 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 identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||||
val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle()
|
val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle()
|
||||||
|
|
@ -294,11 +339,35 @@ fun WolfskinApp(
|
||||||
)
|
)
|
||||||
RequestsRefresh(state, viewModel::refreshRequests)
|
RequestsRefresh(state, viewModel::refreshRequests)
|
||||||
val onBack = rememberWolfskinBackHandler(state, navigationManager)
|
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 {
|
WolfskinTheme {
|
||||||
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
|
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) {
|
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()
|
val current = navigationManager.backStack.last()
|
||||||
|
LaunchedEffect(current, state.person.key) { session.playback.fail() }
|
||||||
if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
|
if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
|
||||||
val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle()
|
val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle()
|
||||||
Backdrop(
|
Backdrop(
|
||||||
|
|
@ -309,40 +378,46 @@ fun WolfskinApp(
|
||||||
}
|
}
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = navigationManager.backStack,
|
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()),
|
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||||
entryProvider = { destination ->
|
entryProvider = { destination ->
|
||||||
NavEntry(
|
NavEntry(
|
||||||
destination,
|
destination,
|
||||||
contentKey = "${destination}_${server.id}_${user.id}",
|
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.
|
// Cached entries must not retain the launch shell after sign-in completes.
|
||||||
val state = LocalShellState.current
|
val state = LocalShellState.current
|
||||||
val discover = LocalDiscoverState.current
|
val discover = LocalDiscoverState.current
|
||||||
val route = destination.toWolfskinRoute()
|
val route = destination.toWolfskinRoute()
|
||||||
val isCurrent = navigationManager.backStack.lastOrNull() == destination
|
val isCurrent = navigationManager.backStack.lastOrNull() == destination
|
||||||
|
if (isCurrent) SideEffect { composedDestination = destination }
|
||||||
LaunchedEffect(destination, state, isCurrent) {
|
LaunchedEffect(destination, state, isCurrent) {
|
||||||
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
|
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
|
||||||
}
|
}
|
||||||
org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) {
|
org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) {
|
||||||
if (destination.fullScreen) {
|
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 {
|
} else {
|
||||||
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
||||||
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
||||||
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
||||||
if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) {
|
if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) {
|
||||||
ShellSurface(state, handlesContentKeys = false) { ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) }
|
ShellSurface(state, handlesContentKeys = false) {
|
||||||
} else TitleDestination(route, state, preferences)
|
if (session.personCommit == null) ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it)
|
||||||
|
}
|
||||||
|
} else titleContent(route, state, preferences)
|
||||||
} else {
|
} else {
|
||||||
ShellSurface(state, handlesContentKeys = upstream == null,
|
ShellSurface(state, handlesContentKeys = upstream == null,
|
||||||
settingsInstalledVersion = installedVersion.versionName,
|
settingsInstalledVersion = installedVersion.versionName,
|
||||||
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
||||||
when {
|
when {
|
||||||
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson ->
|
||||||
|
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)
|
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
|
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
|
||||||
route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates,
|
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) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
|
||||||
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
|
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
|
||||||
}
|
}
|
||||||
|
// 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)
|
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) },
|
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,
|
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 ->
|
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
|
||||||
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
|
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
|
||||||
},
|
},
|
||||||
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) },
|
episodes = seasonEpisodes.map { mapItem(it, pageItem.backdrop) },
|
||||||
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv.update
|
|
||||||
|
|
||||||
import androidx.compose.animation.EnterTransition
|
|
||||||
import androidx.compose.animation.ExitTransition
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.navigation3.ui.NavDisplay
|
|
||||||
|
|
||||||
/** Updates animates bounded content only; the retained nav never crossfades as a full scene. */
|
|
||||||
internal val updatesRouteTransitions =
|
|
||||||
NavDisplay.transitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
|
|
||||||
NavDisplay.popTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
|
|
||||||
NavDisplay.predictivePopTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) }
|
|
||||||
|
|
@ -148,10 +148,51 @@ The on state has no unfocused ring: Justin (2026-11-07) read it as active/select
|
||||||
|
|
||||||
Source — Justin (2026-11-07): “the watches icon on details pages has a border, making it look like it's active/selected.”
|
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
|
## 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`.
|
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.
|
`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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -449,11 +449,11 @@ only when the Advanced debug fixture switch is enabled; release excludes it.
|
||||||
### Household setup and sign-in recovery
|
### Household setup and sign-in recovery
|
||||||
|
|
||||||
With **Wolfskin screens** enabled, `MainContent` intercepts both upstream setup
|
With **Wolfskin screens** enabled, `MainContent` intercepts both upstream setup
|
||||||
destinations before their stock server/user selectors can render. Cold start,
|
destinations before their stock server/user selectors can render. Cold launches
|
||||||
restart with automatic sign-in disabled, the old drawer's user action, legacy
|
that require sign-in, protected-profile restarts, the old drawer's user action,
|
||||||
sign-out-equivalent state, setup fallbacks and intent failures all use Wolfskin's
|
legacy sign-out-equivalent state, setup fallbacks and intent failures all use
|
||||||
own picker. Entering it does not call `SwitchUserViewModel.init` or clear the
|
Wolfskin's own picker. Entering it does not call `SwitchUserViewModel.init` or
|
||||||
remembered server/user preference.
|
clear the remembered server/user preference.
|
||||||
|
|
||||||
Setup reuses the supplied/current/recent household server, recovering the most
|
Setup reuses the supplied/current/recent household server, recovering the most
|
||||||
recently used Room server if the legacy picker erased the preference. Saved people
|
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
|
hidden/unsaved profiles can use username/password sign-in through the setup
|
||||||
picker's Settings gear. An outage never replaces the saved household endpoint.
|
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
|
Turning **Wolfskin screens** off retains the unchanged upstream
|
||||||
`SwitchServerContent` and `SwitchUserContent` setup screens for rollback. Debug
|
`SwitchServerContent` and `SwitchUserContent` setup screens for rollback. Debug
|
||||||
fixtures still seed the existing fixture picker after authentication.
|
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
|
The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its
|
||||||
server and shell state, not to the user-scoped ViewModel. Switching people replaces
|
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 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
|
and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real
|
||||||
shell and user-entry replacement for launch/avatar Left and Right, the initial
|
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.
|
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
|
Person filmography and related-title shelves remain posters; Home continuation
|
||||||
tiles are a separate component and retain their series-name context.
|
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,
|
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
|
||||||
person credits and playback mutations to one boundary. `ConductarrTitlePages`
|
person credits and playback mutations to one boundary. `ConductarrTitlePages`
|
||||||
|
|
@ -1046,6 +1086,23 @@ entry, remembered columns and long-sheet visibility. `TitleRound2Test` covers
|
||||||
menu Back/focus, preserved media info and household requests, movie stacking,
|
menu Back/focus, preserved media info and household requests, movie stacking,
|
||||||
the Mussolini resume example, request revocation, episode skeleton geometry,
|
the Mussolini resume example, request revocation, episode skeleton geometry,
|
||||||
and native menu fade frames sampled in 16 ms steps.
|
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
|
`TitleAsyncFocusTest` delays title and person replies through the real destination
|
||||||
and view-model, then exercises content focus and hardware Back. Subtitles Off
|
and view-model, then exercises content focus and hardware Back. Subtitles Off
|
||||||
maps to the player's explicit disabled sentinel, not its unspecified default.
|
maps to the player's explicit disabled sentinel, not its unspecified default.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
"receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" },
|
"receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" },
|
||||||
"receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "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" },
|
"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" },
|
"buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" },
|
||||||
"skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" },
|
"skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" },
|
||||||
"skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" },
|
"skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" },
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.AnimationVector1D
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
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.drawscope.drawIntoCanvas
|
||||||
import androidx.compose.ui.graphics.nativeCanvas
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
|
@ -29,6 +46,7 @@ import androidx.tv.material3.Text
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.compose.AsyncImagePainter
|
import coil3.compose.AsyncImagePainter
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
|
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. */
|
/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */
|
||||||
@Composable
|
@Composable
|
||||||
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
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 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
|
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()
|
if (ratingsReader == null) state.awaitFocusedRatings()
|
||||||
else if (data.signedIn) state.focused?.let { item ->
|
else if (data.signedIn) state.focused?.let { item ->
|
||||||
ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values ->
|
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"))
|
Box(modifier.fillMaxSize().sheetScrim { fade.value }.focusProperties { canFocus = false }
|
||||||
Column(modifier.fillMaxHeight().width(L.sheetWidth)
|
.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)
|
.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
|
||||||
.drawWithCache {
|
.drawWithCache {
|
||||||
val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
|
@ -61,9 +89,11 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
||||||
onDrawBehind {
|
onDrawBehind {
|
||||||
drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) }
|
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 {
|
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)
|
.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)
|
Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg)
|
||||||
when {
|
when {
|
||||||
data.status == ComingInStatus.Loading && data.items.isEmpty() -> {
|
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() }
|
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() -> {
|
data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> {
|
||||||
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
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)
|
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 -> {
|
else -> {
|
||||||
if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn)
|
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) }
|
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)
|
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 start = frame.start
|
||||||
val visibleRows = remember(data, start, state.windowSize) {
|
val listState = rememberLazyListState()
|
||||||
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
|
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()
|
val keys = visibleRows.map { it.id }.uniqueKeys()
|
||||||
itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { _, item ->
|
itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { index, item ->
|
||||||
ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
|
ComingInRow(item, shell.person, !frame.recoveryFocused && item.id == frame.focusedId, frame.expanded && item.id == frame.focusedId,
|
||||||
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
|
frame.ratingsVisible && data.signedIn && item.id == frame.focusedId,
|
||||||
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
|
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
|
@Composable
|
||||||
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) {
|
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 = onClick) }
|
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = { if (interactive) onClick() }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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. */
|
/** Thumbnail slots do not resize when the text or the readiness block grows. */
|
||||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
|
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean,
|
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 foreground = if (focused) C.onFocus else C.fg
|
||||||
val secondary = if (focused) C.onFocus else C.muted
|
val secondary = if (focused) C.onFocus else C.muted
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val request = remember(item.poster, context) {
|
val request = remember(item.poster, context) {
|
||||||
item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) }
|
item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) }
|
||||||
}
|
}
|
||||||
val posterReady = remember(request) { mutableStateOf(false) }
|
val posterState = remember(request) { mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty) }
|
||||||
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
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))
|
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||||
.pointerInput(item.id) {
|
.then(if (interactive) Modifier.pointerInput(item.id) {
|
||||||
awaitPointerEventScope {
|
awaitPointerEventScope {
|
||||||
while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus()
|
while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus()
|
||||||
}
|
}
|
||||||
}.clickable(onClick = onClick)
|
} else Modifier).clickable(enabled = interactive, onClick = onClick)
|
||||||
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"),
|
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY),
|
||||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
|
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
|
||||||
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
|
Box(Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
|
||||||
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
|
.background(if (posterState.value is AsyncImagePainter.State.Success) Color.Transparent else C.surface3)
|
||||||
.background(if (posterReady.value) Color.Transparent else C.surface3),
|
.testTag("coming-thumb-${item.id}"), contentAlignment = Alignment.Center) {
|
||||||
onState = { posterReady.value = it is AsyncImagePainter.State.Success },
|
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
|
||||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
|
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)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
|
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
|
||||||
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -18,12 +20,23 @@ import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.dropShadow
|
import androidx.compose.ui.draw.dropShadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
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.graphics.shadow.Shadow
|
||||||
import androidx.compose.ui.unit.DpOffset
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.platform.testTag
|
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. */
|
/** Sheets draw after the shell nav: their scrim consumes pointers and focus stays inside. */
|
||||||
@Composable
|
@Composable
|
||||||
fun DiscoverOverlay(state: DiscoverState) {
|
fun DiscoverOverlay(state: DiscoverState) {
|
||||||
val overlay = state.overlay
|
val current = state.overlay
|
||||||
if (overlay != null && !state.shell.pickerVisible) {
|
var retained by remember { mutableStateOf(current) }
|
||||||
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) {
|
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()
|
Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize()
|
||||||
|
.sheetPanelFade { fade.value }
|
||||||
.dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur,
|
.dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur,
|
||||||
color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity),
|
color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity),
|
||||||
offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero)))
|
offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero)))
|
||||||
.background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
|
.background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
|
||||||
when (overlay) {
|
key(overlay) {
|
||||||
is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state)
|
when (overlay) {
|
||||||
is DiscoverOverlayState.Hidden -> {
|
is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state, visible)
|
||||||
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.")
|
is DiscoverOverlayState.Hidden -> {
|
||||||
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
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.")
|
||||||
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) }
|
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) }
|
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 -> {
|
||||||
is DiscoverOverlayState.Restore -> {
|
Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
|
||||||
Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
|
Text("Hidden from ${state.shell.person.name}’s picks.",
|
||||||
Text("Hidden from ${state.shell.person.name}’s picks.",
|
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body),
|
||||||
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body),
|
color = C.muted, style = DiscoverType.body)
|
||||||
color = C.muted, style = DiscoverType.body)
|
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
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("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) }
|
DiscoverButton("Back", overlay.focus == 1, primary = false, enabled = visible) { state.overlay = null }
|
||||||
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null }
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.toast?.let { RecoveryToast(it) }
|
RecoveryToastHost(state.toast)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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(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()}",
|
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)
|
Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body)
|
||||||
if (state.isSignedIn) SheetRatings(sheet.title.ratings)
|
if (state.isSignedIn) SheetRatings(sheet.title.ratings)
|
||||||
when (sheet.phase) {
|
when (phase) {
|
||||||
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
|
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet, interactive)
|
||||||
RequestPhase.LoadingRecipients -> {
|
RequestPhase.LoadingRecipients -> {
|
||||||
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
|
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
|
||||||
repeat(3) {
|
repeat(3) {
|
||||||
|
|
@ -89,10 +121,21 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(Modifier.padding(top = D.sheetButtonTop)) {
|
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
|
@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)
|
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
|
||||||
sheet.people.forEachIndexed { index, person ->
|
sheet.people.forEachIndexed { index, person ->
|
||||||
val focused = sheet.focus == index
|
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)
|
Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f)
|
||||||
.focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
.focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||||
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
||||||
.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) {
|
.padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow)
|
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)
|
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)
|
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)
|
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)) {
|
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
|
@Composable
|
||||||
private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
|
private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) {
|
||||||
val phase = sheet.phase
|
val phase = sheet.phase
|
||||||
val name = sheet.selectedPerson.name
|
val name = sheet.selectedPerson.name
|
||||||
val actor = sheet.result?.actor
|
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)
|
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)) {
|
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) {
|
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(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) { state.overlay = null }
|
DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false, enabled = interactive) { state.overlay = null }
|
||||||
} else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true) { state.overlay = null }
|
} else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true, enabled = interactive) { state.overlay = null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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)) {
|
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) ->
|
(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 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)
|
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))
|
.background(fill, 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))
|
.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) {
|
.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. */
|
/** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */
|
||||||
@Composable
|
@Composable
|
||||||
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
|
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true,
|
||||||
|
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)
|
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||||
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button))
|
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button))
|
||||||
.clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
|
.clickable(enabled = enabled, onClick = onClick).padding(horizontal = L.buttonPaddingX),
|
||||||
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
|
horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(label, color = if (primary) C.onFocus else C.fg, style = DiscoverType.button, maxLines = 1)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.foundation.hoverable
|
import androidx.compose.foundation.hoverable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
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.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
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.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.unit.Dp
|
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. */
|
/** Lift shadows remain specific to cards and buttons. */
|
||||||
enum class FocusLift { None, Card, Button }
|
enum class FocusLift { None, Card, Button }
|
||||||
|
|
||||||
/** Every focus ring straddles the surface edge above content, without a background gap. */
|
/** Hosted cards keep their lift and share the surface stroke; standalone rings straddle the surface edge. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
|
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
|
||||||
val reduced = reducedMotion()
|
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
|
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 {
|
return shadow.drawWithCache {
|
||||||
val stroke = Stroke(width.toPx())
|
val stroke = Stroke(width.toPx())
|
||||||
val corner = CornerRadius(radius.toPx())
|
val corner = CornerRadius(radius.toPx())
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,72 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.animate
|
import androidx.compose.animation.core.AnimationState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.animateTo
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
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. */
|
private class ScrollMomentum {
|
||||||
internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
|
var velocity = 0f
|
||||||
if (distance == 0f) return
|
var frameTime = Long.MIN_VALUE
|
||||||
list.scroll {
|
var generation = 0L
|
||||||
var previous = 0f
|
fun reset() { velocity = 0f; frameTime = Long.MIN_VALUE }
|
||||||
animate(0f, distance, animationSpec = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll,
|
|
||||||
easing = WolfskinTokens.Motion.easeOut)) { value, _ ->
|
fun release(context: CoroutineContext, generation: Long) {
|
||||||
scrollBy(value - previous)
|
if (this.generation != generation) return
|
||||||
previous = value
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
|
@ -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.WolfskinTokens.Layout as L
|
||||||
import org.justinvisser.wolfskin.design.HomeType as T
|
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. */
|
/** The hero occupies one row while its backdrop extends beneath the first shelves. */
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
|
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
|
||||||
|
val start = LocalHomePlaybackStart.current
|
||||||
HeroMotionScope {
|
HeroMotionScope {
|
||||||
Box(Modifier.fillMaxWidth().height(L.row_hero)) {
|
Box(Modifier.fillMaxWidth().height(L.row_hero)) {
|
||||||
if (!row.skeleton) {
|
if (!row.skeleton) {
|
||||||
|
|
@ -57,7 +62,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
||||||
content = {
|
content = {
|
||||||
val primary = (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
val primary = (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
||||||
PickerButton(primary, focused && row.watchReady && column == 0, true, "playback",
|
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 ->
|
) { measurables, constraints ->
|
||||||
val button = measurables.single().measure(constraints)
|
val button = measurables.single().measure(constraints)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
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.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
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()
|
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
|
||||||
}.coerceAtLeast(0f)
|
}.coerceAtLeast(0f)
|
||||||
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
|
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.positionedKey = key
|
||||||
view.home.positionedReset = view.home.resetScroll
|
view.home.positionedReset = view.home.resetScroll
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
@ -82,6 +83,7 @@ class LibraryGridState(
|
||||||
var pagination: LibraryPagination? = null
|
var pagination: LibraryPagination? = null
|
||||||
var pageRevision by mutableStateOf(0)
|
var pageRevision by mutableStateOf(0)
|
||||||
var refreshRevision by mutableStateOf(0)
|
var refreshRevision by mutableStateOf(0)
|
||||||
|
var resultRevision by mutableIntStateOf(0)
|
||||||
var pointerReentry: (() -> Int?)? = null
|
var pointerReentry: (() -> Int?)? = null
|
||||||
var index by mutableStateOf(0)
|
var index by mutableStateOf(0)
|
||||||
var toolbar by mutableStateOf(false)
|
var toolbar by mutableStateOf(false)
|
||||||
|
|
@ -147,8 +149,10 @@ class LibraryGridState(
|
||||||
ShellKey.Left -> tool = (tool - 1).coerceAtLeast(0)
|
ShellKey.Left -> tool = (tool - 1).coerceAtLeast(0)
|
||||||
ShellKey.Right -> tool = (tool + 1).coerceAtMost(count - 1)
|
ShellKey.Right -> tool = (tool + 1).coerceAtMost(count - 1)
|
||||||
ShellKey.Ok -> {
|
ShellKey.Ok -> {
|
||||||
|
val previous = items.map { if (catalogue) it.catalogueKey else it.id }
|
||||||
if (catalogue) mediaType = listOf("all", "movie", "tv")[tool]
|
if (catalogue) mediaType = listOf("all", "movie", "tv")[tool]
|
||||||
else if (tool == 0) sort = (sort + 1) % 3 else unwatched = !unwatched
|
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
|
index = 0
|
||||||
}
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ fun LibraryScreen(
|
||||||
}.uniqueKeys()
|
}.uniqueKeys()
|
||||||
items(rowCount, key = { rowKeys[it] }, contentType = { "grid" }) { row ->
|
items(rowCount, key = { rowKeys[it] }, contentType = { "grid" }) { row ->
|
||||||
Box(Modifier.fillMaxWidth().height(rowHeight)) {
|
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) {
|
for (column in 0 until grid.columns) {
|
||||||
val index = row * grid.columns + column
|
val index = row * grid.columns + column
|
||||||
val title = items.getOrNull(index) ?: break
|
val title = items.getOrNull(index) ?: break
|
||||||
|
|
@ -194,12 +194,14 @@ private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) {
|
||||||
labels.forEachIndexed { index, label ->
|
labels.forEachIndexed { index, label ->
|
||||||
val focused = active && grid.toolbar && grid.tool == index
|
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 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)
|
Row(Modifier.height(G.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||||
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, CircleShape)
|
.background(fill, CircleShape)
|
||||||
.then(if (selected && !focused) Modifier.border(G.borderWidth, C.accent, CircleShape) else Modifier)
|
.border(G.borderWidth, border, CircleShape)
|
||||||
.clickable { grid.toolbar = true; grid.tool = index; grid.key(ShellKey.Ok) }
|
.clickable { grid.toolbar = true; grid.tool = index; grid.key(ShellKey.Ok) }
|
||||||
.padding(horizontal = G.chipPadding), horizontalArrangement = Arrangement.spacedBy(G.chipContentGap), verticalAlignment = Alignment.CenterVertically) {
|
.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) Glyph(if (index == 0) "advanced" else "eye", color, Modifier.size(G.chipIcon))
|
||||||
if (!grid.catalogue && index == 0) Text(buildAnnotatedString {
|
if (!grid.catalogue && index == 0) Text(buildAnnotatedString {
|
||||||
withStyle(SpanStyle(color = color.copy(alpha = G.chipLabelOpacity), fontWeight = FontWeight.Medium)) { append("Sort") }
|
withStyle(SpanStyle(color = color.copy(alpha = G.chipLabelOpacity), fontWeight = FontWeight.Medium)) { append("Sort") }
|
||||||
|
|
|
||||||
|
|
@ -74,14 +74,25 @@ fun Modifier.skeletonPulse(loading: Boolean = true): Modifier {
|
||||||
return graphicsLayer { alpha = clock.alpha.value }
|
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
|
@Composable
|
||||||
fun Modifier.rowReveal(identity: Any, enabled: Boolean = true): Modifier {
|
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 reduced = reducedMotion()
|
||||||
val progress = remember(identity, enabled) { Animatable(if (enabled) 0f else 1f) }
|
val progress = remember(memory, identity, enabled) { Animatable(0f) }
|
||||||
LaunchedEffect(progress, reduced) { if (enabled) progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
|
LaunchedEffect(progress, reduced) {
|
||||||
val rise = with(LocalDensity.current) { M.rowRise.toPx() }
|
memory?.identities?.add(key)
|
||||||
return if (!enabled) this else graphicsLayer {
|
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
|
alpha = progress.value
|
||||||
translationY = if (reduced) 0f else rise * (1f - progress.value)
|
translationY = if (reduced) 0f else rise * (1f - progress.value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -116,7 +115,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
|
||||||
else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2)
|
else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2)
|
||||||
}
|
}
|
||||||
val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset
|
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}" }
|
Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" }
|
||||||
.titleWheel { wheelPending = true; shell.navFocused = false }
|
.titleWheel { wheelPending = true; shell.navFocused = false }
|
||||||
|
|
|
||||||
|
|
@ -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)) })
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.dropShadow
|
import androidx.compose.ui.draw.dropShadow
|
||||||
import androidx.compose.ui.graphics.Color
|
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.graphics.shadow.Shadow
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.DpOffset
|
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.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
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. */
|
/** Recovery messages remain transient and never take focus from the action that failed. */
|
||||||
@Composable
|
@Composable
|
||||||
fun RecoveryToast(message: String, modifier: Modifier = Modifier) {
|
fun RecoveryToast(message: String, modifier: Modifier = Modifier, opacity: () -> Float = { 1f }) {
|
||||||
Box(modifier.fillMaxSize()) {
|
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)
|
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop)
|
||||||
.widthIn(max = H.toastWidth)
|
.widthIn(max = H.toastWidth)
|
||||||
|
.graphicsLayer { alpha = opacity(); compositingStrategy = CompositingStrategy.ModulateAlpha }
|
||||||
.dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity),
|
.dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity),
|
||||||
offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY)))
|
offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY)))
|
||||||
.background(C.surface2, shape)
|
.background(C.surface2, shape)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,20 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.remember
|
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 LocalScreenView = compositionLocalOf<RememberedView?> { null }
|
||||||
internal val LocalScreenActive = compositionLocalOf { true }
|
internal val LocalScreenActive = compositionLocalOf { true }
|
||||||
|
|
@ -16,7 +30,9 @@ fun isScreenEntryActive(): Boolean = LocalScreenActive.current
|
||||||
@Composable
|
@Composable
|
||||||
fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) {
|
fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) {
|
||||||
remember(shell, shell.person.key, route) { shell.prepareDestinationView(route) }
|
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. */
|
/** Direct surface previews use the current view when no navigation entry is installed. */
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -57,13 +55,12 @@ val SETTINGS_GROUPS = listOf(
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
|
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
|
||||||
val list = state.view.listState
|
val list = state.view.listState
|
||||||
val reduced = reducedMotion()
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
LaunchedEffect(state.settingsRow, state.navFocused) {
|
LaunchedEffect(state.settingsRow, state.navFocused) {
|
||||||
if (state.navFocused) return@LaunchedEffect
|
if (state.navFocused) return@LaunchedEffect
|
||||||
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
|
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
|
||||||
val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset
|
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()) {
|
Box(modifier.fillMaxSize()) {
|
||||||
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),
|
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
|
||||||
/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */
|
/** 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. */
|
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
|
||||||
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
|
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
|
||||||
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
|
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
|
||||||
if (distance != 0f) animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.base,
|
animateFrameScroll(this, distance)
|
||||||
easing = WolfskinTokens.Motion.ease))
|
|
||||||
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
|
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ class ShellState(
|
||||||
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
|
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
|
||||||
var recoveryIndex by mutableStateOf(0)
|
var recoveryIndex by mutableStateOf(0)
|
||||||
var requestsVisible by mutableStateOf(false)
|
var requestsVisible by mutableStateOf(false)
|
||||||
|
internal var requestsExpansionInput = 0L
|
||||||
|
private set
|
||||||
var toast by mutableStateOf<String?>(null)
|
var toast by mutableStateOf<String?>(null)
|
||||||
private val personRequests = mutableMapOf<String, ComingInState>()
|
private val personRequests = mutableMapOf<String, ComingInState>()
|
||||||
val requests: ComingInState get() = personRequests.getValue(person.key)
|
val requests: ComingInState get() = personRequests.getValue(person.key)
|
||||||
|
|
@ -161,8 +163,12 @@ class ShellState(
|
||||||
when {
|
when {
|
||||||
key == ShellKey.Back -> closeRequests()
|
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.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests()
|
||||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
|
key == ShellKey.Ok && requests.snapshot.items.isEmpty() && requests.snapshot.status != ComingInStatus.Unavailable -> closeRequests()
|
||||||
requests.key(key) -> activateRequest()
|
else -> {
|
||||||
|
val expanded = requests.expanded
|
||||||
|
if (requests.key(key)) activateRequest()
|
||||||
|
if ((key == ShellKey.Right || key == ShellKey.Left) && requests.expanded != expanded) requestsExpansionInput++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,14 @@ fun ShellSurface(
|
||||||
if (state.pickerVisible) {
|
if (state.pickerVisible) {
|
||||||
WhosWatching(state)
|
WhosWatching(state)
|
||||||
} else {
|
} else {
|
||||||
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, 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)
|
NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||||
if (state.requestsVisible) ComingInSheet(state)
|
ComingInSheet(state)
|
||||||
overlay()
|
overlay()
|
||||||
}
|
}
|
||||||
state.toast?.let { RecoveryToast(it) }
|
RecoveryToastHost(state.toast)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
package org.justinvisser.wolfskin.design
|
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.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
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,
|
height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f,
|
||||||
content: @Composable BoxScope.() -> Unit) {
|
content: @Composable BoxScope.() -> Unit) {
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
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)
|
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
|
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
|
||||||
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2)
|
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)
|
val top = Offset(0f, size.height - edge - fill)
|
||||||
if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill))
|
if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill))
|
||||||
drawRect(WolfskinTokens.Color.accent, top,
|
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)
|
}, 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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.semantics.Role
|
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.Layout as L
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
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. */
|
/** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */
|
||||||
@Composable
|
@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 focusOwner = LocalTitleFocusOwner.current
|
||||||
|
val enabled = LocalTitleControlsEnabled.current
|
||||||
val background = if (option.primary) C.fg else if (overArtwork) {
|
val background = if (option.primary) C.fg else if (overArtwork) {
|
||||||
if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary
|
if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary
|
||||||
} else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
} else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||||
val foreground = if (option.primary) C.onFocus else C.fg
|
val foreground = if (option.primary) C.onFocus else C.fg
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
|
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
|
||||||
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
|
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
|
||||||
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus.takeIf { enabled }).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||||
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
||||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||||
.background(background, shape)
|
.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) {
|
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 (busyLabel != null) BusyLabelSwap(busy, busyLabel, foreground, WolfskinTokens.Type.button) {
|
||||||
if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
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.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
|
@ -13,7 +15,6 @@ import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
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 ->
|
state.data.playTarget?.takeIf { it.resumeSeconds > 0 || it.kind == "episode" }?.let { target ->
|
||||||
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (target.resumeSeconds > 0) {
|
if (target.resumeSeconds > 0) {
|
||||||
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
|
val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f
|
||||||
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.fillMaxWidth(fraction).fillMaxHeight().background(C.accent))
|
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)
|
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress)
|
||||||
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress)
|
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress)
|
||||||
}
|
}
|
||||||
|
|
@ -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)) {
|
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,
|
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index,
|
||||||
Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) },
|
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 }) {
|
if (state.options.any { it.icon }) {
|
||||||
val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon }
|
val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon }
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -41,15 +44,21 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) {
|
||||||
LaunchedEffect(visible, reduced) {
|
LaunchedEffect(visible, reduced) {
|
||||||
if (visible) focus.requestFocus()
|
if (visible) focus.requestFocus()
|
||||||
opacity.animateTo(if (visible) 1f else 0f,
|
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)) {
|
if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) {
|
||||||
Box(Modifier.fillMaxSize().background(C.sheetScrim)
|
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 ->
|
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||||
val key = readShellKey(event)
|
val key = readShellKey(event)
|
||||||
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
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)
|
Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth)
|
||||||
.graphicsLayer { alpha = opacity.value }
|
.graphicsLayer { alpha = opacity.value }
|
||||||
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||||
|
|
@ -64,6 +73,7 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
package org.justinvisser.wolfskin.design
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.focusable
|
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. */
|
/** Sheets sit above navigation and consume pointer and key input across the entire scrim. */
|
||||||
@Composable
|
@Composable
|
||||||
fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
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() }
|
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)) }
|
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 { }
|
Box(Modifier.fillMaxSize().sheetScrim { if (!ownsScrim) 0f else if (fromMenu) 1f else scrim.value }
|
||||||
.testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" }
|
.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 ->
|
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||||
val key = readShellKey(event)
|
val key = readShellKey(event)
|
||||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||||
}.focusable()) {
|
}.focusable(enabled = visible)) {
|
||||||
CssColumn(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface)
|
CompositionLocalProvider(LocalTitleControlsEnabled provides visible, LocalTitleFocusOwner provides null) {
|
||||||
.verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
|
CssColumn(Modifier.sheetPanelFade { fade.value }.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight()
|
||||||
gap = if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) {
|
.dropShadow(RectangleShape, shadow).background(C.surface)
|
||||||
when (state.sheet) {
|
.verticalScroll(rememberScrollState(), enabled = visible).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
|
||||||
TitleSheet.Version -> VersionChoices(state)
|
gap = if (sheet == TitleSheet.Tracks) D.trackGap else if (sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) {
|
||||||
TitleSheet.Tracks -> TrackChoices(state)
|
when (sheet) {
|
||||||
TitleSheet.MediaInfo -> MediaInformation(state)
|
TitleSheet.Version -> VersionChoices(content)
|
||||||
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
|
TitleSheet.Tracks -> TrackChoices(content)
|
||||||
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
|
TitleSheet.MediaInfo -> MediaInformation(content)
|
||||||
TitleSheet.Menu, null -> Unit
|
TitleSheet.ConfirmWatched -> WatchedConfirmation(content)
|
||||||
|
TitleSheet.HiddenReceipt -> HiddenReceipt(content)
|
||||||
|
TitleSheet.Menu, null -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -100,11 +146,12 @@ private fun TrackChoices(state: TitleState) {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
|
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 foreground = if (focused) C.onFocus else C.fg
|
||||||
val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical)
|
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))
|
.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) {
|
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
CssColumn(Modifier.weight(1f)) {
|
CssColumn(Modifier.weight(1f)) {
|
||||||
Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle))
|
Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle))
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,23 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.tween
|
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.drawBehind
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
@ -54,6 +68,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
||||||
val view = screenView(shell)
|
val view = screenView(shell)
|
||||||
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
|
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
|
||||||
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
|
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.
|
// 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
|
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
|
||||||
else when (readShellKey(event)) {
|
else when (readShellKey(event)) {
|
||||||
|
|
@ -69,9 +84,10 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
||||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
||||||
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
||||||
}
|
}
|
||||||
if (!shell.pickerVisible && state.sheet != null && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell)
|
|
||||||
TitleMenu(state, shell)
|
TitleMenu(state, shell)
|
||||||
|
if (!shell.pickerVisible) TitleOptionsSheet(state, shell)
|
||||||
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
||||||
|
PlaybackHandoffVeil(state.playbackStart)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,7 +97,7 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism
|
||||||
LaunchedEffect(message) {
|
LaunchedEffect(message) {
|
||||||
if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() }
|
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. */
|
/** 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
|
val entryActive = LocalScreenActive.current
|
||||||
var wheelPending by remember { mutableStateOf(false) }
|
var wheelPending by remember { mutableStateOf(false) }
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
|
val sheetWasOpen = remember { booleanArrayOf(state.sheet != null) }
|
||||||
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
||||||
val compactHeader = state.data.status == TitleReadStatus.Ready
|
val compactHeader = state.data.status == TitleReadStatus.Ready
|
||||||
val item = state.data.item
|
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
|
D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero
|
||||||
val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead
|
val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead
|
||||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible, entryActive) {
|
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) {
|
val displayedRows = remember(state.data) {
|
||||||
buildList {
|
buildList {
|
||||||
|
|
@ -138,7 +160,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
|
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
|
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
|
||||||
else y - with(density) { L.pinnedLine.toPx() }
|
else y - with(density) { L.pinnedLine.toPx() }
|
||||||
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
|
if (target != position) animateFrameScroll(list, target - position)
|
||||||
}
|
}
|
||||||
val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
|
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 } }
|
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) {
|
private fun TitleSeasons(state: TitleState, active: Boolean) {
|
||||||
val focusOwner = LocalTitleFocusOwner.current
|
val focusOwner = LocalTitleFocusOwner.current
|
||||||
val list = androidx.compose.foundation.lazy.rememberLazyListState()
|
val list = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||||
val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season)
|
val reduced = reducedMotion()
|
||||||
LaunchedEffect(visibleChip, state.data.seasonNumbers) {
|
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 (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 seasonNumbers = state.data.seasonNumbers
|
||||||
val keys = seasonNumbers.map { "season-$it" }.uniqueKeys()
|
val keys = seasonNumbers.map { "season-$it" }.uniqueKeys()
|
||||||
items(seasonNumbers.size, key = { keys[it] }, contentType = { "chip" }) { index ->
|
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 focused = active && state.column == index
|
||||||
val selected = state.season == number
|
val selected = state.season == number
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.chip)
|
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)
|
Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||||
.onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) }
|
.onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) }
|
||||||
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape)
|
.background(if (focused) C.fg else if (selected) androidx.compose.ui.graphics.Color.Transparent else C.chipRest, shape)
|
||||||
.then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier)
|
|
||||||
.clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
|
.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)
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ class TitleState(
|
||||||
private val centers = HashMap<String, MutableMap<Int, Float>>()
|
private val centers = HashMap<String, MutableMap<Int, Float>>()
|
||||||
fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x }
|
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) }
|
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) }
|
fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) }
|
||||||
var sheet by mutableStateOf<TitleSheet?>(null)
|
var sheet by mutableStateOf<TitleSheet?>(null)
|
||||||
private set
|
private set
|
||||||
|
|
@ -37,6 +42,7 @@ class TitleState(
|
||||||
var subtitleIndex by mutableStateOf(initial.selectedSubtitle)
|
var subtitleIndex by mutableStateOf(initial.selectedSubtitle)
|
||||||
var watched by mutableStateOf(initial.watchTarget.watched)
|
var watched by mutableStateOf(initial.watchTarget.watched)
|
||||||
var toast by mutableStateOf<String?>(null)
|
var toast by mutableStateOf<String?>(null)
|
||||||
|
val playbackStart = PlaybackStart()
|
||||||
private var armedCard: String? = null
|
private var armedCard: String? = null
|
||||||
private var seasonChosen = false
|
private var seasonChosen = false
|
||||||
val requestedSeason: Int? get() = season.takeIf { seasonChosen }
|
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. */
|
/** 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 {
|
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
|
||||||
|
if (playbackStart.launch != null) return true
|
||||||
if (key == ShellKey.Ok) {
|
if (key == ShellKey.Ok) {
|
||||||
if (!down) {
|
if (!down) {
|
||||||
okHeld = false
|
okHeld = false
|
||||||
|
|
@ -147,6 +154,8 @@ class TitleState(
|
||||||
|
|
||||||
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
||||||
fun back(shell: ShellState) {
|
fun back(shell: ShellState) {
|
||||||
|
if (playbackStart.launch != null) return
|
||||||
|
playbackStart.fail()
|
||||||
when {
|
when {
|
||||||
sheet != null -> closeSheet()
|
sheet != null -> closeSheet()
|
||||||
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
||||||
|
|
@ -187,7 +196,7 @@ class TitleState(
|
||||||
|
|
||||||
private fun activateOption(id: String) {
|
private fun activateOption(id: String) {
|
||||||
when (id) {
|
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 }
|
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
||||||
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
||||||
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
|
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
|
||||||
|
|
|
||||||
|
|
@ -37,15 +37,23 @@ fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PeoplePicker(state: ShellState) {
|
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) {
|
Box(Modifier.fillMaxWidth().offset(y = S.pickerTitleTop), contentAlignment = Alignment.TopCenter) {
|
||||||
Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
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)) {
|
Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
|
||||||
state.people.forEachIndexed { index, person ->
|
people.forEachIndexed { index, person ->
|
||||||
val focused = !state.gearFocused && state.pickerIndex == index
|
val focused = !gearFocused && focusedIndex == index
|
||||||
Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable {
|
Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||||
state.focusPerson(index); state.key(ShellKey.Ok)
|
.then(if (onPerson != null) Modifier.clickable { onPerson(index) } else Modifier), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
}, horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
|
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
|
||||||
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing))
|
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing))
|
||||||
Spacer(Modifier.height(S.pickerNameGap))
|
Spacer(Modifier.height(S.pickerNameGap))
|
||||||
|
|
@ -54,11 +62,11 @@ private fun PeoplePicker(state: ShellState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val left = (S.stageWidth - L.gear) / 2
|
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)
|
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(gearFocused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
|
.focusRing(gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
|
||||||
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
|
.then(if (onGear != null) Modifier.clickable(onClick = onGear) else Modifier), contentAlignment = Alignment.Center) {
|
||||||
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
|
Glyph("gear", if (gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
|
||||||
if (state.gearFocused) {
|
if (gearFocused) {
|
||||||
Box(Modifier.align(Alignment.TopCenter).offset(y = S.pickerGearCaptionTop).requiredWidth(L.pickerAvatar), contentAlignment = Alignment.TopCenter) {
|
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)
|
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)
|
Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||||
}
|
}
|
||||||
if (loading) {
|
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) {
|
repeat(state.people.size) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Box(Modifier.size(L.pickerAvatar).background(C.surface2, CircleShape))
|
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. */
|
/** Recovery actions remain focusable while profiles cannot be read. */
|
||||||
@Composable
|
@Composable
|
||||||
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null,
|
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
|
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||||
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||||
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
|
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
|
||||||
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
|
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable { if (!busy) onClick() }
|
||||||
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
|
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) {
|
val content: @Composable () -> Unit = {
|
||||||
if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) {
|
||||||
Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,8 @@ object WolfskinTokens {
|
||||||
const val focusMove = 180
|
const val focusMove = 180
|
||||||
const val captionDelay = 60
|
const val captionDelay = 60
|
||||||
const val captionOut = 100
|
const val captionOut = 100
|
||||||
|
const val sheetIn = 160
|
||||||
|
const val sheetOut = 100
|
||||||
const val artReveal = 160
|
const val artReveal = 160
|
||||||
const val heroFade = 320
|
const val heroFade = 320
|
||||||
const val heroCopyOut = 120
|
const val heroCopyOut = 120
|
||||||
|
|
@ -319,6 +321,7 @@ object WolfskinTokens {
|
||||||
val receiptPaddingX = 15.0.dp
|
val receiptPaddingX = 15.0.dp
|
||||||
val receiptGap = 5.0.dp
|
val receiptGap = 5.0.dp
|
||||||
val receiptGlyph = 18.0.dp
|
val receiptGlyph = 18.0.dp
|
||||||
|
val thumbGlyph = 20.0.dp
|
||||||
val buttonMargin = 6.0.dp
|
val buttonMargin = 6.0.dp
|
||||||
val skeletonTitleWidth = 180.0.dp
|
val skeletonTitleWidth = 180.0.dp
|
||||||
val skeletonTitleHeight = 17.0.dp
|
val skeletonTitleHeight = 17.0.dp
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -100,9 +100,9 @@ class RequestsKeysTest {
|
||||||
assertEquals(1, retries)
|
assertEquals(1, retries)
|
||||||
assertTrue(state.requestsVisible)
|
assertTrue(state.requestsVisible)
|
||||||
rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) }
|
rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) }
|
||||||
|
rule.onNodeWithText("New requests show up here.").assertExists()
|
||||||
send(Key.DirectionDown, Key.DirectionCenter)
|
send(Key.DirectionDown, Key.DirectionCenter)
|
||||||
rule.onNodeWithText("Nothing coming in.").assertExists()
|
assertFalse(state.requestsVisible)
|
||||||
assertTrue(state.requestsVisible)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun cachedRatingsRenderInTheTelevisionLocale() {
|
@Test fun cachedRatingsRenderInTheTelevisionLocale() {
|
||||||
|
|
|
||||||
|
|
@ -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) {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -142,6 +142,8 @@
|
||||||
--motion-focusMove: 180ms;
|
--motion-focusMove: 180ms;
|
||||||
--motion-captionDelay: 60ms;
|
--motion-captionDelay: 60ms;
|
||||||
--motion-captionOut: 100ms;
|
--motion-captionOut: 100ms;
|
||||||
|
--motion-sheetIn: 160ms;
|
||||||
|
--motion-sheetOut: 100ms;
|
||||||
--motion-artReveal: 160ms;
|
--motion-artReveal: 160ms;
|
||||||
--motion-heroFade: 320ms;
|
--motion-heroFade: 320ms;
|
||||||
--motion-heroCopyOut: 120ms;
|
--motion-heroCopyOut: 120ms;
|
||||||
|
|
@ -192,6 +194,7 @@
|
||||||
--requests-receiptPaddingX: 30.0px;
|
--requests-receiptPaddingX: 30.0px;
|
||||||
--requests-receiptGap: 10.0px;
|
--requests-receiptGap: 10.0px;
|
||||||
--requests-receiptGlyph: 36.0px;
|
--requests-receiptGlyph: 36.0px;
|
||||||
|
--requests-thumbGlyph: 40.0px;
|
||||||
--requests-buttonMargin: 12.0px;
|
--requests-buttonMargin: 12.0px;
|
||||||
--requests-skeletonTitleWidth: 360.0px;
|
--requests-skeletonTitleWidth: 360.0px;
|
||||||
--requests-skeletonTitleHeight: 34.0px;
|
--requests-skeletonTitleHeight: 34.0px;
|
||||||
|
|
|
||||||
|
|
@ -1408,6 +1408,8 @@
|
||||||
"focusMove": { "$type": "duration", "$value": "180ms" },
|
"focusMove": { "$type": "duration", "$value": "180ms" },
|
||||||
"captionDelay": { "$type": "duration", "$value": "60ms" },
|
"captionDelay": { "$type": "duration", "$value": "60ms" },
|
||||||
"captionOut": { "$type": "duration", "$value": "100ms" },
|
"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" },
|
"artReveal": { "$type": "duration", "$value": "160ms" },
|
||||||
"heroFade": { "$type": "duration", "$value": "320ms" },
|
"heroFade": { "$type": "duration", "$value": "320ms" },
|
||||||
"heroCopyOut": { "$type": "duration", "$value": "120ms" },
|
"heroCopyOut": { "$type": "duration", "$value": "120ms" },
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
||||||
993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py
|
993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py
|
||||||
72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json
|
72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json
|
||||||
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
|
9562bd6e6d8fb4d6ef380380e621d6c32401387560437eb4f999ebecbcda160e shell-tokens.json
|
||||||
42399062a6a22566e26adb71eaa4052b28be93d28d0eb46d9d4b87f3142a2944 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
68a94b81bb0300aa05d241f11d88ff4843caa0b1b16e0ed09f62674b336e69fe src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json
|
98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json
|
||||||
731ff25d6ec3ba12d0840523a1ebd65a81b3474cf74040f13bbd1f54985a3053 tokens.css
|
e93d868d6fdf9ad939f1fd18c452732fb3afa76bbba8fe6fdd0c1007aeff3b01 tokens.css
|
||||||
12d33b7b5740443e068cbb06528d88c42ad3bb1df0d4d45dd6ee6797d3dfc401 tokens.json
|
e729efd2efd6bc52a35480038d64ff0199ce166e6e794332e9cfa7dc84ed98c9 tokens.json
|
||||||
19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json
|
19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue