diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 697ac9b6..2da11b20 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -479,8 +479,6 @@ dependencies { testImplementation("com.squareup.okhttp3:mockwebserver3") testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) - testImplementation(libs.androidx.media3.test.utils) - testImplementation(libs.androidx.media3.test.utils.robolectric) testImplementation(libs.hilt.android.testing) testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0") @@ -493,10 +491,6 @@ dependencies { tasks.withType().configureEach { dependsOn(":wolfskin-design:testDebugUnitTest") - // Android 16 shared-memory setup needs Robolectric's JDK FileDescriptor bridge. - jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED") - // The full suite keeps every Robolectric capture in one worker; the 512 MB default runs out near the end. - maxHeapSize = "2g" systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath) systemProperty("robolectric.offline", "true") systemProperty("roborazzi.test.record", "true") diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt index f3212c23..09dc6745 100644 --- a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -53,11 +53,9 @@ class InstrumentedBasicUiTests { MainContent( backStack = mutableListOf(SetupDestination.ServerList), navigationManager = navigationManager, - userPreferences = mockk(relaxed = true), + appPreferences = mockk(relaxed = true), backdropService = mockk(relaxed = true), screensaverService = screensaverService, - liveSession = MutableStateFlow(null), - trailerReturn = mockk(relaxed = true), modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index dadf10c7..6b3403c6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin +import android.content.Context import android.content.Intent import android.content.res.Configuration import android.os.Bundle @@ -8,6 +9,7 @@ import android.view.WindowManager import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -22,16 +24,23 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewModelScope import androidx.navigation3.runtime.NavBackStack import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService +import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.IntentResult import com.github.damontecres.wholphin.services.IntentService import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService import com.github.damontecres.wholphin.services.NavigationManager @@ -56,6 +65,7 @@ import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree @@ -63,17 +73,20 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.requestSerializersModule import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import timber.log.Timber -import org.justinvisser.wolfskin.tv.TrailerReturnState import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -142,9 +155,6 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var intentService: IntentService - - @Inject - lateinit var trailerReturn: TrailerReturnState private var signInAuto = true private var playerBackend: PlayerBackend? = null @@ -158,7 +168,6 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) instance = this - if (savedInstanceState == null) trailerReturn.clear() Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) @@ -223,7 +232,7 @@ class MainActivity : AppCompatActivity() { playerBackend = prefs.playbackPreferences.playerBackend } - viewModel.appStart(intent, resuming = savedInstanceState != null, savedState = savedInstanceState) + viewModel.appStart(intent) setContent { MaterialTheme { Surface(Modifier.fillMaxSize()) { @@ -273,8 +282,6 @@ class MainActivity : AppCompatActivity() { userPreferences = userPreferences, backdropService = backdropService, screensaverService = screensaverService, - liveSession = viewModel.serverRepository.current, - trailerReturn = trailerReturn, modifier = Modifier.fillMaxSize(), ) } @@ -301,7 +308,6 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() Timber.d("onResume") - trailerReturn.finishHandoff() viewModel.appResume() lifecycleScope.launchDefault { screensaverService.pulse() @@ -311,7 +317,7 @@ class MainActivity : AppCompatActivity() { override fun onRestart() { super.onRestart() Timber.d("onRestart") - viewModel.appStart(null, resuming = true) + viewModel.appStart(null) if (!playExternalViewModel.launched.value) { // If restarting during playback that is not external, go back a page val lastDest = navigationManager.backStack.lastOrNull() @@ -367,7 +373,6 @@ class MainActivity : AppCompatActivity() { Timber.w(ex, "Could not persist back stack; skipping") } outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER) - trailerReturn.save(outState, viewModel.serverRepository.current.value, navigationManager.backStack.lastOrNull()) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -418,6 +423,179 @@ class MainActivity : AppCompatActivity() { } } +@HiltViewModel +class MainActivityViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val preferences: DataStore, + 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 { + try { + Timber.v("Updating userDto") + serverRepository.updateUserDto() + } catch (ex: Exception) { + Timber.w(ex, "Error during appResume") + } + } + } + } + + /** + * 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") + } + } + } + } + } + } private val Destination?.isPlayback: Boolean get() = diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt deleted file mode 100644 index ba2c44a6..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt +++ /dev/null @@ -1,263 +0,0 @@ -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, - 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) - } - } - } - } - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index 9f2183a5..fcaa3165 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -1,9 +1,6 @@ package com.github.damontecres.wholphin import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -14,13 +11,17 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator @@ -28,22 +29,17 @@ import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.AppScreensaver import org.justinvisser.wolfskin.tv.WolfskinApp -import org.justinvisser.wolfskin.tv.WolfskinSetupContent import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.util.InterfaceCustomization import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization -import kotlinx.coroutines.flow.StateFlow -import org.justinvisser.wolfskin.tv.TrailerReturnState -/** Setup entries carry display snapshots; lifecycle privacy checks use the live repository identity. */ @Composable fun MainContent( backStack: MutableList, @@ -51,10 +47,7 @@ fun MainContent( userPreferences: UserPreferences, backdropService: BackdropService, screensaverService: ScreensaverService, - liveSession: StateFlow, - trailerReturn: TrailerReturnState, modifier: Modifier = Modifier, - setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) }, ) { val preferences by rememberUpdatedState(userPreferences) Surface( @@ -71,9 +64,6 @@ fun MainContent( NavDisplay( backStack = backStack, onBack = { backStack.removeLastOrNull() }, - transitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) }, - popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) }, - predictivePopTransitionSpec = { _ -> (EnterTransition.None togetherWith ExitTransition.None).using(null) }, entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), @@ -82,7 +72,6 @@ fun MainContent( entryProvider = { key -> key as SetupDestination NavEntry(key) { - if (setupInterceptor(key, Modifier.fillMaxSize())) return@NavEntry when (key) { SetupDestination.Loading -> { Box( @@ -112,8 +101,16 @@ fun MainContent( backdropService.clearBackdrop() } val current = key.current - SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically, - trailerReturn, { navigationManager.backStack.lastOrNull() }) { + var showContent by remember { + mutableStateOf(true) + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + if (!userPreferences.appPreferences.signInAutomatically) { + showContent = false + } + } + + if (showContent) { WolfskinApp( user = current.user, server = current.server, @@ -121,6 +118,16 @@ fun MainContent( preferences = preferences, modifier = Modifier.fillMaxSize(), ) + } else { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt b/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt deleted file mode 100644 index 781ac28a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt +++ /dev/null @@ -1,63 +0,0 @@ -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, - 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(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)) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 91a939dd..0930129e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -10,8 +10,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -21,8 +19,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.jellyfin.sdk.Jellyfin @@ -53,7 +49,6 @@ class ServerRepository @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val mostRecentServerProvider: MostRecentServerProvider, ) { - private val userChangeMutex = Mutex() private var _current = MutableStateFlow(null) val current: StateFlow = _current @@ -103,68 +98,34 @@ class ServerRepository user: JellyfinUser, ): CurrentUser = withContext(ioDispatcher) { - userChangeMutex.withLock { - if (server.id != user.serverId) { - throw IllegalStateException("User is not part of the server") - } - Timber.v("Changing user to ${user.name} on ${server.url}") - val previous = _current.value - apiClient.update(baseUrl = server.url, accessToken = user.accessToken) - try { - val (updatedServer, userDto) = readSession(server) - var updatedUser = - user.copy( - id = userDto.id, - name = userDto.name, - ) - serverDao.addOrUpdateServer(updatedServer) - updatedUser = serverDao.addOrUpdateUser(updatedUser) - - val currentUser = CurrentUser(updatedServer, updatedUser) - currentCoroutineContext().ensureActive() - mostRecentServerProvider.save(currentUser) - currentCoroutineContext().ensureActive() - _current.value = currentUser - _currentUserDto.value = ServerUserConfig(userDto) - currentUser - } catch (exception: Exception) { - // A failed or cancelled selection must not lend its token to the still-signed-in person. - apiClient.update(baseUrl = previous?.server?.url, accessToken = previous?.user?.accessToken) - throw exception - } + if (server.id != user.serverId) { + throw IllegalStateException("User is not part of the server") } + Timber.v("Changing user to ${user.name} on ${server.url}") + apiClient.update(baseUrl = server.url, accessToken = user.accessToken) + val (updatedServer, userDto) = readSession(server) + var updatedUser = + user.copy( + id = userDto.id, + name = userDto.name, + ) + serverDao.addOrUpdateServer(updatedServer) + updatedUser = serverDao.addOrUpdateUser(updatedUser) + + val currentUser = CurrentUser(updatedServer, updatedUser) + mostRecentServerProvider.save(currentUser) + _current.value = currentUser + _currentUserDto.value = ServerUserConfig(userDto) + return@withContext currentUser } /** Restore only an unprotected saved login; the launch picker must not wait for the network. */ - 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) { + suspend fun restoreSavedSession(): RestoredSession = withContext(ioDispatcher) { val recent = mostRecentServerProvider.get() - if (expected != null && !matchesSavedIdentity(recent, expected)) return@withContext getMostRecentServerInternal(recent) if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent) val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None val user = saved.users.firstOrNull { it.id == recent.userId } - if (user == null || user.isProtected || (expected != null && user.accessToken.isNullOrBlank())) { - return@withContext RestoredSession.ServerOnly(saved.server) - } - currentCoroutineContext().ensureActive() - if (isStillAuthorized?.invoke() == false) return@withContext RestoredSession.ServerOnly(saved.server) + if (user == null || user.isProtected) return@withContext RestoredSession.ServerOnly(saved.server) apiClient.update(baseUrl = saved.server.url, accessToken = user.accessToken) val current = CurrentUser(saved.server, user) _currentUserDto.value = null @@ -423,6 +384,3 @@ data class CurrentUser( val server: JellyfinServer, val user: JellyfinUser, ) - -/** A previously authenticated server/person pair, without credentials or protection state. */ -data class SavedSessionIdentity(val serverId: UUID, val userId: UUID) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 9a6fba35..39b82bc8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -29,7 +29,7 @@ sealed interface HomeRowConfig { fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig /** - * Legacy serialized resume row, migrated to ContinueWatchingCombined when settings are read. + * Continue watching media that the user has started but not finished */ @Serializable @SerialName("ContinueWatching") @@ -40,7 +40,7 @@ sealed interface HomeRowConfig { } /** - * Legacy serialized next-up row, migrated to ContinueWatchingCombined when settings are read. + * Next up row for next episodes in a series the user has started */ @Serializable @SerialName("NextUp") diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeWatchRows.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeWatchRows.kt deleted file mode 100644 index 3e2fdb5f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeWatchRows.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.github.damontecres.wholphin.data.model - -/** Saved split rows become one watch row at their first position, retaining its presentation choices. */ -fun mergeHomeWatchRows(rows: List): List = buildList { - var watchRowSeen = false - for (row in rows) { - when (row) { - is HomeRowConfig.ContinueWatching, is HomeRowConfig.NextUp, is HomeRowConfig.ContinueWatchingCombined -> { - if (!watchRowSeen) add(HomeRowConfig.ContinueWatchingCombined(row.viewOptions)) - watchRowSeen = true - } - else -> add(row) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt index b44dc024..ceb84695 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt @@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.ui.collectLatestIn +import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.WholphinDispatchers import com.google.common.cache.CacheBuilder import dagger.hilt.android.qualifiers.ActivityContext @@ -20,15 +21,13 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetEpisodesRequest +import timber.log.Timber import java.time.LocalDateTime import java.util.UUID import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton -import timber.log.Timber /** * Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row. @@ -47,21 +46,50 @@ class DatePlayedService .expireAfterWrite(2, TimeUnit.HOURS) .build>() - private fun readLastPlayed(seriesId: UUID): Deferred = - scope.async(WholphinDispatchers.IO) { + private fun getLastPlayed( + seriesId: UUID, + item: BaseItem, + ): Deferred { + val request = + GetEpisodesRequest( + seriesId = seriesId, + adjacentTo = item.id, + limit = 1, + ) + return scope.async(WholphinDispatchers.IO) { + val premiereDate = item.data.premiereDate try { - api.itemsApi.getItems(parentId = seriesId, recursive = true, - includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true, - sortBy = listOf(ItemSortBy.DATE_PLAYED), sortOrder = listOf(SortOrder.DESCENDING), limit = 1) - .content.items.firstOrNull()?.userData?.lastPlayedDate ?: LocalDateTime.MIN + val result = + GetEpisodesRequestHandler + .execute( + api, + request, + ).content.items + .firstOrNull() + ?.userData + ?.lastPlayedDate ?: LocalDateTime.MIN + if (premiereDate != null && result.isBefore(premiereDate)) { + premiereDate + } else { + result + } } catch (ex: InvalidStatusException) { - Timber.w(ex, "Error fetching series=%s", seriesId) - LocalDateTime.MIN + Timber.w( + "Error fetching series=%s, item=%s: %s", + seriesId, + item.id, + ex.localizedMessage, + ) + LocalDateTime.MIN.coerceAtLeast(premiereDate ?: LocalDateTime.MIN) } } + } /** - * Get the series' latest actual watch activity, never an upcoming episode's premiere date. + * Get the logical timestamp when the item was last played. + * + * This is calculated using lastest of the actual last played timestamp of the item, + * the previous episode's last played timestamp, and the episode's premiere date */ suspend fun getLastPlayed(item: BaseItem): LocalDateTime? = withContext(WholphinDispatchers.IO) { @@ -69,7 +97,7 @@ class DatePlayedService return@withContext if (seriesId != null) { datePlayedCache .get(SeriesItemId(seriesId, item.id)) { - readLastPlayed(seriesId) + getLastPlayed(seriesId, item) }.await() } else { null diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt index c529b5cb..fe462b98 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt @@ -92,8 +92,44 @@ class HomeDataService usePaging: Boolean = false, ): HomeRowLoadingState = when (row) { - is HomeRowConfig.ContinueWatching, - is HomeRowConfig.NextUp, + is HomeRowConfig.ContinueWatching -> { + val resume = + latestNextUpService.getResume( + userDto.id, + limit, + true, + row.viewOptions.useSeries, + ) + + Success( + title = ResStringProvider(R.string.continue_watching), + items = resume, + viewOptions = row.viewOptions, + rowType = row, + showViewMore = resume.size >= limit, + ) + } + + is HomeRowConfig.NextUp -> { + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + prefs.maxDaysNextUp, + row.viewOptions.useSeries, + ) + + Success( + title = ResStringProvider(R.string.next_up), + items = nextUp, + viewOptions = row.viewOptions, + rowType = row, + showViewMore = nextUp.size >= limit, + ) + } + is HomeRowConfig.ContinueWatchingCombined -> { val resume = latestNextUpService.getResume( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index f0f6c00e..1c05640e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -11,7 +11,6 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION -import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.Cards @@ -160,7 +159,7 @@ class HomeSettingsService null } }.orEmpty() - return HomePageSettings(mergeHomeWatchRows(rows), version) + return HomePageSettings(rows, version) } private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? = @@ -192,7 +191,7 @@ class HomeSettingsService ) return settings?.let { val resolvedRows = - mergeHomeWatchRows(settings.rows).mapIndexed { index, config -> + settings.rows.mapIndexed { index, config -> resolve(index, config) } HomePageResolvedSettings(userId, resolvedSource, resolvedRows) @@ -262,7 +261,7 @@ class HomeSettingsService settings: HomePageSettings, ) { val resolvedRows = - mergeHomeWatchRows(settings.rows).mapIndexed { index, config -> + settings.rows.mapIndexed { index, config -> resolve(index, config) } currentSettings.update { it.copy(rows = resolvedRows) } @@ -314,7 +313,7 @@ class HomeSettingsService listOf( HomeRowConfigDisplay( id = includedIds.size + 1, - title = ResStringProvider(R.string.continue_watching), + title = ResStringProvider(R.string.combine_continue_next), config = HomeRowConfig.ContinueWatchingCombined(), ), ) @@ -435,8 +434,7 @@ class HomeSettingsService null } }.flatten() - val merged = mergeHomeWatchRows(rowConfigs.map { it.config }).mapIndexed { index, config -> resolve(index, config) } - HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, merged) + HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs) } else { null } @@ -459,13 +457,19 @@ class HomeSettingsService ) } - is HomeRowConfig.ContinueWatching, - is HomeRowConfig.NextUp, - is HomeRowConfig.ContinueWatchingCombined -> { + is HomeRowConfig.ContinueWatching -> { HomeRowConfigDisplay( id, ResStringProvider(R.string.continue_watching), - HomeRowConfig.ContinueWatchingCombined(config.viewOptions), + config, + ) + } + + is HomeRowConfig.ContinueWatchingCombined -> { + HomeRowConfigDisplay( + id, + ResStringProvider(R.string.combine_continue_next), + config, ) } @@ -491,6 +495,14 @@ class HomeSettingsService HomeRowConfigDisplay(id, StringStringProvider(config.name), config) } + is HomeRowConfig.NextUp -> { + HomeRowConfigDisplay( + id, + ResStringProvider(R.string.next_up), + config, + ) + } + is HomeRowConfig.RecentlyAdded -> { val title = getItemName(R.string.recently_added_in, config.parentId) HomeRowConfigDisplay( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index f58bacaa..dbaa219e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -158,7 +158,7 @@ class LatestNextUpService val timestamps = mutableMapOf() nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } - val result = (resume + nextUp).distinctBy { it.data.seriesId ?: it.id }.sortedByDescending { timestamps[it.id] } + val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } val duration = (System.currentTimeMillis() - start).milliseconds Timber.v("buildCombined took %s", duration) return@withContext result diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index ef211d7e..87095f8b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -2,8 +2,6 @@ package com.github.damontecres.wholphin.services import androidx.navigation3.runtime.NavBackStack import com.github.damontecres.wholphin.ui.nav.Destination -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.receiveAsFlow import org.acra.ACRA import timber.log.Timber import javax.inject.Inject @@ -17,11 +15,6 @@ class NavigationManager @Inject constructor() { var backStack: MutableList = NavBackStack(Destination.Home()) - private val exits = Channel(Channel.CONFLATED) - val exitRequests = exits.receiveAsFlow() - - /** Finish the containing task; never expose an older setup/user entry. */ - fun requestExit() { exits.trySend(Unit) } /** * Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination] @@ -48,14 +41,12 @@ class NavigationManager } /** - * Go to the previous page, or exit when there is no parent. + * Go to the previous page */ fun goBack() { synchronized(this) { if (backStack.size > 1) { backStack.removeLastOrNull() - } else { - requestExit() } } log() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index 4ccdce61..a2fd0eb7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -54,7 +54,6 @@ import timber.log.Timber import java.lang.reflect.Constructor import javax.inject.Inject import javax.inject.Singleton -import kotlin.time.Duration /** * Constructs a [Player] instance for video playback @@ -175,7 +174,7 @@ class PlayerFactory ExoPlayer .Builder(context) .setMediaSourceFactory(mediaSourceFactory) - .setRenderersFactory(SubtitleDelayRenderersFactory(renderersFactory)) + .setRenderersFactory(renderersFactory) .setTrackSelector(trackSelector) .build() .apply { @@ -304,29 +303,6 @@ val Player.isReleased: Boolean } } -/** - * Delays the subtitles of this player by [delay]; a positive delay shows them later - */ -fun Player.applySubtitleDelay(delay: Duration) { - when (this) { - is MpvPlayer -> { - subtitleDelay = delay - } - - is ExoPlayer -> { - for (index in 0 until rendererCount) { - val renderer = getRenderer(index) - if (renderer is SubtitleDelayRenderer) { - createMessage(renderer) - .setType(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY) - .setPayload(delay.inWholeMicroseconds) - .send() - } - } - } - } -} - data class PlayerCreation( val player: Player, val assHandler: AssHandler? = null, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SubtitleDelayRenderer.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SubtitleDelayRenderer.kt deleted file mode 100644 index 88feaae1..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SubtitleDelayRenderer.kt +++ /dev/null @@ -1,216 +0,0 @@ -@file:OptIn(markerClass = [UnstableApi::class]) - -package com.github.damontecres.wholphin.services - -import android.os.Handler -import androidx.annotation.OptIn -import androidx.media3.common.C -import androidx.media3.common.Format -import androidx.media3.common.Timeline -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ForwardingRenderer -import androidx.media3.exoplayer.Renderer -import androidx.media3.exoplayer.RendererConfiguration -import androidx.media3.exoplayer.RenderersFactory -import androidx.media3.exoplayer.audio.AudioRendererEventListener -import androidx.media3.exoplayer.metadata.MetadataOutput -import androidx.media3.exoplayer.source.MediaSource -import androidx.media3.exoplayer.source.SampleStream -import androidx.media3.exoplayer.text.TextOutput -import androidx.media3.exoplayer.video.VideoRendererEventListener -import io.github.peerless2012.ass.media.render.AssRenderer - -/** - * Delays the subtitles of the wrapped renderer by shifting the playback position it is given. - * - * A positive delay shows subtitles later, a negative delay earlier, the same as mpv's `sub-delay`. - * The delay is set on the playback thread with a [MSG_SET_SUBTITLE_DELAY] message whose payload is - * the delay in microseconds as a [Long]. - * - * Wrapping hides the renderer's type from ExoPlayer's `instanceof TextRenderer` checks, which only - * matter for clipped streams and server-side inserted ads. - */ -class SubtitleDelayRenderer( - internal val delegate: Renderer, -) : ForwardingRenderer(delegate) { - private val period = Timeline.Period() - private var delayUs = 0L - private var timeline: Timeline = Timeline.EMPTY - private var mediaPeriodId: MediaSource.MediaPeriodId? = null - private var streamOffsetUs = 0L - private var periodEndPositionUs = C.TIME_UNSET - private var lastPositionUs = C.TIME_UNSET - - override fun handleMessage( - messageType: Int, - message: Any?, - ) { - if (messageType == MSG_SET_SUBTITLE_DELAY) { - delayUs = message as Long - } else { - super.handleMessage(messageType, message) - } - } - - override fun enable( - configuration: RendererConfiguration, - formats: Array, - stream: SampleStream, - positionUs: Long, - joining: Boolean, - mayRenderStartOfStream: Boolean, - startPositionUs: Long, - offsetUs: Long, - mediaPeriodId: MediaSource.MediaPeriodId, - ) { - lastPositionUs = positionUs - updateStream(offsetUs, mediaPeriodId) - super.enable( - configuration, - formats, - stream, - positionUs - delayUs, - joining, - mayRenderStartOfStream, - startPositionUs, - offsetUs, - mediaPeriodId, - ) - } - - override fun replaceStream( - formats: Array, - stream: SampleStream, - startPositionUs: Long, - offsetUs: Long, - mediaPeriodId: MediaSource.MediaPeriodId, - ) { - updateStream(offsetUs, mediaPeriodId) - super.replaceStream(formats, stream, startPositionUs, offsetUs, mediaPeriodId) - } - - override fun setTimeline(timeline: Timeline) { - this.timeline = timeline - updatePeriodEndPosition() - super.setTimeline(timeline) - } - - override fun render( - positionUs: Long, - elapsedRealtimeUs: Long, - ) { - lastPositionUs = positionUs - super.render(positionUs - delayUs, elapsedRealtimeUs) - } - - override fun resetPosition( - positionUs: Long, - sampleStreamIsResetToKeyFrame: Boolean, - ) { - lastPositionUs = positionUs - super.resetPosition(positionUs - delayUs, sampleStreamIsResetToKeyFrame) - } - - override fun supportsResetPositionWithoutKeyFrameReset(positionUs: Long): Boolean = - super.supportsResetPositionWithoutKeyFrameReset(positionUs - delayUs) - - override fun getDurationToProgressUs( - positionUs: Long, - elapsedRealtimeUs: Long, - ): Long = super.getDurationToProgressUs(positionUs - delayUs, elapsedRealtimeUs) - - /** - * A text renderer only reads the end of its stream once its position is within a second of the - * period end, so a positive delay above a second would hold back - * [androidx.media3.common.Player.STATE_ENDED] by the delay minus that second. Once the real - * position passes the period end there is nothing left to show, so the renderer counts as ended. - */ - override fun isEnded(): Boolean = - super.isEnded() || - ( - delayUs > 0 && - periodEndPositionUs != C.TIME_UNSET && - lastPositionUs >= periodEndPositionUs - ) - - override fun disable() { - mediaPeriodId = null - periodEndPositionUs = C.TIME_UNSET - super.disable() - } - - private fun updateStream( - offsetUs: Long, - mediaPeriodId: MediaSource.MediaPeriodId, - ) { - streamOffsetUs = offsetUs - this.mediaPeriodId = mediaPeriodId - updatePeriodEndPosition() - } - - private fun updatePeriodEndPosition() { - val periodIndex = mediaPeriodId?.let { timeline.getIndexOfPeriod(it.periodUid) } ?: C.INDEX_UNSET - val durationUs = - if (periodIndex == C.INDEX_UNSET) { - C.TIME_UNSET - } else { - timeline.getPeriod(periodIndex, period).durationUs - } - periodEndPositionUs = if (durationUs == C.TIME_UNSET) C.TIME_UNSET else streamOffsetUs + durationUs - } - - companion object { - /** Sets the subtitle delay; the payload is the delay in microseconds as a [Long]. */ - const val MSG_SET_SUBTITLE_DELAY = Renderer.MSG_CUSTOM_BASE - } -} - -/** - * Wraps the subtitle renderers of [delegate] in a [SubtitleDelayRenderer]: media3's text renderer - * and the libass [AssRenderer], which draws at the position it is rendered with. - */ -class SubtitleDelayRenderersFactory( - private val delegate: RenderersFactory, -) : RenderersFactory { - override fun createRenderers( - eventHandler: Handler, - videoRendererEventListener: VideoRendererEventListener, - audioRendererEventListener: AudioRendererEventListener, - textRendererOutput: TextOutput, - metadataRendererOutput: MetadataOutput, - ): Array { - val renderers = - delegate.createRenderers( - eventHandler, - videoRendererEventListener, - audioRendererEventListener, - textRendererOutput, - metadataRendererOutput, - ) - return Array(renderers.size) { index -> - val renderer = renderers[index] - if (renderer.trackType == C.TRACK_TYPE_TEXT || renderer is AssRenderer) { - SubtitleDelayRenderer(renderer) - } else { - renderer - } - } - } - - override fun createSecondaryRenderer( - renderer: Renderer, - eventHandler: Handler, - videoRendererEventListener: VideoRendererEventListener, - audioRendererEventListener: AudioRendererEventListener, - textRendererOutput: TextOutput, - metadataRendererOutput: MetadataOutput, - ): Renderer? = - delegate.createSecondaryRenderer( - (renderer as? SubtitleDelayRenderer)?.delegate ?: renderer, - eventHandler, - videoRendererEventListener, - audioRendererEventListener, - textRendererOutput, - metadataRendererOutput, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt new file mode 100644 index 00000000..2a44c8f4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.ui.main.settings + +sealed interface HomeRowConfigAction { + data object Combine : HomeRowConfigAction + + data object Split : HomeRowConfigAction +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt index dcd46216..48901b0f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt @@ -60,6 +60,7 @@ fun HomeRowSettings( viewOptions: HomeRowViewOptions, onViewOptionsChange: (HomeRowViewOptions) -> Unit, onConfigChange: (HomeRowConfig) -> Unit, + onConfigAction: (HomeRowConfigAction) -> Unit, onApplyApplyAll: () -> Unit, modifier: Modifier = Modifier, defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(), @@ -125,6 +126,7 @@ fun HomeRowSettings( addAdditionalConfig( config = config, onConfigChange = onConfigChange, + onConfigAction = onConfigAction, ) } } @@ -133,8 +135,29 @@ fun HomeRowSettings( private fun LazyListScope.addAdditionalConfig( config: HomeRowConfig, onConfigChange: (HomeRowConfig) -> Unit, + onConfigAction: (HomeRowConfigAction) -> Unit, ) { when (config) { + is HomeRowConfig.ContinueWatchingCombined -> { + item { + ClickPreference( + title = stringResource(R.string.split_into_separate_rows), + onClick = { onConfigAction.invoke(HomeRowConfigAction.Split) }, + ) + } + } + + is HomeRowConfig.ContinueWatching, + is HomeRowConfig.NextUp, + -> { + item { + ClickPreference( + title = stringResource(R.string.combine_continue_next), + onClick = { onConfigAction.invoke(HomeRowConfigAction.Combine) }, + ) + } + } + is HomeRowConfig.ByParent -> { item { val sortAndDirection = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt index 1055455b..439b2505 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt @@ -35,6 +35,8 @@ fun HomeSettingsAddRow( ) { itemsIndexed( listOf( + MetaRowType.CONTINUE_WATCHING, + MetaRowType.NEXT_UP, MetaRowType.COMBINED_CONTINUE_WATCHING, ), ) { index, type -> @@ -92,7 +94,9 @@ fun HomeSettingsAddRow( enum class MetaRowType( @param:StringRes val stringId: Int, ) { - COMBINED_CONTINUE_WATCHING(R.string.continue_watching), + CONTINUE_WATCHING(R.string.continue_watching), + NEXT_UP(R.string.next_up), + COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next), FAVORITES(R.string.favorites), DISCOVER(R.string.discover), COLLECTION(R.string.collection), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 01ef4cb1..0bd6b7db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -157,6 +157,8 @@ fun HomeSettingsPage( onClick = { backStack.add(ChooseRowType(it)) }, onClickMeta = { when (it) { + MetaRowType.CONTINUE_WATCHING, + MetaRowType.NEXT_UP, MetaRowType.COMBINED_CONTINUE_WATCHING, -> { addRow { viewModel.addRow(it) } @@ -244,6 +246,18 @@ fun HomeSettingsPage( onConfigChange = { viewModel.onConfigChange(row, it) }, + onConfigAction = { + viewModel.onConfigAction(row, it) + when (it) { + HomeRowConfigAction.Combine, + HomeRowConfigAction.Split, + -> { + backStack.removeAt( + backStack.lastIndex, + ) + } + } + }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 4c74fc44..1082a23f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -22,7 +22,6 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvChannels import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION -import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.HomeDataService @@ -250,10 +249,26 @@ class HomeSettingsViewModel val id = idCounter++ val newRow = when (type) { - MetaRowType.COMBINED_CONTINUE_WATCHING -> { + MetaRowType.CONTINUE_WATCHING -> { HomeRowConfigDisplay( id = id, title = ResStringProvider(R.string.continue_watching), + config = ContinueWatching(), + ) + } + + MetaRowType.NEXT_UP -> { + HomeRowConfigDisplay( + id = id, + title = ResStringProvider(R.string.next_up), + config = NextUp(), + ) + } + + MetaRowType.COMBINED_CONTINUE_WATCHING -> { + HomeRowConfigDisplay( + id = id, + title = ResStringProvider(R.string.combine_continue_next), config = ContinueWatchingCombined(), ) } @@ -798,7 +813,7 @@ class HomeSettingsViewModel if (result != null) { Timber.v("Got remote settings") val newRows = - mergeHomeWatchRows(result.rows).mapIndexed { index, config -> + result.rows.mapIndexed { index, config -> homeSettingsService.resolve(index, config) } idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 @@ -824,6 +839,76 @@ class HomeSettingsViewModel } } + fun onConfigAction( + row: HomeRowConfigDisplay, + action: HomeRowConfigAction, + ) { + viewModelScope.launchDefault { + when (action) { + HomeRowConfigAction.Combine -> { + val index = + state.value.rows + .indexOf(row) + .coerceAtLeast(0) + val rowsToRemove = + state.value.rows + .filter { it.config is ContinueWatching || it.config is NextUp } + + updateState { + it.copy( + loading = LoadingState.Loading, + rows = + it.rows.toMutableList().apply { + set( + index, + HomeRowConfigDisplay( + id = it.rows[index].id, + title = ResStringProvider(R.string.combine_continue_next), + config = ContinueWatchingCombined(row.config.viewOptions), + ), + ) + removeAll(rowsToRemove) + }, + ) + } + fetchRowData() + } + + HomeRowConfigAction.Split -> { + val index = + state.value.rows + .indexOf(row) + .coerceAtLeast(0) + updateState { + it.copy( + loading = LoadingState.Loading, + rows = + it.rows.toMutableList().apply { + set( + index, + HomeRowConfigDisplay( + id = it.rows[index].id, + title = ResStringProvider(R.string.continue_watching), + config = ContinueWatching(row.config.viewOptions), + ), + ) + add( + index + 1, + HomeRowConfigDisplay( + id = idCounter++, + title = ResStringProvider(R.string.next_up), + config = NextUp(row.config.viewOptions), + ), + ) + }, + ) + } + fetchRowData() + } + } + } + } + fun onConfigChange( row: HomeRowConfigDisplay, config: HomeRowConfig, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index d0cdf6d6..86d64651 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -60,9 +60,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.core.view.children import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect -import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.SubtitleView import androidx.media3.ui.compose.PlayerSurface import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW @@ -77,7 +75,6 @@ import com.github.damontecres.wholphin.preferences.DpadSeekMode import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume -import com.github.damontecres.wholphin.services.applySubtitleDelay import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService @@ -102,8 +99,6 @@ import com.github.damontecres.wholphin.util.Media3SubtitleOverride import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import kotlin.time.Duration @@ -190,8 +185,9 @@ fun PlaybackPageContent( var playbackSpeed by remember { mutableFloatStateOf(1.0f) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } - LaunchedEffect(player, state.currentPlayback?.subtitleDelay) { - player.applySubtitleDelay(state.currentPlayback?.subtitleDelay ?: Duration.ZERO) + LaunchedEffect(state.currentPlayback?.subtitleDelay) { + (player as? MpvPlayer)?.subtitleDelay = + state.currentPlayback?.subtitleDelay ?: Duration.ZERO } val presentationState = rememberPresentationState(player, false) @@ -788,34 +784,8 @@ fun PlaybackPageContent( }, onPlaybackActionClick = onPlaybackActionClick, onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) }, - enableSubtitleDelay = subtitleDelaySupported(player, state.currentPlayback), + enableSubtitleDelay = player is MpvPlayer, enableVideoScale = player !is MpvPlayer, ) } } - -/** - * Whether the subtitle delay can be changed: always with MPV, and with ExoPlayer unless the server - * burns the selected subtitles into the video - */ -fun subtitleDelaySupported( - player: Player, - currentPlayback: CurrentPlayback?, -): Boolean = - when (player) { - is MpvPlayer -> { - true - } - - is ExoPlayer -> { - val subtitleStream = - currentPlayback?.mediaSourceInfo?.mediaStreams?.firstOrNull { - it.type == MediaStreamType.SUBTITLE && it.index == currentPlayback.subtitleIndex - } - subtitleStream?.deliveryMethod != SubtitleDeliveryMethod.ENCODE - } - - else -> { - false - } - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index c5228f07..9cbbe551 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -1669,9 +1669,6 @@ class PlaybackViewModel } } - /** - * Load the enabled subtitle track's saved delay, or zero if no delay is saved. - */ suspend fun loadSubtitleDelay() { state.value.currentPlayback?.let { if (it.subtitleIndexEnabled) { @@ -1684,9 +1681,7 @@ class PlaybackViewModel it.subtitleIndex, it.item.id, ) - } - updateCurrentPlayback { - it?.copy(subtitleDelay = result?.delayMs?.milliseconds ?: Duration.ZERO) + updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) } } } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt b/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt deleted file mode 100644 index f9e853c8..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt +++ /dev/null @@ -1,199 +0,0 @@ -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() - private val setup = mockk(relaxed = true) - private val navigation = NavigationManager() - private val preferences = mockk>() - 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().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(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)) } - } - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/MainActivityTrailerRestoreTest.kt b/app/src/test/java/com/github/damontecres/wholphin/MainActivityTrailerRestoreTest.kt deleted file mode 100644 index fcf8d8e4..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/MainActivityTrailerRestoreTest.kt +++ /dev/null @@ -1,376 +0,0 @@ -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() - 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() - private val system = mockk() - private val api = mockk(relaxed = true) - private val recent = mockk() - private var recentValue: MostRecentServer = MostRecentServer.None - private val preferences = mockk>() - private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false }) - private var elapsed = 1_000L - private val clock = mockk().also { - every { it.readElapsedMillis() } answers { elapsed } - every { it.readBootCount() } returns 7 - } - private val restoredReturn = TrailerReturnState(clock) - private val navigation = NavigationManager() - private val setup = mockk(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(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(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() - val release = CompletableDeferred() - 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()) } - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt b/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt deleted file mode 100644 index 59e1849b..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt +++ /dev/null @@ -1,180 +0,0 @@ -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() - 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(server, user)) - private var elapsed = 1_000L - private var boot = 7 - private val clock = mockk().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() - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt index 09b9986a..d8fe5695 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt @@ -72,7 +72,6 @@ class TestMainActivityViewModel { backdropService, appUpgradeHandler, intentService, - trailerReturn = mockk(relaxed = true), ) private val serverId = UUID.randomUUID() @@ -89,7 +88,6 @@ class TestMainActivityViewModel { @Before fun setUp() { WholphinDispatchers.configure(testDispatcher) - every { setupNavigationManager.backStack } returns mutableListOf(SetupDestination.Loading) every { appUpgradeHandler.needUpgrade() } returns false every { appUpgradeHandler.copySubfont(any()) } returns Unit mockkStatic(Toast::class) @@ -169,22 +167,14 @@ class TestMainActivityViewModel { @Test fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) { setupPreferences { signInAutomatically = true } - val canonical = MutableStateFlow(null) - every { serverRepository.current } returns canonical - coEvery { serverRepository.restoreSavedSession() } coAnswers { - canonical.value = currentUser - RestoredSession.Success(currentUser) - } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser) val release = CompletableDeferred() - val validationStarted = CompletableDeferred() - coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { - validationStarted.complete(Unit) - release.await() - } + coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() } viewModel.handleAppStart() runCurrent() verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) } - Assert.assertTrue(validationStarted.isCompleted) + assertFalse(release.isCompleted) release.complete(Unit) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/HomeWatchRowsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/HomeWatchRowsTest.kt deleted file mode 100644 index 7b8c5655..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/HomeWatchRowsTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.github.damontecres.wholphin.services - -import com.github.damontecres.wholphin.data.model.HomeRowConfig -import com.github.damontecres.wholphin.data.model.HomeRowViewOptions -import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows -import org.junit.Assert.* -import org.junit.Test - -/** Legacy saved watch lanes retain the first lane's order and display options after merging. */ -class HomeWatchRowsTest { - @Test fun splitAndAlreadyCombinedRowsBecomeOneWithoutChangingOtherPresets() { - val options = HomeRowViewOptions(heightDp = 148) - val other = HomeRowConfig.GetItems(name = "Saved shelf", filter = com.github.damontecres.wholphin.data.model.HomeGetItemsFilter()) - for (watchRows in listOf( - listOf(HomeRowConfig.ContinueWatching(options), other, HomeRowConfig.NextUp()), - listOf(HomeRowConfig.NextUp(options), other, HomeRowConfig.ContinueWatching()), - listOf(HomeRowConfig.ContinueWatchingCombined(options), other, HomeRowConfig.NextUp()), - )) { - val result = mergeHomeWatchRows(watchRows) - assertEquals(listOf(HomeRowConfig.ContinueWatchingCombined(options), other), result) - assertEquals(result, mergeHomeWatchRows(result)) - } - } - - @Test fun settingsWithoutWatchRowsStayWithoutWatchRows() { - val rows = listOf(HomeRowConfig.GetItems(name = "Saved shelf", filter = com.github.damontecres.wholphin.data.model.HomeGetItemsFilter())) - assertEquals(rows, mergeHomeWatchRows(rows)) - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt index 1241a8cd..2724cc61 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt @@ -10,13 +10,8 @@ import com.github.damontecres.wholphin.ui.successResponse import io.mockk.* import java.util.UUID import kotlinx.coroutines.CompletableDeferred -import java.io.IOException -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.withContext import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -111,64 +106,6 @@ class ServerSavedSessionTest { assertNull(repository.currentUserDto) } - @Test fun failedPersonSwitchPreservesTheAuthenticatedTokenAndSavedIdentity() = runTest(dispatcher) { - verifyFailedSwitch(cancel = false) - } - - @Test fun cancelledPersonSwitchPreservesTheAuthenticatedTokenAndSavedIdentity() = runTest(dispatcher) { - verifyFailedSwitch(cancel = true) - } - - @Test fun cancelledOlderSwitchCannotReplaceTheNewerAuthenticatedToken() = runTest(dispatcher) { - var activeToken: String? = null - every { api.update(any(), any(), any(), any()) } answers { activeToken = secondArg() } - repository.restoreSavedSession() - val older = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Older selection", accessToken = "older-token")) - val newer = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Newer selection", accessToken = "newer-token")) - val entered = CompletableDeferred() - val release = CompletableDeferred() - coEvery { recent.save(any()) } just Runs - coEvery { users.getCurrentUser() } coAnswers { - if (activeToken == older.accessToken) { - entered.complete(Unit) - withContext(NonCancellable) { release.await() } - throw IOException("late failed selection") - } - successResponse(dto.copy(id = newer.id, name = newer.name)) - } - val pending = async { runCatching { repository.changeUser(server, older) } } - entered.await() - pending.cancel() - val selected = async { repository.changeUser(server, newer) } - runCurrent() - release.complete(Unit) - pending.join() - val current = selected.await() - assertEquals(newer.id, current.user.id) - assertEquals(current, repository.current.value) - assertEquals(newer.accessToken, activeToken) - } - - private suspend fun verifyFailedSwitch(cancel: Boolean) { - var activeToken: String? = null - every { api.update(any(), any(), any(), any()) } answers { activeToken = secondArg() } - val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser - val selected = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Other person", accessToken = "other-token")) - coEvery { users.getCurrentUser() } coAnswers { - if (cancel) awaitCancellation() else throw IOException("offline") - } - if (cancel) withTimeoutOrNull(1_000) { repository.changeUser(server, selected) } - else { - try { - repository.changeUser(server, selected) - fail("Offline selection must fail") - } catch (_: IOException) { } - } - assertEquals(first, repository.current.value) - assertEquals(user.accessToken, activeToken) - coVerify(exactly = 0) { recent.save(any()) } - } - @Test fun launchNameRefreshPreservesSettingsAndPinChangedWhileTheRequestWaits() = runTest(dispatcher) { val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser val entered = CompletableDeferred() diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayPlaybackTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayPlaybackTest.kt deleted file mode 100644 index 5a4a275f..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayPlaybackTest.kt +++ /dev/null @@ -1,186 +0,0 @@ -@file:OptIn(markerClass = [UnstableApi::class]) - -package com.github.damontecres.wholphin.services - -import android.content.Context -import android.net.Uri -import androidx.annotation.OptIn -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.MimeTypes -import androidx.media3.common.Player -import androidx.media3.common.text.CueGroup -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.test.utils.FakeClock -import androidx.media3.test.utils.robolectric.CapturingRenderersFactory -import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.play -import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.run -import androidx.test.core.app.ApplicationProvider -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.io.File -import java.nio.ByteBuffer -import java.nio.ByteOrder -import kotlin.math.abs -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -/** - * Plays a short audio file with a sideloaded SRT through a real [ExoPlayer] and checks when the - * subtitles appear for a given delay, and that playback still ends when the media does. - */ -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE, sdk = [30]) -class SubtitleDelayPlaybackTest { - @get:Rule val folder = TemporaryFolder() - - private val context = ApplicationProvider.getApplicationContext() - private val clock = FakeClock(true) - private val shownCues = mutableListOf>() - private val playbackStates = mutableListOf() - private lateinit var player: ExoPlayer - - @Before - fun setUp() { - player = - ExoPlayer - .Builder(context, SubtitleDelayRenderersFactory(CapturingRenderersFactory(context, clock))) - .setMediaSourceFactory(DefaultMediaSourceFactory(context)) - .setClock(clock) - .build() - player.addListener( - object : Player.Listener { - override fun onCues(cueGroup: CueGroup) { - cueGroup.cues.firstOrNull()?.let { shownCues.add(it.text.toString() to player.currentPosition) } - } - - override fun onPlaybackStateChanged(playbackState: Int) { - playbackStates.add(playbackState) - } - }, - ) - player.setMediaItem(mediaItem()) - player.prepare() - // Subtitles never hold up playback, so let the sideloaded file load before playing - run(player).untilState(Player.STATE_READY) - run(player).untilLoadingIs(false) - assertTrue(player.currentTracks.isTypeSelected(C.TRACK_TYPE_TEXT)) - } - - @After - fun tearDown() { - player.release() - } - - @Test - fun positiveDelayShowsSubtitlesLaterAndPlaybackEndsOnTime() { - player.applySubtitleDelay(2.seconds) - val playStartMs = clock.elapsedRealtime() - - play(player).untilState(Player.STATE_ENDED) - - assertShownAt("First" to 3.seconds) - val playedMs = clock.elapsedRealtime() - playStartMs - assertTrue( - "ended after $playedMs ms of playback, expected $DURATION", - abs(playedMs - DURATION.inWholeMilliseconds) <= TOLERANCE_MS, - ) - } - - @Test - fun negativeDelayShowsSubtitlesEarlier() { - player.applySubtitleDelay((-500).milliseconds) - - play(player).untilState(Player.STATE_ENDED) - - assertShownAt("First" to 500.milliseconds, "Last" to 2.seconds) - } - - @Test - fun changingTheDelayWhilePlayingRetimesWithoutRebuffering() { - play(player).untilPosition(0, 500) - player.applySubtitleDelay(1.seconds) - - play(player).untilState(Player.STATE_ENDED) - - assertShownAt("First" to 2.seconds, "Last" to 3_500.milliseconds) - assertEquals(listOf(Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED), playbackStates) - } - - private fun assertShownAt(vararg expected: Pair) { - assertEquals(expected.map { it.first }, shownCues.map { it.first }) - expected.zip(shownCues).forEach { (want, got) -> - assertTrue( - "${want.first} shown at ${got.second} ms, expected ${want.second}", - abs(got.second - want.second.inWholeMilliseconds) <= TOLERANCE_MS, - ) - } - } - - private fun mediaItem(): MediaItem { - val audio = folder.newFile("silence.wav").apply { writeBytes(silentWav()) } - val subtitles = folder.newFile("subtitles.srt").apply { writeText(SRT) } - return MediaItem - .Builder() - .setUri(Uri.fromFile(audio)) - .setSubtitleConfigurations( - listOf( - MediaItem.SubtitleConfiguration - .Builder(Uri.fromFile(subtitles)) - .setMimeType(MimeTypes.APPLICATION_SUBRIP) - .setLanguage("en") - .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) - .build(), - ), - ).build() - } - - /** A mono 16-bit PCM WAV file of silence lasting [DURATION] */ - private fun silentWav(): ByteArray { - val dataSize = (SAMPLE_RATE * DURATION.inWholeMilliseconds / 1000 * 2).toInt() - return ByteBuffer - .allocate(44 + dataSize) - .order(ByteOrder.LITTLE_ENDIAN) - .put("RIFF".toByteArray()) - .putInt(36 + dataSize) - .put("WAVEfmt ".toByteArray()) - .putInt(16) - .putShort(1) - .putShort(1) - .putInt(SAMPLE_RATE) - .putInt(SAMPLE_RATE * 2) - .putShort(2) - .putShort(16) - .put("data".toByteArray()) - .putInt(dataSize) - .array() - } - - private companion object { - const val SAMPLE_RATE = 8_000 - const val TOLERANCE_MS = 100L - val DURATION = 4.seconds - val SRT = - """ - 1 - 00:00:01,000 --> 00:00:02,000 - First - - 2 - 00:00:02,500 --> 00:00:03,500 - Last - - """.trimIndent() - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayRendererTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayRendererTest.kt deleted file mode 100644 index af8464e8..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayRendererTest.kt +++ /dev/null @@ -1,165 +0,0 @@ -@file:OptIn(markerClass = [UnstableApi::class]) - -package com.github.damontecres.wholphin.services - -import android.os.Handler -import android.os.Looper -import androidx.annotation.OptIn -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.Renderer -import androidx.media3.exoplayer.RendererConfiguration -import androidx.media3.exoplayer.RenderersFactory -import androidx.media3.exoplayer.source.MediaSource -import androidx.media3.exoplayer.source.SinglePeriodTimeline -import io.github.peerless2012.ass.media.AssHandler -import io.github.peerless2012.ass.media.factory.AssRenderersFactory -import io.github.peerless2012.ass.media.render.AssRenderer -import io.github.peerless2012.ass.media.type.AssRenderType -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE, sdk = [30]) -class SubtitleDelayRendererTest { - private val delegate = mockk(relaxed = true) - private val renderer = SubtitleDelayRenderer(delegate) - private val timeline = SinglePeriodTimeline(DURATION_US, true, false, false, null, MediaItem.EMPTY) - private val mediaPeriodId = MediaSource.MediaPeriodId(timeline.getUidOfPeriod(0)) - - @Test - fun shiftsEveryPositionByTheDelay() { - for (delayUs in listOf(2_000_000L, -500_000L)) { - val delegate = mockk(relaxed = true) - val renderer = SubtitleDelayRenderer(delegate) - renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, delayUs) - val positionUs = OFFSET_US + 5_000_000L - val shiftedUs = positionUs - delayUs - - enable(renderer, positionUs) - renderer.render(positionUs, 7L) - renderer.resetPosition(positionUs, true) - renderer.getDurationToProgressUs(positionUs, 7L) - renderer.supportsResetPositionWithoutKeyFrameReset(positionUs) - - verify { - delegate.enable(any(), any(), any(), shiftedUs, false, true, OFFSET_US, OFFSET_US, mediaPeriodId) - delegate.render(shiftedUs, 7L) - delegate.resetPosition(shiftedUs, true) - delegate.getDurationToProgressUs(shiftedUs, 7L) - delegate.supportsResetPositionWithoutKeyFrameReset(shiftedUs) - } - } - } - - @Test - fun delayMessageChangesTheShiftAndOtherMessagesPassThrough() { - renderer.render(10_000_000L, 0L) - renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 250_000L) - renderer.render(10_000_000L, 0L) - renderer.handleMessage(Renderer.MSG_SET_VOLUME, 0.5f) - - verify { - delegate.render(10_000_000L, 0L) - delegate.render(9_750_000L, 0L) - delegate.handleMessage(Renderer.MSG_SET_VOLUME, 0.5f) - } - verify(exactly = 0) { delegate.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, any()) } - } - - @Test - fun endsAtThePeriodEndWithAPositiveDelay() { - every { delegate.isEnded } returns false - enable(renderer, OFFSET_US) - renderer.setTimeline(timeline) - renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 2_000_000L) - - renderer.render(OFFSET_US + DURATION_US - 1, 0L) - assertFalse(renderer.isEnded) - - renderer.render(OFFSET_US + DURATION_US, 0L) - assertTrue(renderer.isEnded) - } - - @Test - fun otherwiseMirrorsTheDelegateAtThePeriodEnd() { - enable(renderer, OFFSET_US) - renderer.setTimeline(timeline) - for (delayUs in listOf(0L, -2_000_000L)) { - renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, delayUs) - renderer.render(OFFSET_US + DURATION_US, 0L) - for (delegateEnded in listOf(false, true)) { - every { delegate.isEnded } returns delegateEnded - assertEquals(delegateEnded, renderer.isEnded) - } - } - } - - @Test - fun doesNotEndWithoutAKnownPeriodDuration() { - every { delegate.isEnded } returns false - val unknownDuration = SinglePeriodTimeline(C.TIME_UNSET, false, false, false, null, MediaItem.EMPTY) - enable(renderer, OFFSET_US, MediaSource.MediaPeriodId(unknownDuration.getUidOfPeriod(0))) - renderer.setTimeline(unknownDuration) - renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 2_000_000L) - - renderer.render(OFFSET_US + DURATION_US * 10, 0L) - - assertFalse(renderer.isEnded) - } - - @Test - fun factoryWrapsOnlySubtitleRenderers() { - val video = fakeRenderer(C.TRACK_TYPE_VIDEO) - val audio = fakeRenderer(C.TRACK_TYPE_AUDIO) - val text = fakeRenderer(C.TRACK_TYPE_TEXT) - val base = - mockk { - every { createRenderers(any(), any(), any(), any(), any()) } returns arrayOf(video, audio, text) - } - val factory = - SubtitleDelayRenderersFactory(AssRenderersFactory(AssHandler(AssRenderType.OVERLAY_OPEN_GL), base)) - - val renderers = - factory.createRenderers(Handler(Looper.getMainLooper()), mockk(), mockk(), mockk(), mockk()) - - assertSame(video, renderers[0]) - assertSame(audio, renderers[1]) - assertSame(text, (renderers[2] as SubtitleDelayRenderer).delegate) - assertTrue((renderers.last() as SubtitleDelayRenderer).delegate is AssRenderer) - assertEquals(4, renderers.size) - } - - private fun enable( - renderer: Renderer, - positionUs: Long, - periodId: MediaSource.MediaPeriodId = mediaPeriodId, - ) = renderer.enable( - RendererConfiguration.DEFAULT, - arrayOf(), - mockk(), - positionUs, - false, - true, - OFFSET_US, - OFFSET_US, - periodId, - ) - - private fun fakeRenderer(trackType: Int) = mockk(relaxed = true) { every { this@mockk.trackType } returns trackType } - - private companion object { - const val OFFSET_US = 1_000_000_000_000L - const val DURATION_US = 10_000_000L - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index 064f70e8..87e5670a 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -3,19 +3,16 @@ package com.github.damontecres.wholphin.ui.playback import android.content.Context import androidx.media3.common.MediaItem import androidx.media3.common.Player -import androidx.media3.common.TrackSelectionParameters import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.ItemTrackModification import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.ServerUserConfig -import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.ShowNextUpWhen @@ -85,7 +82,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -296,76 +292,6 @@ class PlaybackViewModelTests { return viewModel } - @Test - fun `Subtitle track switches load saved delays or zero and ignore disabled subtitles`() = - runTest(testDispatcher) { - setupPreferences { cinemaMode = false } - val tracks = - TestTracks - .Builder() - .addVideo() - .addAudio() - .addSubtitle(2) - val source = tracks.buildMediaSourceInfo() - val subtitles = source.mediaStreams!!.filter { it.type == MediaStreamType.SUBTITLE } - val trackA = subtitles[0].index - val trackB = subtitles[1].index - coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) - coEvery { mockMediaInfoApi.getPostedPlaybackInfo(any(), any()) } returns - successResponse( - PlaybackInfoResponse( - mediaSources = listOf(source), - playSessionId = playSessionId, - ), - ) - coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns source - coEvery { - mockStreamChoiceService.chooseAudioStream(any(), any(), any(), any(), any()) - } returns - StreamChoiceResult( - source.mediaStreams!!.first { it.type == MediaStreamType.AUDIO }, - StreamChoiceReason.Unknown, - ) - coEvery { - mockStreamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) - } returns StreamChoiceResult(subtitles[0], StreamChoiceReason.Unknown) - coEvery { - mockStreamChoiceService.resolveSubtitleIndex(any(), any(), any(), any(), any()) - } answers { arg(3) } - every { mockPlayer.currentTracks } returns tracks.buildForExoPlayer().toTracks() - every { mockPlayer.trackSelectionParameters } returns TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT - coEvery { mockItemPlaybackRepository.getTrackModifications(movie.id, trackA) } returns - ItemTrackModification(user.rowId, movie.id, trackA, 2000L) - - val cases = - listOf( - Triple(trackB, null, Duration.ZERO), - Triple(trackB, -750L, (-750).milliseconds), - Triple(TrackIndex.DISABLED, null, 2.seconds), - ) - cases.forEach { (targetIndex, savedDelayMs, expectedDelay) -> - coEvery { mockItemPlaybackRepository.getTrackModifications(movie.id, trackB) } returns - savedDelayMs?.let { - ItemTrackModification(user.rowId, movie.id, trackB, it) - } - val viewModel = createViewModel(Destination.Playback(movie.id, 0L)) - val initialPlayback = viewModel.state.value.currentPlayback - Assert.assertEquals(trackA, initialPlayback?.subtitleIndex) - Assert.assertEquals(2.seconds, initialPlayback?.subtitleDelay) - - viewModel.changeSubtitleStream(targetIndex).join() - testScheduler.advanceUntilIdle() - - val playback = viewModel.state.value.currentPlayback - Assert.assertEquals(targetIndex, playback?.subtitleIndex) - Assert.assertEquals(expectedDelay, playback?.subtitleDelay) - } - coVerify(exactly = 0) { - mockItemPlaybackRepository.getTrackModifications(movie.id, TrackIndex.DISABLED) - } - verify(exactly = cases.size) { mockPlayer.setMediaItem(any(), any()) } - } - @Test fun `Direct play includes the opened live stream ID`() = runTest(testDispatcher) { diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelayControlTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelayControlTest.kt deleted file mode 100644 index 44ac46c4..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelayControlTest.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import android.content.Context -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performSemanticsAction -import androidx.media3.common.Player -import androidx.media3.exoplayer.ExoPlayer -import androidx.test.core.app.ApplicationProvider -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.TrackIndex -import com.github.damontecres.wholphin.mpv.MpvPlayer -import com.github.damontecres.wholphin.preferences.PlayerBackend -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import io.mockk.mockk -import kotlinx.serialization.json.Json -import org.jellyfin.sdk.model.api.MediaSourceInfo -import org.jellyfin.sdk.model.api.PlayMethod -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.nio.file.Paths -import kotlin.io.path.readText -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - -/** - * The subtitle delay control shows for every player that can move the selected subtitles, and only - * for those - */ -@Config(sdk = [34]) -@RunWith(RobolectricTestRunner::class) -class SubtitleDelayControlTest { - @get:Rule val rule = createComposeRule() - - private val subtitleDelay = ApplicationProvider.getApplicationContext().getString(R.string.subtitle_delay) - - @Test - fun supportedUnlessTheSubtitlesAreBurnedIn() { - val exoPlayer = mockk() - val cases = - listOf( - Triple(mockk(), playback(EMBEDDED_INDEX), true), - Triple(exoPlayer, playback(EMBEDDED_INDEX), true), - Triple(exoPlayer, playback(EXTERNAL_INDEX), true), - Triple(exoPlayer, playback(EMBEDDED_INDEX, burnedIn = true), false), - Triple(exoPlayer, playback(TrackIndex.DISABLED, burnedIn = true), true), - Triple(mockk(), playback(EMBEDDED_INDEX), false), - ) - for ((player, playback, expected) in cases) { - assertEquals( - "${player.javaClass.simpleName} ${playback.subtitleIndex}", - expected, - subtitleDelaySupported(player, playback), - ) - } - } - - @Test - fun settingsListTheSubtitleDelayOnlyWhenEnabled() { - val enabled = mutableStateOf(true) - rule.setContent { WholphinTheme { Dialog(PlaybackDialogType.SETTINGS, enabled.value) {} } } - rule.onNodeWithText(subtitleDelay).assertExists() - - enabled.value = false - rule.onNodeWithText(subtitleDelay).assertDoesNotExist() - } - - @Test - fun delayButtonsReportTheChange() { - val changes = mutableListOf() - rule.setContent { WholphinTheme { Dialog(PlaybackDialogType.SUBTITLE_DELAY, true) { changes.add(it) } } } - - rule.onNodeWithText("+250ms").performSemanticsAction(SemanticsActions.OnClick) - - assertEquals(listOf(250.milliseconds), changes) - } - - @Composable - private fun Dialog( - type: PlaybackDialogType, - enableSubtitleDelay: Boolean, - onChangeSubtitleDelay: (Duration) -> Unit, - ) = PlaybackDialog( - enableSubtitleDelay = enableSubtitleDelay, - enableVideoScale = true, - type = type, - settings = - PlaybackSettings( - showDebugInfo = false, - audioIndex = null, - audioStreams = listOf(), - subtitleIndex = null, - subtitleStreams = listOf(), - playbackSpeed = 1f, - contentScale = ContentScale.Fit, - subtitleDelay = Duration.ZERO, - hasSubtitleDownloadPermission = false, - playbackSpeedEnabled = true, - maxBitrate = 0L, - ), - onDismissRequest = {}, - onControllerInteraction = {}, - onClickPlaybackDialogType = {}, - onPlaybackActionClick = {}, - onChangeSubtitleDelay = onChangeSubtitleDelay, - ) - - /** - * A playback of `external_subs.json`, a PlaybackInfo response whose subtitle streams carry the - * server's delivery method, with [subtitleIndex] selected; [burnedIn] makes the server burn the - * embedded subtitles into the video - */ - private fun playback( - subtitleIndex: Int, - burnedIn: Boolean = false, - ): CurrentPlayback { - val resource = javaClass.classLoader!!.getResource("external_subs.json") - var json = Paths.get(resource.toURI()).readText() - if (burnedIn) { - json = json.replace("\"DeliveryMethod\": \"Embed\"", "\"DeliveryMethod\": \"Encode\"") - } - return CurrentPlayback( - item = mockk(), - tracks = listOf(), - backend = PlayerBackend.EXO_PLAYER, - playMethod = PlayMethod.DIRECT_PLAY, - playSessionId = null, - liveStreamId = null, - mediaSourceInfo = Json.decodeFromString(json), - subtitleIndex = subtitleIndex, - ) - } - - private companion object { - const val EXTERNAL_INDEX = 0 - const val EMBEDDED_INDEX = 5 - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt index 2c1d5fee..ad3d4647 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt @@ -88,7 +88,7 @@ class AnimeAvailabilityTest { } } val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { requireNotNull(model.onForeground(server, user)).join() assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt index 0cfd7ef2..125a67bd 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt @@ -69,7 +69,7 @@ class AnimeRootRemovalTest { val navigation = NavigationManager() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { model.load(server, user) requireNotNull(model.onForeground(server, user)).join() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt index bfa997eb..052aa05c 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt @@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest { val client = session.identity.value!!.client val libraries = LibraryReadFacade(mockk()) val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { requireNotNull(model.onForeground(server, user)).join() assertNull(libraries.audiences) @@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest { session.authenticate() val libraries = LibraryReadFacade(mockk()) val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { val identity = session.identity.value!! val intent = org.justinvisser.wolfskin.design.DiscoverRequest( diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt index b7e18bf8..7842289d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt @@ -57,7 +57,7 @@ class ConductarrHomeSurfaceTest { coEvery { home.readLibrary(user.id) } returns HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null - val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession()) + val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session) try { val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false } rule.setContent { WolfskinTheme { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt index 2cb05947..3c77fa66 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt @@ -70,7 +70,7 @@ class ConductarrTitlePagesTest { assertEquals(TitleConnection.SignedOut, revoked.connection) assertTrue(revoked.item.ratings.isEmpty()) assertTrue(revoked.playTarget!!.ratings.isEmpty()) - assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.menuOptions.any { it.id == "add" }) + assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" }) } } } @@ -85,36 +85,4 @@ class ConductarrTitlePagesTest { assertTrue(emptyList().withEpisode(8.0, null).isEmpty()) assertTrue(emptyList().withEpisode(8.0, 0).isEmpty()) } - - @Test fun requestIdentityUsesTheTitleRootNotTheEpisodeBeingWatched() { - val viewer = ShellPerson("shared", "Shared", "S", true) - val episode = TitleItem("episode-id", "Episode", "episode", tmdbId = 999) - val series = TitlePageData(TitleItem("series-id", "Series", "tv", held = true, tmdbId = 44), - watchTarget = episode, seasonNumbers = listOf(0, 1, 2)) - val request = requireNotNull(series.requestTitle(viewer)) - assertEquals(44L, request.tmdb) - assertEquals("tv", request.type) - assertEquals(listOf(1, 2), request.seasons) - assertEquals(setOf("shared"), request.heldBy) - val catalogue = TitlePageData(TitleItem("movie:42", "Film", "movie")).requestTitle(viewer) - assertEquals(42L, catalogue?.tmdb) - assertEquals("movie", catalogue?.type) - } - - @Test fun incompleteOrNonCatalogueRootsCannotBecomeRequestsForAnotherTitle() { - val viewer = ShellPerson("shared", "Shared", "S", true) - val invalid = listOf( - TitleItem("fixture-slug", "Unknown", "movie"), - TitleItem("fixture-slug", "Unknown", "movie", tmdbId = 0), - TitleItem("fixture-slug", "Unknown", "tv", tmdbId = -1), - TitleItem("tv:42", "Wrong media", "movie"), - TitleItem("42", "Not a catalogue route", "tv"), - TitleItem("movie:0", "Zero", "movie"), - TitleItem("tv:-42", "Negative", "tv"), - TitleItem("movie:42:1", "Malformed", "movie"), - TitleItem("episode-id", "Orphan episode", "episode", tmdbId = 999), - TitleItem("person:216", "Person", "person", tmdbId = 216), - ) - invalid.forEach { item -> assertNull(item.toString(), TitlePageData(item).requestTitle(viewer)) } - } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt index edc7c984..e41862d8 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt @@ -22,7 +22,6 @@ import com.github.takahirom.roborazzi.captureRoboImage import io.mockk.coEvery import io.mockk.mockk import java.io.File -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.* @@ -50,8 +49,7 @@ class ConductarrTitleSurfaceTest { runBlocking { session.authenticate() } val facade = mockk() coEvery { facade.findHeld(any(), any()) } returns null - val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) - val revokedLookup = CompletableDeferred() + val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), session, mockk(relaxed = true)) try { val route = titleRoute("movie:42") val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { @@ -78,9 +76,6 @@ class ConductarrTitleSurfaceTest { val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath) rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) } - rule.mainClock.advanceTimeBy(300) - rule.onNodeWithText("Add for someone").assertIsDisplayed() - rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } rule.onNodeWithText("Request for Mindel").assertIsDisplayed() rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) } rule.waitUntil(30_000) { @@ -95,26 +90,15 @@ class ConductarrTitleSurfaceTest { assertFalse(body.containsKey("actor")) rule.onRoot().captureRoboImage(File(directory, "conductarr-title-receipt.png").absolutePath) rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } - rule.mainClock.autoAdvance = false - coEvery { facade.findHeld(any(), any()) } coAnswers { revokedLookup.await() } fake.ratingsStatus = 401 runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) } rule.waitUntil(30_000) { org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - rule.mainClock.advanceTimeByFrame() viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable } - repeat(12) { index -> - rule.mainClock.advanceTimeBy(16) - assertEquals("Revoking access must clear private facts without replaying the first-load skeleton", - TitleReadStatus.Unavailable, viewModel.title!!.data.status) - if (index == 0 || index == 11) rule.onRoot().captureRoboImage(File(directory, "conductarr-title-revoked-$index.png").absolutePath) - } - revokedLookup.complete(null) assertTrue(viewModel.title!!.data.item.ratings.isEmpty()) - assertFalse(viewModel.title!!.options.any { it.id == "request" }) - assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" }) - } finally { viewModel.viewModelScope.cancel(); revokedLookup.complete(null) } + assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") }) + } finally { viewModel.viewModelScope.cancel() } } } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt deleted file mode 100644 index d8057fdb..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt +++ /dev/null @@ -1,255 +0,0 @@ -package org.justinvisser.wolfskin.tv - -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.SideEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.takahirom.roborazzi.captureRoboImage -import io.mockk.coEvery -import io.mockk.mockk -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.cancel -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.TitleReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -import java.io.File - -/** Refreshing a loaded Discover title must preserve its real header instead of rebuilding a skeleton. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class DiscoverRefreshMotionTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun loadedCatalogueRefreshKeepsReadyActionsOpaqueAndStationary() { - FakeConductarr().use { fake -> - fake.localArt = true - val stored = MemoryCacheStore() - val cache = object : org.justinvisser.wolfskin.conductarr.CacheStore by stored { - @Volatile var lost = false - override fun read(scope: String, key: String) = if (lost) null else stored.read(scope, key) - } - ConductarrSession(cache).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - runBlocking { session.authenticate() } - val facade = mockk() - coEvery { facade.findHeld(any(), any()) } returns null - val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) - val person = ShellPerson("shared", "Shared", "S", false) - val route = titleRoute("tv:44") - val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) } - val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) - val refreshLookup = CompletableDeferred() - try { - render(model, shell) - rule.runOnIdle { model.load(route, person, preferences, fixtures = false) } - rule.waitUntil(30_000) { - Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - model.title?.data?.status == TitleReadStatus.Ready && model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true - } - rule.mainClock.advanceTimeBy(640) - val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-refresh").apply { mkdirs() } - val bounds = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot - val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt()) - val baseline = brightness(File(directory, "loaded.png"), bounds, pixels) - cache.lost = true - coEvery { facade.findHeld(any(), any()) } coAnswers { refreshLookup.await() } - rule.runOnIdle { - model.load(route, person, preferences, fixtures = false, force = true) - assertEquals("An already-loaded catalogue title must never emit Loading on refresh", TitleReadStatus.Ready, model.title!!.data.status) - } - repeat(12) { index -> - rule.mainClock.advanceTimeBy(16) - assertEquals(TitleReadStatus.Ready, model.title!!.data.status) - assertFalse("A refresh cannot focus the root navigation", shell.navFocused) - assertEquals("Loaded actions cannot replay their rise", bounds, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot) - assertEquals("Loaded actions cannot replay their fade", baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels)) - } - refreshLookup.complete(null) - rule.waitUntil(30_000) { - Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - model.title?.data?.watchReady == true - } - repeat(12) { - rule.mainClock.advanceTimeByFrame() - assertEquals(TitleReadStatus.Ready, model.title!!.data.status) - } - assertSameFrame(File(directory, "loaded.png"), File(directory, "refreshed.png")) - } finally { - model.viewModelScope.cancel() - refreshLookup.complete(null) - } - } - } - } - - @Test fun loadedFixtureRefreshKeepsTheSameReadyHeaderAndCatalogueIdentity() { - ConductarrSession(MemoryCacheStore()).use { session -> - val model = WolfskinTitleViewModel(rule.activity, mockk(relaxed = true), TitleFixtureProvider(rule.activity), - NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) - val person = ShellPerson("justin", "Justin", "J", true) - val route = titleRoute("movie-the-amazing-spider-man-2012") - val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) } - val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) - try { - render(model, shell) - rule.runOnIdle { model.load(route, person, preferences, fixtures = true) } - rule.waitUntil(30_000) { - Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - model.title?.data?.status == TitleReadStatus.Ready - } - rule.mainClock.advanceTimeBy(640) - val loaded = model.title!! - val request = requireNotNull(loaded.data.requestTitle(person)) - assertEquals(1930L, request.tmdb) - assertEquals("movie", request.type) - val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-fixture-refresh").apply { mkdirs() } - val bounds = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot - val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt()) - val baseline = brightness(File(directory, "loaded.png"), bounds, pixels) - val refreshedWatched = !loaded.data.watchTarget.watched - rule.activity.getSharedPreferences("wolfskin-title-fixture", android.content.Context.MODE_PRIVATE).edit() - .putBoolean("${person.key}:${loaded.data.watchTarget.id}:watched", refreshedWatched).commit() - rule.runOnIdle { - model.load(route, person, preferences, fixtures = true, force = true) - assertEquals("A fixture foreground refresh cannot reinstate Loading", TitleReadStatus.Ready, model.title!!.data.status) - } - repeat(12) { index -> - rule.mainClock.advanceTimeBy(16) - assertEquals(TitleReadStatus.Ready, model.title!!.data.status) - assertFalse(shell.navFocused) - assertEquals(bounds, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot) - assertEquals(baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels)) - } - rule.waitUntil(30_000) { - Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - model.title?.data?.watchTarget?.watched == refreshedWatched - } - assertEquals(refreshedWatched, model.title!!.watched) - rule.mainClock.advanceTimeBy(640) - assertEquals(TitleReadStatus.Ready, model.title!!.data.status) - assertSameFrame(File(directory, "loaded.png"), File(directory, "refreshed.png")) - } finally { model.viewModelScope.cancel() } - } - } - - @Test fun loadedHeldRefreshKeepsTheHeaderVisibleWhilePlaybackIsRevalidated() { - ConductarrSession(MemoryCacheStore()).use { session -> - val id = java.util.UUID.fromString("00000000-0000-0000-0000-000000000042") - val held = TitlePageData(TitleItem(id.toString(), "Held film", "movie", held = true)) - val facade = mockk() - coEvery { facade.readSaved(any(), any()) } returns null - coEvery { facade.read(any(), any(), any()) } returns held - val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) - val person = ShellPerson("shared", "Shared", "S", false) - val route = titleRoute(id.toString()) - val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination(route) } - val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) - val refresh = CompletableDeferred() - try { - render(model, shell) - rule.runOnIdle { model.load(route, person, preferences, fixtures = false) } - rule.waitUntil(30_000) { - Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - model.title?.data?.status == TitleReadStatus.Ready - } - rule.mainClock.advanceTimeBy(640) - val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-held-refresh").apply { mkdirs() } - val bounds = rule.onNodeWithText("Held film").fetchSemanticsNode().boundsInRoot - val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt()) - val baseline = brightness(File(directory, "loaded.png"), bounds, pixels) - coEvery { facade.read(any(), any(), any()) } coAnswers { refresh.await() } - rule.runOnIdle { - model.load(route, person, preferences, fixtures = false, force = true) - assertEquals(TitleReadStatus.Ready, model.title!!.data.status) - assertFalse(model.title!!.data.watchReady) - } - repeat(12) { index -> - rule.mainClock.advanceTimeBy(16) - assertEquals(TitleReadStatus.Ready, model.title!!.data.status) - assertEquals(bounds, rule.onNodeWithText("Held film").fetchSemanticsNode().boundsInRoot) - assertEquals(baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels)) - } - refresh.complete(held) - rule.waitUntil(30_000) { - Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - model.title?.data?.watchReady == true - } - assertEquals(TitleReadStatus.Ready, model.title!!.data.status) - assertTrue(model.title!!.options.any { it.id == "play" }) - rule.mainClock.advanceTimeBy(640) - assertSameFrame(File(directory, "loaded.png"), File(directory, "refreshed.png")) - } finally { model.viewModelScope.cancel(); refresh.complete(held) } - } - } - - private fun render(model: WolfskinTitleViewModel, shell: ShellState) { - 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 - val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap() - rule.setContent { WolfskinTheme { - model.title?.let { state -> - val presented = remember(model) { booleanArrayOf(false) } - val status = state.data.status - SideEffect { - if (presented[0]) assertEquals("Every rendered refresh frame stays Ready", TitleReadStatus.Ready, status) - if (status == TitleReadStatus.Ready) presented[0] = true - } - val paths = listOfNotNull(state.data.item.poster, state.data.item.backdrop) + - state.data.cast.mapNotNull { it.portrait } + - (state.data.related + state.data.episodes).flatMap { listOfNotNull(it.poster, it.backdrop) } - CompositionLocalProvider(LocalTitleBitmaps provides paths.associateWith { art }) { - TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) - } - } - } } - } - - private fun assertSameFrame(before: File, after: File) { - rule.onRoot().captureRoboImage(after.absolutePath) - val first = BitmapFactory.decodeFile(before.absolutePath) - val second = BitmapFactory.decodeFile(after.absolutePath) - try { - assertTrue("Same-data refresh must return the identical rendered frame", first.sameAs(second)) - } finally { first.recycle(); second.recycle() } - } - - private fun brightness(file: File, bounds: Rect, pixels: IntArray): Long { - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - return try { - val width = bounds.width.toInt() - val height = bounds.height.toInt() - bitmap.getPixels(pixels, 0, width, bounds.left.toInt(), bounds.top.toInt(), width, height) - pixels.sumOf { (android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it)).toLong() } - } finally { bitmap.recycle() } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSkeletonKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSkeletonKeysTest.kt index bcb0d5a0..9dc1786b 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSkeletonKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSkeletonKeysTest.kt @@ -43,7 +43,7 @@ class DiscoverSkeletonKeysTest { } press(Key.DirectionDown) rule.runOnIdle { assertEquals(1, state.focusedRow); assertNull(state.cardKey) } - press(Key.Escape) + press(Key.Back) rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible); assertEquals("discover", shell.route) } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt index e1d8c0e7..9fbbeaa0 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt @@ -36,31 +36,6 @@ import org.robolectric.annotation.GraphicsMode class DiscoverTitleTimingTest { @get:Rule val rule = createAndroidComposeRule() - @Test fun detailsPushKeepsTheOutgoingDiscoverHeroUntilNavigationReplacesIt() { - val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply { - pickerVisible = false; pickerLaunch = false; enterDestination("discover") - } - var pushed = "" - lateinit var discover: DiscoverState - rule.setContent { - discover = rememberDiscoverState(shell, { pushed = it }, {}, fixtures = true) - WolfskinTheme { ScreenEntry(shell, "discover", true) { - ShellSurface(shell) { DiscoverScreen(discover, it) } - } } - } - rule.waitUntil(30_000) { discover.snapshot.status == DiscoverStatus.Ready } - rule.mainClock.advanceTimeBy(640) - rule.onNodeWithText("Details").assertIsDisplayed() - val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-entry").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "hero-before-push.png").absolutePath) - rule.runOnIdle { discover.openTitle(requireNotNull(discover.hero)) } - rule.waitForIdle() - assertEquals(titleRoute("movie:1930"), pushed) - rule.onNodeWithText("Details").assertIsDisplayed() - rule.onNodeWithText("For someone else").assertDoesNotExist() - rule.onRoot().captureRoboImage(File(directory, "hero-after-push.png").absolutePath) - } - @Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() { FakeConductarr().use { fake -> fake.localArt = true @@ -71,7 +46,7 @@ class DiscoverTitleTimingTest { val facade = mockk() coEvery { facade.findHeld(any(), any()) } returns null val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) } - val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true)) val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply { pickerVisible = false; pickerLaunch = false; enterDestination("discover") } @@ -109,7 +84,7 @@ class DiscoverTitleTimingTest { assertEquals("related", model.title!!.row) rule.onNodeWithText("More like this").assertIsDisplayed() rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath) - rule.onRoot().performKeyInput { pressKey(Key.Escape) } + rule.onRoot().performKeyInput { pressKey(Key.Back) } rule.waitForIdle() assertNull(discover.title) assertEquals("discover", shell.route) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt index 98d25a86..c412265e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -91,7 +91,7 @@ class FirstSignInNavigationTest { val navigation = NavigationManager() val session = WolfskinSession() val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), - LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) + LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf( HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume"))) try { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt deleted file mode 100644 index 9fffeed4..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt +++ /dev/null @@ -1,192 +0,0 @@ -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() - 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() - private val release = CompletableDeferred() - 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() - 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() - 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() - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt index fcf22467..49fc269f 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt @@ -17,7 +17,7 @@ import org.justinvisser.wolfskin.tv.read.HomeLibraryCache class HomeLibraryCacheTest { @Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() { val context = ApplicationProvider.getApplicationContext() - val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42, seriesId = "series-id", lastPlayedAt = 123456L) + val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42) val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43) val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84)) HomeLibraryCache(context).write("server:person", library, 12345L) @@ -26,19 +26,4 @@ class HomeLibraryCacheTest { assertEquals(12345L, saved.second) assertNull(HomeLibraryCache(context).read("server:someone-else")) } - - @Test fun preMergeCacheWithoutWatchIdentityOrTimestampStillReopens() { - val context = ApplicationProvider.getApplicationContext() - val cache = HomeLibraryCache(context) - val title = HomeTitle("legacy", "An episode", "episode", seriesTitle = "Series", resumeSeconds = 24.0, held = true) - cache.write("legacy-scope", HomeLibrary(resume = listOf(title)), 12L) - val store = org.justinvisser.wolfskin.conductarr.FileCacheStore(java.io.File(context.filesDir, "wolfskin-home")) - val entry = store.read("legacy-scope", "home:jellyfin")!! - val body = org.json.JSONObject(entry.body) - body.getJSONArray("resume").getJSONObject(0).apply { remove("seriesId"); remove("lastPlayedAt") } - store.write("legacy-scope", "home:jellyfin", entry.copy(body = body.toString())) - val saved = cache.read("legacy-scope")!! - assertEquals(title, saved.first.resume.single()) - assertEquals(12L, saved.second) - } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt index 547fa4b9..128dae1e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt @@ -45,7 +45,7 @@ class HomeRecoveryTest { val facade = mockk() coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() } coEvery { facade.readLibrary(any()) } returns HomeLibrary() - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) } started.await() assertEquals(film.id, model.snapshot.rows.first().cards.first().id) @@ -67,7 +67,7 @@ class HomeRecoveryTest { coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline") coEvery { facade.readLibrary(any()) } returns HomeLibrary() - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) } startedA.await() assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) @@ -88,7 +88,7 @@ class HomeRecoveryTest { val cache = mockk() every { cache.read(any()) } returns null every { cache.write(any(), any(), any()) } throws IOException("fixture disk full") - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) model.loadLibrary(server, personA, viewerA) assertTrue(model.snapshot.jellyfinAvailable) assertEquals(film.id, model.snapshot.rows.first().cards.first().id) @@ -101,7 +101,7 @@ class HomeRecoveryTest { coEvery { facade.readLibrary(any()) } returns HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name")) assertTrue(model.snapshot.jellyfinAvailable) assertEquals(film.id, model.snapshot.rows.first().cards.first().id) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt index 55b92066..f8322b5f 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt @@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest { } val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) - val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) + val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) val facade = mockk() rule.setContent { val destination = navigation.backStack.last() @@ -90,7 +90,7 @@ class HomeSeeAllNavigationTest { try { rule.onNodeWithTag("library-grid").assertIsDisplayed() exerciseGrid(grid) - press(Key.Escape) + press(Key.Back) rule.waitForIdle() assertTrue(navigation.backStack.last() is Destination.Home) assertEquals(row.id, snapshot.rows[shell.view.row].id) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt deleted file mode 100644 index 81fc0058..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt +++ /dev/null @@ -1,413 +0,0 @@ -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() - 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() - 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() - 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.ServerList) - val colors = mapOf("ServerList" to 0xff304060.toInt(), "UserList" to 0xff406030.toInt(), "AppContent" to 0xff603040.toInt()) - val screensaver = mockk() - 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(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() - 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, 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(relaxed = true) - val setup = mockk(relaxed = true) - private val conductarr = ConductarrSession(MemoryCacheStore()) - private val models = mutableListOf() - 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(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() - 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 - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/OfflinePersonSwitchTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/OfflinePersonSwitchTest.kt deleted file mode 100644 index bd403f16..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/OfflinePersonSwitchTest.kt +++ /dev/null @@ -1,239 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.os.Looper -import androidx.activity.ComponentActivity -import androidx.compose.runtime.* -import androidx.compose.runtime.snapshots.Snapshot -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.MainContent -import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.* -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.* -import com.github.takahirom.roborazzi.captureRoboImage -import io.mockk.* -import java.io.File -import java.io.IOException -import java.time.Duration -import java.util.UUID -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** A failed person switch must leave an escapable picker without changing the authenticated person. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class OfflinePersonSwitchTest { - @get:Rule val rule = createAndroidComposeRule() - private val fake = FakeConductarr() - private val conductarr = ConductarrSession(MemoryCacheStore()) - private val server = JellyfinServer(UUID.randomUUID(), "Household", fake.url, null) - private val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN).map { (name, token) -> - JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token) - } - private val original = CurrentUser(server, users.first()) - private val current = MutableStateFlow(original) - private val repository = mockk(relaxed = true) - private val navigation = NavigationManager() - private val setup = mockk(relaxed = true) - private val stack = mutableStateListOf(SetupDestination.AppContent(original)) - private val session = WolfskinSession() - private val setupModel = WolfskinSetupViewModel(repository, navigation, setup, session, conductarr) - private lateinit var model: WolfskinShellViewModel - private var oldFixtures = false - private var oldScreens = true - private var oldUrl = "" - - @Before fun configure() { - ConductarrPreference.initialize(rule.activity) - WolfskinScreenPreference.initialize(rule.activity) - oldFixtures = ConductarrPreference.fixtures - oldScreens = WolfskinScreenPreference.enabled - oldUrl = ConductarrPreference.baseUrl - ConductarrPreference.updateFixtures(false) - ConductarrPreference.updateBaseUrl(fake.url) - WolfskinScreenPreference.enabled = true - every { repository.current } returns current - coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) - coEvery { repository.changeUser(server, users[1]) } throws IOException("offline") - every { setup.navigateTo(any()) } answers { stack[0] = firstArg() } - val backdrop = mockk(relaxed = true) - every { backdrop.backdropFlow } returns MutableStateFlow(BackdropResult.NONE) - model = 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)) - val screensaver = mockk(relaxed = true) - every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) - rule.setContent { - MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), backdrop, - screensaver, current, mockk(relaxed = true), setupInterceptor = { destination, modifier -> - if (destination is SetupDestination.AppContent) { - WolfskinApp(server, destination.current.user, navigation, - UserPreferences(AppPreferences.getDefaultInstance(), null), modifier, model, - homeContent = { shell, _, user, contentModifier -> - val home = remember(user.id) { HomeFixture(rule.activity).snapshot(user.name!!.lowercase()) } - HomeScreen(shell, home, contentModifier) {} - }) - true - } else WolfskinSetupContent(destination, modifier, setupModel) - }) - } - await { session.state?.people?.size == 2 && model.signedInPerson == "justin" } - } - - @After fun close() { - if (::model.isInitialized) model.viewModelScope.cancel() - setupModel.viewModelScope.cancel() - conductarr.close() - fake.close() - ConductarrPreference.updateFixtures(oldFixtures) - ConductarrPreference.updateBaseUrl(oldUrl) - WolfskinScreenPreference.enabled = oldScreens - } - - @Test fun failedAvatarSwitchBackDismissesToOriginalNavThenExits() { - failSwitch(fromAvatar = true) - back() - assertFalse("Dismissing a reopened picker must not exit yet", rule.activity.isFinishing) - assertEquals("Back must dismiss to the still-authenticated app", SetupDestination.AppContent(original), stack.single()) - assertFalse("Back cannot remain trapped on Who's watching", session.state!!.pickerVisible) - assertEquals(users.first().id.toString(), session.state!!.person.jellyfinUserId) - assertTrue(session.state!!.navFocused) - assertEquals(original, current.value) - back() - assertTrue("The following Back from nav must exit", rule.activity.isFinishing) - } - - @Test fun failedAvatarSwitchExplainsThatTheServerDidNotAnswer() { - failSwitch(fromAvatar = true) - rule.onNodeWithText("The household server didn’t answer", substring = true).assertIsDisplayed() - rule.onRoot().captureRoboImage(File(System.getProperty("wolfskin.captureDirectory"), "offline-switch-sdk${android.os.Build.VERSION.SDK_INT}.png")) - assertEquals(original, current.value) - } - - @Test fun failedAvatarSwitchRestoresOnlyTheOriginalHouseholdSession() { - failSwitch(fromAvatar = true) - await { conductarr.identity.value?.signedIn == true } - val identity = requireNotNull(conductarr.identity.value) - assertEquals("${server.id}:${users.first().id}", identity.person) - assertEquals("justin", identity.operator!!.requester) - assertEquals(original, current.value) - } - - @Test fun expiredTargetCredentialsStillOpenTheSignInGate() { - coEvery { repository.changeUser(server, users[1]) } throws org.jellyfin.sdk.api.client.exception.InvalidStatusException(401) - failSwitch(fromAvatar = true) - await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() } - rule.onNodeWithText("Mindel").performClick() - await { setupModel.stage == WolfskinSetupViewModel.Stage.Login } - rule.onNodeWithTag("profile-password").assertIsDisplayed() - assertEquals(original, current.value) - } - - @Test fun failedLaunchPickerSelectionBackExitsWithoutChangingIdentity() { - failSwitch(fromAvatar = false) - back() - assertTrue("A launch picker must exit rather than expose the old person's Home", rule.activity.isFinishing) - assertEquals(original, current.value) - } - - @Test fun coldSetupPickerBackExitsWithoutRestoringTheSignedInEntry() { - rule.runOnIdle { stack[0] = SetupDestination.UserList(server) } - await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() } - rule.onNodeWithText("Who’s watching?").assertIsDisplayed() - back() - assertTrue("Cold setup picker Back must exit, even with an old identity in memory", rule.activity.isFinishing) - assertEquals(original, current.value) - } - - @Test fun failedSetupSelectionEscapeStillExitsThePicker() { - val response = CompletableDeferred() - coEvery { repository.changeUser(server, users[1]) } coAnswers { - response.await() - throw IOException("offline") - } - rule.runOnIdle { stack[0] = SetupDestination.UserList(server) } - await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() } - rule.mainClock.advanceTimeBy(1_000) - rule.onNodeWithTag("wolfskin-setup-picker").performKeyInput { pressKey(Key.DirectionRight) } - assertEquals(1, setupModel.picker!!.pickerIndex) - rule.onNodeWithTag("wolfskin-setup-picker").performKeyInput { pressKey(Key.DirectionCenter) } - response.complete(Unit) - await { setupModel.stage == WolfskinSetupViewModel.Stage.Unavailable } - back() - await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() } - rule.onNodeWithTag("wolfskin-setup-picker").performKeyInput { pressKey(Key.Escape) } - assertTrue("Escape and system Back must share the setup picker's exit", rule.activity.isFinishing) - assertEquals(original, current.value) - } - - @Test fun unansweredSwitchCannotKeepInputCoveredIndefinitely() { - var entered = false - coEvery { repository.changeUser(server, users[1]) } coAnswers { - entered = true - awaitCancellation() - } - press(listOf(Key.DirectionRight, Key.DirectionCenter)) - await { entered } - rule.runOnIdle { Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(15)) } - rule.mainClock.advanceTimeBy(11_000) - rule.waitForIdle() - assertNull("An unanswered switch must release its input cover within 15 seconds", session.personCommit) - await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() } - rule.onNodeWithText("The household server didn’t answer", substring = true).assertIsDisplayed() - back() - assertTrue(rule.activity.isFinishing) - assertEquals(original, current.value) - } - - private fun failSwitch(fromAvatar: Boolean) { - if (fromAvatar) { - press(listOf(Key.DirectionCenter)) - await { session.personCommit == null && session.state?.pickerVisible == false } - press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter)) - } - press(listOf(Key.DirectionRight, Key.DirectionCenter)) - await { session.personCommit == null && (session.state?.pickerVisible == true || setupModel.stage == WolfskinSetupViewModel.Stage.Picker) } - assertEquals("A failed switch cannot select another authenticated person", original, current.value) - } - - private fun press(keys: List) { - rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } } - } - - private fun back() { - rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } - rule.mainClock.advanceTimeBy(32) - rule.waitForIdle() - } - - private fun await(condition: () -> Boolean) { - rule.waitUntil(10_000) { - Snapshot.sendApplyNotifications() - Shadows.shadowOf(Looper.getMainLooper()).idle() - condition() - } - rule.waitForIdle() - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt deleted file mode 100644 index 295b7799..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt +++ /dev/null @@ -1,173 +0,0 @@ -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 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() - private lateinit var host: PolishAppHost - private val release = CompletableDeferred() - @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() - val others = mutableListOf() - val chosen = mutableListOf() - 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)) } - } - - - private fun choose(keys: List): 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) = rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it, 0L) } } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt index 6fe1b9f1..35c5cb57 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt @@ -85,7 +85,7 @@ class PersonPickerSessionTest { val navigation = remember { NavigationManager() } val model = remember { WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)).also { models.add(it) } + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) } } DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } } WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt index 1ae749c1..9a1ff11e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -87,7 +87,7 @@ class PersonSwitchThreadingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val clients = mutableListOf() try { model.load(server, justin) @@ -163,7 +163,7 @@ class PersonSwitchThreadingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { model.load(server, justin) requireNotNull(model.onForeground(server, justin)).join() @@ -219,8 +219,8 @@ class PersonSwitchThreadingTest { coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null - val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, WolfskinSession()) - val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) + val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr) + val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository) try { conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url) conductarr.authenticate() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt index 72b68b47..dca356a7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt @@ -52,7 +52,7 @@ class PlayerHandoffTest { @Suppress("DEPRECATION") rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false } - model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) + model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) } @Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() { @@ -78,30 +78,15 @@ class PlayerHandoffTest { } @Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() { - val snapshot = HomeFixture(rule.activity).snapshot("mindel") - val title = snapshot.rows.first().cards.first() + val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() val id = UUID.fromString("00000000-0000-0000-0000-000000000003") coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto( id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE, userData = playbackUserData(id, 11_362_740_000L, playCount = 2)) val view = shell.view view.cards["hero"] = 0 - rule.setContent { androidx.compose.runtime.CompositionLocalProvider(LocalHomePlaybackStart provides model.session.playback) { WolfskinTheme { - androidx.compose.foundation.layout.Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { - ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } } - PlaybackHandoffVeil(model.session.playback) - } - } } } - rule.mainClock.advanceTimeBy(1000) - rule.mainClock.autoAdvance = false rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) } - awaitModel { 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) + awaitModel { navigation.backStack.last() is Destination.Playback } val destination = navigation.backStack.last() as Destination.Playback assertEquals(id, destination.itemId) assertEquals(1_136_274L, destination.positionMs) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt index 798a3d89..01a2e425 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt @@ -63,18 +63,13 @@ class SavedPeoplePickerTest { val session = WolfskinSession() val navigation = NavigationManager() val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), - mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) + mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { rule.setContent { WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, homeContent = { _, _, _, _ -> }) } - rule.waitUntil(5_000) { - // IO completion publishes Compose state on Android's queue, not the test frame clock. - androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - entered.get() && session.state?.people?.size == 3 - } + rule.waitUntil(5_000) { entered.get() && session.state?.people?.size == 3 } users.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() } rule.runOnIdle { assertTrue(session.state!!.pickerVisible) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt index 56b9cd2a..8a01861d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt @@ -66,7 +66,7 @@ class ShellStartupTimingTest { coEvery { libraries.readDestinations(user.id) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val load = launch { model.load(server, user) } try { withTimeout(5_000) { savedPeopleStarted.await() } @@ -121,7 +121,7 @@ class ShellStartupTimingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val pending = CompletableDeferred() val release = java.util.concurrent.CountDownLatch(1) val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt index 215c1582..9809967f 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt @@ -6,7 +6,6 @@ import androidx.activity.ComponentActivity import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performKeyInput @@ -15,7 +14,6 @@ import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.nav.Destination import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.CompletableDeferred @@ -48,7 +46,6 @@ import java.util.UUID class TitleAsyncFocusTest { @get:Rule val rule = createAndroidComposeRule() private lateinit var viewModel: WolfskinTitleViewModel - private val navigation = NavigationManager() @After fun dispose() { if (::viewModel.isInitialized) viewModel.viewModelScope.cancel() @@ -60,10 +57,9 @@ class TitleAsyncFocusTest { val facade = mockk() coEvery { facade.readSaved(id, any()) } returns null coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) val person = ShellPerson("justin", "Justin", "J", false) val route = titleRoute(id.toString()) - navigation.navigateTo(Destination.Wolfskin(route)) val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false pickerLaunch = false @@ -73,12 +69,9 @@ class TitleAsyncFocusTest { rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } } rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } rule.waitForIdle() - assertFalse("Loading retains destination focus instead of focusing the root nav", shell.navFocused) - rule.onNodeWithTag("title-page").assertIsFocused() + assertTrue(shell.navFocused) press(Key.DirectionDown) - assertFalse("Down cannot transfer focus to nav or an initial skeleton", shell.navFocused) - assertEquals("header", viewModel.title!!.row) - assertEquals(0, viewModel.title!!.column) + assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused) val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList()) rule.runOnIdle { @@ -103,10 +96,9 @@ class TitleAsyncFocusTest { val facade = mockk() coEvery { facade.readPersonSaved(id) } returns null coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) val viewer = ShellPerson("justin", "Justin", "J", false) val route = personRoute(id.toString()) - navigation.navigateTo(Destination.Wolfskin(route)) val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false pickerLaunch = false @@ -117,12 +109,9 @@ class TitleAsyncFocusTest { } } rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } rule.waitForIdle() - assertFalse(shell.navFocused) - rule.onNodeWithTag("title-page").assertIsFocused() + assertTrue(shell.navFocused) press(Key.DirectionDown) - assertFalse("Down cannot enter a pending person skeleton", shell.navFocused) - assertEquals("header", viewModel.title!!.row) - assertEquals(0, viewModel.title!!.column) + assertTrue(shell.navFocused) rule.runOnIdle { reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead( org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"), @@ -132,7 +121,7 @@ class TitleAsyncFocusTest { rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable } assertNotNull("A real person reply must populate the performer destination", viewModel.person) rule.waitForIdle() - assertFalse("The loaded person retains destination focus on its first credit", shell.navFocused) + assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused) assertEquals(0, viewModel.person!!.index) press(Key.DirectionRight) assertEquals(1, viewModel.person!!.index) @@ -150,10 +139,9 @@ class TitleAsyncFocusTest { reads.incrementAndGet() throw java.io.IOException("offline") } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) val viewer = ShellPerson("justin", "Justin", "J", false) val route = titleRoute(id.toString()) - navigation.navigateTo(Destination.Wolfskin(route)) val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination(route) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt index beac9c16..55f9d747 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt @@ -24,8 +24,6 @@ import org.json.JSONObject import org.junit.Before import org.junit.After import org.junit.Rule -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.justinvisser.wolfskin.design.LocalTitleBitmaps @@ -89,7 +87,7 @@ class TitleCaptureTest { data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } } data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } } data.episodes.forEach { item -> - (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt())) } + (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) } } } return requests.distinctBy { it.first }.associate { (path, width, height) -> @@ -172,10 +170,7 @@ class TitleCaptureTest { @Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") } @Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") } @Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") } - @Test fun st047_mediaInfo() = capture(47) { state, shell -> - open(state, shell, "menu") - state.clickSheet(0) - } + @Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") } @Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." } @Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") } @Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") } @@ -221,33 +216,4 @@ class TitleCaptureTest { @Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel") @Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man") @Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) } - @Test fun round2Menu() = capture(120) { state, shell -> open(state, shell, "menu") } - @Test fun round2MussoliniResume() = capture(121, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) - @Test fun round2Episodes() = capture(122, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) { state, _ -> state.focus("episodes", 0) } - @Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days") - @Test fun round2HeldPosterFallback() = capture(124, "The Girl Who Leapt Through Time", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) }) - @Test fun round2NotHeldShortOverview() = capture(125, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) }) - - @Test fun watchedRingBelongsOnlyToFocus() { - lateinit var state: TitleState - capture(126) { current, _ -> state = current; state.watched = false; option(state, "tracks") } - val edges = mutableListOf>() - for ((index, configuration) in listOf(false to false, true to false, false to true, true to true).withIndex()) { - val (watched, focused) = configuration - rule.runOnIdle { state.watched = watched; option(state, if (focused) "watched" else "tracks") } - save(126 + index) - val bounds = rule.onNodeWithTag("title-option-watched").fetchSemanticsNode().boundsInRoot - val file = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "st-${126 + index}.png") - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - val edge = (-8..8).map { offset -> bitmap.getPixel(bounds.left.toInt() + offset, bounds.center.y.toInt()) } - edges.add(edge) - bitmap.recycle() - } - val accent = WolfskinTokens.Color.accent.toArgb() - assertEquals("Unwatched at rest has no ring", 0, edges[0].count { it == accent }) - assertEquals("Watched at rest has no ring", 0, edges[1].count { it == accent }) - assertEquals("Toggling watched must not change the resting edge", edges[0], edges[1]) - assertTrue("Keyboard focus keeps its accent ring", edges[2].any { it == accent }) - assertEquals("Watched keeps exactly the same focused ring", edges[2], edges[3]) - } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt deleted file mode 100644 index 417c72e3..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt +++ /dev/null @@ -1,345 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.os.Build -import android.os.Looper -import androidx.activity.ComponentActivity -import androidx.activity.BackEventCompat -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import androidx.lifecycle.viewModelScope -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.ui.NavDisplay -import com.github.damontecres.wholphin.MainContent -import com.github.damontecres.wholphin.SignedInContent -import com.github.damontecres.wholphin.data.CurrentUser -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.services.BackdropService -import com.github.damontecres.wholphin.services.BackdropResult -import com.github.damontecres.wholphin.services.ScreensaverService -import com.github.damontecres.wholphin.services.ScreensaverState -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.util.WholphinDispatchers -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import java.util.UUID -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.test.StandardTestDispatcher -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.PersonRead -import org.justinvisser.wolfskin.tv.read.TitleReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Real NavDisplay and system/predictive Back must pop one title/person entry before the root ladder. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-television-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class TitleEntryBackTest { - private var systemAnimationScale = 1f - @get:Rule val rule = createAndroidComposeRule( - effectContext = object : androidx.compose.ui.MotionDurationScale { - override val scaleFactor get() = systemAnimationScale - }, - ) - private lateinit var viewModel: WolfskinTitleViewModel - private lateinit var conductarr: ConductarrSession - private var homeModel: WolfskinHomeViewModel? = null - private var shellModel: WolfskinShellViewModel? = null - private var previousFixtures: Boolean? = null - private var previousHomeIo: CoroutineDispatcher? = null - private var previousLeanback: Boolean? = null - - @After fun dispose() { - rule.mainClock.autoAdvance = true - if (::viewModel.isInitialized) viewModel.viewModelScope.cancel() - homeModel?.viewModelScope?.cancel() - shellModel?.viewModelScope?.cancel() - previousHomeIo?.let { WholphinDispatchers.IO = it } - previousHomeIo = null - previousLeanback?.let { org.robolectric.Shadows.shadowOf(rule.activity.packageManager).setSystemFeature(android.content.pm.PackageManager.FEATURE_LEANBACK, it) } - previousFixtures?.let(ConductarrPreference::updateFixtures) - if (::conductarr.isInitialized) conductarr.close() - } - - @Test fun outgoingLoadedTitleCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = false) - @Test fun outgoingLoadedPersonCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = true) - @Test fun predictiveTitleBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = false, predictive = true) - @Test fun predictivePersonBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = true, predictive = true) - - @Test fun homeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1) - @Test fun reducedMotionHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = true, depth = 1) - @Test fun deepHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 8) - @Test fun sharedHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1, person = "shared") - @Test fun loadingHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1, person = "shared", paintLoading = true) - @Test fun systemAnimationsOffHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1, person = "shared", systemScale = 0f) - - private fun verifyHomeViewport(reduced: Boolean, depth: Int, person: String = "justin", paintLoading: Boolean = false, systemScale: Float = 1f) { - // A television qualifier alone does not advertise Leanback or leave Robolectric's touch mode. - systemAnimationScale = systemScale - val packageManager = rule.activity.packageManager - previousLeanback = packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_LEANBACK) - org.robolectric.Shadows.shadowOf(packageManager).setSystemFeature(android.content.pm.PackageManager.FEATURE_LEANBACK, true) - val navigation = NavigationManager() - val session = WolfskinSession() - val heldReads = if (paintLoading) StandardTestDispatcher().also { - previousHomeIo = WholphinDispatchers.IO - WholphinDispatchers.IO = it - } else null - conductarr = ConductarrSession(MemoryCacheStore()) - ConductarrPreference.initialize(rule.activity) - previousFixtures = ConductarrPreference.fixtures - ConductarrPreference.updateFixtures(true) - val server = com.github.damontecres.wholphin.data.model.JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) - val user = com.github.damontecres.wholphin.data.model.JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = person.replaceFirstChar { it.uppercase() }, serverId = server.id, accessToken = null) - viewModel = WolfskinTitleViewModel(rule.activity, mockk(), TitleFixtureProvider(rule.activity), navigation, conductarr, - mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) - val backdrop = mockk(relaxed = true) - every { backdrop.backdropFlow } returns MutableStateFlow(BackdropResult.NONE) - val app = WolfskinShellViewModel(rule.activity, session, mockk(relaxed = true), navigation, mockk(relaxed = true), - backdrop, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), - org.justinvisser.wolfskin.tv.update.FakeUpdateController(), mockk(relaxed = true)) - shellModel = app - val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) - val current = CurrentUser(server, user) - val live = MutableStateFlow(current) - val setup = androidx.compose.runtime.mutableStateListOf(SetupDestination.AppContent(current)) - val trailer = mockk(relaxed = true) - val screensaver = mockk(relaxed = true) - every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) - rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { - val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current - androidx.compose.runtime.LaunchedEffect(inputMode) { inputMode.requestInputMode(androidx.compose.ui.input.InputMode.Keyboard) } - MainContent(setup, navigation, preferences, backdrop, screensaver, live, trailer, - modifier = Modifier.fillMaxSize(), - setupInterceptor = { destination, modifier -> - val entry = (destination as SetupDestination.AppContent).current - SignedInContent(entry, live, preferences.appPreferences.signInAutomatically, trailer, - { navigation.backStack.lastOrNull() }) { - WolfskinApp(entry.server, entry.user, navigation, preferences, modifier, app, - homeContent = { shell, homeServer, homeUser, contentModifier -> - val source = androidx.lifecycle.viewmodel.compose.viewModel { - WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, session) - } - homeModel = source - HomeDestination(shell, homeServer, homeUser, contentModifier, source) - }, - titleContent = { route, shell, settings -> TitleDestination(route, shell, settings, viewModel = viewModel, fixtures = true) }) - } - true - }) - } } - rule.waitUntil(30_000) { - androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() - Shadows.shadowOf(Looper.getMainLooper()).idle() - session.state != null && app.signedInPerson == person - } - rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } - if (heldReads != null) { - rule.waitUntil(30_000) { - Snapshot.sendApplyNotifications() - Shadows.shadowOf(Looper.getMainLooper()).idle() - homeModel != null && session.personCommit == null - } - rule.onNodeWithTag("wolfskin-home").assertExists() - assertEquals(HomeAvailability.Loading, homeModel!!.snapshot.conductarr) - val loadingCaptures = File(System.getProperty("wolfskin.captureDirectory"), "home-loading-$person").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(loadingCaptures, "sdk${Build.VERSION.SDK_INT}.png").absolutePath) - heldReads.scheduler.runCurrent() - WholphinDispatchers.IO = previousHomeIo!! - previousHomeIo = null - } - rule.waitUntil(30_000) { - androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() - Shadows.shadowOf(Looper.getMainLooper()).idle() - homeModel?.snapshot?.rows?.any { it.id == "cw" } == true && session.personCommit == null - } - val shell = requireNotNull(session.state) - val snapshot = requireNotNull(homeModel).snapshot - rule.onRoot().performKeyInput { repeat(depth) { pressKey(Key.DirectionDown) } } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val home = shell.rememberedView("home") - val tag = "home-card:${snapshot.rows[home.row].id}:0" - val before = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset } - val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot - val motion = if (systemScale == 0f) "system-off" else if (reduced) "reduced" else "animated" - val captures = File(System.getProperty("wolfskin.captureDirectory"), "home-title-back-$person-$motion-$depth${if (paintLoading) "-loading" else ""}").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-before.png").absolutePath) - rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } - rule.waitUntil(30_000) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - viewModel.title?.data?.status == TitleReadStatus.Ready - } - rule.mainClock.advanceTimeBy(1000) - rule.mainClock.autoAdvance = false - rule.runOnIdle { - rule.activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK)) - rule.activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK)) - } - if (navigation.backStack.size > 1) dispatchBack(predictive = false) - assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) - rule.runOnIdle { - Snapshot.sendApplyNotifications() - Shadows.shadowOf(Looper.getMainLooper()).idle() - } - rule.mainClock.advanceTimeBy(64) - rule.waitForIdle() - rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-after.png").absolutePath) - val after = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset } - val afterBounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot - rule.mainClock.advanceTimeBy(1000) - Shadows.shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(1000)) - rule.waitForIdle() - rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-settled.png").absolutePath) - val settled = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset } - val settledBounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot - assertEquals("settled card bounds before=$bounds after=$settledBounds; list before=$before after=$settled", before, settled) - assertEquals(bounds, settledBounds) - assertEquals("card bounds before=$bounds after=$afterBounds; list before=$before after=$after", before, after) - assertEquals(bounds, afterBounds) - } - - @Test fun nativeBackKeyWaitsForSystemCompletionBeforePoppingTheTitle() { - val (navigation, shell) = mountTitle(personPage = false) - val before = navigation.backStack.toList() - rule.runOnIdle { rule.activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK)) } - assertEquals("Compose must not pop on native Back key-down before the system completes Back", before, navigation.backStack.toList()) - // Predictive completion is a platform callback, not a raw key-up delivered to Compose. - dispatchBack(predictive = true) - rule.mainClock.advanceTimeBy(400) - assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) - assertFalse(rule.activity.isFinishing) - assertFalse(shell.navFocused) - val captures = File(System.getProperty("wolfskin.captureDirectory"), "system-back").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-title-home.png").absolutePath) - } - - @Test fun immediateSecondSystemBackUsesHomeBeforeExitRecomposition() { - val (navigation, shell) = mountTitle(personPage = false) - rule.runOnIdle { - rule.activity.onBackPressedDispatcher.onBackPressed() - assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) - rule.activity.onBackPressedDispatcher.onBackPressed() - assertTrue("The next Back belongs to Home even before Compose retires the title handler", shell.navFocused) - assertFalse(rule.activity.isFinishing) - } - rule.mainClock.advanceTimeBy(16) - assertFalse(rule.activity.isFinishing) - dispatchBack(predictive = false) - rule.mainClock.advanceTimeBy(16) - assertTrue(rule.activity.isFinishing) - } - - private fun mountTitle(personPage: Boolean): Pair { - val activity = rule.activity - val navigation = NavigationManager() - val viewer = ShellPerson("justin", "Justin", "J", false) - val shell = ShellState(listOf(viewer), viewer.key) { - when (it) { - ShellAction.Exit -> navigation.requestExit() - ShellAction.Pop -> navigation.goBack() - else -> Unit - } - }.apply { pickerVisible = false; pickerLaunch = false; navFocused = false } - val id = UUID.fromString("00000000-0000-0000-0000-000000000031") - val route = if (personPage) personRoute(id.toString()) else titleRoute(id.toString()) - val destination = Destination.Wolfskin(route) - val facade = mockk() - if (personPage) { - coEvery { facade.readPersonSaved(id) } returns null - coEvery { facade.readPerson(id, 0) } returns PersonRead(TitlePerson(id.toString(), "Outgoing actor"), - listOf(TitleItem("credit", "Credit", "movie"))) - } else { - coEvery { facade.readSaved(id, any()) } returns null - coEvery { facade.read(id, any(), any()) } returns TitlePageData(TitleItem(id.toString(), "Outgoing film", "movie", held = true)) - } - conductarr = ConductarrSession(MemoryCacheStore()) - viewModel = WolfskinTitleViewModel(activity, facade, mockk(), navigation, conductarr, - mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) - shell.enterDestination(route) - navigation.navigateTo(destination) - val home = HomeSnapshot(listOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(HomeTitle("home-film", "Home film", "movie"))))) - val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) - rule.setContent { - WolfskinTheme { - val onBack = rememberWolfskinBackHandler(shell, navigation) - NavDisplay(backStack = navigation.backStack, onBack = onBack, entryProvider = { entry -> - NavEntry(entry) { - val active = navigation.backStack.lastOrNull() == entry - val entryRoute = if (entry is Destination.Home) "home" else route - LaunchedEffect(entry, active) { if (active) shell.enterDestination(entryRoute) } - ScreenEntry(shell, entryRoute, active) { - if (entry is Destination.Home) ShellSurface(shell) { modifier -> HomeScreen(shell, home, modifier) {} } - else TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = false) - } - } - }) - } - } - rule.waitUntil(5_000) { - Snapshot.sendApplyNotifications() - Shadows.shadowOf(Looper.getMainLooper()).idle() - (if (personPage) viewModel.person != null else viewModel.title?.data?.status == TitleReadStatus.Ready) && !shell.navFocused - } - rule.waitForIdle() - return navigation to shell - } - - private fun verifyOutgoingBack(personPage: Boolean, predictive: Boolean = false) { - val activity = rule.activity - val (navigation, shell) = mountTitle(personPage) - dispatchBack(predictive) - rule.mainClock.advanceTimeBy(400) - assertFalse("One system Back from a title/person must never finish the task", activity.isFinishing) - rule.waitForIdle() - assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) - assertFalse(shell.navFocused) - dispatchBack(predictive) - rule.mainClock.advanceTimeBy(16) - assertTrue("The incoming Home must receive Back while the outgoing entry is retained", shell.navFocused) - assertFalse("A second Back must not finish the task through the outgoing entry", activity.isFinishing) - assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) - dispatchBack(predictive) - rule.mainClock.advanceTimeBy(16) - assertTrue("Only the following Back from Home nav should exit", activity.isFinishing) - } - - private fun dispatchBack(predictive: Boolean) { - rule.runOnIdle { - val dispatcher = rule.activity.onBackPressedDispatcher - if (predictive) { - dispatcher.dispatchOnBackStarted(BackEventCompat(0f, 0f, 0f, BackEventCompat.EDGE_LEFT)) - dispatcher.dispatchOnBackProgressed(BackEventCompat(100f, 0f, .5f, BackEventCompat.EDGE_LEFT)) - } - dispatcher.onBackPressed() - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 1706f4f0..0b6dac33 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -12,9 +12,11 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performMouseInput import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.text.TextLayoutResult import org.justinvisser.wolfskin.design.WolfskinTokens import org.json.JSONArray import org.json.JSONObject @@ -34,6 +36,7 @@ import org.justinvisser.wolfskin.design.TitleSurface import org.justinvisser.wolfskin.design.WolfskinTheme import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode /** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */ @RunWith(RobolectricTestRunner::class) @@ -144,7 +147,7 @@ class TitleKeysTest { assertEquals("Version", state.options[state.column].label) press(Key.DirectionCenter) assertEquals(1, state.sheetIndex) - press(Key.Escape) + press(Key.Back) assertNull(state.sheet) assertEquals("Version", state.options[state.column].label) assertFalse(shell.navFocused) @@ -161,7 +164,7 @@ class TitleKeysTest { assertEquals(2, state.subtitleIndex) press(Key.DirectionUp); press(Key.DirectionCenter) assertEquals(-1, state.subtitleIndex) - press(Key.Escape) + press(Key.Back) assertNull(state.sheet) assertEquals("tracks", state.options[state.column].id) } @@ -281,19 +284,24 @@ class TitleKeysTest { press(Key.DirectionDown) assertEquals(expected, state.row) } + @Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() { + render("Andor") + val layouts = mutableListOf() + rule.onNodeWithText("Add for someone", useUnmergedTree = true) + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) } + val layout = layouts.single() + assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth) + assertEquals(1, layout.lineCount) + } @Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() { render(transform = { it.copy(status = TitleReadStatus.Loading) }) rule.onNodeWithText("Cast").assertDoesNotExist() press(Key.DirectionDown) - assertFalse(shell.navFocused) - rule.onNodeWithTag("title-page").assertIsFocused() + assertTrue(shell.navFocused) assertEquals("header", state.row) - assertEquals(0, state.column) - assertFalse(shell.pickerVisible) rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } assertEquals(listOf(TitleAction.Pop), effects) - assertFalse(shell.pickerVisible) } @@ -307,6 +315,26 @@ class TitleKeysTest { assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f) } + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() { + render() + rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) } + press(Key.DirectionCenter) + val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2 + assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f) + val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f) + } + + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Test fun filmHeaderKeepsItsFixedCssStack() { + render() + assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f) + assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f) + assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f) + } @Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt index 2888c113..75c92ed5 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt @@ -1,9 +1,6 @@ package org.justinvisser.wolfskin.tv import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import org.junit.Rule import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences @@ -38,7 +35,6 @@ import java.util.UUID @Config(sdk = [34], application = Application::class) @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class TitlePlaybackNavigationTest { - @get:Rule val rule = createAndroidComposeRule() private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001") private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002") private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003") @@ -59,7 +55,7 @@ class TitlePlaybackNavigationTest { episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor"))) } val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) + val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true)) fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true) try { navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString()))) @@ -113,37 +109,25 @@ class TitlePlaybackNavigationTest { coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE, userData = playbackUserData(firstId, 960_780_000L, playCount = 1)) val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(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() - } + val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true)) try { model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false) advanceUntilIdle() model.title!!.click("header", 0) advanceUntilIdle() - finishHandoff() assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last()) navigation.goBack() model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" }) advanceUntilIdle() - finishHandoff() assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last()) navigation.goBack() model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false) advanceUntilIdle() model.title!!.click("header", 0) advanceUntilIdle() - finishHandoff() assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last()) coVerify(exactly = 1) { facade.preparePlayback(seriesId) } } finally { - rule.mainClock.autoAdvance = true model.viewModelScope.cancel() session.close() Dispatchers.resetMain() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePolishTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePolishTest.kt deleted file mode 100644 index ddc474be..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePolishTest.kt +++ /dev/null @@ -1,481 +0,0 @@ -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() - 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() - private var bitmaps: Map = 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(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) { - 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() - val panel = mutableListOf() - 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() - 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, NavigationManager> { - val facade = mockk() - val prepared = CompletableDeferred() - 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() - 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() - 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() - 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() - 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() - 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() - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt deleted file mode 100644 index 051f2e4e..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performClick -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.takahirom.roborazzi.captureRoboImage -import io.mockk.coEvery -import io.mockk.mockk -import kotlinx.coroutines.cancel -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.TitleReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -import java.io.File - -/** Even an incomplete/stale requestable page must recover through Wolfskin, not throw. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class TitleRequestSafetyTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun missingCatalogueIdentityShowsRecoveryAndCannotProduceAFakeHideReceipt() { - FakeConductarr().use { fake -> - ConductarrSession(MemoryCacheStore()).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - runBlocking { session.authenticate() } - val facade = mockk() - coEvery { facade.findHeld(any(), any()) } returns null - val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) - val person = ShellPerson("shared", "Shared", "S", true) - val route = titleRoute("movie:42") - val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) } - val art = ImageBitmap(1, 1) - try { - rule.setContent { WolfskinTheme { - val data = model.title?.data - val paths = listOfNotNull(data?.item?.poster, data?.item?.backdrop) + - data?.cast.orEmpty().mapNotNull { it.portrait } + - data?.related.orEmpty().flatMap { listOfNotNull(it.poster, it.backdrop) } - CompositionLocalProvider(LocalTitleBitmaps provides paths.associateWith { art }) { - TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false) - } - } } - rule.waitUntil(30_000) { - Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - model.title?.data?.connection == TitleConnection.SignedIn && model.conductarrAction != null - } - // A stale/partial read must not make the action adapter throw even if its - // previous requestable snapshot still exists. - rule.runOnIdle { - val current = model.title!! - current.update(current.data.copy(item = TitleItem("missing-identity", "Unidentified film", "movie"), pick = true)) - } - rule.onNodeWithText("Request").performClick() - rule.onNodeWithText(RecoveryMessage.TitleActionUnavailable).assertIsDisplayed() - rule.onNodeWithText("Unidentified film").assertIsDisplayed() - assertFalse(shell.navFocused) - val directory = File(System.getProperty("wolfskin.captureDirectory"), "title-request-safety").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "missing-identity-request.png").absolutePath) - rule.onNodeWithText("Not interested").performClick() - assertFalse(model.title!!.data.hidden) - assertNull(model.title!!.sheet) - rule.onNodeWithText(RecoveryMessage.TitleActionUnavailable).assertIsDisplayed() - rule.onRoot().captureRoboImage(File(directory, "missing-identity-hide.png").absolutePath) - } finally { model.viewModelScope.cancel() } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt deleted file mode 100644 index 10842787..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt +++ /dev/null @@ -1,291 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.graphics.BitmapFactory -import androidx.activity.ComponentActivity -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage -import org.json.JSONObject -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.* -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -import java.io.File - -/** Native title interactions keep secondary actions, focus and section geometry intact. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class TitleRound2Test { - @get:Rule val rule = createAndroidComposeRule() - private val effects = mutableListOf() - private lateinit var state: TitleState - private lateinit var shell: ShellState - - private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) - private fun render(title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }) { - val fixture = fixture() - shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/test") } - state = TitleState(transform(fixture.title(title, person)), shell.person, effects::add) - rule.setContent { WolfskinTheme { - BackHandler { state.back(shell) } - TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) - } } - } - private fun press(key: Key) = rule.onRoot().performKeyInput { pressKey(key) } - private fun menu() { - rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "menu" }) } - press(Key.DirectionCenter) - rule.mainClock.advanceTimeBy(300) - rule.waitForIdle() - rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertIsDisplayed() - } - - @Test fun menuBackRestoresItsButtonAndKeepsMediaInfoAndRequestReachable() { - render() - menu() - val opener = state.column - press(Key.Escape) - assertNull(state.sheet) - assertEquals(opener, state.column) - assertFalse(shell.navFocused) - press(Key.DirectionCenter) - assertEquals(TitleSheet.Menu, state.sheet) - press(Key.DirectionCenter) - assertEquals(TitleSheet.MediaInfo, state.sheet) - rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed() - press(Key.Escape) - assertEquals(TitleSheet.Menu, state.sheet) - press(Key.DirectionDown) - press(Key.DirectionCenter) - assertNull(state.sheet) - assertEquals(TitleAction.Request(state.data.item, true), effects.single()) - assertEquals(opener, state.column) - } - - @Test fun notHeldMovieKeepsRequestAndHideOutsideTheMenu() { - render("The Amazing Spider-Man") - rule.onNodeWithTag("title-option-request").assertIsDisplayed() - rule.onNodeWithTag("title-option-hide").assertIsDisplayed() - menu() - rule.onNodeWithText("Add for someone").assertIsDisplayed() - press(Key.DirectionCenter) - assertEquals(TitleAction.Request(state.data.item, true), effects.single()) - } - - @Test fun movieCastStacksAfterActionsInsteadOfAReservedHeaderHeight() { - render("The Amazing Spider-Man") - val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot - val cast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot - val gap = cast.top - actions.bottom - assertTrue("Cast follows the actions without the old fixed header reservation", gap > 0 && cast.top < WolfskinTokens.Layout.row_dhead.value * 2) - rule.runOnIdle { state.update(state.data.copy(requestedStage = "Searching", requestActor = "Justin")) } - val requestedActions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot - val requestedCast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot - assertTrue(requestedActions.bottom < requestedCast.top) - assertTrue("A readiness line cannot move Cast backwards or recreate the old reservation", requestedCast.top >= cast.top && requestedCast.top < WolfskinTokens.Layout.row_dhead.value * 2) - } - - @Test fun mussoliniResumeHasOnlyFactsRatingsAndNoRedundantSeasonsAction() { - render("episode-episode-1-2025-s1e1", "shared") { data -> data.copy(episodes = data.episodes.distinctBy { it.id }) } - assertTrue(state.data.playTarget!!.resumeSeconds > 0) - rule.onNodeWithTag("title-progress").assertIsDisplayed() - rule.onAllNodesWithContentDescription("rtCritics", useUnmergedTree = true).assertCountEquals(1) - rule.onNodeWithText("Ep", useUnmergedTree = true).assertIsDisplayed() - rule.onAllNodesWithText("7.4", useUnmergedTree = true).assertCountEquals(1) - rule.onNode(hasText("7.4") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed() - rule.onNodeWithTag("title-option-seasons").assertDoesNotExist() - press(Key.DirectionDown) - assertEquals("seasons", state.row) - rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed() - } - - @Test fun seriesUsesDownToReachSeasonsWithoutARedundantShortcut() { - render("Andor") - rule.onNodeWithTag("title-option-seasons").assertDoesNotExist() - menu() - rule.onNodeWithText("Add for someone").assertIsDisplayed() - press(Key.Escape) - press(Key.DirectionDown) - assertEquals("seasons", state.row) - rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed() - } - - @Test fun episodeFactsUseTheEpisodeSetRatherThanTheSeriesSet() { - render("episode-episode-1-2025-s1e1", "shared") { data -> - data.copy(item = data.item.copy(ratings = listOf(LibraryRating("rtCritics", 20.0, "Rotten"))), - watchTarget = data.watchTarget.copy(ratings = listOf(LibraryRating("rtCritics", 84.0, "Fresh"), LibraryRating("tmdb", 8.7, episode = true))), - episodes = data.episodes.distinctBy { it.id }) - } - rule.onAllNodesWithText("84%", useUnmergedTree = true).assertCountEquals(1) - rule.onAllNodesWithText("8.7", useUnmergedTree = true).assertCountEquals(1) - rule.onNodeWithText("20%", useUnmergedTree = true).assertDoesNotExist() - rule.onNode(hasText("8.7") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed() - } - - @Test fun episodeSkeletonAndReadySlotsMatchWhileMetadataFitsOneLine() { - render("Andor") { it.copy(episodesStatus = TitleReadStatus.Loading) } - rule.runOnIdle { shell.view.listState.requestScrollToItem(2) } - rule.waitForIdle() - val skeleton = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot - rule.runOnIdle { state.update(state.data.copy(episodesStatus = TitleReadStatus.Ready)) } - val ready = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot - assertEquals(skeleton, ready) - assertEquals(448f, ready.width, 0.1f) - assertEquals("Wider cards retain the approved taller episode art", 280f, ready.height, 0.1f) - rule.runOnIdle { state.focus("episodes", 0) } - val episode = state.data.episodes.first() - rule.onNodeWithText(episode.episodeLabel + " · " + episode.runtime).assertIsDisplayed() - rule.onNode(hasText(episode.seriesName!!) and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true).assertDoesNotExist() - } - - @Test fun revokedRequestsDisappearFromMenuWithoutRemovingMediaInfo() { - render() - menu() - rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) } - rule.onNodeWithText("Add for someone").assertDoesNotExist() - press(Key.DirectionCenter) - assertEquals(TitleSheet.MediaInfo, state.sheet) - rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed() - } - - @Test fun shortOverviewMoviesKeepCastBelowThePosterForHeldAndNotAddedTitles() { - render("The Amazing Spider-Man") { it.copy(item = it.item.copy(overview = "Short overview.", backdrop = null, isPosterBackdrop = true)) } - for (held in listOf(false, true)) { - rule.runOnIdle { state.update(state.data.copy(item = state.data.item.copy(held = held))) } - val poster = rule.onNodeWithTag("title-poster-backdrop").fetchSemanticsNode().boundsInRoot - val firstActor = rule.onNodeWithTag("title-cast-${state.data.cast.first().id}").fetchSemanticsNode().boundsInRoot - assertTrue("Cast clears the poster on held=$held: $firstActor vs $poster", firstActor.top >= poster.bottom + 20f - 0.5f) - } - } - - @Test fun revokingTheOnlyMenuActionClosesAnOtherwiseEmptyMenu() { - render("The Amazing Spider-Man") - menu() - rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) } - assertNull(state.sheet) - rule.mainClock.advanceTimeBy(300) - rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertDoesNotExist() - } - - @Test fun mediaInfoKeepsTableTextSeparatedInsteadOfAccumulatingOverlap() { - render() - menu() - press(Key.DirectionCenter) - rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed() - rule.onNodeWithText("Dutch", useUnmergedTree = true).assertIsDisplayed() - val text = rule.onAllNodes(SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsActions.GetTextLayoutResult) and hasAnyAncestor(hasTestTag("title-sheet")), useUnmergedTree = true) - .fetchSemanticsNodes().map { it.boundsInRoot }.filter { it.width > 0 && it.height > 0 } - text.forEachIndexed { index, first -> text.drop(index + 1).forEach { second -> - if (minOf(first.right, second.right) > maxOf(first.left, second.left)) { - assertTrue("Media info text must never collide vertically: $first vs $second", minOf(first.bottom, second.bottom) <= maxOf(first.top, second.top)) - } - } } - } - - @Test fun menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim() { - render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) } - menu() - rule.mainClock.autoAdvance = false - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P06/menu-to-info").apply { mkdirs() } - rule.runOnIdle { state.clickSheet(0) } - var waited = 0 - while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.sheetIn + 16) { - rule.mainClock.advanceTimeByFrame() - rule.waitForIdle() - waited += 16 - } - val records = mutableListOf() - fun frame(phase: String, index: Int): Pair { - val path = File(directory, "$phase-${index.toString().padStart(2, '0')}.png") - rule.onRoot().captureRoboImage(path.absolutePath) - val bitmap = BitmapFactory.decodeFile(path.absolutePath) - fun brightness(x: Int, y: Int): Int { - val pixel = bitmap.getPixel(x, y) - return (android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)) / 3 - } - return try { - (brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") } - } finally { bitmap.recycle() } - } - val info = List(14) { index -> - rule.waitForIdle() - frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() } - } - info.forEach { assertEquals("Media info keeps one full scrim on every frame", info.last().second, it.second) } - info.zipWithNext().forEach { (before, after) -> assertTrue("The panel fades monotonically instead of popping", after.first >= before.first - 1) } - assertNotEquals("The Media info panel visibly fades", info.first().first, info.last().first) - assertEquals("The panel settles by sheetIn plus one frame", info.last().first, info[(WolfskinTokens.Motion.sheetIn / 16 + 1).coerceAtMost(info.lastIndex)].first) - rule.runOnIdle { state.back(shell) } - repeat(14) { index -> - rule.mainClock.advanceTimeBy(16) - rule.waitForIdle() - assertEquals("Returning to Menu keeps exactly one scrim", info.last().second, frame("back", index).second) - } - rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertExists() - File(directory, "samples.csv").writeText("phase,timeMs,sheetBrightness,leftBrightness\n" + records.joinToString("\n")) - rule.mainClock.autoAdvance = true - } - - @Test fun menuFadeFramesStayMonotonicAndNeverTranslate() { - render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) } - rule.mainClock.autoAdvance = false - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-motion").apply { mkdirs() } - rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "menu" }) } - var waited = 0 - while (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) { - rule.mainClock.advanceTimeByFrame() - rule.waitForIdle() - waited += 16 - } - val bounds = rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - val samples = mutableListOf() - fun frame(phase: String, index: Int): Int { - val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val image = BitmapFactory.decodeFile(file.absolutePath) - val brightness = try { - val pixel = image.getPixel((bounds.left + 40).toInt(), (bounds.top + 6).toInt()) - android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) - } finally { image.recycle() } - samples.add("$phase,${index * 16},$brightness,${bounds.left},${bounds.top}") - return brightness - } - fun assertMonotonic(values: List) { - assertNotEquals("Fade must visibly change the menu pixel", values.first(), values.last()) - val direction = if (values.last() > values.first()) 1 else -1 - values.zipWithNext().forEach { (before, after) -> - assertTrue("Menu alpha must not flicker or snap back: $values", (after - before) * direction >= -1) - } - } - val entering = List(14) { index -> - rule.waitForIdle() - assertEquals(bounds, rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot) - frame("open", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() } - } - assertMonotonic(entering) - rule.runOnIdle { state.back(shell) } - val leaving = mutableListOf() - repeat(9) { index -> - rule.waitForIdle() - if (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) leaving.add(frame("close", index)) - rule.mainClock.advanceTimeBy(16) - } - assertMonotonic(leaving) - rule.onNodeWithTag("title-menu").assertDoesNotExist() - File(directory, "samples.csv").writeText("phase,timeMs,brightness,leftPx,topPx\n" + samples.joinToString("\n")) - rule.mainClock.autoAdvance = true - press(Key.DirectionCenter) - assertEquals(TitleSheet.Menu, state.sheet) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleSeasonReelTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleSeasonReelTest.kt deleted file mode 100644 index 93253dd1..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleSeasonReelTest.kt +++ /dev/null @@ -1,169 +0,0 @@ -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() - @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() - - @Before fun setup() { - mockkObject(WholphinApplication.Companion) - every { WholphinApplication.instance } returns mockk { - every { resources } returns rule.activity.resources - } - val library = mockk() - val items = mockk() - val shows = mockk() - val api = mockk() - 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()) } returns successQueryResult((1..8).map { number -> - BaseItemDto(id = UUID(0, 10L + number), type = BaseItemKind.SEASON, indexNumber = number) - }) - coEvery { shows.getEpisodes(any()) } returns successQueryResult(episodes) - coEvery { shows.getNextUp(any()) } returns successQueryResult(listOf(episodes.last())) - val playback = mockk() - coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null - val repository = mockk { - every { currentServer } returns null - every { currentUser } returns null - } - val context = mockk { every { filesDir } returns directory.root } - facade = TitleReadFacade(api, mockk(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 { - 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 { - 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() - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt deleted file mode 100644 index 5f96b327..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt +++ /dev/null @@ -1,228 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.InterfacePreferences -import com.github.damontecres.wholphin.preferences.ThemeSongVolume -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.ThemeSongPlayer -import com.github.damontecres.wholphin.ui.nav.Destination -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.cancel -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind -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.ScreenEntry -import org.justinvisser.wolfskin.design.ShellPerson -import org.justinvisser.wolfskin.design.ShellState -import org.justinvisser.wolfskin.design.TitleItem -import org.justinvisser.wolfskin.design.TitlePageData -import org.justinvisser.wolfskin.design.WolfskinTheme -import org.justinvisser.wolfskin.design.WolfskinTokens -import org.justinvisser.wolfskin.tv.read.TitleReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -import java.util.UUID - -/** Real Compose/lifecycle transitions must relinquish theme ownership before another page or video. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class TitleThemeMusicTest { - @get:Rule val rule = createAndroidComposeRule() - private val movieId = UUID.fromString("00000000-0000-0000-0000-000000000041") - private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000042") - private val episodeId = UUID.fromString("00000000-0000-0000-0000-000000000043") - private val movie = TitleItem(movieId.toString(), "Held movie", "movie", held = true) - private val playback = ThemePlayback() - private val item = mutableStateOf(movie) - private val preferences = mutableStateOf(preferences(ThemeSongVolume.HIGH)) - private val active = mutableStateOf(true) - private val visible = mutableStateOf(true) - private val viewer = mutableStateOf("server:justin") - private val lifecycleOwner = ThemeLifecycleOwner() - private var model: WolfskinTitleViewModel? = null - private var session: ConductarrSession? = null - - @After fun dispose() { - model?.viewModelScope?.cancel() - session?.close() - } - - @Test fun heldMovieStartsOnceAcrossRefreshAndRecreationNeverOverlaps() { - mountEffect() - assertEquals(listOf("play:$movieId"), playback.events) - rule.runOnIdle { item.value = movie.copy(name = "Refreshed metadata") } - rule.waitForIdle() - assertEquals(listOf("play:$movieId"), playback.events) - rule.runOnIdle { visible.value = false } - rule.waitForIdle() - assertEquals(listOf("play:$movieId", "stop"), playback.events) - rule.runOnIdle { visible.value = true } - rule.waitForIdle() - assertEquals(listOf("play:$movieId", "stop", "play:$movieId"), playback.events) - } - - @Test fun episodeUsesItsSeriesAndMissingSeriesStaysSilent() { - item.value = movie.copy(id = episodeId.toString(), kind = "episode", seriesId = seriesId.toString()) - mountEffect() - assertEquals(listOf("play:$seriesId"), playback.events) - rule.runOnIdle { item.value = item.value!!.copy(seriesId = null) } - rule.waitForIdle() - assertEquals(listOf("play:$seriesId", "stop"), playback.events) - } - - @Test fun catalogueOnlyAndDisabledTitlesStaySilentAndDisablingStopsPlayingTheme() { - item.value = movie.copy(id = "movie:42", held = false) - mountEffect() - assertEquals(emptyList(), playback.events) - rule.runOnIdle { - item.value = movie - preferences.value = preferences(ThemeSongVolume.DISABLED) - } - rule.waitForIdle() - assertEquals(emptyList(), playback.events) - rule.runOnIdle { preferences.value = preferences(ThemeSongVolume.HIGH) } - rule.waitForIdle() - assertEquals(listOf("play:$movieId"), playback.events) - rule.runOnIdle { preferences.value = preferences(ThemeSongVolume.DISABLED) } - rule.waitForIdle() - assertEquals(listOf("play:$movieId", "stop"), playback.events) - } - - @Test fun pausedPageStopsAndOnlyResumedPageRestarts() { - rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.STARTED } - mountEffect(resumed = false) - assertEquals(emptyList(), playback.events) - rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.RESUMED } - rule.waitForIdle() - assertEquals(listOf("play:$movieId"), playback.events) - rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.CREATED } - rule.waitForIdle() - assertEquals(listOf("play:$movieId", "stop"), playback.events) - rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.RESUMED } - rule.waitForIdle() - assertEquals(listOf("play:$movieId", "stop", "play:$movieId"), playback.events) - } - - @Test fun outgoingEntryStopsEvenWhileStillComposedAndCanResumeOnBack() { - mountEffect() - rule.runOnIdle { active.value = false } - rule.waitForIdle() - assertEquals(listOf("play:$movieId", "stop"), playback.events) - rule.runOnIdle { item.value = movie.copy(overview = "Exit animation still composed") } - rule.waitForIdle() - assertNull(playback.playing) - rule.runOnIdle { active.value = true } - rule.waitForIdle() - assertEquals(listOf("play:$movieId", "stop", "play:$movieId"), playback.events) - } - - @Test fun personSwitchReleasesOldThemeAndSignOutDisposesIt() { - mountEffect() - rule.runOnIdle { viewer.value = "server:shared" } - rule.waitForIdle() - assertEquals(listOf("play:$movieId", "stop", "play:$movieId"), playback.events) - rule.runOnIdle { visible.value = false } - rule.waitForIdle() - assertNull(playback.playing) - assertEquals("stop", playback.events.last()) - } - - @Test fun realEpisodePageStartsSeriesThemeAndStopsBeforePlaybackNavigation() { - val series = movie.copy(id = seriesId.toString(), name = "Series", kind = "tv") - val episode = movie.copy(id = episodeId.toString(), name = "Episode", kind = "episode", seriesId = seriesId.toString()) - val navigation = NavigationManager() - val route = titleRoute(episodeId.toString()) - val destination = Destination.Wolfskin(route) - val person = ShellPerson("justin", "Justin", "J", false) - val shell = ShellState(listOf(person), person.key).apply { - pickerVisible = false; pickerLaunch = false; enterDestination(route) - } - val facade = mockk() - coEvery { facade.readSaved(episodeId, any()) } returns null - coEvery { facade.read(episodeId, any(), any()) } returns TitlePageData(series, playTarget = episode, watchTarget = episode) - coEvery { facade.preparePlayback(episodeId) } returns BaseItemDto(id = episodeId, type = BaseItemKind.EPISODE) - val conductarr = ConductarrSession(MemoryCacheStore()).also { session = it } - val titleModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, conductarr, - mockk(relaxed = true), playback.player, trailerReturn = mockk(relaxed = true)).also { model = it } - rule.runOnIdle { navigation.navigateTo(destination) } - rule.setContent { - WolfskinTheme { - ScreenEntry(shell, route, navigation.backStack.lastOrNull() == destination) { - TitleDestination(route, shell, preferences.value, viewModel = titleModel, fixtures = false) - } - } - } - rule.waitUntil(5_000) { playback.playing == seriesId } - assertEquals(listOf("play:$seriesId"), playback.events) - playback.onStop = { if (playback.playing != null) assertEquals(destination, navigation.backStack.last()) } - rule.runOnIdle { titleModel.title!!.click("header", 0) } - rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.base.toLong() + 48) - rule.waitForIdle() - rule.waitUntil(5_000) { navigation.backStack.last() is Destination.Playback } - playback.onStop = {} - rule.waitForIdle() - assertEquals(Destination.Playback(episodeId, 0L), navigation.backStack.last()) - assertNull(playback.playing) - assertEquals(1, playback.events.count { it.startsWith("play:") }) - } - - private fun mountEffect(resumed: Boolean = true) { - if (resumed) rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.RESUMED } - rule.setContent { - CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { - if (visible.value) TitleThemeMusic(item.value, preferences.value, playback.player, active.value, viewer.value) - } - } - rule.waitForIdle() - } - - private fun preferences(volume: ThemeSongVolume) = UserPreferences( - AppPreferences.newBuilder().setInterfacePreferences( - InterfacePreferences.newBuilder().setPlayThemeSongs(volume).build(), - ).build(), null, - ) - - private class ThemeLifecycleOwner : LifecycleOwner { - val registry = LifecycleRegistry(this) - override val lifecycle: Lifecycle get() = registry - } - - private class ThemePlayback { - val player = mockk() - val events = mutableListOf() - var playing: UUID? = null - var onStop: () -> Unit = {} - init { - every { player.playThemeFor(any()) } answers { - assertNull("Two title entries must never own theme playback simultaneously", playing) - playing = firstArg() - events += "play:$playing" - } - every { player.stop() } answers { - onStop() - playing = null - events += "stop" - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt index ac7af2db..afa137ee 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt @@ -19,28 +19,26 @@ import org.robolectric.annotation.Config @Config(sdk = [34], application = Application::class) class TitleTrailerTest { private val context = ApplicationProvider.getApplicationContext() - private val url = "https://www.youtube.com/watch?v=ab_-defghij" + private val url = "https://www.youtube.com/watch?v=abcdefghijk" @Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() { - register(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/watch?v=ab_-defghij")), "test.browser") - assertFalse(TitleTrailer(context).open(url) {}) + register(Intent(Intent.ACTION_VIEW, Uri.parse(url)), "test.browser") + assertFalse(TitleTrailer(context).open(url)) assertNull(shadowOf(context as Application).nextStartedActivity) } @Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() { - register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")), "test.youtube") - assertTrue(TitleTrailer(context).open(url) {}) + register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:abcdefghijk")), "test.youtube") + assertTrue(TitleTrailer(context).open(url)) val launched = shadowOf(context as Application).nextStartedActivity - assertEquals(Intent.ACTION_VIEW, launched.action) assertEquals("test.youtube", launched.`package`) assertEquals("vnd.youtube", launched.data?.scheme) - // SmartTube (IntentExtractor.extractVideoId) takes the id from Uri.getHost() for this scheme; the opaque `vnd.youtube:ID` form yields null there. - assertEquals("ab_-defghij", launched.data?.host) + assertEquals("abcdefghijk", launched.data?.schemeSpecificPart) } @Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() { register(Intent(Intent.ACTION_VIEW, Uri.parse("https://trailers.example/video")), "test.browser") - assertFalse(TitleTrailer(context).open("https://trailers.example/video") {}) + assertFalse(TitleTrailer(context).open("https://trailers.example/video")) assertNull(shadowOf(context as Application).nextStartedActivity) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TrailerReturnStateTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TrailerReturnStateTest.kt deleted file mode 100644 index e2d42700..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TrailerReturnStateTest.kt +++ /dev/null @@ -1,150 +0,0 @@ -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().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)) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt index 4fb79d6c..5c55f1fc 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt @@ -113,7 +113,6 @@ class UpstreamContentFocusTest { val model = mockk(relaxed = true) every { model.session } returns WolfskinSession().also { it.state = state } every { model.conductarr } returns ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()) - every { model.updates } returns org.justinvisser.wolfskin.tv.update.FakeUpdateController() every { model.libraryDestinations } returns emptyMap() // An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads. every { model.signedInPerson } returns "unavailable" diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt index 3b9b2377..a7901727 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt @@ -115,13 +115,13 @@ class WatchReadTest { val cache = mockk(relaxed = true) every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L) val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession()) + val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) try { val loading = launch { model.loadLibrary(server, user, person) } runCurrent() assertFalse(loading.isCompleted) assertTrue(model.snapshot.rows.first().watchReady) - assertEquals(1136.274, model.snapshot.rows.single { it.id == "cw" }.cards.single().resumeSeconds, .0001) + assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001) releaseLibrary.complete(Unit) loading.join() assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001) @@ -132,28 +132,4 @@ class WatchReadTest { WholphinDispatchers.reset() } } - - @Test fun mergedHomeUsesSeriesWatchActivityNotAnUnwatchedNextEpisodeDate() = runTest { - val otherSeries = UUID.fromString("00000000-0000-0000-0000-000000000004") - val checkpoint = decode(resumeWire).let { it.copy(seriesId = seriesId, seriesName = "Series A", - userData = it.userData!!.copy(lastPlayedDate = java.time.LocalDateTime.parse("2026-11-05T12:00:00"))) } - val hero = checkpoint.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000005"), type = BaseItemKind.MOVIE, seriesId = null, seriesName = null) - val next = decode(nextWire).let { it.copy(seriesId = seriesId, seriesName = "Series A", - userData = it.userData!!.copy(playbackPositionTicks = 0, lastPlayedDate = null)) } - val otherNext = next.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000006"), seriesId = otherSeries, seriesName = "Series B") - coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(hero, checkpoint)) - coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next, otherNext)) - for ((id, date) in listOf(seriesId to "2026-11-04T12:00:00", otherSeries to "2026-11-07T12:00:00")) { - coEvery { library.getItem(userId = userId, itemId = id) } returns successResponse(BaseItemDto(id = id, type = BaseItemKind.SERIES)) - val watched = checkpoint.copy(userData = checkpoint.userData!!.copy(lastPlayedDate = java.time.LocalDateTime.parse(date))) - coEvery { items.getItems(userId = userId, parentId = id, recursive = true, - includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true, - sortBy = listOf(org.jellyfin.sdk.model.api.ItemSortBy.DATE_PLAYED), - sortOrder = listOf(org.jellyfin.sdk.model.api.SortOrder.DESCENDING), limit = 1) } returns successQueryResult(listOf(watched)) - } - val rows = buildHomeRows(HomeReadFacade(api, images, mockk()).readWatch(userId), emptyList()) - val watching = rows.single { it.id == "cw" }.cards - assertEquals(listOf(otherNext.id.toString(), checkpoint.id.toString()), watching.map { it.id }) - assertEquals(96.078, watching.last().resumeSeconds, .0001) - } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt index 8c200d56..494c8412 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt @@ -47,7 +47,7 @@ class WatchReturnTest { val cache = mockk(relaxed = true) every { cache.read(any()) } returns null val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession()) + val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) val oldFixtures = ConductarrPreference.fixtures ConductarrPreference.updateFixtures(false) try { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinBackStackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinBackStackTest.kt deleted file mode 100644 index 31f71803..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinBackStackTest.kt +++ /dev/null @@ -1,147 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -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.services.NavigationManager -import com.github.damontecres.wholphin.services.SeerrRatingsService -import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService -import com.github.damontecres.wholphin.ui.main.settings.HomePageSettingsState -import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage -import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsViewModel -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage -import com.github.damontecres.wholphin.ui.setup.InstallUpdateState -import com.github.damontecres.wholphin.ui.setup.UpdateViewModel -import com.github.damontecres.wholphin.util.LoadingState -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Assert.* -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.* -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Real full-screen pages keep their local Back ladder before task-level exit. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class WolfskinBackStackTest { - @get:Rule val rule = createAndroidComposeRule() - private lateinit var activity: ComponentActivity - private val navigation = NavigationManager() - private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) - private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") { - when (it) { - ShellAction.Pop -> navigation.goBack() - ShellAction.Exit -> navigation.requestExit() - else -> Unit - } - }.apply { pickerVisible = false; pickerLaunch = false; navFocused = true } - - @Before fun initialize() { activity = rule.activity } - - @Test fun singletonUpdatesBackFinishesInsteadOfBecomingADeadEnd() { - navigation.replace(Destination.UpdateApp) - val model = updatesModel() - rule.setContent { - rememberWolfskinBackHandler(shell, navigation) - WolfskinTheme { InstallUpdatePage(preferences, viewModel = model) } - } - rule.onNodeWithText("No release found! Check the update URL.").assertIsDisplayed() - back() - assertTrue(activity.isFinishing) - assertEquals(listOf(Destination.UpdateApp), navigation.backStack.toList()) - } - - @Test fun pushedUpdatesPopsSettingsThenPickerBeforeRootExit() { - navigation.navigateTo(Destination.Wolfskin("settings")) - navigation.navigateTo(Destination.UpdateApp) - shell.enterDestination("settings") - shell.navFocused = false - val model = updatesModel() - rule.setContent { - rememberWolfskinBackHandler(shell, navigation) - WolfskinTheme { - if (navigation.backStack.last() == Destination.UpdateApp) InstallUpdatePage(preferences, viewModel = model) - else ShellSurface(shell) { SettingsList(shell, it) } - } - } - rule.onNodeWithText("No release found! Check the update URL.").assertIsDisplayed() - back() - assertFalse(activity.isFinishing) - assertEquals(listOf(Destination.Home(), Destination.Wolfskin("settings")), navigation.backStack.toList()) - back() - assertFalse(activity.isFinishing) - assertTrue(shell.pickerVisible) - assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) - back() - assertFalse(shell.pickerVisible) - assertTrue(shell.navFocused) - assertFalse(activity.isFinishing) - back() - assertTrue(activity.isFinishing) - } - - @Test fun unsavedHomeSettingsPromptsBeforeDiscardCanExitTheTask() { - navigation.replace(Destination.HomeSettings) - val model = mockk(relaxed = true) - every { model.state } returns MutableStateFlow(HomePageSettingsState.EMPTY.copy(loading = LoadingState.Success)) - every { model.serverPluginActive } returns MutableStateFlow(false) - every { model.settingsChanged } returns MutableStateFlow(true) - every { model.navigationManager } returns navigation - every { model.discardChanges() } returns Job().apply { complete() } - val images = mockk(relaxed = true) - val ratings = SeerrRatingsService(mockk(relaxed = true), mockk(relaxed = true)) - rule.setContent { - rememberWolfskinBackHandler(shell, navigation) - WolfskinTheme { - CompositionLocalProvider(LocalSeerrRatingsService provides ratings, LocalImageUrlService provides images) { - HomeSettingsPage(preferences, Modifier.fillMaxSize(), viewModel = model) - } - } - } - back() - assertFalse("Back must not discard unsaved settings or finish the task", activity.isFinishing) - assertEquals(listOf(Destination.HomeSettings), navigation.backStack.toList()) - val discard = rule.onNodeWithText("Discard").assertIsDisplayed().assertIsEnabled() - discard.performSemanticsAction(SemanticsActions.RequestFocus) { it() } - discard.assertIsFocused().performKeyInput { pressKey(Key.DirectionCenter) } - rule.waitUntil(10_000) { - Snapshot.sendApplyNotifications() - Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - activity.isFinishing - } - assertEquals(listOf(Destination.HomeSettings), navigation.backStack.toList()) - } - - private fun updatesModel(): UpdateViewModel = mockk(relaxed = true).also { model -> - every { model.state } returns MutableStateFlow(InstallUpdateState(loading = LoadingState.Success)) - every { model.navigationManager } returns navigation - every { model.updater.hasPermissions() } returns true - } - - private fun back() { - rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() } - rule.mainClock.advanceTimeBy(400) - if (!activity.isFinishing) rule.waitForIdle() - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt deleted file mode 100644 index bcc17544..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt +++ /dev/null @@ -1,193 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.os.Build -import androidx.activity.ComponentActivity -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinServerUsers -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.nav.Destination -import io.mockk.coEvery -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import io.mockk.mockk -import java.util.UUID -import kotlinx.coroutines.cancel -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Actual Wolfskin NavDisplay and Android Back finish the activity instead of exposing upstream setup. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class WolfskinRootExitTest { - @get:Rule val rule = createAndroidComposeRule() - private lateinit var activity: ComponentActivity - private lateinit var model: WolfskinShellViewModel - private lateinit var conductarr: ConductarrSession - private val navigation = NavigationManager() - private val session = WolfskinSession() - private var oldFixtures = false - private var oldScreens = true - - @Before fun initialize() { - activity = rule.activity - ConductarrPreference.initialize(activity) - WolfskinScreenPreference.initialize(activity) - oldFixtures = ConductarrPreference.fixtures - oldScreens = WolfskinScreenPreference.enabled - ConductarrPreference.updateFixtures(true) - WolfskinScreenPreference.enabled = true - } - - @After fun cleanup() { - if (::model.isInitialized) model.viewModelScope.cancel() - if (::conductarr.isInitialized) conductarr.close() - ConductarrPreference.updateFixtures(oldFixtures) - WolfskinScreenPreference.enabled = oldScreens - } - - @Test fun homeContentThenNavBackFinishesTheActivity() = verifyRoot("home") - @Test fun discoverContentThenNavBackFinishesTheActivity() = verifyRoot("discover") - @Test fun moviesContentThenNavBackFinishesTheActivity() = verifyRoot("movies") - @Test fun showsContentThenNavBackFinishesTheActivity() = verifyRoot("shows") - @Test fun animeContentThenNavBackFinishesTheActivity() = verifyRoot("anime") - - @Test fun hardwareBackUsesTheSameContentNavExitLadder() { - mount() - val shell = session.state!! - rule.runOnIdle { shell.pickerVisible = false; shell.pickerLaunch = false; shell.navFocused = false; shell.view.row = 1 } - rule.runOnIdle { activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK)) } - assertFalse("Native Back key-down must not run ahead of system Back completion", shell.navFocused) - rule.runOnIdle { activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK)) } - assertTrue(shell.navFocused) - assertFalse(activity.isFinishing) - val captures = File(System.getProperty("wolfskin.captureDirectory"), "system-back").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-home-nav.png").absolutePath) - rule.runOnIdle { - activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK)) - activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK)) - } - rule.mainClock.advanceTimeBy(400) - assertTrue(activity.isFinishing) - assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) - } - - @Test fun coldPickerBackFinishesWithoutSelectingAnAvatarOrShowingUpstreamUi() { - mount() - assertTrue(session.state!!.pickerLaunch) - back() - assertTrue("Back from the cold picker must finish the activity", activity.isFinishing) - assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) - } - - @Test fun reopenedPickerBackDismissesToNavAndTheFollowingBackExits() { - mount() - val shell = session.state!! - rule.runOnIdle { shell.pickerVisible = false; shell.focusNav(shell.tabs.size); shell.key(ShellKey.Ok) } - assertTrue(shell.pickerVisible) - back() - assertFalse(shell.pickerVisible) - assertTrue(shell.navFocused) - assertFalse(activity.isFinishing) - back() - assertTrue(activity.isFinishing) - } - - @Test fun comingInSheetDismissesBeforeContentNavAndExit() { - mount() - val shell = session.state!! - rule.runOnIdle { - shell.pickerVisible = false - shell.pickerLaunch = false - shell.navFocused = false - shell.openRequests() - } - assertTrue(shell.requestsVisible) - back() - assertFalse(shell.requestsVisible) - assertFalse(shell.navFocused) - assertFalse(activity.isFinishing) - back() - assertTrue(shell.navFocused) - assertFalse(activity.isFinishing) - back() - assertTrue(activity.isFinishing) - } - - private fun verifyRoot(route: String) { - mount() - val shell = session.state!! - val destination = if (route == "home") Destination.Home() else Destination.Wolfskin(route) - rule.runOnIdle { - shell.pickerVisible = false - shell.pickerLaunch = false - shell.enterDestination(route) - navigation.replace(destination) - } - rule.mainClock.advanceTimeBy(1200) - rule.waitForIdle() - rule.waitUntil(10_000) { - Snapshot.sendApplyNotifications() - Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - shell.contentFocus?.hasContent == true && - (route !in LIBRARY_DESTINATIONS || shell.rememberedView(route).library?.status == LibraryStatus.Ready) - } - rule.runOnIdle { shell.navFocused = false } - back() - assertFalse("The first Back must not finish $route content", activity.isFinishing) - assertTrue("The first Back must move $route content to nav", shell.navFocused) - assertEquals(listOf(destination), navigation.backStack.toList()) - back() - assertTrue("Back from $route nav must finish the activity", activity.isFinishing) - assertEquals("Exit cannot detour to old Home", listOf(destination), navigation.backStack.toList()) - } - - private fun mount() { - val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) - val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null) - val repository = mockk(relaxed = true) - coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user)) - val libraries = mockk(relaxed = true) - coEvery { libraries.readDestinations(user.id) } returns emptyMap() - conductarr = ConductarrSession(MemoryCacheStore()) - model = WolfskinShellViewModel(activity, session, repository, navigation, mockk(relaxed = true), - mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) - val home = HomeFixture(activity).snapshot("justin") - rule.setContent { - WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, - homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} }) - } - rule.waitUntil(10_000) { - Snapshot.sendApplyNotifications() - Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - session.state?.people?.size == 3 && model.signedInPerson == "justin" - } - rule.waitForIdle() - } - - private fun back() { - rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() } - rule.mainClock.advanceTimeBy(400) - if (!activity.isFinishing) rule.waitForIdle() - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt deleted file mode 100644 index 775a63ca..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt +++ /dev/null @@ -1,209 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.graphics.BitmapFactory -import android.content.Intent -import android.os.Looper -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.snapshots.Snapshot -import org.robolectric.Shadows -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.datastore.core.DataStore -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.async -import kotlinx.coroutines.Dispatchers -import com.github.damontecres.wholphin.MainActivityViewModel -import com.github.damontecres.wholphin.MainContent -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.RestoredSession -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinServerUsers -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.* -import com.github.takahirom.roborazzi.captureRoboImage -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import io.mockk.coVerify -import java.io.File -import java.io.IOException -import java.util.UUID -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.robolectric.RobolectricTestRunner -import org.justinvisser.wolfskin.design.WolfskinTokens -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Exercise the actual MainContent boundary, not a parallel copy of its destination mapping. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class WolfskinSetupNavigationTest { - @get:Rule val rule = createAndroidComposeRule() - private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null) - private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token") - private val repository = mockk(relaxed = true) - private val navigation = NavigationManager() - private val setup = mockk(relaxed = true) - private val stack = mutableStateListOf(SetupDestination.Loading) - private val conductarr = ConductarrSession(MemoryCacheStore()) - private val model = WolfskinSetupViewModel(repository, navigation, setup, WolfskinSession(), conductarr) - private val preferences = mockk>() - private val intentService = mockk() - private var previousScreens = true - private var previousFixtures = false - - @Before fun configure() { - WolfskinScreenPreference.initialize(rule.activity) - previousScreens = WolfskinScreenPreference.enabled - WolfskinScreenPreference.enabled = true - ConductarrPreference.initialize(rule.activity) - previousFixtures = ConductarrPreference.fixtures - ConductarrPreference.updateFixtures(false) - every { repository.current } returns MutableStateFlow(null) - coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(server) - every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user)) - every { repository.serverDao.getServers() } returns listOf(JellyfinServerUsers(server, listOf(user))) - every { setup.navigateTo(any()) } answers { stack[0] = firstArg() } - every { preferences.data } returns MutableStateFlow(AppPreferences.getDefaultInstance()) - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - } - - @After fun close() { - conductarr.close() - WolfskinScreenPreference.enabled = previousScreens - ConductarrPreference.updateFixtures(previousFixtures) - } - - private fun mount() { - val screensaver = mockk(relaxed = true) - every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) - rule.setContent { - MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), - mockk(relaxed = true), screensaver, liveSession = repository.current, trailerReturn = mockk(relaxed = true), modifier = Modifier, - setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) }) - } - } - - private fun appModel() = MainActivityViewModel(rule.activity, preferences, repository, setup, navigation, - mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intentService, trailerReturn = mockk(relaxed = true)) - - private fun awaitStage(stage: WolfskinSetupViewModel.Stage) { - rule.waitUntil(5_000) { - Snapshot.sendApplyNotifications() - Shadows.shadowOf(Looper.getMainLooper()).idle() - model.stage == stage - } - } - - private fun startApp() { - val startupModel = appModel() - val result = rule.activity.lifecycleScope.async(Dispatchers.Default) { startupModel.handleAppStart() } - rule.waitUntil(15_000) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - result.isCompleted - } - runBlocking { result.await() } - } - - private fun assertOwnPicker() { - awaitStage(WolfskinSetupViewModel.Stage.Picker) - rule.onNodeWithText("Who’s watching?").assertIsDisplayed() - rule.onNodeWithText("Justin").assertIsDisplayed() - rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist() - rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist() - } - - @Test fun coldStartLegacySignOutDrawerSwitchAndIntentFailureNeverComposeStockSelectors() { - startApp() - mount() - assertOwnPicker() - // The legacy sign-out-equivalent erased the recent preference, but Room still has the household. - coEvery { repository.getMostRecentServer() } returns RestoredSession.None - rule.runOnIdle { setup.navigateTo(SetupDestination.ServerList) } - assertOwnPicker() - rule.runOnIdle { setup.navigateTo(SetupDestination.UserList(server)) } - assertOwnPicker() - coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Cannot restore sign-in") - rule.runOnIdle { rule.activity.lifecycleScope.launch { appModel().handleIntent(Intent()) } } - rule.waitUntil(5_000) { stack.last() == SetupDestination.ServerList } - assertOwnPicker() - coVerify(exactly = 0) { repository.switchServerOrUser() } - } - - @GraphicsMode(GraphicsMode.Mode.NATIVE) - @Test fun coldStartSavedPeopleKeepTheirOwnColorsWithoutConductarr() { - val saved = listOf(user, user.copy(id = UUID.randomUUID(), name = "Mindel"), - user.copy(id = UUID.randomUUID(), name = "Shared")) - every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, saved) - startApp() - mount() - assertOwnPicker() - saved.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() } - rule.mainClock.advanceTimeBy(1_000) - rule.waitForIdle() - val people = model.picker!!.people - val bounds = people.map { rule.onNodeWithTag("picker-person:${it.key}").fetchSemanticsNode().boundsInRoot } - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "setup").apply { mkdirs() } - val frame = File(directory, "cold-start-people.png") - rule.onRoot().captureRoboImage(frame) - val bitmap = BitmapFactory.decodeFile(frame.absolutePath) - try { - val fills = bounds.map { bitmap.getPixel(it.center.x.toInt(), (it.top + it.width / 5).toInt()) } - assertEquals(listOf(WolfskinTokens.PersonColor.justin.toArgb(), - WolfskinTokens.PersonColor.mindel.toArgb(), WolfskinTokens.PersonColor.shared.toArgb()), fills) - } finally { - bitmap.recycle() - } - assertNull(conductarr.identity.value) - assertEquals(saved.map { it.id.toString() }, people.map { it.jellyfinUserId }) - } - - @GraphicsMode(GraphicsMode.Mode.NATIVE) - @Test fun unreachableSelectionRendersWolfskinRecoveryFrame() { - stack[0] = SetupDestination.UserList(server) - coEvery { repository.changeUser(server, user) } throws IOException("offline") - mount() - assertOwnPicker() - rule.onNodeWithText("Justin").performClick() - awaitStage(WolfskinSetupViewModel.Stage.Unavailable) - rule.onNodeWithText("Try again").assertIsDisplayed() - rule.onNodeWithText("Sign in").assertIsDisplayed() - rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist() - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "setup").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "household-unreachable.png")) - } - - @Test fun unconfiguredFirstRunOffersUrlInsteadOfStockServerSelector() { - coEvery { repository.getMostRecentServer() } returns RestoredSession.None - every { repository.serverDao.getServers() } returns emptyList() - stack[0] = SetupDestination.ServerList - mount() - awaitStage(WolfskinSetupViewModel.Stage.Unconfigured) - rule.onNodeWithText("Connect your household server").assertIsDisplayed() - rule.onNodeWithTag("household-server-url").assertIsDisplayed().performTextInput("https://jellyfin.test") - rule.onNodeWithText("Try again").assertIsEnabled() - rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist() - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt deleted file mode 100644 index 3e855e33..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.content.pm.ActivityInfo -import android.os.Looper -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.test.core.app.ApplicationProvider -import com.github.damontecres.wholphin.MainContent -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.ScreensaverService -import com.github.damontecres.wholphin.services.ScreensaverState -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.test.TestActivity -import com.github.damontecres.wholphin.ui.TestModule -import com.github.damontecres.wholphin.ui.successResponse -import com.github.damontecres.wholphin.util.WholphinDispatchers -import com.github.damontecres.wholphin.util.configure -import com.github.damontecres.wholphin.util.reset -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import dagger.hilt.android.testing.HiltTestApplication -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import java.util.UUID -import javax.inject.Inject -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import org.jellyfin.sdk.Jellyfin -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.quickConnectApi -import org.jellyfin.sdk.api.client.extensions.systemApi -import org.jellyfin.sdk.api.client.extensions.userApi -import org.jellyfin.sdk.api.operations.QuickConnectApi -import org.jellyfin.sdk.api.operations.SystemApi -import org.jellyfin.sdk.api.operations.UserApi -import org.jellyfin.sdk.model.api.PublicSystemInfo -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TestWatcher -import org.junit.runner.Description -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import org.robolectric.annotation.Config - -/** The rollout switch must still expose both real upstream selectors, not a second forked copy. */ -@OptIn(ExperimentalCoroutinesApi::class) -@HiltAndroidTest -@Config(application = HiltTestApplication::class, sdk = [34]) -@RunWith(RobolectricTestRunner::class) -class WolfskinSetupRollbackTest { - @get:Rule(order = 0) val hilt = HiltAndroidRule(this) - @get:Rule(order = 1) val registerActivity = object : TestWatcher() { - override fun starting(description: Description?) { - val context = ApplicationProvider.getApplicationContext() - shadowOf(context.packageManager).addOrUpdateActivity(ActivityInfo().apply { - name = TestActivity::class.java.name - packageName = context.packageName - }) - } - } - @get:Rule(order = 2) val rule = createAndroidComposeRule() - @Inject lateinit var api: ApiClient - @Inject lateinit var jellyfin: Jellyfin - private var previousScreens = true - - @Before fun setup() { - hilt.inject() - WholphinDispatchers.configure(TestModule.testDispatcher) - WolfskinScreenPreference.initialize(rule.activity) - previousScreens = WolfskinScreenPreference.enabled - WolfskinScreenPreference.enabled = false - val users = mockk() - val system = mockk() - val quickConnect = mockk() - every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api - every { api.userApi } returns users - every { api.systemApi } returns system - every { api.quickConnectApi } returns quickConnect - coEvery { users.getPublicUsers() } returns successResponse(emptyList()) - coEvery { system.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(version = "10.11.11")) - coEvery { quickConnect.getQuickConnectEnabled() } returns successResponse(false) - } - - @After fun restore() { - WolfskinScreenPreference.enabled = previousScreens - WholphinDispatchers.reset() - } - - @Test fun rollbackRendersBothStockSelectors() { - val stack = mutableStateListOf(SetupDestination.ServerList) - val screensaver = mockk() - every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) - rule.setContent { - MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null), - mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null), trailerReturn = mockk(relaxed = true)) - } - fun awaitHeading(resource: Int) { - rule.waitUntil(5_000) { - TestModule.testDispatcher.scheduler.advanceUntilIdle() - shadowOf(Looper.getMainLooper()).idle() - rule.onAllNodes(androidx.compose.ui.test.hasText(rule.activity.getString(resource))).fetchSemanticsNodes().isNotEmpty() - } - rule.onNodeWithText(rule.activity.getString(resource)).assertIsDisplayed() - rule.onNodeWithText("Who’s watching?").assertDoesNotExist() - } - awaitHeading(R.string.select_server) - rule.runOnIdle { stack[0] = SetupDestination.UserList(JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)) } - awaitHeading(R.string.select_user) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupTest.kt deleted file mode 100644 index 0c1515cc..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupTest.kt +++ /dev/null @@ -1,238 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.room.Room -import androidx.test.core.app.ApplicationProvider -import com.github.damontecres.wholphin.data.AppDatabase -import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.MostRecentServer -import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.services.SetupNavigationManager -import com.github.damontecres.wholphin.ui.successResponse -import com.github.damontecres.wholphin.ui.TestModule -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import java.io.IOException -import java.util.UUID -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.async -import kotlinx.coroutines.test.runTest -import org.jellyfin.sdk.model.ClientInfo -import org.jellyfin.sdk.model.DeviceInfo -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.exception.InvalidStatusException -import org.jellyfin.sdk.api.client.extensions.systemApi -import org.jellyfin.sdk.api.client.extensions.userApi -import org.jellyfin.sdk.api.operations.SystemApi -import org.jellyfin.sdk.api.operations.UserApi -import org.jellyfin.sdk.discovery.RecommendedServerInfo -import org.jellyfin.sdk.discovery.RecommendedServerInfoScore -import org.jellyfin.sdk.model.api.AuthenticationResult -import org.jellyfin.sdk.model.api.PublicSystemInfo -import org.jellyfin.sdk.model.api.UserDto -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.tv.WolfskinSetupViewModel.Stage -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Setup preserves saved credentials, rejects protected-profile bypasses and recovers real auth failures. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class WolfskinSetupTest { - private val context get() = ApplicationProvider.getApplicationContext() - private val database by lazy { Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).allowMainThreadQueries().build() } - private val recent by lazy { MostRecentServerSharedPreferences(context) } - private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null) - private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "saved-test-token") - private val api = mockk(relaxed = true) - private val usersApi = mockk() - private val systemApi = mockk() - private val jellyfin by lazy { TestModule.jellyfin(mockk(), context, ClientInfo("Wolfskin test", "test"), DeviceInfo("setup-test", "Setup test")) } - private val navigation = NavigationManager() - private val setup = mockk(relaxed = true) - private val session = WolfskinSession() - private val conductarr = ConductarrSession(MemoryCacheStore()) - private val repository by lazy { ServerRepository(context, jellyfin, database.serverDao(), api, Dispatchers.IO, recent) } - private val model by lazy { WolfskinSetupViewModel(repository, navigation, setup, session, conductarr) } - private val dto get() = UserDto(id = user.id, name = user.name, hasPassword = true, hasConfiguredPassword = true, hasConfiguredEasyPassword = false) - private var previousFixtures = false - - @Before fun saveHousehold(): Unit = runBlocking { - ConductarrPreference.initialize(context) - previousFixtures = ConductarrPreference.fixtures - ConductarrPreference.updateFixtures(false) - recent.clear() - database.serverDao().addOrUpdateServer(server) - user = database.serverDao().addOrUpdateUser(user) - recent.save(CurrentUser(server, user)) - val client = jellyfin - every { api.userApi } returns usersApi - every { api.systemApi } returns systemApi - every { client.createApi(any(), any(), any(), any(), any()) } returns api - coEvery { usersApi.getCurrentUser() } returns successResponse(dto) - coEvery { systemApi.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(id = server.id.toString(), serverName = server.name)) - } - - @After fun close() { - conductarr.close() - database.close() - ConductarrPreference.updateFixtures(previousFixtures) - } - - @Test fun enteringPickerPreservesServerAndUserAndRecoversErasedPreferenceFromRoom() = runBlocking { - val saved = recent.get() - model.load(null) - assertEquals(Stage.Picker, model.stage) - assertEquals(user.id.toString(), model.picker!!.person.jellyfinUserId) - assertEquals(saved, recent.get()) - recent.clear() - model.load(null) - assertEquals(server, model.server) - assertEquals(Stage.Picker, model.stage) - assertEquals(MostRecentServer.None, recent.get()) - } - - @Test fun expiredTokenRequiresCredentialsAndSuccessfulLoginRestoresSessionWithoutSecondPicker() = runBlocking { - model.load(server) - coEvery { usersApi.getCurrentUser() } throws InvalidStatusException(401) - model.select(model.picker!!.person) - assertEquals(Stage.Login, model.stage) - assertEquals(user, model.loginUser) - assertNull(repository.current.value) - assertTrue(recent.get() is MostRecentServer.ServerAndUser) - coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(user = dto, serverId = server.id.toString(), accessToken = "renewed-test-token")) - coEvery { usersApi.getCurrentUser() } returns successResponse(dto) - model.login(user.name!!, "test-password") - assertEquals("renewed-test-token", repository.current.value!!.user.accessToken) - assertEquals(user.id, (recent.get() as MostRecentServer.ServerAndUser).userId) - assertFalse(session.state!!.pickerVisible) - assertEquals(user.id.toString(), session.state!!.person.jellyfinUserId) - } - - @Test fun unreachableServerOffersRetryAndSavedPersonCanSignInWhenItReturns() = runTest { - model.load(server) - val saved = recent.get() - coEvery { usersApi.getCurrentUser() } throws IOException("offline") - model.select(model.picker!!.person) - assertEquals(Stage.Unavailable, model.stage) - assertEquals(saved, recent.get()) - val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() } - assertEquals(Stage.Loading, model.stage) - retry.await() - assertEquals(Stage.Picker, model.stage) - coEvery { usersApi.getCurrentUser() } returns successResponse(dto) - model.select(model.picker!!.person) - assertEquals(user.id, repository.current.value!!.user.id) - assertFalse(session.state!!.pickerVisible) - } - - @Test fun retryCanRemainUnavailableWithoutErasingHousehold() = runTest { - database.serverDao().deleteUser(server.id, user.id) - val saved = recent.get() - coEvery { usersApi.getPublicUsers() } throws IOException("offline") - model.load(null) - assertEquals(Stage.Unavailable, model.stage) - val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() } - assertEquals(Stage.Loading, model.stage) - retry.await() - assertEquals(Stage.Unavailable, model.stage) - assertEquals(server, model.server) - assertEquals(saved, recent.get()) - } - - @Test fun pinAndRequireLoginCannotReuseSavedTokenWithoutUnlocking() = runBlocking { - user = user.copy(pin = "URDL") - database.serverDao().updateUser(user) - model.load(server) - model.select(model.picker!!.person) - assertEquals(user, model.pinUser) - assertTrue(model.picker!!.pickerVisible) - model.enterPin("WRONG") - assertNull(repository.current.value) - model.enterPin("URDL") - assertEquals(user.id, repository.current.value!!.user.id) - user = user.copy(pin = null, requireLogin = true) - database.serverDao().updateUser(user) - model.load(server) - model.select(model.picker!!.person) - assertEquals(Stage.Login, model.stage) - assertEquals(user, model.loginUser) - } - - @Test fun unavailablePublicProfilesKeepHouseholdAndAllowCredentialRecovery() = runBlocking { - database.serverDao().deleteUser(server.id, user.id) - coEvery { usersApi.getPublicUsers() } throws IOException("offline") - model.load(null) - assertEquals(Stage.Unavailable, model.stage) - assertEquals(server, model.server) - model.showLogin(null) - coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult( - user = dto, serverId = server.id.toString(), accessToken = "new-test-token")) - model.login(user.name!!, "test-password") - assertEquals(user.id, repository.current.value!!.user.id) - } - - @Test fun expiredLaunchRecoveryIgnoresOutagesLateIdentitiesAndRollbackMode() = runBlocking { - WolfskinScreenPreference.initialize(context) - val previous = WolfskinScreenPreference.enabled - try { - WolfskinScreenPreference.enabled = true - val expected = (repository.restoreSavedSession() as com.github.damontecres.wholphin.data.RestoredSession.Success).currentUser - var destination: SetupDestination = SetupDestination.AppContent(expected) - every { setup.navigateTo(any()) } answers { destination = firstArg() } - recoverWolfskinSession(context, IOException("offline"), expected, repository, setup) - assertTrue(destination is SetupDestination.AppContent) - WolfskinScreenPreference.enabled = false - recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup) - assertTrue(destination is SetupDestination.AppContent) - WolfskinScreenPreference.enabled = true - val other = database.serverDao().addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel")) - recent.save(CurrentUser(server, other)) - repository.restoreSavedSession() - recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup) - assertTrue(destination is SetupDestination.AppContent) - recoverWolfskinSession(context, InvalidStatusException(401), repository.current.value, repository, setup) - model.load((destination as SetupDestination.UserList).server) - assertEquals(Stage.Picker, model.stage) - assertTrue(model.picker!!.people.any { it.jellyfinUserId == other.id.toString() }) - assertEquals(other.id, (recent.get() as MostRecentServer.ServerAndUser).userId) - } finally { WolfskinScreenPreference.enabled = previous } - } - - @Test fun firstRunDiscoversAndPersistsHouseholdBeforeFetchingPublicProfiles() = runBlocking { - database.serverDao().deleteUser(server.id, user.id) - database.serverDao().deleteServer(server.id) - recent.clear() - model.load(null) - assertEquals(Stage.Unconfigured, model.stage) - coEvery { jellyfin.discovery.getRecommendedServers("bad-url") } returns emptyList() - model.connect("bad-url") - assertEquals(Stage.Unconfigured, model.stage) - assertTrue(database.serverDao().getServers().isEmpty()) - coEvery { jellyfin.discovery.getRecommendedServers(server.url) } returns listOf(RecommendedServerInfo( - address = server.url, responseTime = 1, score = RecommendedServerInfoScore.GREAT, issues = emptyList(), - systemInfo = Result.success(PublicSystemInfo(id = server.id.toString(), serverName = server.name, startupWizardCompleted = true)))) - model.connect(server.url) - assertEquals(server.id, database.serverDao().getServers().single().server.id) - coEvery { usersApi.getPublicUsers() } returns successResponse(listOf(dto)) - model.load(model.server) - assertEquals(Stage.Picker, model.stage) - model.select(model.picker!!.person) - assertEquals(Stage.Login, model.stage) - assertNull(repository.current.value) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt index 3d1ed93a..e593675d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt @@ -92,8 +92,6 @@ class UpdatesKeysTest { launch(UpdateUiState.Checking, settings = true) repeat(9) { press(listOf(Key.DirectionDown)) } assertEquals(9, shell.settingsRow) - // Visual settling precedes the spring's idle state and final row boundary. - rule.mainClock.advanceTimeUntil(timeoutMillis = 320) { !shell.view.listState.isScrollInProgress } assertEquals(4, shell.view.listState.firstVisibleItemIndex) capture("classic") press(listOf(Key.DirectionCenter)) @@ -106,10 +104,10 @@ class UpdatesKeysTest { assertTrue(shell.contentOverlayVisible) assertFalse(shell.navFocused) assertTrue(controller.autoCheck.value) - press(listOf(Key.Escape)) + press(listOf(Key.Back)) assertFalse(shell.contentOverlayVisible) assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex) - press(listOf(Key.Escape)) + press(listOf(Key.Back)) assertEquals("settings", shell.route) assertEquals(1, shell.settingsRow) assertEquals(0, shell.view.listState.firstVisibleItemIndex) @@ -122,7 +120,7 @@ class UpdatesKeysTest { assertTrue(range.value() > 0f) assertFalse(shell.navFocused) assertFalse((shell.contentFocus as UpdateFocus).autoFocused) - press(listOf(Key.Escape)) + press(listOf(Key.Back)) assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt index 56f39145..9030249d 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt @@ -115,16 +115,10 @@ private fun Title.titleItem(provider: ConductarrProvider): TitleItem = TitleItem otherOwners = provider.holders(this).toList(), tmdbId = tmdbId, ) -internal fun TitlePageData.requestTitle(viewer: ShellPerson): DiscoverTitle? { - if (item.kind != "movie" && item.kind != "tv") return null - val tmdb = item.tmdbId?.takeIf { it > 0 } - ?: item.id.takeIf { it.startsWith("${item.kind}:") }?.substringAfter(':')?.toLongOrNull()?.takeIf { it > 0 } - ?: return null - return DiscoverTitle( - tmdb, item.kind, item.name, item.year, - genres = item.genres, overview = item.overview, poster = item.poster, backdrop = item.backdrop, - runtimeSeconds = item.runtimeSeconds.toInt(), seasonCount = item.seasons.takeIf { it > 0 }, - seasons = seasonNumbers.filter { it > 0 }.takeIf { it.isNotEmpty() }, ratings = item.ratings.map { DiscoverRating(it.source, it.score, it.label) }, - heldBy = item.otherOwners.toSet() + if (item.held) setOf(viewer.key) else emptySet(), - ) -} +internal fun TitlePageData.requestTitle(viewer: ShellPerson): DiscoverTitle = DiscoverTitle( + item.tmdbId ?: item.id.substringAfter(':').toLongOrNull() ?: error("Request needs a catalogue identity"), if (item.kind == "movie") "movie" else "tv", item.name, item.year, + genres = item.genres, overview = item.overview, poster = item.poster, backdrop = item.backdrop, + runtimeSeconds = item.runtimeSeconds.toInt(), seasonCount = item.seasons.takeIf { it > 0 }, + seasons = seasonNumbers.filter { it > 0 }.takeIf { it.isNotEmpty() }, ratings = item.ratings.map { DiscoverRating(it.source, it.score, it.label) }, + heldBy = item.otherOwners.toSet() + if (item.held) setOf(viewer.key) else emptySet(), +) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index d1bd3482..35f10b1e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.ensureActive import org.justinvisser.wolfskin.tv.read.playbackPositionMs import org.justinvisser.wolfskin.design.* import org.justinvisser.wolfskin.tv.read.HomeLibraryCache @@ -38,7 +37,6 @@ class WolfskinHomeViewModel @Inject constructor( private val navigation: NavigationManager, private val seeAll: WolfskinSeeAllLists, private val conductarr: ConductarrSession, - val session: WolfskinSession, ) : ViewModel() { val conductarrState get() = conductarr.identity val audiences get() = home.audiences @@ -51,15 +49,6 @@ class WolfskinHomeViewModel @Inject constructor( private var loadRevision = 0L private var catalogueSnapshot = DiscoverSnapshot() private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null - private var playbackJob: kotlinx.coroutines.Job? = null - private var playbackRevision = 0L - - /** Leaving Home invalidates late preparation results as well as a pending black handoff. */ - fun cancelPlayback() { - playbackRevision++ - playbackJob?.cancel() - session.playback.fail() - } suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { catalogueJob?.cancel() @@ -175,45 +164,23 @@ class WolfskinHomeViewModel @Inject constructor( HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) } is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell) is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id))) - is HomeAction.Play -> startPlayback(action.title, shell, server, user) - } - } - - private fun startPlayback(title: HomeTitle, shell: ShellState, server: JellyfinServer, user: JellyfinUser) { - val start = session.playback - if (start.starting != null) return - playbackJob?.cancel() - val revision = ++playbackRevision - val personKey = shell.person.key - start.begin("hero-play") - playbackJob = viewModelScope.launch { - if (revision != playbackRevision || start.starting == null) return@launch - val selectedId = shell.person.jellyfinUserId - val matchesUser = if (selectedId != null) selectedId == user.id.toString() else - personKey == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && personKey == conductarr.identity.value?.operator?.requester) - if (!snapshot.jellyfinAvailable || !matchesUser) { - start.fail() - showToast(RecoveryMessage.PlaybackUnavailable) - return@launch - } - try { - val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, title) } - coroutineContext.ensureActive() - if (revision != playbackRevision || start.starting == null) return@launch - if (item == null) { - start.fail() + is HomeAction.Play -> viewModelScope.launch { + val personKey = shell.person.key + val selectedId = shell.person.jellyfinUserId + val matchesUser = if (selectedId != null) selectedId == user.id.toString() else + shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester) + if (!snapshot.jellyfinAvailable || !matchesUser) { + showToast(RecoveryMessage.PlaybackUnavailable) + return@launch + } + try { + val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) } + if (item == null) showToast(RecoveryMessage.PlaybackUnavailable) + else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs())) + } catch (exception: Exception) { + if (exception is CancellationException) throw exception 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 } } } @@ -267,7 +234,6 @@ fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUse val identity by model.conductarrState.collectAsState() val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}") LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { model.load(server, user, shell.person) } - DisposableEffect(shell.person.key, server.id, user.id) { onDispose { model.cancelPlayback() } } val view = shell.rememberedView("home") LaunchedEffect(view.row, model.snapshot.rows) { val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index e497cc5e..4db5512d 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -4,10 +4,8 @@ import android.content.Context import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -16,19 +14,15 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.type import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.compose.LifecycleResumeEffect import org.justinvisser.wolfskin.design.DiscoverOverlay import org.justinvisser.wolfskin.design.ShellKey -import org.justinvisser.wolfskin.design.isScreenEntryActive import org.justinvisser.wolfskin.design.readShellKey import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.ui.nav.Destination import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -71,8 +65,6 @@ class WolfskinTitleViewModel @Inject constructor( private val navigation: NavigationManager, val conductarr: ConductarrSession, private val repository: ServerRepository, - val themeSongPlayer: ThemeSongPlayer, - private val trailerReturn: TrailerReturnState, ) : ViewModel() { var title by mutableStateOf(null) private set @@ -85,42 +77,22 @@ class WolfskinTitleViewModel @Inject constructor( private var personReadPending = false private var personReadFinished = false private var loadJob: Job? = null - private var playbackJob: Job? = null - private var playbackGeneration = 0 - private val seasonEpisodes = mutableMapOf>() private val saveMutex = Mutex() private var performerPage = 1 var conductarrAction: ((TitleAction) -> Unit)? = null val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}" - fun openRoute(route: String) { - 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() - } + fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route)) + fun popRoute() = navigation.goBack() private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) } /** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */ fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) { if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return - val sameTitle = route == destination && ::viewer.isInitialized && viewer.key == selected.key && !destination.startsWith("person/") - if (!sameTitle) { - cancelPlayback() - seasonEpisodes.clear() - } - val readyTitle = sameTitle && title?.data?.status == TitleReadStatus.Ready - val fixtureRefresh = title?.takeIf { force && readyTitle && fixture != null && fixtures && BuildConfig.DEBUG } - if (force && readyTitle && fixture == null && - (title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true)) { + if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key && + title?.data?.status == TitleReadStatus.Ready && title?.data?.item?.held == true && + (title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) && + !destination.startsWith("person/") && fixture == null) { loadJob?.cancel() preferences = userPreferences title?.let { it.update(it.data.copy(watchReady = false)) } @@ -130,19 +102,14 @@ class WolfskinTitleViewModel @Inject constructor( route = destination loadJob?.cancel() person = null - if (fixtureRefresh == null) fixture = null + fixture = null personReadPending = false personReadFinished = false performerPage = 1 viewer = selected preferences = userPreferences val id = Uri.decode(destination.substringAfter('/')) - if (fixtureRefresh == null) { - cancelPlayback() - // Losing authorization clears the old data, but is not another first-load skeleton. - val status = if (sameTitle && title?.data?.status != TitleReadStatus.Loading) TitleReadStatus.Unavailable else TitleReadStatus.Loading - title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = status), viewer, ::handle) - } + title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = TitleReadStatus.Loading), viewer, ::handle) loadJob = viewModelScope.launch { if (fixtures && BuildConfig.DEBUG && id.toUuidOrNull() == null) { val source = fixtureProvider.read() @@ -154,34 +121,18 @@ class WolfskinTitleViewModel @Inject constructor( val data = withContext(Dispatchers.IO) { source.title(id, selected.key) } val key = "${viewer.key}:${data.item.id}" val watchKey = "${viewer.key}:${data.watchTarget.id}" - val restored = data.copy( + title = TitleState(data.copy( selectedVersion = fixtureSelections.getString("$key:version", null), selectedAudio = if (fixtureSelections.contains("$key:audio")) fixtureSelections.getInt("$key:audio", -1) else null, selectedSubtitle = fixtureSelections.getInt("$key:subtitle", -1), watchTarget = data.watchTarget.copy(watched = fixtureSelections.getBoolean("$watchKey:watched", data.watchTarget.watched)), hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden), - ) - if (fixtureRefresh != null) { - if (title === fixtureRefresh) updateTitle(fixtureRefresh, restored) - } else { - title = TitleState(restored, viewer, ::handle) - rememberSeason(restored) - } + ), viewer, ::handle) } } else if (destination.startsWith("person/")) readPerson(id) else readTitle(id) } } - private fun rememberSeason(data: TitlePageData) { - if (data.episodesStatus == TitleReadStatus.Ready) data.selectedSeason?.let { seasonEpisodes[it] = data.episodes } - } - - private fun updateTitle(current: TitleState, data: TitlePageData) { - if (title !== current) return - current.update(data) - rememberSeason(data) - } - private suspend fun readTitle(id: String, retry: Boolean = false) { val current = title ?: return val uuid = id.toUuidOrNull() @@ -192,9 +143,9 @@ class WolfskinTitleViewModel @Inject constructor( val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) } if (title !== current) return if (heldId != null) { - if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { updateTitle(current, it) } + if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) } held = facade.read(heldId, current.requestedSeason, preferences) - updateTitle(current, held) + current.update(held) } } catch (exception: Exception) { if (exception is CancellationException) throw exception @@ -209,7 +160,7 @@ class WolfskinTitleViewModel @Inject constructor( if (identity != null && tmdb != null) { val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV ConductarrTitlePages(identity).title(tmdb, media, held).collect { data -> - if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) updateTitle(current, data) + if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) current.update(data) } } else if (uuid == null) current.update(current.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut, requestsAllowed = false)) } @@ -280,21 +231,11 @@ class WolfskinTitleViewModel @Inject constructor( private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List) = PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } } - /** A callback retained during exit must hand Back to the live destination, not pop its old title again. */ - internal inline fun handleBack(route: String, shell: ShellState, onCurrentEntry: () -> Unit) { - if (navigation.backStack.last().toWolfskinRoute() == route) onCurrentEntry() - else handleWolfskinBack(shell, navigation) - } - private fun handle(action: TitleAction) { when (action) { - TitleAction.Pop -> popRoute() - is TitleAction.Person -> { - cancelPlayback() - navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id))) - } + TitleAction.Pop -> navigation.goBack() + is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id))) is TitleAction.OpenTitle -> { - cancelPlayback() val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE @@ -306,36 +247,22 @@ class WolfskinTitleViewModel @Inject constructor( is TitleAction.Play -> { val opened = title?.data?.watchTarget play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(), - restart = action.restart, control = if (action.restart) "restart" else "play") + restart = action.restart) } is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true) - else { - themeSongPlayer.stop() - 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." - } - } + else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer." is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action) is TitleAction.Season -> { - val current = title ?: return loadJob?.cancel() - val cached = seasonEpisodes[action.number] - if (cached == null) current.showSeasonLoading() else current.showSeason(action.number, cached) loadJob = viewModelScope.launch { + val current = title ?: return@launch + current.showSeasonLoading() val source = fixture if (source == null) readTitle(Uri.decode(route.substringAfter('/'))) - else try { + else { val data = withContext(Dispatchers.IO) { source.title(Uri.decode(route.substringAfter('/')), viewer.key, action.number) } - updateTitle(current, data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex, + current.update(data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex, selectedSubtitle = current.subtitleIndex, watchTarget = current.data.watchTarget.copy(watched = current.watched), hidden = current.data.hidden)) - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason() - current.toast = RecoveryMessage.StillUnreachable } } } @@ -354,33 +281,18 @@ class WolfskinTitleViewModel @Inject constructor( } } - private fun play(id: UUID?, restart: Boolean, control: String = "restart") { - val current = title ?: return - if (current.playbackStart.starting != null) return - val generation = ++playbackGeneration - playbackJob?.cancel() - current.playbackStart.begin(control) + private fun play(id: UUID?, restart: Boolean) { + val current = title val playbackScope = watchScope - playbackJob = viewModelScope.launch { + viewModelScope.launch { try { if (id == null) throw IllegalArgumentException("No Jellyfin playback identity") val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item") - if (generation != playbackGeneration) return@launch - if (title !== current || watchScope != playbackScope || current.playbackStart.starting == null) { - current.playbackStart.fail() - return@launch - } - current.playbackStart.ready { - if (generation == playbackGeneration && title === current && watchScope == playbackScope) { - themeSongPlayer.stop() - navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs())) - } - } + if (title !== current || watchScope != playbackScope) return@launch + navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs())) } catch (exception: Exception) { - val active = generation == playbackGeneration && current.playbackStart.starting != null - if (active) current.playbackStart.fail() if (exception is CancellationException) throw exception - if (active && title === current && watchScope == playbackScope) current.toast = RecoveryMessage.PlaybackUnavailable + title?.toast = RecoveryMessage.PlaybackUnavailable } } } @@ -422,30 +334,15 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen val watchRefresh = rememberWatchRefresh(viewModel.watchScope) LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) } val title = viewModel.title?.takeIf { it.person.key == shell.person.key } - val active = isScreenEntryActive() - DisposableEffect(viewModel, title, active) { - if (!active) viewModel.cancelPlayback() - onDispose { viewModel.cancelPlayback() } - } - TitleThemeMusic(title?.data?.item, preferences, viewModel.themeSongPlayer, - active = active && route.startsWith("title/") && title?.data?.status == TitleReadStatus.Ready, - owner = "${shell.person.key}:${viewModel.watchScope}") val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null LaunchedEffect(title?.data?.status) { if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false } val person = viewModel.person.takeIf { title != null } - BackHandler(enabled = active && title != null) { - if (title?.playbackStart?.launch == null) { - viewModel.cancelPlayback() - viewModel.handleBack(route, shell) { - if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) - } - } + BackHandler(enabled = title != null) { + if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) } Box(modifier.onPreviewKeyEvent { event -> - if (title?.playbackStart?.launch != null) return@onPreviewKeyEvent true - if (readShellKey(event) == ShellKey.Back && event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) viewModel.cancelPlayback() if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let { if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it) true @@ -457,19 +354,4 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen } } -/** Only the resumed, current title entry owns theme playback; episodes borrow their series theme. */ -@Composable -internal fun TitleThemeMusic(item: TitleItem?, preferences: UserPreferences, player: ThemeSongPlayer, active: Boolean, owner: String) { - val itemId = remember(item?.id, item?.seriesId, item?.held, item?.kind) { - item?.takeIf { it.held }?.let { (if (it.kind == "episode") it.seriesId else it.id)?.toUuidOrNull() } - } - val volume = preferences.appPreferences.interfacePreferences.playThemeSongs - if (active && itemId != null && volume != ThemeSongVolume.DISABLED && volume != ThemeSongVolume.UNRECOGNIZED) { - LifecycleResumeEffect(itemId, owner, volume) { - player.playThemeFor(itemId) - onPauseOrDispose { player.stop() } - } - } -} - private fun String.toUuidOrNull(): UUID? = try { UUID.fromString(this) } catch (_: IllegalArgumentException) { null } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt index 4cad6e4f..3e9709aa 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt @@ -75,7 +75,6 @@ class TitleFixture(private val fixture: JSONObject) { seriesName = source.optString("seriesTitle").takeIf { it.isNotEmpty() }, season = source.optInt("seasonNumber"), episode = source.optInt("episodeNumber"), ratings = ratings(source), otherOwners = owners.filter { it.getString("key") != person }.map { it.getString("name") }, isPosterBackdrop = source.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop", - tmdbId = source.optLong("tmdbId").takeIf { it > 0 }, ) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt index 2b23a738..f4188e3f 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt @@ -24,20 +24,17 @@ internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: She } } SideEffect { if (!fixtures) requests?.updateRequesters(requesters) } - val requestItem = remember(fixtures, title?.data, shell.person.key) { - if (fixtures) null else title?.data?.requestTitle(shell.person) - } - LaunchedEffect(requests, title?.data, requestItem) { + LaunchedEffect(requests, title?.data) { val data = title?.data ?: return@LaunchedEffect - val item = requestItem ?: return@LaunchedEffect - if (requests != null) { + if (!fixtures && requests != null && data.item.tmdbId != null) { + val item = data.requestTitle(shell.person) requests.updateSnapshot(DiscoverSnapshot(picks = if (data.pick) listOf(item) else emptyList(), hidden = if (data.hidden) listOf(item) else emptyList(), status = if (data.connection == TitleConnection.SignedOut) DiscoverStatus.NeedsSignIn else DiscoverStatus.Ready, savedAt = data.savedAt, reachable = data.connection == TitleConnection.SignedIn, requestsAllowed = data.requestsAllowed)) } } - val hidden = requestItem?.let { requests?.isHidden(it) } + val hidden = title?.data?.takeIf { it.item.tmdbId != null }?.let { data -> requests?.isHidden(data.requestTitle(shell.person)) } LaunchedEffect(requests, hidden, requests?.toast) { val data = title?.data ?: return@LaunchedEffect if (!fixtures && hidden != null && data.hidden != hidden) title.update(data.copy(hidden = hidden)) @@ -52,21 +49,12 @@ internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: She viewModel.conductarrAction = { action -> val current = viewModel.title when (action) { - is TitleAction.Request -> if (current != null) { - val item = current.data.requestTitle(shell.person) - if (item == null || requests == null) current.toast = RecoveryMessage.TitleActionUnavailable - else requests.openRequest(action.forSomeoneElse, item) - } + is TitleAction.Request -> if (current != null && requests != null) requests.openRequest(action.forSomeoneElse, current.data.requestTitle(shell.person)) is TitleAction.Requests -> shell.openRequests() - is TitleAction.Hide -> if (current != null) { + is TitleAction.Hide -> if (current != null && requests != null) { val item = current.data.requestTitle(shell.person) - if (item == null || requests == null) { - current.update(current.data.copy(hidden = !action.hidden)) - current.toast = RecoveryMessage.TitleActionUnavailable - } else { - if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false) - requests.overlay = null // TitleState already owns the native hide receipt and Undo. - } + if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false) + requests.overlay = null // TitleState already owns the native hide receipt and Undo. } else -> error("Not a Conductarr title action") } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt index a46c6a0a..0dce1f9f 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt @@ -9,7 +9,7 @@ import android.net.Uri /** Only an installed app that handles the YouTube scheme may receive a remote trailer. */ class TitleTrailer(private val context: Context) { /** A missing handler leaves the title in place rather than falling back to a browser. */ - fun open(url: String, beforeLaunch: () -> Unit): Boolean { + fun open(url: String): Boolean { val source = Uri.parse(url) val video = when (source.host?.lowercase()) { "youtu.be" -> source.pathSegments.firstOrNull() @@ -17,13 +17,11 @@ class TitleTrailer(private val context: Context) { ?: source.pathSegments.takeIf { it.firstOrNull() in setOf("shorts", "embed") }?.getOrNull(1) else -> null } ?: return false - // Authority form: SmartTube's IntentExtractor reads the id from Uri.getHost(), which is null for the opaque `vnd.youtube:ID`. - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://$video")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$video")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val handler = context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) .firstOrNull { it.activityInfo.exported } ?: return false intent.setPackage(handler.activityInfo.packageName) return try { - beforeLaunch() context.startActivity(intent) true } catch (_: ActivityNotFoundException) { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt deleted file mode 100644 index 0581b74e..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt +++ /dev/null @@ -1,154 +0,0 @@ -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(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, -) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 79dcfca1..caeb9543 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -2,28 +2,17 @@ package org.justinvisser.wolfskin.tv import android.content.Context import androidx.activity.compose.BackHandler -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.input.pointer.pointerInput import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -56,7 +45,6 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import org.jellyfin.sdk.api.client.exception.InvalidStatusException import kotlinx.coroutines.withContext import org.justinvisser.wolfskin.design.PeopleStatus import org.justinvisser.wolfskin.design.SettingsList @@ -77,9 +65,6 @@ class WolfskinSession @Inject constructor() { var serverId: String? = null internal var fixtureMode: Boolean? = null var onAction: (ShellAction) -> Unit = {} - var personCommit by mutableStateOf(null) - var setupMessage: String? = null - val playback = org.justinvisser.wolfskin.design.PlaybackStart() } /** The shell routes only through the existing NavigationManager and real settings pages. */ @@ -96,7 +81,6 @@ class WolfskinShellViewModel @Inject constructor( val conductarr: ConductarrSession, artwork: WolfskinArtwork, val updates: org.justinvisser.wolfskin.tv.update.UpdateController, - private val trailerReturn: TrailerReturnState, ) : ViewModel() { init { artwork.configure(context) } val backdropFlow: StateFlow = backdrop.backdropFlow @@ -124,20 +108,10 @@ class WolfskinShellViewModel @Inject constructor( session.serverId = server.id.toString() session.fixtureMode = ConductarrPreference.fixtures val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) } - 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 - } - } + session.state = ShellState(people, signedInPerson) { session.onAction(it) } val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) } session.state?.updatePeople(savedPeople) - } else { - trailerReturn.consumeRestoredSession(null) } identityJob = viewModelScope.launch { if (!ConductarrPreference.fixtures) loadHousehold(user) @@ -228,9 +202,8 @@ class WolfskinShellViewModel @Inject constructor( } } ShellAction.Pop -> navigation.goBack() - ShellAction.Exit -> navigation.requestExit() is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) - is ShellAction.Person -> selectPerson(action, server, user) + is ShellAction.Person -> selectPerson(action.person, server) ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) } ShellAction.RetryRequests -> viewModelScope.launch { if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user) @@ -240,76 +213,32 @@ class WolfskinShellViewModel @Inject constructor( } } - private fun selectPerson(selection: ShellAction.Person, server: JellyfinServer, user: JellyfinUser) { - val person = selection.person - val previousPerson = signedInPerson - 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() + private fun selectPerson(person: ShellPerson, server: JellyfinServer) { navigation.replace(Destination.Home()) - if (person.key == signedInPerson) { - commit?.switchEnded = true - return - } + if (person.key == signedInPerson) return identityJob?.cancel() personSwitchJob?.cancel() signedInPerson = "" conductarr.close() libraries.audiences = null personSwitchJob = viewModelScope.launch { - try { - personSwitchMutex.withLock { - val saved = withContext(Dispatchers.IO) { - repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } - } - if (saved == null || saved.isProtected) { - if (!ConductarrPreference.fixtures) { - val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) } - if (commit == null) open() else commit.fallback = open - } - return@launch - } - try { - val current = repository.changeUser(server, saved) - coroutineContext.ensureActive() - setupNavigation.navigateTo(SetupDestination.AppContent(current)) - commit?.appReplacementPending = true - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - coroutineContext.ensureActive() - val open = { - val current = repository.current.value - val isAuthenticationFailure = exception is InvalidStatusException && (exception.status == 401 || exception.status == 403) - if (!isAuthenticationFailure && current?.server?.id == server.id && current.user.id == user.id) { - signedInPerson = previousPerson - session.state?.restorePersonPicker(previousPerson, selection.fromLaunch) - session.state?.toast = org.justinvisser.wolfskin.design.RecoveryMessage.PersonSwitchUnavailable - identityJob = viewModelScope.launch { retryConductarr(server, user) } - } else if (isAuthenticationFailure) setupNavigation.navigateTo(SetupDestination.UserList(server)) - else openSwitchRecovery(server) - } - if (commit == null) open() else commit.fallback = open - } + personSwitchMutex.withLock { + val saved = withContext(Dispatchers.IO) { + repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } + } + if (saved == null || saved.isProtected) return@launch + try { + val current = repository.changeUser(server, saved) + coroutineContext.ensureActive() + setupNavigation.navigateTo(SetupDestination.AppContent(current)) + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + coroutineContext.ensureActive() + session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true } } - } finally { - commit?.switchEnded = true } } } - - /** A stuck replacement cannot retain an inert picker or expose a possibly changed identity. */ - fun expirePersonSwitch(server: JellyfinServer) { - personSwitchJob?.cancel() - session.personCommit = null - openSwitchRecovery(server) - } - - private fun openSwitchRecovery(server: JellyfinServer) { - session.setupMessage = org.justinvisser.wolfskin.design.RecoveryMessage.PersonSwitchUnavailable - setupNavigation.navigateTo(SetupDestination.UserList(server)) - } } /** Settings groups open the fork's own pages; the prototype hand-off panel is never shipped. */ @@ -319,8 +248,6 @@ fun handOffDestination(group: String): Destination = when (group) { else -> Destination.Settings(PreferenceScreenOption.BASIC) } -private const val PERSON_SWITCH_TIMEOUT_MILLIS = 10_000L - private val LocalShellState = compositionLocalOf { error("Wolfskin destination requires the shell") } /** The one upstream hook preserves destination decorators and full-screen hand-offs. */ @@ -334,10 +261,6 @@ fun WolfskinApp( viewModel: WolfskinShellViewModel = hiltViewModel(), homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit = { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, - titleContent: @Composable (String, ShellState, UserPreferences) -> Unit = - { route, shell, userPreferences -> TitleDestination(route, shell, userPreferences) }, - fullScreenContent: @Composable (Destination, Modifier) -> Unit = - { destination, contentModifier -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) }, ) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle() @@ -350,9 +273,7 @@ fun WolfskinApp( } } val loading = remember(user.id) { - ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()) { - if (it == ShellAction.Exit) navigationManager.requestExit() - }.apply { peopleStatus = PeopleStatus.Loading } + ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading } } val state = viewModel.session.state ?: loading val discover = rememberDiscoverState( @@ -366,42 +287,11 @@ fun WolfskinApp( }, ) RequestsRefresh(state, viewModel::refreshRequests) - val onBack = rememberWolfskinBackHandler(state, navigationManager) - val session = viewModel.session - val commit = session.personCommit - LaunchedEffect(commit) { - if (commit != null) { - kotlinx.coroutines.delay(PERSON_SWITCH_TIMEOUT_MILLIS) - if (session.personCommit === commit) viewModel.expirePersonSwitch(server) - } - } - 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(null) } - val playerEntryPending by remember(server.id, user.id, navigationManager) { - derivedStateOf { - val target = navigationManager.backStack.lastOrNull() - target is Destination.Playback && composedDestination != target - } - } - // Keep input blocked until the composition that removes the black cover has actually replaced it. - val playerCoverVisible = playerEntryPending WolfskinTheme { CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover, - org.justinvisser.wolfskin.design.LocalHomePlaybackStart provides session.playback, org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) { - Box(modifier.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 - } - }) { + Box(modifier) { val current = navigationManager.backStack.last() - LaunchedEffect(current, state.person.key) { session.playback.fail() } if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) { val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle() Backdrop( @@ -412,46 +302,45 @@ fun WolfskinApp( } NavDisplay( backStack = navigationManager.backStack, - 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) }, + onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }, entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryProvider = { destination -> NavEntry( destination, contentKey = "${destination}_${server.id}_${user.id}", + metadata = if (destination is Destination.Wolfskin && destination.route == "updates") { + org.justinvisser.wolfskin.tv.update.updatesRouteTransitions + } else emptyMap(), ) { // Cached entries must not retain the launch shell after sign-in completes. val state = LocalShellState.current val discover = LocalDiscoverState.current - val route = destination.toWolfskinRoute() + val route = when (destination) { + is Destination.Home -> "home" + is Destination.Wolfskin -> destination.route + is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString()) + else -> "pushed" + } val isCurrent = navigationManager.backStack.lastOrNull() == destination - if (isCurrent) SideEffect { composedDestination = destination } LaunchedEffect(destination, state, isCurrent) { if (isCurrent && !destination.fullScreen) state.enterDestination(route) } - org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) { if (destination.fullScreen) { - // 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)) + DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) } else { + BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) { - ShellSurface(state, handlesContentKeys = false) { - if (session.personCommit == null) ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) - } - } else titleContent(route, state, preferences) + ShellSurface(state, handlesContentKeys = false) { ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) } + } else TitleDestination(route, state, preferences) } else { ShellSurface(state, handlesContentKeys = upstream == null, settingsInstalledVersion = installedVersion.versionName, overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> when { - (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> - if (session.personCommit == null) ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) + (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) destination is Destination.Home -> homeContent(state, server, user, contentModifier) route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates, @@ -465,24 +354,9 @@ 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) { } } } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt deleted file mode 100644 index ab74ae56..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import androidx.activity.compose.BackHandler -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.findActivity -import org.justinvisser.wolfskin.design.ShellKey -import org.justinvisser.wolfskin.design.ShellState - -/** Own root Back even when NavDisplay has no parent; child menus/dialogs retain first refusal. */ -@Composable -internal fun rememberWolfskinBackHandler(shell: ShellState, navigation: NavigationManager): () -> Unit { - val activity = checkNotNull(LocalContext.current.findActivity()) { "Wolfskin root requires an Activity" } - LaunchedEffect(navigation, activity) { - navigation.exitRequests.collect { activity.finishAndRemoveTask() } - } - val onBack: () -> Unit = remember(shell, navigation) { - { handleWolfskinBack(shell, navigation) } - } - BackHandler(onBack = onBack) - return onBack -} - -/** Back reads navigation synchronously; an outgoing entry may outlive the composition that disabled it. */ -internal fun handleWolfskinBack(shell: ShellState, navigation: NavigationManager) { - val destination = navigation.backStack.last() - if (destination.fullScreen) { - navigation.goBack() - return - } - val route = destination.toWolfskinRoute() - if (shell.route != route) shell.enterDestination(route) - shell.key(ShellKey.Back) -} - -/** Navigation and Back use the same route identity for each rendered entry. */ -internal fun Destination.toWolfskinRoute(): String = when (this) { - is Destination.Home -> "home" - is Destination.Wolfskin -> route - is Destination.MediaItem -> if (type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(itemId.toString()) else titleRoute(itemId.toString()) - else -> "pushed" -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSessionRecovery.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSessionRecovery.kt deleted file mode 100644 index 88c89ae5..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSessionRecovery.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.content.Context -import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.services.SetupNavigationManager -import org.jellyfin.sdk.api.client.exception.InvalidStatusException - -/** Only an expired current login leaves saved content; outages and late failures keep it usable. */ -fun recoverWolfskinSession( - context: Context, - exception: Exception, - expected: CurrentUser?, - repository: ServerRepository, - setup: SetupNavigationManager, -) { - if (exception !is InvalidStatusException || exception.status != 401 || expected == null || repository.current.value !== expected) return - WolfskinScreenPreference.initialize(context) - if (WolfskinScreenPreference.enabled) setup.navigateTo(SetupDestination.UserList(expected.server)) -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupContent.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupContent.kt deleted file mode 100644 index a4509c52..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupContent.kt +++ /dev/null @@ -1,192 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.input.KeyboardType -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.tv.material3.Button -import androidx.tv.material3.ButtonDefaults -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.ui.components.EditTextBox -import com.github.damontecres.wholphin.ui.setup.PinEntryDialog -import kotlinx.coroutines.launch -import com.github.damontecres.wholphin.ui.findActivity -import org.justinvisser.wolfskin.design.ShellAction -import org.justinvisser.wolfskin.design.ShellSurface -import org.justinvisser.wolfskin.design.WolfskinTheme -import org.justinvisser.wolfskin.design.WolfskinTokens -import org.justinvisser.wolfskin.design.Wordmark -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S -import org.justinvisser.wolfskin.tv.WolfskinSetupViewModel.Stage - -/** Intercept both stock setup destinations before either upstream selector can be composed. */ -@Composable -fun WolfskinSetupContent(destination: SetupDestination, modifier: Modifier = Modifier, model: WolfskinSetupViewModel? = null): Boolean { - if (destination != SetupDestination.ServerList && destination !is SetupDestination.UserList) return false - val context = LocalContext.current - remember(context) { - WolfskinScreenPreference.initialize(context) - ConductarrPreference.initialize(context) - true - } - if (!WolfskinScreenPreference.enabled) return false - WolfskinSetupScreen(destination, modifier, model ?: hiltViewModel()) - return true -} - -/** Saved people remain local; network failures expose retry and real server authentication. */ -@Composable -internal fun WolfskinSetupScreen( - destination: SetupDestination, - modifier: Modifier = Modifier, - model: WolfskinSetupViewModel = hiltViewModel(), -) { - val scope = rememberCoroutineScope() - val activity = checkNotNull(LocalContext.current.findActivity()) - val onBack = { - if (model.pinUser != null) model.dismissPin() - else if (model.stage == Stage.Picker || model.stage == Stage.Loading || model.stage == Stage.Unconfigured || model.busy || model.picker == null) activity.finishAndRemoveTask() - else model.returnToPicker() - } - val hint = (destination as? SetupDestination.UserList)?.server - LaunchedEffect(destination) { model.load(hint) } - val retrySetup: () -> Unit = { scope.launch { model.retry() } } - model.onAction = { action -> - when (action) { - is ShellAction.Person -> scope.launch { model.select(action.person) } - is ShellAction.Destination -> model.showSettings() - ShellAction.Exit -> onBack() - else -> Unit - } - } - BackHandler(onBack = onBack) - WolfskinTheme { - val picker = model.picker - if (model.stage == Stage.Picker && picker != null) { - ShellSurface(picker, modifier.testTag("wolfskin-setup-picker")) - } else { - SetupPanel(model.stage, modifier) { - when (model.stage) { - Stage.Loading -> Text("Reading saved profiles…", color = C.muted, style = WolfskinTokens.Type.body) - Stage.Unconfigured -> ServerForm(model) - Stage.Login -> LoginForm(model) - Stage.Settings -> { - SetupButton("Sign in to another profile", !model.busy, initialFocus = true) { model.showLogin(null) } - SetupButton("Back to profiles", !model.busy, onClick = model::returnToPicker) - } - Stage.Unavailable -> { - Text(model.message.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body) - Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - SetupButton("Try again", !model.busy, initialFocus = true, onClick = retrySetup) - if (model.server != null) SetupButton("Sign in", !model.busy) { model.showLogin(null) } - } - } - Stage.Picker -> Unit - } - } - } - model.pinUser?.let { user -> - PinEntryDialog( - onDismissRequest = model::dismissPin, - onClickServerAuth = { model.showLogin(user) }, - onTextChange = { value -> scope.launch { model.enterPin(value) } }, - ) - } - } -} - -@Composable -private fun SetupPanel(stage: Stage, modifier: Modifier, content: @Composable () -> Unit) { - Box(modifier.fillMaxSize().background(C.bg).testTag("wolfskin-setup-${stage.name.lowercase()}")) { - Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker)) - Column(Modifier.align(Alignment.Center).width(S.pickerErrorPanelWidth), - verticalArrangement = Arrangement.spacedBy(S.panelGap)) { - Text(when (stage) { - Stage.Unconfigured -> "Connect your household server" - Stage.Login -> "Sign in to your profile" - Stage.Settings -> "Settings" - else -> "Who’s watching?" - }, color = C.fg, style = WolfskinTokens.Type.screenTitle) - content() - } - } -} - -@Composable -private fun ServerForm(model: WolfskinSetupViewModel) { - var url by remember { mutableStateOf("") } - val scope = rememberCoroutineScope() - val focus = remember { FocusRequester() } - LaunchedEffect(Unit) { focus.requestFocus() } - Text("Enter your Jellyfin server URL once. Wolfskin will remember it on this TV.", color = C.muted, style = WolfskinTokens.Type.body) - EditTextBox(value = url, onValueChange = { url = it }, modifier = Modifier.fillMaxWidth().focusRequester(focus).testTag("household-server-url"), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)) - model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) } - SetupButton(if (model.busy) "Connecting…" else "Try again", !model.busy && url.isNotBlank()) { - scope.launch { model.connect(url) } - } -} - -@Composable -private fun LoginForm(model: WolfskinSetupViewModel) { - var username by remember(model.loginUser) { mutableStateOf(model.loginUser?.name.orEmpty()) } - var password by remember(model.loginUser) { mutableStateOf("") } - val scope = rememberCoroutineScope() - val focus = remember { FocusRequester() } - val initialUsername = remember(model.loginUser) { username.isBlank() } - LaunchedEffect(model.loginUser) { focus.requestFocus() } - Text(model.server?.name ?: model.server?.url.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body) - Text("Username", color = C.fg, style = WolfskinTokens.Type.body) - EditTextBox(value = username, onValueChange = { username = it }, modifier = Modifier.fillMaxWidth() - .then(if (initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-username")) - Text("Password", color = C.fg, style = WolfskinTokens.Type.body) - EditTextBox(value = password, onValueChange = { password = it }, modifier = Modifier.fillMaxWidth() - .then(if (!initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-password"), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)) - model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) } - SetupButton(if (model.busy) "Signing in…" else "Sign in", !model.busy && username.isNotBlank()) { - val submitted = password - password = "" - scope.launch { model.login(username, submitted) } - } -} - -@Composable -private fun SetupButton(label: String, enabled: Boolean, initialFocus: Boolean = false, onClick: () -> Unit) { - val focus = remember { FocusRequester() } - LaunchedEffect(Unit) { if (initialFocus && enabled) focus.requestFocus() } - Button(onClick = onClick, enabled = enabled, modifier = Modifier.focusRequester(focus), - shape = ButtonDefaults.shape(shape = RoundedCornerShape(WolfskinTokens.Radius.button)), - colors = ButtonDefaults.colors(containerColor = C.buttonSecondary, contentColor = C.fg, - focusedContainerColor = C.fg, focusedContentColor = C.onFocus)) { - Text(label, style = WolfskinTokens.Type.button) - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupViewModel.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupViewModel.kt deleted file mode 100644 index 4e7a06aa..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupViewModel.kt +++ /dev/null @@ -1,211 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import com.github.damontecres.wholphin.data.RestoredSession -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.services.SetupNavigationManager -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.exception.InvalidStatusException -import org.jellyfin.sdk.api.client.extensions.authenticateUserByName -import org.jellyfin.sdk.api.client.extensions.userApi -import org.jellyfin.sdk.discovery.RecommendedServerInfoScore -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import org.justinvisser.wolfskin.design.ShellAction -import org.justinvisser.wolfskin.design.ShellPerson -import org.justinvisser.wolfskin.design.ShellState - -/** Setup never clears the remembered household endpoint or login merely to show the picker. */ -@HiltViewModel -class WolfskinSetupViewModel @Inject constructor( - private val repository: ServerRepository, - private val navigation: NavigationManager, - private val setup: SetupNavigationManager, - private val session: WolfskinSession, - private val conductarr: ConductarrSession, -) : ViewModel() { - internal enum class Stage { Loading, Unconfigured, Picker, Login, Settings, Unavailable } - - internal var stage by mutableStateOf(Stage.Loading) - private set - internal var server by mutableStateOf(null) - private set - internal var picker by mutableStateOf(null) - private set - internal var message by mutableStateOf(null) - private set - internal var busy by mutableStateOf(false) - private set - internal var pinUser by mutableStateOf(null) - private set - internal var loginUser by mutableStateOf(null) - private set - private var users: List = emptyList() - internal var onAction: (ShellAction) -> Unit = {} - - /** Recover Room's saved household server even if the old selector erased its preference. */ - internal suspend fun load(hint: JellyfinServer?) = perform { - conductarr.close() - server = hint ?: repository.current.value?.server ?: when (val recent = repository.getMostRecentServer()) { - is RestoredSession.ServerOnly -> recent.server - is RestoredSession.Success -> recent.currentUser.server - RestoredSession.None -> withContext(Dispatchers.IO) { - repository.serverDao.getServers().maxWithOrNull(compareBy { it.server.lastUsed })?.server - } - } - val household = server - if (household == null) { - stage = Stage.Unconfigured - return@perform - } - users = withContext(Dispatchers.IO) { repository.serverDao.getServer(household.id)?.users.orEmpty() } - if (users.isEmpty()) { - users = withContext(Dispatchers.IO) { - repository.jellyfin.createApi(household.url).userApi.getPublicUsers().content.map { - JellyfinUser(id = it.id, name = it.name, serverId = household.id, accessToken = null) - } - } - } - if (users.isEmpty()) { - showLogin(null) - } else { - val people = users.map { ShellPerson(it.name.orEmpty().lowercase(), it.name.orEmpty(), it.name.orEmpty().take(1).uppercase(), false, it.id.toString()) } - picker = ShellState(people, people.first().key) { onAction(it) } - stage = Stage.Picker - } - }.also { - picker?.toast = session.setupMessage - session.setupMessage = null - } - - /** Retry the startup server/profile recovery, never the failed saved-token request. */ - internal suspend fun retry() { - if (busy) return - stage = Stage.Loading - load(null) - } - - /** Discovery and persistence use the same Jellyfin contract as upstream's add-server action. */ - internal suspend fun connect(url: String) = perform { - val household = withContext(Dispatchers.IO) { - val candidate = repository.jellyfin.discovery.getRecommendedServers(url.trim()) - .sortedBy { it.score }.firstOrNull { it.score != RecommendedServerInfoScore.BAD } - val info = candidate?.systemInfo?.getOrNull() - val id = info?.id?.toUUIDOrNull() - require(candidate != null && info != null && id != null && info.startupWizardCompleted == true) { "Could not connect to the household server. Check the URL and try again." } - JellyfinServer(id, info.serverName, candidate.address, info.version).also { - repository.addAndChangeServer(it) - } - } - server = household - setup.navigateTo(SetupDestination.UserList(household)) - } - - /** Protected saved users must authenticate before changeUser is allowed to run. */ - internal suspend fun select(person: ShellPerson) { - picker?.apply { pickerVisible = true; pickerLaunch = true } - if (busy) return - val selected = users.firstOrNull { it.id.toString() == person.jellyfinUserId } ?: return - when { - selected.accessToken.isNullOrBlank() || selected.requireLogin -> showLogin(selected) - selected.hasPin -> pinUser = selected - else -> changeUser(selected) - } - } - - internal fun showLogin(user: JellyfinUser?) { - pinUser = null - loginUser = user - message = null - stage = Stage.Login - } - - internal fun dismissPin() { pinUser = null } - - internal fun showSettings() { - picker?.pickerVisible = true - if (!busy) stage = Stage.Settings - } - - internal fun returnToPicker() { - if (busy || picker == null) return - picker?.apply { pickerVisible = true; pickerLaunch = true } - stage = Stage.Picker - } - - internal suspend fun enterPin(value: String) { - val selected = pinUser ?: return - if (value == selected.pin) { - pinUser = null - changeUser(selected) - } - } - - private suspend fun changeUser(user: JellyfinUser) = perform { - try { - complete(repository.changeUser(requireNotNull(server), user)) - } catch (exception: InvalidStatusException) { - if (exception.status != 401) throw exception - showLogin(user) - message = "Your saved sign-in expired. Sign in again to continue." - } - } - - /** A password is sent only to the saved household server and never retained by this model. */ - internal suspend fun login(username: String, password: String) = perform { - val household = requireNotNull(server) - val current = withContext(Dispatchers.IO) { - val result = repository.jellyfin.createApi(household.url).userApi - .authenticateUserByName(username = username, password = password).content - repository.changeUser(household.url, result, loginUser) - } - complete(current) - } - - private fun complete(current: com.github.damontecres.wholphin.data.CurrentUser) { - if (ConductarrPreference.fixtures) { - session.state = null - } else { - val saved = users.filterNot { it.id == current.user.id } + current.user - val people = saved.map { ShellPerson(it.name.orEmpty().lowercase(), it.name.orEmpty(), it.name.orEmpty().take(1).uppercase(), false, it.id.toString()) } - session.state = ShellState(people, current.user.name.orEmpty().lowercase()) { session.onAction(it) }.apply { - pickerVisible = false - pickerLaunch = false - } - } - session.serverId = current.server.id.toString() - session.fixtureMode = ConductarrPreference.fixtures - navigation.reloadHome() - setup.navigateTo(SetupDestination.AppContent(current)) - } - - private suspend fun perform(action: suspend () -> Unit) { - if (busy) return - busy = true - message = null - try { - action() - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - message = if (exception is InvalidStatusException && exception.status == 401) "Invalid username or password. Try again." - else if (exception is InvalidStatusException && exception.status == 403) "This profile is not authorized. Choose another profile or sign in again." - else if (server == null && stage == Stage.Unconfigured) "Could not connect to the household server. Check the URL and try again." - else if (server == null) "Could not read the saved household server. Try again." - else "The household server didn’t answer. Try again or sign in with your server credentials." - if (stage != Stage.Login && stage != Stage.Unconfigured) stage = Stage.Unavailable - } finally { - busy = false - } - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt index 796c0b29..10422c09 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt @@ -47,7 +47,6 @@ class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Co put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode) put("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt) put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId) - put("seriesId", title.seriesId); put("lastPlayedAt", title.lastPlayedAt) }) } } @@ -60,7 +59,6 @@ class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Co item.getString("id"), item.getString("title"), item.getString("type"), number("year"), number("runtime"), number("seasons"), item.getString("overview"), string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"), List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"), - seriesId = string("seriesId"), lastPlayedAt = if (item.isNull("lastPlayedAt")) null else item.getLong("lastPlayedAt"), ) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt index 109a7a0a..bb86e68a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt @@ -3,12 +3,7 @@ package org.justinvisser.wolfskin.tv.read import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.services.ImageUrlService import java.util.UUID -import java.time.ZoneOffset import javax.inject.Inject -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi @@ -32,39 +27,18 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val suspend fun readWatch(userId: UUID): HomeLibrary { val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items - val seriesIds = buildSet { - resumeItems.forEach { it.seriesId?.let(::add) } - nextItems.forEach { it.seriesId?.let(::add) } - } - val series = coroutineScope { - val limiter = Semaphore(WATCH_READ_CONCURRENCY) - seriesIds.associateWith { id -> async { limiter.withPermit { readSeriesWatch(userId, id) } } } - .mapValues { it.value.await() } + val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id -> + try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() } + catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + null + } } val resume = resumeItems.map { title(it, series) } val next = nextItems.map { title(it, series) } return HomeLibrary(resume = resume, nextUp = next) } - private suspend fun readSeriesWatch(userId: UUID, id: UUID): SeriesWatch { - val tmdb = try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() } - catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - null - } - // Next-up episodes are unwatched; date the series by its latest actually watched episode. - val playedAt = try { - api.itemsApi.getItems(userId = userId, parentId = id, recursive = true, - includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true, - sortBy = listOf(ItemSortBy.DATE_PLAYED), sortOrder = listOf(SortOrder.DESCENDING), limit = 1) - .content.items.firstOrNull()?.userData?.lastPlayedDate?.toInstant(ZoneOffset.UTC)?.toEpochMilli() - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - null - } - return SeriesWatch(tmdb, playedAt) - } - /** Library failures do not discard an already successful watch read. */ suspend fun readLibrary(userId: UUID): HomeLibrary { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items @@ -92,7 +66,7 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val return readPlayback(api, userId, id) } - private fun title(item: BaseItemDto, series: Map = emptyMap()): HomeTitle { + private fun title(item: BaseItemDto, series: Map = emptyMap()): HomeTitle { val wrapped = BaseItem(item) return HomeTitle( id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" }, @@ -102,18 +76,12 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true, addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, - tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId]?.tmdbId else item.providerIds?.get("Tmdb")?.toIntOrNull(), - seriesId = item.seriesId?.toString(), - lastPlayedAt = maxOf(item.userData?.lastPlayedDate?.toInstant(ZoneOffset.UTC)?.toEpochMilli() ?: Long.MIN_VALUE, - series[item.seriesId]?.lastPlayedAt ?: Long.MIN_VALUE).takeUnless { it == Long.MIN_VALUE }, + tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] else item.providerIds?.get("Tmdb")?.toIntOrNull(), ) } - private data class SeriesWatch(val tmdbId: Int?, val lastPlayedAt: Long?) - companion object { private const val TICKS_PER_SECOND = 10_000_000L - private const val WATCH_READ_CONCURRENCY = 3 private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT) } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index 974e22c8..1530b8df 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -192,9 +192,6 @@ class TitleReadFacade @Inject constructor( streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }, streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }) } - // Rich playback/watch rows are prefixed in storage, not in the season's display order. - val seasonEpisodes = rows.filterTo(ArrayList()) { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason } - seasonEpisodes.sortBy { it.indexNumber } val pageItem = mapItem(item).copy(seasons = seasonNumbers.size) return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) }, rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index, @@ -203,7 +200,7 @@ class TitleReadFacade @Inject constructor( cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person -> TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) }) }, - episodes = seasonEpisodes.map { mapItem(it, pageItem.backdrop) }, + episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) }, connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt new file mode 100644 index 00000000..ec5de195 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt @@ -0,0 +1,13 @@ +// 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) } diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index cf33e6db..913f31f5 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -4,19 +4,7 @@ ## Who’s watching -The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption. - -Down from any avatar reaches the gear; Up returns to its origin, including after visiting Settings. Left/Right avatar traversal is unchanged. - -Source — Justin (2026-11-07): “the settings icon moved, but pressing down only works from the last profile icon. I want it to always move there when pressing down in that screen”. - -Saved profiles retain their approved household colours before Conductarr answers: Justin **#62c4ff**, Mindel **#ff9d72**, Shared **#9be38f**. Setup uses the same lowercase-name presentation key as the signed-in picker; the separate Jellyfin user ID remains the authentication identity. `WolfskinSetupNavigationTest.coldStartSavedPeopleKeepTheirOwnColorsWithoutConductarr` exercises the real cold-start setup boundary and checks the native avatar-fill pixels without a Conductarr session. - -Root content Back moves to the nav; the next Back **finishes and removes Wolfskin’s Android task**, returning to the launcher without visiting an old Home/setup interface. This applies to Home, Discover, Movies, Shows and Anime. Cold-launch picker Back exits; a reopened picker first dismisses to its opener’s nav. Pushed title/person/settings/Updates pages pop their parent stack, and menus/sheets/unsaved-settings prompts get first refusal. A singleton full-screen page exits rather than becoming a dead end. - -Native Android Back (`KEYCODE_BACK`) belongs exclusively to the activity’s system dispatcher, including Navigation 3 predictive completion; Compose key previews must not also navigate on its raw key-down. Escape remains the existing local keyboard Back shortcut. Each completed system Back has one consumer. A retained title/person callback checks the live navigation entry at invocation and hands Back to the incoming entry if its own entry has already popped; it does not depend on an enabled flag catching up with the exit animation. Root handling synchronizes the shell route to the current entry before applying content → nav → task exit. Back ownership is exercised on both SDK 34 (Android 14) and SDK 36. - -Source — Justin (2026-11-07): “pressing back enough times should close wolfskin, currently there is no way to go back to projectivity apart from weird methods via the old ui”. +The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption. Down still reaches it only from the final person; Up returns to that person. ## Focus visibility @@ -26,23 +14,6 @@ The focused card and its ring remain fully inside the viewport, with at least th Library and Person grids retain six columns and the existing Level 2 row spacing. The final Person column keeps the full poster width; horizontal padding must not shrink it. -## Home continuation and navigation — round 2 amendment - -Justin asked for one **Continue watching** lane merging in-progress films/episodes and next-up episodes. **Implementation choice:** reuse the existing Next up landscape-tile geometry for the merged lane, rather than the old Resume/wide geometry. Order by most recent actual watch activity and show a series once: its resumable episode takes precedence over its next-up episode. Unknown activity dates retain source order after known dates. The non-rotating hero keeps its resume → next-up → newest → pick precedence and is not duplicated in the lane. - -The lane retains the `cw` identity. Legacy local, server, web and plugin Home settings merge saved Resume/Next up rows at the first watch row's position and preserve that row's view options. Existing serialized types remain readable; new settings offer only Continue watching. - -Every navigation entry owns its own viewport even while an outgoing page and incoming page coexist during push/Back. Only the active entry may acquire Android focus or register the shell's logical key controller. An outgoing title/person page must never rebind to Home's `LazyListState` when the shared route changes. Down skips non-focusable/pending rows, reaches the final real row, and scrolls it into view after Back and refresh. - -An unvisited Home row starts at its leftmost card rather than inheriting horizontal alignment from the previous row. Visited rows retain their remembered column and first-visible position across row changes, title Back and root-tab leave/return. Home does not adopt Discover’s tab-leave memory reset; Library keeps standard grid navigation. Outgoing title/person entries cannot intercept Android Back during their exit animation. - -Landscape-card bottom gradients extend through the reserved state-line area, ending at the solid bottom backing with no exposed artwork band, as Justin requested. **Implementation choice for the amount:** title-gradient top padding is **36 px / 18 dp**, down from 54 px / 27 dp. The existing approved 92% bottom opacity is unchanged. Captions and state lines retain their existing geometry. - -Home hero actions retain the approved primary + secondary Details layout: [st-007, Mindel’s Resume hero](https://pages.justinvisser.org/wolfskin/tv-spec/img/st-007-mindel-resume-in-the-hero.jpg) and [st-006, Justin’s Play-next hero](https://pages.justinvisser.org/wolfskin/tv-spec/img/st-006-justin-no-resume-hero-falls-back-to-next-up.jpg). Cached Home can paint before fresh watch data verifies playback. During that interval, measure and reserve the real primary button’s footprint without placing, drawing or enabling it; keep Details in its final secondary position and available for browsing. Playback readiness reveals the primary with the existing 200 ms late-line fade, never a lateral layout shift. The copy layer and button/spacing tokens do not change. - -Source — Justin (2026-11-07): “the resume button for the home hero item only appears after a second or two, it appearing causes the details button to shift places, this needs to be fixed as well”. - - ## Episode artwork and dimensions Episode cards use **400×280 px (200×140 dp)** art: 55 px / 24.4% taller than the previous 400×225 px tile. Other tile and wide-card sizes are unchanged. The episode shelf is `84 + 280 + 68 = 432 px`; its heading band and 68 px clearance before Cast remain Level 2. Cast moves down by the additional card height rather than overlapping it. @@ -83,8 +54,6 @@ Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. Wi Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged. -Round-2 Discover navigation amendment: Up/Down into an untouched lane starts at its leftmost item, not the previous lane's nearest column. A moved lane restores its own selected item and horizontal track for this visit. Pushed titles and service/collection pages preserve that visit; selecting another root tab or rebuilding Discover clears its lane positions. Root vertical position, filters and hero selection retain their existing shell policy. Home's nearest-centre shelves and Library's column-preserving continuous grids are unchanged pending a separate behavior decision. - ## Motion and loading — revision 6 Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. All loading shelves under the screen's theme share one clock, so late arrivals join the current phase. The clock stops when the last loading shelf leaves. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur. @@ -95,20 +64,17 @@ Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episo | Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted | | Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant | | Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise | -| Initial hero/title copy | 180 ms, ease-out | 16 px rise (`copyEntryRise`); saved hero entry state suppresses replay on remount, not first appearance | +| Initial hero/title copy | 180 ms, ease-out | 16 px rise | | Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready | -| Hero copy hand-off | Outgoing 120 ms ease (`.25,.1,.25,1`); incoming starts after 140 ms and enters over 200 ms ease-out, gated on artwork or final fallback readiness | One copy slot: no overlapping text; 14 px incoming rise, outgoing translation held where entry stopped | +| Hero copy hand-off | Old copy out 120 ms ease (`.25,.1,.25,1`); new copy starts at 140 ms and enters over 200 ms ease-out | 14 px rise on entry | | Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade | | Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry | -| Discover pager | 180 ms, ease-out, retargeting current width and opacity | Buttons and pager remain outside the copy transition; reduced motion snaps width and caps opacity fades at 80 ms | -Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the entire painted backdrop stays static while incoming artwork loads, and the lower backdrop changes only when artwork or its final fallback is ready, without an animated screen-sized layer. Hero copy is a separate bounded block with stationary action controls. Ordinary image reveal uses Coil's painter; hero-controlled requests bypass that independent fade so only the token-backed hero blend owns the transition. Existing poster-only blur is static. +Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the lower backdrop changes without an animated screen-sized layer. Hero copy is a separate bounded block. Image reveal uses Coil's painter, not a new full-screen compositing layer. Existing poster-only blur is static. -The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. An ordinary hero hand-off animates **2** bounded owners (outgoing art and one sequential copy block) above an opaque artwork base. Art interruptions retain the painted composite without resetting alpha; each outgoing art owner keeps its original 320 ms lifetime. Copy retargets its current alpha, fades out before replacing text, then uses the approved delay/entry tokens; never two copy layers are visible together. Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; ordinary artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen. +The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen. -The approved title follow-up (2026-11-07) supersedes the fixed readiness reservation: an absent readiness line occupies **0 px**, while a present line occupies **56 px (28 dp)**. Late arrival/removal animates that height over the **200 ms late-line duration with token ease-out**; reduced motion snaps. Overview, actions and following rows move monotonically with that band; an already-pinned focused row stays pinned through same-remeasure scroll compensation. Ratings remain inside the fixed-height facts band and appear once: an explicit episode page uses its opened episode's own set, including the episode score, rather than the series-only set. Series and episode pages have no redundant Seasons shortcut; Down reaches the season chips. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial title/person loading retains destination focus rather than focusing the root nav; Down cannot enter a skeleton, and Back still pops the route without opening the picker. An already-ready catalogue or held title stays Ready while a same-route, same-person refresh reads new data; the existing signed-in authentication guard remains authoritative, and loaded copy/rows do not remount or replay their entry motion. - -Fixture foreground/watch refreshes likewise reread saved choices in place without losing Ready or remounting rows. Revoked catalogue authorization clears signed-in facts directly to the unavailable state without replaying the first-load skeleton. Catalogue-backed request/hide actions require a positive movie/series identity; incomplete metadata shows the Wolfskin recovery toast instead of throwing or acknowledging an unperformed hide. Debug title fixtures carry their existing TMDB identity, including the series root when viewing an episode. +The series readiness band reserves **56 px (28 dp)** from its first content paint, including when empty or when readiness later disappears. Ratings remain inside the fixed-height facts band. The series header's measured height therefore cannot change when late ratings/readiness arrive; overview, actions, season and episode anchors retain their coordinates. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route. Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range. @@ -116,8 +82,6 @@ Discover key traversal skips pending lanes in both directions. If every remainin Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target. -The [round-2 Discover native proof](https://pages.justinvisser.org/wolfskin/tv-round2-discover/) publishes paired 1920×1080 frame strips and exact 16 ms alpha/translation/position series for ready, slow, failed, sustained rapid-selection and reduced-motion scenarios. The reviewed counter-fade remains available separately for comparison with the corrected sequential copy; initial-entry/remount and Home hero series are also published. The regression suite checks an opaque artwork base, at most one visible copy, monotonic per-phase motion, stationary controls and token-bounded outgoing artwork lifetimes. The approved fade-through may briefly have no visible copy; it never paints overlapping titles. These are native-rendering proofs, not on-box frame-pacing measurements. - ## Card metadata and bottom state line — revision 6, option B @@ -142,57 +106,10 @@ Justin's additional correction is binding even where a reference frame leaks: ** Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. It also checks Home/library parity when progress reaches or exceeds completion. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances to 0.1 px, and asserts every pixel in reserved loading caption bands remains background-coloured. `CardCaptionMotionTest` compares native glyph pixels after changing a focused title and moving across a focused Home shelf. Reference comparisons retain documented renderer/fixture artifacts; unapproved visible design changes remain merge blockers. -## Details watched control - -The on state has no unfocused ring: Justin (2026-11-07) read it as active/selected. This overrides the older IconButton “on = inset 3 px accent ring” and Watched “inset accent ring” descriptions in the written specification. The existing open-eye versus crossed-eye glyph, “Watched” header text, and “Mark as unwatched” action caption distinguish the state without a new colour or token. The focus ring, scale, fill and selected accessibility semantics are unchanged. - -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//` captures listed under Sources and verification. - ## Sources and verification Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`. -Revision-7 polish proofs are listed in `docker/README.md` under UI polish. - `MotionSurfaceTest`, `HeroMotionTest`, `NetworkArtTransitionTest` and `DiscoverSkeletonKeysTest` cover late-line geometry, loading/ready row eligibility, Back/nav ownership, rendered pulse suppression under both motion controls, strict main-thread I/O policy, network-versus-cache reveal, bounded hero blending, delayed art and interrupted hand-offs. Native follow-up cases compare the pulse pixels of a late-joining shelf, hold caption translation through both settled and interrupted exits, and observe late ratings/genres/overview fading without moving their slots or actions. Manually clocked captures explicitly deliver snapshot changes before advancing animation time; interrupted-exit assertions sample after the hide has committed. The frozen prototype includes `index.html`, its local media, the original CSS/JS/builder and a complete SHA-256 manifest; replay the HTML, not the preserved builder's live-publishing entry point. The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation. diff --git a/docker/README.md b/docker/README.md index 72421201..a4a688da 100644 --- a/docker/README.md +++ b/docker/README.md @@ -200,12 +200,6 @@ execution order. Keep worker heap and parallelism unchanged between samples. `PersonPickerSessionTest` must also pass late in the full app JVM, not just alone or promoted to the front by previous failures. -`SavedPeoplePickerTest` publishes pending Compose snapshots and pumps Android's -main queue while awaiting the saved household, just like the session-picker -await above. Its five-second deadline, blocked first network response and saved -identity assertions are unchanged; the virtual frame clock alone does not drain -an IO continuation queued on Android's host looper. - ## Saved-person launch and DNS Launch restores an unprotected saved Jellyfin login from local storage before @@ -216,22 +210,6 @@ require-login profiles still go through the existing protected sign-in flow. cannot replace a different selected person. Name revalidation updates only the name column and reloads the saved user, preserving newer settings and PINs. -A failed person switch reopens the picker on the still-authenticated person and -shows “The household server didn’t answer.” Back from a reopened avatar picker -dismisses to that person's navigation; the next Back exits. Back from a launch or -setup picker exits instead of exposing a previous person's content. Settings, -login and error panels return to the picker first; Back can exit a busy setup -request. PIN, require-login and rejected-credential authentication gates remain -strict: an expired target sign-in still reaches the setup login form. - -Failed or cancelled Jellyfin switches restore the previous API token as well as -retaining the signed-in identity. Recovering the picker also reattaches only that -person's household client. A person-switch cover has a ten-second deadline; -an unfinished switch/replacement falls back to the setup picker with a message, -without choosing a different authenticated person. Repository user changes share -one transaction gate, so late cancellation/rollback cannot overwrite a newer -person's authenticated token. - Jellyfin, Conductarr, artwork, playback and crash-report clients share one `PersistentDns` instance through the application graph. It keeps system-ordered IPv6/IPv4 answers in memory and `noBackupFilesDir/wolfskin-dns.json` (at most 32 hosts). @@ -419,12 +397,6 @@ padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-w ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%. The small gear centred beneath the complete people row opens Settings with Requests focused. -Down from any avatar reaches it; Up returns to the originating avatar, including -after a Settings visit. Left/Right avatar traversal is unchanged. -The navigation requirement comes from Justin's words: “the settings icon moved, -but pressing down only works from the last profile icon. I want it to always -move there when pressing down in that screen”. The approved gear placement stays -centred beneath the complete people row. Settings groups hand off to existing upstream pages; Requests opens the household Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent rollout switch: turning it off renders upstream Home and library destinations with @@ -433,94 +405,11 @@ Settings list. Native Up traversal stays in content until the first focusable ro then enters the nav. Turning the switch on renders Wolfskin Home and Libraries. Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled. -Root content Back focuses the nav; another Back exits the Android task via -`finishAndRemoveTask`, including Discover, Movies, Shows and Anime without a Home -detour. Cold picker Back exits; a reopened picker dismisses to nav first. Pushed -pages pop normally. Child dialogs, title menus, request sheets and unsaved-setting -prompts keep Back priority. Singleton full-screen pages also exit; they cannot -fall through to the older setup/user UI. - -Source — Justin: “pressing back enough times should close wolfskin, currently -there is no way to go back to projectivity apart from weird methods via the old ui”. - -The activity's `OnBackPressedDispatcher` owns native `KEYCODE_BACK`. It is not -mapped by `readShellKey`: a Compose key-down cannot navigate before the system -Back callback commits. Escape remains the local keyboard Back shortcut. -`rememberWolfskinBackHandler` resolves the live navigation entry and synchronizes -the shell route before the root nav/exit ladder. A title/person callback retained -during exit forwards to that live entry instead of popping its old title again; -correctness does not depend on a recomposed enabled flag. -`TitleEntryBackTest` mounts real `NavDisplay`, Home and loaded title/person -surfaces; `WolfskinRootExitTest` mounts actual Home/Discover/library surfaces. -Both run on SDK 34 and 36, including system/predictive callbacks, native key -ownership, immediate successive Back before recomposition, picker and Coming-in -precedence. `WolfskinBackStackTest` keeps the real Updates/Home Settings parent -pops and unsaved-edit protection. - Saved Jellyfin identities use the existing upstream session. Conductarr supplies household labels, requester capabilities and library audiences after sign-in. The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used only when the Advanced debug fixture switch is enabled; release excludes it. -### Household setup and sign-in recovery - -With **Wolfskin screens** enabled, `MainContent` intercepts both upstream setup -destinations before their stock server/user selectors can render. Cold launches -that require sign-in, protected-profile restarts, the old drawer's user action, -legacy sign-out-equivalent state, setup fallbacks and intent failures all use -Wolfskin's own picker. Entering it does not call `SwitchUserViewModel.init` or -clear the remembered server/user preference. - -Setup reuses the supplied/current/recent household server, recovering the most -recently used Room server if the legacy picker erased the preference. Saved people -render without a network request. Only an install with no saved server gets the -one-time Jellyfin URL field; it uses existing Jellyfin discovery and Room -persistence, not a new compiled endpoint. Conductarr remains on its existing -configured URL and enriches the signed-in shell as before. - -Selecting a protected profile still requires its PIN or server credentials. -Expired credentials during saved-session revalidation or foreground refresh -return to the picker without allowing a late failure to replace another person's -session. A failed selection offers Wolfskin **Try again** and **Sign in** actions; -hidden/unsaved profiles can use username/password sign-in through the setup -picker's Settings gear. An outage never replaces the saved household endpoint. - -With **Sign in automatically** off, ordinary returns from the background show the -profile picker. Only the first return after a trailer handoff keeps the originating -title and person: the same unprotected live identity and active signed-in entry -must match the pending handoff, exact title, same boot and under-ten-minute window. -Live protection is checked at stop and again when the stopped entry starts. The -handoff is cleared on return; a second, ordinary background return shows the picker. -Automatic sign-in on keeps its existing ordinary-return behaviour. - -If Android kills Wolfskin while the trailer is in front, the same saved Android -task can continue for **strictly less than ten minutes from the handoff, on the -same boot**. Only an exported YouTube-handler launch from an authenticated, -unprotected title creates this return record. It stores server/person IDs, the -exact title route, elapsed time and boot counter in the Activity's saved Bundle: -no token, password, PIN or new last-person preference. Saving/recreating does not -renew the deadline; missing boot evidence keeps the picker. - -Before installing a saved token, restoration re-reads the profile and requires -the exact most-recent server/person, a present token, no PIN and no require-login, -then rechecks the deadline. The recommendations worker may already have restored -that same unprotected canonical identity before setup leaves Loading; continuing -it does not install credentials again. A different current person, sign-out, -removed/tokenless profile or explicit picker cannot be replaced by the return -path. PIN/require-login people remain gated. A fresh task, fresh explicit intent, -wrong route or expired/missing record follows ordinary startup policy. - -After identity acceptance, a one-use confirmation lets only that person skip the -new shell's launch chooser and reach the saved title. Ordinary cold automatic -sign-in still opens the chooser. `MainActivityTrailerRestoreTest` and -`TrailerReturnStateTest` cover the process-death window and identity gates; -`MainActivityResumeTest` and `SignedInContentTest` cover live return and protection -changes while away. - -Turning **Wolfskin screens** off retains the unchanged upstream -`SwitchServerContent` and `SwitchUserContent` setup screens for rollback. Debug -fixtures still seed the existing fixture picker after authentication. - Person selection immediately clears the old Conductarr identity and Jellyfin read gate before saved-login I/O. Detached clients are disposed exactly once on the application's IO scope, after the replacement (or signed-out) identity is @@ -532,7 +421,7 @@ late reply cannot navigate back to an earlier person. The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its server and shell state, not to the user-scoped ViewModel. Switching people replaces the upstream app entry and ViewModel but retains the same-server, same-mode shell: -the picker stays closed on Home after the first OK. A fresh task still opens it, +the picker stays closed on Home after the first OK. A cold start still opens it, and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real shell and user-entry replacement for launch/avatar Left and Right, the initial person, gear and back, and fast Left-Left-OK; each selection emits one person action. @@ -597,26 +486,21 @@ the actual host's suspended fixture load and presses Android OK: it must open th hero rather than activate the nav. Fixture completion does not steal a different destination or nav selection. -Native Roborazzi captures normally use Robolectric SDK 34 and -`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). System-Back smokes also run SDK 36. -`make test` writes capture PNGs and the sampled Compose nav-spring curve to -`build/wolfskin-captures/`. +Native Roborazzi captures use Robolectric SDK 34 and +`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and +the sampled Compose nav-spring curve to `build/wolfskin-captures/`. Home, Library and Discover captures use `home/`, `library/` and `discover/` subdirectories so shared spec state numbers cannot overwrite another renderer's image. Key replays exercise the Android event adapter, including the 6/8/10/11-press shell paths. The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a separate press-count path. -Robolectric SDK 28/30/34/36 runtimes resolve through Gradle's existing persistent +Robolectric SDK 28/30/34 runtimes resolve through Gradle's existing persistent dependency cache, then copy to `wolfskin-design/build/robolectric-sdk/`. Test workers use that local directory in offline mode instead of downloading Android JARs into the container's ephemeral home. A cache miss still needs Maven Central; rate-limited hosts can seed the existing cache from SHA512-verified local Maven artifacts without changing the wrapper or adding volumes. -SDK 36 Back tests use the pinned Android 16 runtime and a test-worker-only -`--add-exports=java.base/jdk.internal.access=ALL-UNNAMED` for Robolectric's -`ApplicationSharedMemory` / JDK FileDescriptor bridge. Other capture suites -retain their existing SDK selection. Default-flavour launcher artwork and English, Hungarian and Polish string overrides live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated @@ -631,10 +515,7 @@ attribution and project URL. The check does not depend on incremental merge file ## Wolfskin Home Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero, -one merged **Continue watching** lane, newest and per-destination Jellyfin shelves, -and the Discover continuation. The watch lane uses landscape tiles, prefers a -resumable episode over another next-up episode from the same series, and sorts -the chosen items by latest actual watch activity. The hero stays off the lane. +Jellyfin shelves, per-destination "Your …" shelves and the Discover continuation. The final action opens the real merged Discover screen at its top through the shell's root re-selection transition. Home's See all cards open `WolfskinSeeAllLists`, carrying the complete underlying list rather than the visible shelf window. Title content @@ -643,49 +524,27 @@ remains the later-slice target. All Home components and immutable screen models live in `:wolfskin-design`. The shell's remembered view owns vertical position, each row's selected card and actual horizontal `LazyListState`, and the bounded six-hour ratings session cache. -Every `NavEntry` installs `ScreenEntry` with its own `rememberedView(route)` and -active state before navigation effects run. Home, Library, Discover, title and -person surfaces consume that entry's viewport; an outgoing page cannot borrow -Home's scroller during Back. Only the active entry registers logical key handling -or acquires Android focus. Re-entry restores the remembered horizontal track -with `scrollToItem`, preserving exact indices and pixel offsets. Cards use the -shell's logical focus without requests against uncomposed lazy items. +Composing destinations bind `rememberedView(route)` before navigation effects run, so a +new See all grid cannot borrow or clamp Home's scroller. Re-entry restores the +remembered track before the page with `scrollToItem`, preserving exact indices and +pixel offsets rather than animating back. Android focus stays with the shell; +cards use its logical focus, without requests against uncomposed lazy items. Back preserves the viewport; returning to Home does not re-run focus-follow over a manually scrolled saved view. The next arrow re-enters visible content when scrolling moved focus off-screen. -Shell-owned Home, Library/See all, Discover and Settings/Updates lists use -Foundation's minimum-visibility bring-into-view policy, not TV's 30% focus pivot. -Restoring focus therefore leaves already-visible cards at their saved position; -native title/person and legacy handoffs retain their inherited policy. Pointing changes focus without activation, cards activate on key-up, and held OK events cannot activate twice. -Between Home shelves, an unvisited row starts at its leftmost card. A visited row -restores its column and first-visible position, including after leaving and -returning to the Home tab; Home memory is not reset on tab leave. Library keeps -standard grid navigation. Horizontal movement keeps the track still while focus -remains in its visible middle. Title/person Android Back handlers use the same -active-entry ownership flag, so outgoing pages cannot consume a second Back. +Between shelves, an unvisited row selects the nearest visible card center even +when card widths differ. A visited row restores its column. Horizontal movement +keeps the track still while focus remains in its visible middle. The initial skeleton parks focus on the nav. When the first content arrives, an untouched fresh entry focuses hero button zero; intervening nav input prevents that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus` release/repeat guard and Back ladder. -Landscape title scrims include the reserved state-line area inside their gradient -rather than leaving an exposed image strip below it. Their top padding is the -shared Home token (18 dp); opacity remains the approved 92%. - -The legacy opt-out Home and its presets also use one combined watch row. Saved -local/server/web/plugin split rows migrate at their first position, preserving -that row's display options and all unrelated rows. Old serialized row names stay -decodable; settings no longer offer separate lanes or a Split action. `HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up publish independently of slow or failed library shelves; saved metadata still paints immediately, but a saved hero offers Details until watch data revalidates. -Series chronology reads the latest actually watched episode, not the next-up -episode's unwatched user data or premiere date. Enrichment is bounded to three -concurrent series reads; absent history leaves the date unknown rather than -inventing a timestamp. The disk projection preserves series IDs and watch dates, -and still accepts older cache entries without either field. Home and title Play actions resolve the current Jellyfin item and its current checkpoint at the click, preserving millisecond positions. Series actions use live NextUp with resumable episodes enabled; an explicitly opened episode stays @@ -947,31 +806,24 @@ The [revision-5 amendment](../data/tv-spec/spec.md) and [native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html) retain the approved frames except for the requested feedback changes: -- The picker gear retains its approved whole-row centring and picker-person ring. - Down from every avatar reaches it; Up returns to the originating avatar. +- The picker gear is centred under everyone and uses the picker-person ring. - Shelf tracks share second-last-slot lookahead, count clamping and settled offsets. Home records completed frame positioning rather than an animation that may still be cancelled; first mount restores the intended shelf item. Discover uses all six approved poster slots. Person keeps the final column at full poster width. -- Episode images choose episode Primary, then Thumb, then the already-read series Backdrop. - Title episode cards now use 448×280 px, preserving the taller approved crop. - Focused summaries ellipsise to one line, the show-name overlay is absent, and - `S1 E3 · duration` plus the watched check share one line. - Ready title headers measure their copy/actions and leave 40 px before the - following section. Missing readiness lines collapse completely on every title - kind; late lines expand/collapse over the 200 ms late-line token with ease-out, - or snap with reduced motion. Already-pinned rows compensate in the same - remeasure rather than bouncing through a second focus-follow animation. - Poster-only movies floor that stack so Cast circles clear the poster by at - least 20 px, including held movie fallbacks and short overviews. +- Episode images choose episode Primary, then Thumb, then the already-read + series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance. + Episode context uses the episode overview, with two summary lines on the + focused card. Held-series headers measure their copy/actions and leave 40 px + before seasons instead of reserving a fixed empty 900 px header. - Every Discover heading uses a 104 px band, including a 60 px title-to-card gap. Home heading geometry is unchanged. - Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request 420×630. The shared request builder replaces conflicting Jellyfin size/encoding parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and - 448×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and + 400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and authentication query parameters remain intact. TMDB uses w342 for posters and portraits, w780 for landscape cards and w1280 for hero backdrops, never original. Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks @@ -979,15 +831,9 @@ retain the approved frames except for the requested feedback changes: cache, 128 MiB disk cache and four image-read limit remain unchanged. `HomeViewportTest` covers short/long poster, tile and wide rows, both row -directions, first focus and exact scroll/card bounds after returning from the -actual title surface. `TitleEntryBackTest` also checks production MainContent, -the Home/title loaders, `NavDisplay` decorators and native system Back on SDK 34 -and 36. It models Leanback capabilities and keyboard input, animation/reduced -motion, initial Home loading and system animation scale zero. Phone/touch-mode -tests miss the TV focus pivot; the zero-scale regression reproduced the exact -72-pixel drift before the shared policy fix. Native proof records real Bear -season-three metadata through the title read facade; the proof-only harness is -not part of the shipped tests or APK. +directions, first focus and native Back from the actual title surface. Native +proof records real Bear season-three metadata through the title read facade; +the proof-only harness is not part of the shipped tests or APK. Episode lists request only ProviderIds and Overview. Next up reads only identity, then hydrates the selected playback target through the existing single-item read, @@ -1017,10 +863,6 @@ Selections persist through the same playback repository as the upstream player. Marking a whole series asks first; an explicit episode remains the exact play and watched target even while another season is selected. Nested person/title visits retain their entry's focus and selection state. -Discover Details opens this same title destination without replacing the outgoing -Discover page with a separate preview first. The shared destination owns its -initial loading skeleton; an already-ready title keeps its header and actions -through catalogue, held-title and fixture refreshes. Choosing another episode from an episode page replaces only the top navigation entry, preserving its series/Home parent. Choosing the first episode from a series, related titles, cast and performer credits still push normal drill-ins. @@ -1033,44 +875,6 @@ pop a pushed title/person from content **or** nav. Initial loading has no conten target: Down stays in nav, while Back can still leave the title. A missing rating set emits no layout node, so it cannot reserve an invisible extra facts gap. -The title Menu replaces the old More/media-info icon action and contains Media -info plus Add for someone when requests are permitted. It is shared by held -movie, series and episode pages; non-held pages expose Add for someone in the -same menu while Request/status and Not interested/Show again stay in the row. -Version, tracks, watched and trailer controls keep their existing positions. -Back from Media info returns to the menu; Back from the menu restores its -opening button. The compact menu fades in for 160 ms and out for 100 ms using -the motion ease-out token, with reduced-motion fades capped at 80 ms. Only the -bounded menu panel owns an alpha layer; the scrim is static. Opening another -sheet immediately removes the menu panel and scrim, preventing double darkening. -An empty menu closes if its last permitted action disappears. Controls -use the shared 4 px straddle focus ring. Neither series nor episode details have -a redundant Seasons shortcut; Down reaches the season chips directly. Ratings -appear only in the header facts line, not again beside progress. Explicit episode -pages use the opened episode's own set, including its `Ep` score; movie and series -pages keep their own sets. - -The watched on state has no unfocused ring: Justin (2026-11-07) read it as -active/selected — “the watches icon on details pages has a border, making it -look like it's active/selected.” The open-eye/crossed-eye glyph, Watched header -and Mark as unwatched caption still communicate the state. The shared focus -ring is unchanged. `TitleCaptureTest.watchedRingBelongsOnlyToFocus` records the -same Details row in all four watched/focus combinations and checks its rendered -edge pixels. - -Episode shelf skeletons use the same width, height and pitch as ready cards. -Person filmography and related-title shelves remain posters; Home continuation -tiles are a separate component and retain their series-name context. - -Season episode reels use episode-number order independently of the Play/Resume -or explicitly opened episode. The shared live/saved projection keeps rich -playback metadata without promoting that target into the first tile. A refresh -therefore cannot replace the episode under a remembered reel index merely -because the playback target changed. `TitleSeasonReelTest` replays native remote -input on SDK 34 and 36 with 24 season-five episodes and next-up at E24, including -row return, exact activation, cached reads and an explicit-episode refresh. - - `TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies, person credits and playback mutations to one boundary. `ConductarrTitlePages` adds real detail, cast, related titles, performer/filmography, readiness/receipts @@ -1080,16 +884,6 @@ votes enrich those real episode identities; no catalogue stub becomes playable. Debug-only `wolfskin-titles.json` and local artwork remain behind the explicit Advanced fixture switch; release never packages or selects them. -Held title pages use the existing upstream `ThemeSongPlayer` while their current -navigation entry is resumed. Episodes use the series Jellyfin id, never the -episode id; catalogue-only titles have no theme identity. The existing -`AppPreference.PlayThemeMusic` controls playback (Disabled is silent; High is -volume 0.5). Pause, disposal, outgoing navigation and person/session changes -release the theme; video and trailer hand-offs stop it before opening playback. -`TitleThemeMusicTest` exercises these ownership transitions with a fake player -and the real native title destination. Jellyfin still needs an indexed local -`theme.*` file supplied by the library publisher; empty theme lists stay silent. - Held trailers use Jellyfin remote/local trailer data. YouTube URLs target an installed YouTube handler such as SmartTube, never a browser. No handler leaves the title visible with a calm message. Titles nobody holds have no Trailer @@ -1105,27 +899,7 @@ headers, sheets, cast and poster captions without changing shared type tokens. Fractional stack placement rounds only the accumulated position, preventing a one-pixel-per-row drift. Focused controls share the canonical lift shadow. `TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row -entry, remembered columns and long-sheet visibility. `TitleRound2Test` covers -menu Back/focus, preserved media info and household requests, movie stacking, -the Mussolini resume example, request revocation, episode skeleton geometry, -and native menu fade frames sampled in 16 ms steps. - -**UI polish (revision 7).** The tests below write 16 ms frame strips and `samples.csv` to `build/wolfskin-captures/ui-polish//`: -- `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. - +entry, remembered columns, long-sheet visibility and full media-table geometry. `TitleAsyncFocusTest` delays title and person replies through the real destination and view-model, then exercises content focus and hardware Back. Subtitles Off maps to the player's explicit disabled sentinel, not its unspecified default. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0d256f4..9d1d45b2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -120,8 +120,6 @@ androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-da androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" } -androidx-media3-test-utils = { module = "androidx.media3:media3-test-utils", version.ref = "androidx-media3" } -androidx-media3-test-utils-robolectric = { module = "androidx.media3:media3-test-utils-robolectric", version.ref = "androidx-media3" } exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" } coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" } diff --git a/wolfskin-design/build.gradle.kts b/wolfskin-design/build.gradle.kts index eb68b7ba..edc60ea2 100644 --- a/wolfskin-design/build.gradle.kts +++ b/wolfskin-design/build.gradle.kts @@ -24,7 +24,6 @@ val cacheRobolectricSdk by tasks.registering(Copy::class) { "9-robolectric-4913185-2-i7", "11-robolectric-6757853-i7", "14-robolectric-10818077-i7", - "16-robolectric-13921718-i7", ) // One configuration would evict the older SDKs through version conflict resolution. from(versions.map { version -> diff --git a/wolfskin-design/home-tokens.json b/wolfskin-design/home-tokens.json index 8a903933..7cceeaa7 100644 --- a/wolfskin-design/home-tokens.json +++ b/wolfskin-design/home-tokens.json @@ -50,9 +50,9 @@ }, "cardOverlayTop": { "$type": "dimension", - "px": 36, - "dp": 18.0, - "source": "TV round 2: lower card bottom gradient; cover the state-line reserve" + "px": 54, + "dp": 27.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" }, "cardOverlayBottom": { "$type": "dimension", diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json index 47a40ac2..3c29b7ae 100644 --- a/wolfskin-design/shell-tokens.json +++ b/wolfskin-design/shell-tokens.json @@ -30,7 +30,6 @@ "receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" }, "receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" }, "receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" }, - "thumbGlyph": { "$type": "dimension", "px": 40, "dp": 20, "source": "ui-polish P12, approved 2026-11-07: film/TV glyph centred in a request thumbnail without a poster" }, "buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" }, "skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" }, "skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" }, diff --git a/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt b/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt index f56c3459..c046c290 100644 --- a/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt +++ b/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt @@ -99,8 +99,6 @@ class HomeFixture(context: Context) { resumeSeconds = item.optDouble("resumeSeconds", 0.0).takeIf { it.isFinite() } ?: 0.0, played = item.optBoolean("played"), addedAt = item.optString("addedAt", ""), genres = genres(item).toList(), ratings = ratings(item), held = item.optString("availability") == "in_library", tmdbId = item.intOrNull("tmdbId"), - seriesId = item.stringOrNull("seriesId"), - lastPlayedAt = item.stringOrNull("lastPlayedAt")?.let { java.time.Instant.parse(it).toEpochMilli() }, ) private fun ratings(item: JSONObject): List { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt index 619f7ac8..d02b4835 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt @@ -1,24 +1,14 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.LazyListLayoutInfo -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -26,13 +16,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.layout.layout -import kotlin.math.roundToInt import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb @@ -46,7 +29,6 @@ import androidx.tv.material3.Text import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import androidx.compose.runtime.mutableStateOf -import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T @@ -54,19 +36,10 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T /** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */ @Composable fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { - val visible = shell.requestsVisible - val fade = rememberSheetFade(visible) - if (!visible && fade.value == 0f) return val state = shell.requests - val 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 data = state.snapshot val ratingsReader = LocalScreenRatings.current - LaunchedEffect(visible, state, state.focusedId, data.signedIn, data.status) { - if (!visible) return@LaunchedEffect + LaunchedEffect(state, state.focusedId, data.signedIn, data.status) { if (ratingsReader == null) state.awaitFocusedRatings() else if (data.signedIn) state.focused?.let { item -> ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values -> @@ -77,9 +50,8 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { } } } - Box(modifier.fillMaxSize().sheetScrim { fade.value }.focusProperties { canFocus = false } - .clickable { if (visible) shell.closeRequests() }.testTag("coming-in-scrim")) - Column(modifier.sheetPanelFade { fade.value }.fillMaxHeight().width(L.sheetWidth) + Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim")) + Column(modifier.fillMaxHeight().width(L.sheetWidth) .offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth) .drawWithCache { val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { @@ -89,11 +61,9 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { onDrawBehind { drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) } } - }.focusProperties { canFocus = visible } - .then(if (visible) Modifier else Modifier.clearAndSetSemantics { }) - .pointerInput(visible) { + }.pointerInput(Unit) { awaitPointerEventScope { - while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() } + while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() } } } .padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY) @@ -101,10 +71,10 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg) when { data.status == ComingInStatus.Loading && data.items.isEmpty() -> { - LazyColumn(Modifier.skeletonPulse(), verticalArrangement = Arrangement.spacedBy(T.listGap)) { + LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) { items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() } } - SheetButton("Close", interactive = visible) { shell.closeRequests() } + SheetButton("Close") { shell.closeRequests() } } data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> { Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)) @@ -116,50 +86,34 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { } Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted) } - 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() } + SheetButton("Try again", primary = true) { shell.retryRequests() } } + data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted) else -> { if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn) data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) } if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn) - val start = frame.start - val listState = rememberLazyListState() - val placement = rememberRequestPlacement(frame, listState) - val visibleRows = remember(data, start, frame.windowSize) { - data.ordered.subList(start, (start + frame.windowSize).coerceAtMost(data.ordered.size)) + val start = state.windowStart + val visibleRows = remember(data, start, state.windowSize) { + data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size)) } - LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), - state = listState, verticalArrangement = Arrangement.spacedBy(T.listGap)) { - val keys = visibleRows.map { it.id }.uniqueKeys() - itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { index, item -> - ComingInRow(item, shell.person, !frame.recoveryFocused && item.id == frame.focusedId, frame.expanded && item.id == frame.focusedId, - frame.ratingsVisible && data.signedIn && item.id == frame.focusedId, - onFocus = { if (visible) state.focus(item.id) }, onClick = { if (visible) { state.focus(item.id); shell.activateRequest() } }, - modifier = Modifier.layout { measurable, constraints -> - val row = measurable.measure(constraints) - layout(row.width, row.height) { - row.placeRelative(0, placement.offset(keys[index], listState.layoutInfo)) - } - }, - readinessProgress = { placement.progress.value }, interactive = visible) + LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) { + itemsIndexed(visibleRows, + key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item -> + ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId, + state.ratingsVisible && data.signedIn && item.id == state.focusedId, + onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() }) } } - if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = frame.recoveryFocused, interactive = visible) { shell.retryRequests() } + if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() } } } } } @Composable -private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, interactive: Boolean = true, onClick: () -> Unit) { - Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = { if (interactive) onClick() }) } +private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) { + Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) } } @Composable @@ -174,95 +128,32 @@ 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) { - val progress: Animatable = Animatable(if (animate) 0f else 1f) - private var measured: LazyListLayoutInfo? = null - private var targets: Map = emptyMap() - - fun offset(key: Any, info: LazyListLayoutInfo): Int { - val progress = progress.value - if (!animate || progress == 1f) return 0 - if (measured !== info) { - targets = info.visibleItemsInfo.associate { it.key to it.offset } - measured = info - } - val target = targets[key] ?: return 0 - return (((origins[key] ?: target) - target) * (1f - progress)).roundToInt() - } -} - -/** Only remote expansion toggles animate; interruptions start at the current visual placement. */ -@Composable -private fun rememberRequestPlacement(frame: RequestsPanelFrame, listState: LazyListState): RequestsPlacement { - val previous = remember { RequestPlacementMemory(frame) } - val reduced = reducedMotion() - val motion = remember(frame, reduced) { - val animate = !reduced && previous.frame.let { - it.expansionInput != frame.expansionInput && it.data === frame.data && it.focusedId == frame.focusedId && - it.start == frame.start && it.expanded != frame.expanded - } - val info = listState.layoutInfo - val origins = if (animate) info.visibleItemsInfo.associate { - it.key to it.offset + (previous.motion?.offset(it.key, info) ?: 0) - } else emptyMap() - RequestsPlacement(animate, origins) - } - SideEffect { previous.frame = frame; previous.motion = motion } - LaunchedEffect(motion) { - if (motion.animate) motion.progress.animateTo(1f, tween(M.lateLineFade, easing = M.easeOut)) - } - return motion -} - /** Thumbnail slots do not resize when the text or the readiness block grows. */ @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class) @Composable fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean, - showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier, - readinessProgress: () -> Float = { 1f }, interactive: Boolean = true) { + showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { val foreground = if (focused) C.onFocus else C.fg val secondary = if (focused) C.onFocus else C.muted val context = LocalContext.current val request = remember(item.poster, context) { item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) } } - val posterState = remember(request) { mutableStateOf(AsyncImagePainter.State.Empty) } - var collapsedHeight by remember { mutableIntStateOf(0) } - Row(modifier.fillMaxWidth().testTag("coming-row-${item.id}") - .onSizeChanged { if (!expanded) collapsedHeight = it.height } - .drawWithContent { - val progress = readinessProgress() - if (expanded && collapsedHeight > 0 && progress < 1f) { - val bottom = collapsedHeight + (size.height - collapsedHeight) * progress - clipRect(left = -size.width, top = -size.height, right = size.width * 2, bottom = bottom) { - this@drawWithContent.drawContent() - } - } else drawContent() - }.focusScale(focused, WolfskinTokens.Focus.scaleRow) + val posterReady = remember(request) { mutableStateOf(false) } + Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow) .background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel)) - .then(if (interactive) Modifier.pointerInput(item.id) { + .pointerInput(item.id) { awaitPointerEventScope { while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus() } - } else Modifier).clickable(enabled = interactive, onClick = onClick) - .padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY), + }.clickable(onClick = onClick) + .padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) { - Box(Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb)) - .background(if (posterState.value is AsyncImagePainter.State.Success) Color.Transparent else C.surface3) - .testTag("coming-thumb-${item.id}"), contentAlignment = Alignment.Center) { - AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null, - modifier = Modifier.fillMaxSize(), onState = { posterState.value = it }, - contentScale = androidx.compose.ui.layout.ContentScale.Crop) - if (request == null || posterState.value is AsyncImagePainter.State.Error) { - Glyph(if (item.mediaType == "movie") "film" else "tv", C.dim, Modifier.size(T.thumbGlyph)) - } - } + AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null, + modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb)) + .background(if (posterReady.value) Color.Transparent else C.surface3), + onState = { posterReady.value = it is AsyncImagePainter.State.Success }, + contentScale = androidx.compose.ui.layout.ContentScale.Crop) Column(Modifier.weight(1f)) { Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline), style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt index 8ba618c3..905deb57 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt @@ -33,8 +33,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import coil3.compose.AsyncImage import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import coil3.request.transitionFactory -import coil3.transition.Transition import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L @@ -52,14 +50,13 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) } }) else null } val context = LocalContext.current - val heroReady = LocalHeroArtReady.current - val request = remember(context, path, poster, dim, heroReady != null) { + val request = remember(context, path, poster, dim) { val upright = poster || dim WolfskinImages.request(context, path, if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth, if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright) - .let { if (heroReady != null) it.newBuilder().transitionFactory(Transition.Factory.NONE).build() else it } } + val heroReady = LocalHeroArtReady.current if (painter != null) { androidx.compose.runtime.SideEffect { heroReady?.invoke() } Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt index 636d5682..df050bc3 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt @@ -1,8 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -20,23 +18,12 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.platform.testTag @@ -50,67 +37,48 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L /** Sheets draw after the shell nav: their scrim consumes pointers and focus stays inside. */ @Composable fun DiscoverOverlay(state: DiscoverState) { - val current = state.overlay - var retained by remember { mutableStateOf(current) } - SideEffect { if (current != null) retained = current } - val visible = current != null && !state.shell.pickerVisible - val fade = rememberSheetFade(visible) - val overlay = current ?: retained - if (overlay != null && !state.shell.pickerVisible && (visible || fade.value > 0f)) { - Box(Modifier.fillMaxSize().sheetScrim { fade.value }.clickable(enabled = visible, onClick = {}) - .pointerInput(visible) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - if (!visible) event.changes.forEach { it.consume() } - } - } - }.testTag("discover-overlay")) { + val overlay = state.overlay + if (overlay != null && !state.shell.pickerVisible) { + Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) { Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize() - .sheetPanelFade { fade.value } .dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity), offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero))) .background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) { - key(overlay) { - when (overlay) { - is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state, visible) - is DiscoverOverlayState.Hidden -> { - ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.") - Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - DiscoverButton("Undo", overlay.focus == 0, enabled = visible) { state.restore(overlay.title, showToast = false) } - DiscoverButton("Done", overlay.focus == 1, primary = false, enabled = visible) { overlay.focus = 1; state.key(ShellKey.Ok) } - } + when (overlay) { + is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state) + is DiscoverOverlayState.Hidden -> { + ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.") + Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) } + DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) } } - is DiscoverOverlayState.Restore -> { - Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) - Text("Hidden from ${state.shell.person.name}’s picks.", - Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), - color = C.muted, style = DiscoverType.body) - Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - DiscoverButton("Show again", overlay.focus == 0, enabled = visible) { state.restore(overlay.title, showToast = true) } - DiscoverButton("Back", overlay.focus == 1, primary = false, enabled = visible) { state.overlay = null } - } + } + is DiscoverOverlayState.Restore -> { + Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) + Text("Hidden from ${state.shell.person.name}’s picks.", + Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), + color = C.muted, style = DiscoverType.body) + Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) } + DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null } } } } } } } - RecoveryToastHost(state.toast) + state.toast?.let { RecoveryToast(it) } } @Composable -private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) { - var previousPhase by remember(sheet) { mutableStateOf(sheet.phase) } - val phase = sheet.phase - SideEffect { previousPhase = phase } +private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}", Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body) if (state.isSignedIn) SheetRatings(sheet.title.ratings) - when (phase) { - RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet, interactive) + when (sheet.phase) { + RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet) RequestPhase.LoadingRecipients -> { Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) repeat(3) { @@ -121,21 +89,10 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState, interac } } Box(Modifier.padding(top = D.sheetButtonTop)) { - DiscoverButton("Close", true, primary = false, enabled = interactive) { state.overlay = null } + DiscoverButton("Close", true, primary = false) { state.overlay = null } } } - 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) } - } + else -> RequestOutcome(state, sheet) } } @@ -162,7 +119,7 @@ private fun SheetRatings(ratings: List) { } @Composable -private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) { +private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) { Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) sheet.people.forEachIndexed { index, person -> val focused = sheet.focus == index @@ -170,7 +127,7 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState, inter Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f) .focusScale(focused, WolfskinTokens.Focus.scaleRow) .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) - .clickable(enabled = interactive && !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex } + .clickable(enabled = !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex } .padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) { Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow) Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = DiscoverType.listTitle) @@ -184,16 +141,12 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState, inter if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = DiscoverType.caption) else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = DiscoverType.caption) Box(Modifier.padding(top = D.sheetButtonTop)) { - DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, - glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked, enabled = interactive, - busy = sheet.phase == RequestPhase.Sending, busyLabel = if (sheet.blocked) null else "Sending…") { - 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) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } } } @Composable -private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) { +private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) { val phase = sheet.phase val name = sheet.selectedPerson.name val actor = sheet.result?.actor @@ -216,9 +169,9 @@ private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState, inter if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = DiscoverType.caption) Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) { - DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex, enabled = interactive) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } - DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false, enabled = interactive) { state.overlay = null } - } else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true, enabled = interactive) { state.overlay = null } + DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } + DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false) { state.overlay = null } + } else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true) { state.overlay = null } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index 620d9f66..e3137e45 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -1,8 +1,6 @@ // 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 kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -14,9 +12,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -48,17 +44,12 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L @Composable fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { val shell = state.shell - val view = screenView(shell) - val entryActive = LocalScreenActive.current val focus = remember(state) { object : ShellContentFocus by state {} } - DisposableEffect(focus, entryActive) { - if (entryActive) shell.contentFocus = focus - onDispose { - if (shell.tabs.getOrNull(shell.currentTab) != "discover") state.resetLaneMemory() - if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } - } + DisposableEffect(focus) { + shell.contentFocus = focus + onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } } } - LaunchedEffect(view, state.snapshot.savedAt) { view.savedAt = state.snapshot.savedAt } + LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt } LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } } val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column) val ratingsReader = LocalScreenRatings.current @@ -76,6 +67,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { } } } + if (state.title != null) { DiscoverTitleDestination(state, modifier); return } state.collection?.let { LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false) return @@ -86,7 +78,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { return } val density = LocalDensity.current - val list = view.listState + val list = shell.view.listState val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable) val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) { buildList { @@ -111,7 +103,6 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset) } } - HeroMotionScope { Box(modifier.fillMaxSize().testTag("discover-screen")) { if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { title -> HeroArtCrossfade(title.key, title, L.row_phero, Modifier.offset { IntOffset(0, -list.firstVisibleItemScrollOffset) }) { DiscoverBackdrop(it) } @@ -138,15 +129,13 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { } } } - } } @Composable private fun DiscoverHero(state: DiscoverState) { val title = state.hero ?: return - Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) { - HeroCopyHandoff(title.key, title) { copy -> - Column { + HeroCopyHandoff(title.key, title) { copy -> + Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) { Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) { Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon)) Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label) @@ -162,32 +151,17 @@ private fun DiscoverHero(state: DiscoverState) { } Spacer(Modifier.height(D.heroMetaGap)) Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - } Spacer(Modifier.height(D.heroOverviewGap)) Row(verticalAlignment = Alignment.CenterVertically) { DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) } Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) { Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) - state.heroes.forEachIndexed { index, _ -> DiscoverHeroDot(index, index == state.slide) } + state.heroes.forEachIndexed { index, _ -> Box(Modifier.size(if (index == state.slide) D.dotActive else D.dotSize, D.dotSize).background(if (index == state.slide) C.fg else C.fg.copy(alpha = .3f), CircleShape)) } Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) } } } -} - -/** Pager geometry retargets from its current value independently of artwork loading. */ -@Composable -private fun DiscoverHeroDot(index: Int, selected: Boolean) { - val progress = remember { Animatable(if (selected) 1f else 0f) } - val reduced = reducedMotion() - LaunchedEffect(selected, reduced) { - progress.animateTo(if (selected) 1f else 0f, - tween(motionDuration(WolfskinTokens.Motion.focusMove, reduced), easing = WolfskinTokens.Motion.easeOut)) } - val widthProgress = if (reduced) { if (selected) 1f else 0f } else progress.value - Box(Modifier.testTag("hero-dot-$index").size(D.dotSize + (D.dotActive - D.dotSize) * widthProgress, D.dotSize) - .background(C.fg.copy(alpha = .3f + .7f * progress.value), CircleShape)) } @Composable @@ -195,15 +169,11 @@ private fun DiscoverChips(state: DiscoverState) { Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = D.chipTop), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { (if (state.service == null) listOf("all" to "All", "movie" to "Movies", "tv" to "Series") else listOf("movie" to "Movies", "tv" to "Series")).forEachIndexed { index, (value, label) -> val focused = state.contentFocused && state.focusedRow == 1 && state.column == index - val selected = state.filter == value - val fill = animatedFocusColor(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest) - val foreground = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg) - val border = animatedFocusColor(if (selected && !focused) C.accent else C.accent.copy(alpha = 0f)) Box(Modifier.height(D.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .background(fill, RoundedCornerShape(WolfskinTokens.Radius.chip)) - .border(WolfskinTokens.Focus.ringCardRing / 2, border, RoundedCornerShape(WolfskinTokens.Radius.chip)) + .background(if (focused) C.fg else if (state.filter == value) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip)) + .border(if (!focused && state.filter == value) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && state.filter == value) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip)) .clickable { state.focus(1, index); state.key(ShellKey.Ok) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) { - Text(label, color = foreground, style = DiscoverType.label) + Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = DiscoverType.label) } } } @@ -229,21 +199,12 @@ private fun DiscoverStatePanel(state: DiscoverState) { /** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */ @Composable -fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, - 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) - } +fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) { Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button)) - .clickable(enabled = enabled, onClick = onClick).padding(horizontal = L.buttonPaddingX), - horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { - if (busyLabel == null) content() - else BusyLabelSwap(busy, busyLabel, color = foreground, style = DiscoverType.button) { - Row(Modifier.fillMaxHeight(), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically, content = content) - } + .clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { + if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) + Text(label, color = if (primary) C.onFocus else C.fg, style = DiscoverType.button, maxLines = 1) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index a4c4faab..ed527a73 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -40,14 +40,14 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { val height = if (services) K.entryH else K.posterH val cards = rememberLazyListState() val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1 - val column = state.laneColumn(lane.id).coerceIn(0, (count - 1).coerceAtLeast(0)) + val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0)) val active = state.contentFocused && state.focusedRow == row val slots = K.posterSlots - val first = state.laneFirsts[lane.id] ?: 0 + val first = state.shell.view.cards["first:${lane.id}"] ?: 0 val density = LocalDensity.current - LaunchedEffect(state.service?.id, column, count, active) { + LaunchedEffect(column, count, active) { val target = shelfFirst(column, first, slots, count) - state.laneFirsts[lane.id] = target + state.shell.view.cards["first:${lane.id}"] = target cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() }) } Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index b629497e..8b0d8209 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -/** Each person retains the root viewport; lane movement belongs only to this Discover visit. */ +/** One person owns a Discover view; the shell retains its root list and card memory. */ class DiscoverState( val shell: ShellState, initial: DiscoverSnapshot, @@ -75,20 +75,6 @@ class DiscoverState( private val pendingDismissals = mutableMapOf() private var rootFilter = "all" - private val rootLaneMemory = DiscoverLaneMemory() - private val serviceLaneMemory = mutableMapOf() - private val laneMemory get() = service?.let { serviceLaneMemory.getValue(it.id) } ?: rootLaneMemory - private val laneColumns get() = laneMemory.columns - internal val laneFirsts get() = laneMemory.firsts - - internal fun laneColumn(id: String): Int = laneColumns[id] ?: 0 - - /** Pushed titles preserve this visit; selecting another root tab discards lane movement. */ - internal fun resetLaneMemory() { - rootLaneMemory.clear() - serviceLaneMemory.values.forEach { it.clear() } - } - fun updateSnapshot(value: DiscoverSnapshot) { snapshot = value if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime() @@ -153,7 +139,7 @@ class DiscoverState( val rowCount: Int get() = firstShelf + lanes.size val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0)) val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf) - val column: Int get() = (if (focusedRow >= firstShelf) laneColumn(rowKey) else shell.view.cards[rowKey].orZero()).coerceAtMost(maxColumn()) + val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn()) val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() } val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null @@ -166,8 +152,7 @@ class DiscoverState( fun focus(row: Int, column: Int = 0) { shell.navFocused = false; shell.view.row = row - val selected = column.coerceIn(0, maxColumn()) - if (focusedRow >= firstShelf) laneColumns[rowKey] = selected else shell.view.cards[rowKey] = selected + shell.view.cards[rowKey] = column.coerceIn(0, maxColumn()) } private fun moveRow(direction: Int) { @@ -176,7 +161,10 @@ class DiscoverState( if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return } if (target >= rowCount) return val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id } - focus(target, if (target >= firstShelf) laneColumn(key) else shell.view.cards[key].orZero()) + val nearest = if (focusedRow >= firstShelf && target >= firstShelf) + column - shell.view.cards["first:$rowKey"].orZero() + shell.view.cards["first:$key"].orZero() + else 0 + focus(target, shell.view.cards[key] ?: nearest.coerceAtLeast(0)) } fun openTitle(item: DiscoverTitle) { @@ -237,14 +225,12 @@ class DiscoverState( fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item) fun openService(item: DiscoverService) { - serviceLaneMemory.getOrPut(item.id) { DiscoverLaneMemory() } rootFilter = filter; service = item; filter = "movie"; revision++ onPush("discover-service/${item.id}") focus(firstShelf) } fun enterRoute(route: String) { - if (route in shell.tabs && route != "discover") resetLaneMemory() if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null } else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null } @@ -275,7 +261,6 @@ class DiscoverState( ShellKey.Left, ShellKey.Right -> { val direction = if (key == ShellKey.Left) -1 else 1 if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0)) - else if (focusedRow >= firstShelf) laneColumns[rowKey] = (column + direction).coerceIn(0, maxColumn()) else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn()) } ShellKey.Ok -> when { @@ -432,12 +417,6 @@ class DiscoverState( } } -private class DiscoverLaneMemory { - val columns = mutableStateMapOf() - val firsts = mutableStateMapOf() - fun clear() { columns.clear(); firsts.clear() } -} - private fun Int?.orZero(): Int = this ?: 0 private val DISCOVER_FILTERS = listOf("all", "movie", "tv") diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt new file mode 100644 index 00000000..81247975 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L + +/** Explicit destination stub exposes only the Discover-owned request and pick actions. */ +@Composable +fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier) { + val title = state.title ?: return + val focused = state.contentFocused + Box(modifier.fillMaxSize().testTag("discover-title-destination-stub")) { + DiscoverBackdrop(title, detail = true) + Column(Modifier.offset(L.safeSide, L.detailCopyTop).width(L.detailCopyWidth)) { + Text(title.title, color = C.fg, style = WolfskinTokens.Type.displayDetail, maxLines = 2) + Spacer(Modifier.height(D.heroTitleGap)) + Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()} · ${title.genres.take(2).joinToString(", ")}", color = C.muted, style = WolfskinTokens.Type.body) + Spacer(Modifier.height(D.heroMetaGap)) + if (state.isSignedIn) DiscoverRatings(title.ratings) + Spacer(Modifier.height(D.heroMetaGap)) + Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3) + Spacer(Modifier.height(D.heroOverviewGap)) + Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + if (!state.canRequest) DiscoverButton("Try again", focused) { state.refresh() } else { + val receipt = state.requests[title.key]?.firstOrNull { it.requester == state.shell.person.key } + DiscoverButton(if (receipt == null) "Request" else "Requested · ${receipt.stage}", focused && state.titleButton == 0, glyph = if (receipt == null) "plus" else "clock") { + state.titleButton = 0; state.key(ShellKey.Ok) + } + DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, glyph = "profile", primary = false) { state.openRequest(true) } + if (state.isPick(title)) DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, glyph = if (state.isHidden(title)) "undo" else "hide", primary = false) { state.titleButton = 2; state.key(ShellKey.Ok) } + } + } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt index 10b8bab2..9a386180 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt @@ -7,8 +7,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.remember import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.SideEffect import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -20,9 +18,6 @@ import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp @@ -42,7 +37,7 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier { /** Lift shadows remain specific to cards and buttons. */ enum class FocusLift { None, Card, Button } -/** Hosted cards keep their lift and share the surface stroke; standalone rings straddle the surface edge. */ +/** Every focus ring straddles the surface edge above content, without a background gap. */ @Composable fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier { val reduced = reducedMotion() @@ -60,23 +55,6 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity } } - val host = LocalCardRing.current.takeIf { lift == FocusLift.Card } - if (host != null) { - val owner = remember { Any() } - val density = LocalDensity.current - val scale = WolfskinTokens.Focus.scaleCard - val corner = with(density) { radius.toPx() } * scale - val stroke = with(density) { width.toPx() } * scale - DisposableEffect(host, focused) { - onDispose { if (focused) host.release(owner) } - } - SideEffect { if (focused) host.retainFocus() } - return if (!focused) shadow else shadow.onGloballyPositioned { coordinates -> - val center = coordinates.localToRoot(Offset(coordinates.size.width / 2f, coordinates.size.height / 2f)) - val size = Size(coordinates.size.width * scale, coordinates.size.height * scale) - host.update(owner, CardRingGeometry(Rect(center - Offset(size.width / 2, size.height / 2), size), corner, stroke)) - } - } return shadow.drawWithCache { val stroke = Stroke(width.toPx()) val corner = CornerRadius(radius.toPx()) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt deleted file mode 100644 index 927f7190..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt +++ /dev/null @@ -1,136 +0,0 @@ -// 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( - { 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 { 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(null) - private var retained by mutableStateOf(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) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt index 9390b0cd..bab07b25 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt @@ -1,72 +1,19 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.AnimationState -import androidx.compose.animation.core.animateTo -import androidx.compose.animation.core.spring +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween 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 -private class ScrollMomentum { - var velocity = 0f - var frameTime = Long.MIN_VALUE - var generation = 0L - fun reset() { velocity = 0f; frameTime = Long.MIN_VALUE } - - fun release(context: CoroutineContext, generation: Long) { - if (this.generation != generation) return - if (context.isActive) { reset(); return } - // A replacement must claim the carry before the next frame, without keeping the old mutation alive. - CoroutineScope(context + NonCancellable).launch(start = CoroutineStart.UNDISPATCHED) { - try { withFrameNanos { } } - finally { if (this@ScrollMomentum.generation == generation) reset() } - } - } -} -// Scroll mutations and animation callbacks run on the UI thread; values never retain their weak keys. -private val scrollMomentum = WeakHashMap() -private val scrollAngularFrequency = sqrt(WolfskinTokens.Motion.navBlobStiffness) - -/** Key-driven frames and shelves share a bounded nav spring and carry same-direction velocity. */ +/** Every frame follows focus with the same bounded scroll tween. */ 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 - } - } + if (distance == 0f) return + list.scroll { + var previous = 0f + animate(0f, distance, animationSpec = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, + easing = WolfskinTokens.Motion.easeOut)) { value, _ -> + scrollBy(value - previous) + previous = value } - } finally { - val context = currentCoroutineContext() - momentum.release(context, generation) - context.ensureActive() } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt index 3d46efa0..13faf0f5 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt @@ -6,156 +6,95 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.SemanticsPropertyKey -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.launch +import androidx.compose.ui.platform.testTag import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M +import kotlinx.coroutines.launch +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.first /** Artwork success or its final fallback releases a pending hero hand-off. */ internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null } -internal val HeroLayerAlpha = SemanticsPropertyKey("HeroLayerAlpha") -internal val HeroLayerTranslation = SemanticsPropertyKey("HeroLayerTranslation") -internal val HeroLayerIdentity = SemanticsPropertyKey("HeroLayerIdentity") -private class HeroPresentation { - var identity by mutableStateOf(null) -} -private val LocalHeroPresentation = compositionLocalOf { null } - -/** Artwork and copy share readiness without coupling either layer to a screen's selection state. */ -@Composable -internal fun HeroMotionScope(content: @Composable () -> Unit) { - val presentation = remember { HeroPresentation() } - CompositionLocalProvider(LocalHeroPresentation provides presentation, content = content) -} - -private class HeroLayer(val identity: Any, var target: T, initialAlpha: Float) { - val alpha = Animatable(initialAlpha) - var ready by mutableStateOf(false) - var presented = false - var outgoing by mutableStateOf(false) -} - -private class HeroCopyTarget(val identity: Any, var target: T) - -/** Keep the painted composite while loading; interruptions fade each retained layer from its current alpha. */ +/** Only the upper hero region blends; the lower backdrop changes without an animated screen layer. */ @Composable fun HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) { - val initial = remember { HeroLayer(identity, target, 1f).apply { presented = true } } - val layers = remember { mutableStateListOf(initial) } - val selected = remember(identity) { layers.firstOrNull { it.identity == identity && it.presented && !it.outgoing } ?: HeroLayer(identity, target, 1f) } + var displayed by remember { mutableStateOf(target) } + var displayedIdentity by remember { mutableStateOf(identity) } + var outgoing by remember { mutableStateOf(null) } + val alpha = remember { Animatable(0f) } val reduced = reducedMotion() - val presentation = LocalHeroPresentation.current - LaunchedEffect(selected, reduced) { - layers.removeAll { it !== selected && !it.presented } - if (selected !in layers) layers.add(0, selected) - snapshotFlow { selected.ready }.first { it } - selected.presented = true - presentation?.identity = identity - layers.forEach { if (it !== selected) it.outgoing = true } + var readyIdentity by remember { mutableStateOf(identity) } + val latestIdentity by rememberUpdatedState(identity) + val onReady = remember(identity) { { if (latestIdentity == identity) readyIdentity = identity } } + LaunchedEffect(identity, reduced) { + if (displayedIdentity != identity) { + outgoing = displayed + alpha.snapTo(1f) + snapshotFlow { readyIdentity == identity }.first { it } + displayed = target + displayedIdentity = identity + } + if (outgoing != null) { + alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut)) + outgoing = null + } } Box(modifier) { - layers.forEach { layer -> key(layer) { - SideEffect { if (layer.identity == identity) layer.target = target } - LaunchedEffect(layer.outgoing, reduced) { - if (layer.outgoing) { - layer.alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut)) - layers.remove(layer) - } + CompositionLocalProvider(LocalHeroArtReady provides onReady) { content(target) } + outgoing?.let { old -> + Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height).graphicsLayer { this.alpha = alpha.value; clip = true }.testTag("hero-outgoing")) { + Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { content(old) } } - val outgoing = layer.outgoing - val onReady = remember(layer) { { layer.ready = true } } - val bounds = if (outgoing) Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height) else Modifier - Box(bounds.graphicsLayer { alpha = layer.alpha.value; clip = outgoing } - .testTag(if (outgoing) "hero-outgoing" else "hero-incoming") - .semantics { this[HeroLayerAlpha] = layer.alpha.value; this[HeroLayerTranslation] = 0f; this[HeroLayerIdentity] = layer.identity.toString() }) { - CompositionLocalProvider(LocalHeroArtReady provides onReady) { - Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { - content(if (layer.identity == identity) target else layer.target) - } - } - } - } } + } } } -/** One copy slot fades through after artwork readiness; no two titles can be painted together. */ +/** One bounded copy layer fades out, hands off, then rises; controls keep their logical owner. */ @Composable fun HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) { - var entered by rememberSaveable { mutableStateOf(false) } - val selected = remember(identity) { HeroCopyTarget(identity, target) } - val currentSelected by rememberUpdatedState(selected) - var displayed by remember { mutableStateOf(selected) } - val alpha = remember { Animatable(if (entered) 1f else 0f) } - val progress = remember { Animatable(if (entered) 1f else 0f) } - var initialEntry by remember { mutableStateOf(!entered) } - var handingOff by remember { mutableStateOf(false) } - var outgoing by remember { mutableStateOf(false) } val reduced = reducedMotion() - val presentation = LocalHeroPresentation.current - val rise = with(LocalDensity.current) { (if (initialEntry) M.copyEntryRise else M.copyRise).toPx() } - SideEffect { selected.target = target } - LaunchedEffect(presentation, reduced) { - snapshotFlow { currentSelected.takeIf { presentation == null || presentation.identity == it.identity } } - .filterNotNull() - .distinctUntilChangedBy { it.identity } - .collectLatest { next -> - if (!entered) { - displayed = next - entered = true - } else if (displayed.identity != next.identity || handingOff) { - handingOff = true - outgoing = true - coroutineScope { - val fade = if (alpha.value > 0f) launch { - alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease)) - } else null - if (!reduced) kotlinx.coroutines.delay(M.heroCopyDelay.toLong()) - fade?.join() - } - outgoing = false - displayed = next - initialEntry = false - progress.snapTo(0f) - } - if (alpha.value != 1f || progress.value != 1f) coroutineScope { - val duration = motionDuration(if (initialEntry) M.rowEnter else M.heroCopyIn, reduced) - launch { alpha.animateTo(1f, tween(duration, easing = M.easeOut)) } - launch { progress.animateTo(1f, tween(duration, easing = M.easeOut)) } - } - handingOff = false + val alpha = remember { Animatable(0f) } + val progress = remember { Animatable(0f) } + var displayed by remember { mutableStateOf(target) } + var previous by remember { mutableStateOf(identity) } + var handingOff by remember { mutableStateOf(false) } + var entered by remember { mutableStateOf(false) } + val rise = with(androidx.compose.ui.platform.LocalDensity.current) { (if (entered) M.copyRise else M.copyEntryRise).toPx() } + LaunchedEffect(identity, reduced) { + if (previous != identity || handingOff) { + handingOff = true + alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease)) + displayed = target + previous = identity + progress.snapTo(0f) + if (!reduced) kotlinx.coroutines.delay((M.heroCopyDelay - M.heroCopyOut).toLong()) + kotlinx.coroutines.coroutineScope { + launch { alpha.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) } + launch { progress.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) } } + handingOff = false + } else if (!entered) { + kotlinx.coroutines.coroutineScope { + launch { alpha.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) } + launch { progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) } + } + } + entered = true + displayed = target + } + Box(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - progress.value) }) { + content(if (handingOff || previous != identity) displayed else target) } - Box(modifier.graphicsLayer { - this.alpha = alpha.value - translationY = if (reduced) 0f else rise * (1f - progress.value) - }.testTag(if (outgoing) "hero-copy-outgoing" else "hero-copy").semantics { - this[HeroLayerAlpha] = alpha.value - this[HeroLayerTranslation] = if (reduced) 0f else rise * (1f - progress.value) - this[HeroLayerIdentity] = displayed.identity.toString() - }) { content(if (displayed.identity == identity) target else displayed.target) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt index 548292fd..1078d8db 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt @@ -1,5 +1,7 @@ package org.justinvisser.wolfskin.design +import kotlin.math.abs + /** Home navigation mutates the shell's per-person view, including each row's remembered column. */ class HomeFocus( private val shell: ShellState, @@ -36,7 +38,8 @@ class HomeFocus( while (next in rows.indices && !rows[next].focusable) next += direction if (next in rows.indices) { val target = rows[next] - focus(next, view.cards[target.id] ?: 0) + val nearest = nearest(current, target) + focus(next, view.cards[target.id] ?: nearest) } else if (direction < 0) { view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0) view.home.resetScroll++ @@ -74,6 +77,27 @@ class HomeFocus( else -> Unit } } + + private fun nearest(from: HomeRow, to: HomeRow): Int { + if (to.kind != HomeRowKind.Shelf) return (column * pitch(from) / pitch(to)).toInt() + val fromFirst = if (from.kind == HomeRowKind.Shelf) view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0 + val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2 + val first = (view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0)) + val last = minOf(to.count - 1, first + to.focusSlots - 1) + var best = first + var distance = Float.POSITIVE_INFINITY + for (index in first..last) { + val candidate = abs((index - first) * pitch(to) + to.cardWidth.value / 2 - x) + if (candidate < distance) { best = index; distance = candidate } + } + return best + } + + private fun pitch(row: HomeRow): Float = when (row.cardKind) { + HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value + HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value + HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value + } } internal val HomeRow.focusSlots: Int get() = when (cardKind) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt index 5b47355f..8057e033 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -5,11 +5,9 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.layout.Layout import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.tv.material3.Text @@ -18,14 +16,9 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.HomeType as T -/** Home's pending playback belongs to the app session rather than a transient hero composition. */ -val LocalHomePlaybackStart = compositionLocalOf { null } - /** The hero occupies one row while its backdrop extends beneath the first shelves. */ @Composable fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List, onButton: (Int) -> Unit, onHover: (Int) -> Unit) { - val start = LocalHomePlaybackStart.current - HeroMotionScope { Box(Modifier.fillMaxWidth().height(L.row_hero)) { if (!row.skeleton) { val title = row.cards.first() @@ -35,8 +28,7 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List + HeroCopyHandoff(title.id, title, Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) { copy -> Column { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) { Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f))) @@ -52,28 +44,15 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List - val button = measurables.single().measure(constraints) - // Reserve the real primary width; an unplaced button is neither drawn nor interactive. - layout(button.width, button.height) { if (row.watchReady) button.placeRelative(0, 0) } - } - val detailsColumn = if (detailsOnly || !row.watchReady) 0 else 1 - PickerButton("Details", focused && column == detailsColumn, detailsOnly, "about", onFocus = { onHover(detailsColumn) }, textStyle = T.button) { onButton(detailsColumn) } + val detailsOnly = row.heroKind == "pick" || !row.watchReady + val primary = if (detailsOnly) "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else "" + PickerButton(primary, focused && column == 0, true, if (detailsOnly) "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) } + if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) } } } + } } else { HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).skeletonPulse()) { @@ -85,7 +64,6 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List = buildList { if (loading) { add(HomeRow("hero-sk", HomeRowKind.Hero, skeleton = true)) - listOf(HomeCardKind.Tile, HomeCardKind.Tile, HomeCardKind.Poster).forEachIndexed { index, kind -> + listOf(HomeCardKind.Tile, HomeCardKind.Tile, HomeCardKind.Poster, HomeCardKind.Poster).forEachIndexed { index, kind -> add(HomeRow("sk$index", HomeRowKind.Shelf, cardKind = kind, skeleton = true)) } return@buildList } val choice = when { - // Hero precedence remains independent of the merged shelf's recency order. library.resume.isNotEmpty() -> "resume" to library.resume.first() library.nextUp.isNotEmpty() -> "next" to library.nextUp.first() library.newest.isNotEmpty() -> "new" to library.newest.first() @@ -35,10 +34,10 @@ fun buildHomeRows( if (!libraryAvailable) { add(HomeRow("jf-down", HomeRowKind.State, "Your library can’t be reached", text = "Jellyfin isn’t answering, so nothing can play right now.")) } else { - val watching = continueWatching(library).filter { it.watchIdentity != hero?.watchIdentity } - if (watching.isNotEmpty()) add(HomeRow("cw", HomeRowKind.Shelf, "Continue watching", watching, HomeCardKind.Tile)) - val newest = library.newest.filter { it.id != hero?.id } - if (newest.isNotEmpty()) add(HomeRow("new", HomeRowKind.Shelf, "New in your library", newest, HomeCardKind.Tile)) + listOf(Triple("cw", "Continue watching", library.resume), Triple("nu", "Next up", library.nextUp), Triple("new", "New in your library", library.newest)).forEach { (id, title, source) -> + val cards = source.filter { it.id != hero?.id } + if (cards.isNotEmpty()) add(HomeRow(id, HomeRowKind.Shelf, title, cards, if (id == "cw") HomeCardKind.Wide else HomeCardKind.Tile)) + } listOf("movies", "shows", "anime").forEach { destination -> val titles = library.destinations[destination].orEmpty().sortedWith(compareBy { it.played }.thenByDescending { it.addedAt }) if (titles.isNotEmpty()) add(HomeRow("lib-$destination", HomeRowKind.Shelf, if (destination == "anime") "Anime" else "Your $destination", titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), total = library.destinationTotals[destination] ?: titles.size, target = destination)) @@ -48,27 +47,6 @@ fun buildHomeRows( addAll(continuation) } -/** Prefer a resumable episode per series, then sort the chosen items by actual watch activity. */ -internal fun continueWatching(library: HomeLibrary): List { - val chosen = linkedMapOf() - fun choose(source: List) { - for (title in source) { - val key = title.watchIdentity - val previous = chosen[key] - if (previous == null || (previous.resumeSeconds <= 0 && title.resumeSeconds > 0) || - ((previous.resumeSeconds > 0) == (title.resumeSeconds > 0) && (title.lastPlayedAt ?: Long.MIN_VALUE) > (previous.lastPlayedAt ?: Long.MIN_VALUE))) { - chosen[key] = title - } - } - } - choose(library.resume) - choose(library.nextUp) - return chosen.values.sortedByDescending { it.lastPlayedAt } -} - -private val HomeTitle.watchIdentity: String - get() = if (isEpisode) seriesId?.let { "series:$it" } ?: seriesTitle?.let { "series-name:$it" } ?: id else id - /** Authentication failure is distinct from an unreachable signed-in catalogue. */ fun homeDiscoverState(status: HomeAvailability): HomeRow = if (status == HomeAvailability.SignedOut) { HomeRow("signin", HomeRowKind.State, "Discover needs sign-in", text = "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual.", calm = true) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index eb4c5645..11725f4a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -1,5 +1,7 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -27,12 +29,11 @@ fun HomeScreen( onAction: (HomeAction) -> Unit, ) { val view = shell.rememberedView("home") - val active = LocalScreenActive.current LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt } val action by rememberUpdatedState(onAction) val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } } - DisposableEffect(shell, focus, active) { - if (active) shell.contentFocus = focus + DisposableEffect(shell, focus) { + shell.contentFocus = focus onDispose { if (shell.contentFocus === focus) shell.contentFocus = null } } LaunchedEffect(snapshot.rows, view) { @@ -67,7 +68,7 @@ fun HomeScreen( (heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx() }.coerceAtLeast(0f) val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset - animateFrameScroll(view.listState, target - current) + view.listState.animateScrollBy(target - current, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) view.home.positionedKey = key view.home.positionedReset = view.home.resetScroll } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt index 01555c7c..d1827758 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -49,8 +49,7 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide)) val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList() LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + caption + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) { - val keys = cards.mapIndexed { index, title -> title?.id ?: "${row.id}:tail:$index" }.uniqueKeys() - itemsIndexed(cards, key = { index, _ -> keys[index] }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title -> + itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title -> val selected = !row.skeleton && focused && column == index val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index") .then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier) @@ -87,10 +86,9 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In private fun TileOverlay(title: HomeTitle) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) { val metadata = WolfskinTokens.CardMetadata - Column(Modifier.fillMaxWidth() - .background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = WolfskinTokens.Title.cardOverlayOpacity)))) - .padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, - bottom = H.cardOverlayBottom + metadata.bottom + metadata.separator + metadata.resumeLine)) { + Column(Modifier.padding(bottom = metadata.bottom + metadata.separator + metadata.resumeLine).fillMaxWidth() + .background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))) + .padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) { Text(title.displayTitle, modifier = Modifier.exactLineBox(metadata.titleFocus), color = C.fg, style = metadata.titleFocus, maxLines = 1, overflow = TextOverflow.Ellipsis) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LazyKeys.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LazyKeys.kt deleted file mode 100644 index b2cdcf1d..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LazyKeys.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.justinvisser.wolfskin.design - -/** - * Lazy lists throw "Key was already used" when two items share a key, and server lists can repeat an identity - * (one person credited for several roles, a title listed twice). The first occurrence keeps its plain key so focus - * and scroll restoration by key are unchanged for every list that has no repeat; each repeat gets a stable - * `key#n` that cannot collide with another key in the list. - */ -internal fun List.uniqueKeys(): List { - val used = HashSet(size) - val repeats = HashMap() - return map { base -> - var key = base - while (!used.add(key)) { - val count = (repeats[base] ?: 0) + 1 - repeats[base] = count - key = "$base#$count" - } - key - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt index 35ed0809..d4f09754 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt @@ -2,7 +2,6 @@ package org.justinvisser.wolfskin.design import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.derivedStateOf import java.util.Locale @@ -83,7 +82,6 @@ class LibraryGridState( var pagination: LibraryPagination? = null var pageRevision by mutableStateOf(0) var refreshRevision by mutableStateOf(0) - var resultRevision by mutableIntStateOf(0) var pointerReentry: (() -> Int?)? = null var index by mutableStateOf(0) var toolbar by mutableStateOf(false) @@ -149,10 +147,8 @@ class LibraryGridState( ShellKey.Left -> tool = (tool - 1).coerceAtLeast(0) ShellKey.Right -> tool = (tool + 1).coerceAtMost(count - 1) ShellKey.Ok -> { - val previous = items.map { if (catalogue) it.catalogueKey else it.id } if (catalogue) mediaType = listOf("all", "movie", "tv")[tool] else if (tool == 0) sort = (sort + 1) % 3 else unwatched = !unwatched - if (previous != items.map { if (catalogue) it.catalogueKey else it.id }) resultRevision++ index = 0 } else -> Unit diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index e6dd0982..93c55c85 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -39,11 +39,10 @@ fun LibraryScreen( cachedRatings: Set = emptySet(), ratingsReachable: Boolean = true, offlineArt: ((String) -> ImageBitmap?)? = null, - view: RememberedView = screenView(shell), + view: RememberedView = shell.view, registerFocus: Boolean = true, ) { val density = LocalDensity.current - val entryActive = LocalScreenActive.current val context = LocalContext.current val active = !shell.navFocused && !shell.pickerVisible val items = grid.items @@ -51,8 +50,8 @@ fun LibraryScreen( val count = if (grid.catalogue) items.size else grid.source.size var plateKey by remember { mutableStateOf(null) } val ratingsReader = LocalScreenRatings.current - DisposableEffect(shell, grid, registerFocus, entryActive) { - if (registerFocus && entryActive) shell.contentFocus = grid + DisposableEffect(shell, grid, registerFocus) { + if (registerFocus) shell.contentFocus = grid onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null } } LaunchedEffect(grid) { @@ -129,15 +128,12 @@ fun LibraryScreen( LibraryStatus.Unavailable -> item(key = "unavailable", contentType = "state") { LibraryRecovery(grid, active) } LibraryStatus.Ready -> if (items.isEmpty()) item(key = "empty", contentType = "state") { LibraryRecovery(grid, active && !grid.toolbar) } else { - val rowCount = (items.size + grid.columns - 1) / grid.columns - // Keys come from the same items and column count as the row count, never from an earlier composition. - val rowKeys = (0 until rowCount).map { row -> + items((items.size + grid.columns - 1) / grid.columns, key = { row -> val title = items[row * grid.columns] "grid-${if (grid.catalogue) title.catalogueKey else title.id}" - }.uniqueKeys() - items(rowCount, key = { rowKeys[it] }, contentType = { "grid" }) { row -> + }, contentType = { "grid" }) { row -> Box(Modifier.fillMaxWidth().height(rowHeight)) { - Row(Modifier.padding(start = L.safeSide).rowReveal("grid-${grid.resultRevision}-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { + Row(Modifier.padding(start = L.safeSide).rowReveal("grid-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { for (column in 0 until grid.columns) { val index = row * grid.columns + column val title = items.getOrNull(index) ?: break @@ -194,14 +190,12 @@ private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) { labels.forEachIndexed { index, label -> val focused = active && grid.toolbar && grid.tool == index val selected = if (grid.catalogue) listOf("all", "movie", "tv")[index] == grid.mediaType else index == 1 && grid.unwatched - val fill = animatedFocusColor(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest) - val color = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg) - val border = animatedFocusColor(if (selected && !focused) C.accent else C.accent.copy(alpha = 0f)) Row(Modifier.height(G.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .background(fill, CircleShape) - .border(G.borderWidth, border, CircleShape) + .background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, CircleShape) + .then(if (selected && !focused) Modifier.border(G.borderWidth, C.accent, CircleShape) else Modifier) .clickable { grid.toolbar = true; grid.tool = index; grid.key(ShellKey.Ok) } .padding(horizontal = G.chipPadding), horizontalArrangement = Arrangement.spacedBy(G.chipContentGap), verticalAlignment = Alignment.CenterVertically) { + val color = if (focused) C.onFocus else if (selected) C.accentText else C.fg if (!grid.catalogue) Glyph(if (index == 0) "advanced" else "eye", color, Modifier.size(G.chipIcon)) if (!grid.catalogue && index == 0) Text(buildAnnotatedString { withStyle(SpanStyle(color = color.copy(alpha = G.chipLabelOpacity), fontWeight = FontWeight.Medium)) { append("Sort") } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt index 4ad6c0be..111966df 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt @@ -74,25 +74,14 @@ fun Modifier.skeletonPulse(loading: Boolean = true): Modifier { return graphicsLayer { alpha = clock.alpha.value } } -/** - * 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. - */ +/** Content enters on its row layer, never one animation per card or a full-screen transition. */ @Composable fun Modifier.rowReveal(identity: Any, enabled: Boolean = true): Modifier { - val memory = LocalRevealMemory.current - val key = identity.toString() - val shouldReveal = remember(memory, identity, enabled) { enabled && memory?.identities?.contains(key) != true } - if (!shouldReveal) return this val reduced = reducedMotion() - val progress = remember(memory, identity, enabled) { Animatable(0f) } - LaunchedEffect(progress, reduced) { - memory?.identities?.add(key) - progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) - } - val rise = if (reduced) 0f else with(LocalDensity.current) { M.rowRise.toPx() } - return graphicsLayer { + val progress = remember(identity, enabled) { Animatable(if (enabled) 0f else 1f) } + LaunchedEffect(progress, reduced) { if (enabled) progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) } + val rise = with(LocalDensity.current) { M.rowRise.toPx() } + return if (!enabled) this else graphicsLayer { alpha = progress.value translationY = if (reduced) 0f else rise * (1f - progress.value) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt deleted file mode 100644 index aae4fb84..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt +++ /dev/null @@ -1,71 +0,0 @@ -// 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, 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) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index 80507be8..7da2fba8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -85,11 +86,10 @@ class PersonState(initialPerson: TitlePerson, initialCredits: List, p /** Portrait, biography and the full continuous grid use the shell's remembered frame. */ @Composable fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { - val view = screenView(shell) - val entryActive = LocalScreenActive.current + val view = shell.view LaunchedEffect(view, state.savedAt) { view.savedAt = state.savedAt } val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } } - LaunchedEffect(state, entryActive) { if (entryActive) shell.navFocused = false } + LaunchedEffect(state) { shell.navFocused = false } Box(modifier.fillMaxSize().background(C.bg)) { androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) { ShellSurface(shell, handlesContentKeys = false) { PersonPage(state, shell, it) } @@ -101,8 +101,7 @@ fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Mo @Composable private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { val density = LocalDensity.current - val list = screenView(shell).listState - val entryActive = LocalScreenActive.current + val list = shell.view.listState var wheelPending by remember { mutableStateOf(false) } val columns = WolfskinTokens.Behaviour.gridColumns.toInt() LaunchedEffect(state.index / columns, shell.navFocused) { @@ -115,7 +114,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2) } val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset - if (target != current) animateFrameScroll(list, target - current) + if (target != current) list.animateScrollBy(target - current, titleFrameScrollSpec) } Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" } .titleWheel { wheelPending = true; shell.navFocused = false } @@ -128,7 +127,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier if (key != ShellKey.Ok) return@onPreviewKeyEvent true } key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false - }.focusable(enabled = entryActive)) { + }.focusable()) { LazyColumn(state = list, modifier = Modifier.fillMaxSize()) { item(key = "person-header", contentType = "header") { Box(Modifier.height(L.row_phead)) { @@ -145,9 +144,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier item(key = "filmography", contentType = "header") { Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop)) } - val rowsOfCredits = state.credits.chunked(columns) - val rowKeys = rowsOfCredits.map { it.first().id }.uniqueKeys() - itemsIndexed(rowsOfCredits, key = { index, _ -> rowKeys[index] }, contentType = { _, _ -> "grid" }) { rowIndex, row -> + itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row -> Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { row.forEachIndexed { column, item -> val index = rowIndex * columns + column diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PlaybackHandoff.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PlaybackHandoff.kt deleted file mode 100644 index 197cea7e..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PlaybackHandoff.kt +++ /dev/null @@ -1,53 +0,0 @@ -// 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(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)) }) -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PolishMotion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PolishMotion.kt deleted file mode 100644 index 393a25e5..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PolishMotion.kt +++ /dev/null @@ -1,75 +0,0 @@ -// 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 { - 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) - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt index 2ab89023..3e8dd23c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt @@ -1,24 +1,14 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.DpOffset @@ -26,31 +16,14 @@ import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M - -/** Callers pass their current message; a cleared message fades out with its last text instead of vanishing. */ -@Composable -fun RecoveryToastHost(message: String?, modifier: Modifier = Modifier) { - val reduced = reducedMotion() - var last by remember { mutableStateOf(message) } - val opacity = remember { Animatable(0f) } - SideEffect { if (message != null) last = message } - LaunchedEffect(message != null, reduced) { - opacity.animateTo(if (message != null) 1f else 0f, tween(motionDuration(M.base, reduced), easing = M.ease)) - } - val text = message ?: last ?: return - if (message == null && opacity.value == 0f) return - RecoveryToast(text, modifier) { opacity.value } -} /** Recovery messages remain transient and never take focus from the action that failed. */ @Composable -fun RecoveryToast(message: String, modifier: Modifier = Modifier, opacity: () -> Float = { 1f }) { +fun RecoveryToast(message: String, modifier: Modifier = Modifier) { Box(modifier.fillMaxSize()) { - val shape = remember { RoundedCornerShape(WolfskinTokens.Radius.panel) } + val shape = androidx.compose.runtime.remember { RoundedCornerShape(WolfskinTokens.Radius.panel) } Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop) .widthIn(max = H.toastWidth) - .graphicsLayer { alpha = opacity(); compositingStrategy = CompositingStrategy.ModulateAlpha } .dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity), offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY))) .background(C.surface2, shape) @@ -67,6 +40,4 @@ fun RecoveryToast(message: String, modifier: Modifier = Modifier, opacity: () -> object RecoveryMessage { const val StillUnreachable = "Still can’t reach it. The last saved view stays on screen." const val PlaybackUnavailable = "Can’t reach Jellyfin to play. Check the network and try again." - const val TitleActionUnavailable = "Can’t update this title right now." - const val PersonSwitchUnavailable = "The household server didn’t answer. Choose a profile or try again." } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt deleted file mode 100644 index b3710f1f..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable - -/** Entrance identities belong to one navigation entry, not the lifetime of a recycled lazy item. */ -internal class RevealMemory(val identities: MutableSet = mutableSetOf()) { - companion object { - val saver = Saver>( - save = { ArrayList(it.identities) }, - restore = { RevealMemory(it.toMutableSet()) }, - ) - } -} - -internal val LocalRevealMemory = compositionLocalOf { null } - -internal val LocalScreenView = compositionLocalOf { null } -internal val LocalScreenActive = compositionLocalOf { true } - -/** Outgoing entries cannot consume Back while their exit animation remains composed. */ -@Composable -fun isScreenEntryActive(): Boolean = LocalScreenActive.current - -/** Overlapping navigation entries retain their own viewport; only the current entry owns input. */ -@Composable -fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) { - remember(shell, shell.person.key, route) { shell.prepareDestinationView(route) } - 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. */ -@Composable -internal fun screenView(shell: ShellState): RememberedView = LocalScreenView.current ?: shell.view diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt index cae18253..fe7adad1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -1,5 +1,7 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -55,12 +57,13 @@ val SETTINGS_GROUPS = listOf( @Composable fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") { val list = state.view.listState + val reduced = reducedMotion() val density = LocalDensity.current LaunchedEffect(state.settingsRow, state.navFocused) { if (state.navFocused) return@LaunchedEffect val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0) val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset - animateFrameScroll(list, distance) + list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) } Box(modifier.fillMaxSize()) { Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight), diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt index 8e72e00f..614caff7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt @@ -1,5 +1,7 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.lazy.LazyListState /** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */ @@ -13,6 +15,7 @@ internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int /** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */ internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) { val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset - animateFrameScroll(this, distance) + if (distance != 0f) animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.base, + easing = WolfskinTokens.Motion.ease)) if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index 398e8a72..2992e93f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -12,10 +12,9 @@ data class ShellPerson(val key: String, val name: String, val initial: String, v /** The shell owns keys; the integration owns navigation and authenticated user changes. */ sealed interface ShellAction { data class Destination(val route: String, val preservePushed: Boolean = false) : ShellAction - data class Person(val person: ShellPerson, val fromLaunch: Boolean) : ShellAction + data class Person(val person: ShellPerson) : ShellAction data class HandOff(val group: String) : ShellAction data object Pop : ShellAction - data object Exit : ShellAction data object RetryPeople : ShellAction data object RetryRequests : ShellAction data class RequestTitle(val request: ComingInRequest) : ShellAction @@ -82,8 +81,6 @@ class ShellState( var peopleStatus by mutableStateOf(PeopleStatus.Ready) var recoveryIndex by mutableStateOf(0) var requestsVisible by mutableStateOf(false) - internal var requestsExpansionInput = 0L - private set var toast by mutableStateOf(null) private val personRequests = mutableMapOf() val requests: ComingInState get() = personRequests.getValue(person.key) @@ -119,9 +116,6 @@ class ShellState( fun rememberedView(destination: String): RememberedView = views[person.key to destination] ?: RememberedView() - /** Register an entry before composition; rememberedView also serves read-only snapshot observers. */ - internal fun prepareDestinationView(destination: String) { retainView(destination) } - /** External intents and upstream pops remain authoritative over the screen. */ fun enterDestination(destination: String) { retainView(destination) @@ -163,12 +157,8 @@ class ShellState( when { key == ShellKey.Back -> closeRequests() key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests() - key == ShellKey.Ok && requests.snapshot.items.isEmpty() && requests.snapshot.status != ComingInStatus.Unavailable -> closeRequests() - else -> { - val expanded = requests.expanded - if (requests.key(key)) activateRequest() - if ((key == ShellKey.Right || key == ShellKey.Left) && requests.expanded != expanded) requestsExpansionInput++ - } + key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests() + requests.key(key) -> activateRequest() } return true } @@ -203,7 +193,7 @@ class ShellState( when (key) { ShellKey.Left -> pickerIndex = (pickerIndex - 1).coerceAtLeast(0) ShellKey.Right -> pickerIndex = (pickerIndex + 1).coerceAtMost(people.lastIndex) - ShellKey.Down -> gearFocused = true + ShellKey.Down -> if (pickerIndex == people.lastIndex) gearFocused = true ShellKey.Ok -> choosePerson(people[pickerIndex]) ShellKey.Back -> closePicker() else -> Unit @@ -212,7 +202,6 @@ class ShellState( } private fun choosePerson(selected: ShellPerson) { - val fromLaunch = pickerLaunch person = selected pickerVisible = false pickerLaunch = false @@ -224,23 +213,11 @@ class ShellState( requestsVisible = false preparePersonMemory() views[person.key to route] = RememberedView() - onAction(ShellAction.Person(selected, fromLaunch)) - } - - /** A failed switch may reopen only the identity that the integration still has authenticated. */ - fun restorePersonPicker(personKey: String, fromLaunch: Boolean) { - person = people.first { it.key == personKey } - preparePersonMemory() - pickerIndex = people.indexOf(person) - gearFocused = false - navFocused = false - pickerLaunch = fromLaunch - pickerVisible = true + onAction(ShellAction.Person(selected)) } private fun closePicker() { - if (pickerLaunch) onAction(ShellAction.Exit) - else { pickerVisible = false; navFocused = true; navIndex = tabs.size } + if (!pickerLaunch) { pickerVisible = false; navFocused = true; navIndex = tabs.size } } private fun navKey(key: ShellKey) { @@ -312,7 +289,7 @@ class ShellState( onAction(ShellAction.RequestTitle(request)) } - /** Back retains root view memory; a second root Back exits instead of detouring through Home. */ + /** Back never discards a root view, including the card below a focused nav. */ fun back() { if (requestsVisible) { closeRequests(); return } val returnRoute = requestReturnRoute @@ -330,14 +307,18 @@ class ShellState( pickerVisible = true pickerLaunch = false gearFocused = true + pickerIndex = people.lastIndex } else if (route !in tabs) { if (route == "updates") navFocused = false onAction(ShellAction.Pop) } else if (!navFocused) { navFocused = true navIndex = currentTab - } else { - onAction(ShellAction.Exit) + } else if (route != "home") { + route = "home" + rootRoute = "home" + navIndex = 0 + onAction(ShellAction.Destination(route)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index 9411fe70..cf1da9c4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -1,9 +1,6 @@ package org.justinvisser.wolfskin.design import androidx.compose.foundation.background -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.BringIntoViewSpec -import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.focusable import androidx.compose.foundation.focusGroup import androidx.compose.ui.focus.focusRestorer @@ -13,7 +10,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -36,17 +32,14 @@ import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -@OptIn(ExperimentalFoundationApi::class) -private object MinimumScrollBringIntoViewSpec : BringIntoViewSpec - -/** Native Back belongs to the Activity dispatcher; Escape remains the local Back shortcut. */ +/** Screens and the shell interpret the same Android directional and activation keys. */ fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) { Key.DirectionUp -> ShellKey.Up Key.DirectionDown -> ShellKey.Down Key.DirectionLeft -> ShellKey.Left Key.DirectionRight -> ShellKey.Right Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok - Key.Escape -> ShellKey.Back + Key.Back, Key.Escape -> ShellKey.Back else -> null } @@ -58,7 +51,6 @@ fun ShellState.handleKey(event: KeyEvent): Boolean { /** Content and nav share a single focus policy; upstream full-screen pages bypass this surface. */ @Composable -@OptIn(ExperimentalFoundationApi::class) fun ShellSurface( state: ShellState, modifier: Modifier = Modifier, @@ -70,8 +62,6 @@ fun ShellSurface( ) { val focus = remember { FocusRequester() } val contentFocus = remember { FocusRequester() } - val active = LocalScreenActive.current - val view = screenView(state) val focusManager = LocalFocusManager.current LaunchedEffect(state.toast) { val message = state.toast @@ -80,43 +70,34 @@ fun ShellSurface( if (state.toast == message) state.toast = null } } - LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys, active) { - if (active) { - if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus() - else contentFocus.requestFocus() - } + LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) { + if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus() + else contentFocus.requestFocus() } Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell") .semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" } .focusRequester(focus) .onPreviewKeyEvent { - if (active && (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys)) state.handleKey(it) else false + if (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys) state.handleKey(it) else false } .onKeyEvent { - if (active && it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) { + if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) { if (!focusManager.moveFocus(FocusDirection.Up)) { state.navFocused = true state.navIndex = state.currentTab } true } else false - }.focusable(enabled = active)) { + }.focusable()) { if (state.pickerVisible) { WhosWatching(state) } else { - // Logical-focus screens own their scroll position; TV's 30% focus pivot must not recenter it. - CompositionLocalProvider( - LocalBringIntoViewSpec provides if (handlesContentKeys) MinimumScrollBringIntoViewSpec else LocalBringIntoViewSpec.current, - ) { - 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) - ComingInSheet(state) + if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) + if (state.requestsVisible) ComingInSheet(state) overlay() } - RecoveryToastHost(state.toast) + state.toast?.let { RecoveryToast(it) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt index 3e8fce33..3a3efd9b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt @@ -32,13 +32,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.tv.material3.Text import coil3.compose.AsyncImage -import coil3.request.transitionFactory -import coil3.transition.Transition import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D @@ -57,9 +54,8 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo } else if (path != null) { val context = LocalContext.current - val request = remember(context, path, width, height, poster, heroReady != null) { + val request = remember(context, path, width, height, poster) { WolfskinImages.request(context, path, width, height, poster) - .let { if (heroReady != null) it.newBuilder().transitionFactory(Transition.Factory.NONE).build() else it } } AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, @@ -114,7 +110,7 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) { val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) } TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(), Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop) - .size(L.heroSheetWidth, L.heroSheetHeight).testTag("title-poster-backdrop").dropShadow(posterShape, posterShadow).clip(posterShape), poster = true) + .size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape), poster = true) } Box(Modifier.fillMaxSize().background(horizontal)) Box(Modifier.fillMaxSize().background(vertical)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt index cacef3f0..ea760054 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt @@ -1,11 +1,5 @@ package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -39,7 +33,6 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f, content: @Composable BoxScope.() -> Unit) { val shape = RoundedCornerShape(WolfskinTokens.Radius.card) - val animatedProgress = rememberTitleProgress(null, progress.coerceIn(0f, 1f), hasResumeLine = progress > 0f) Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) { Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2) @@ -58,21 +51,9 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs val top = Offset(0f, size.height - edge - fill) if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill)) drawRect(WolfskinTokens.Color.accent, top, - Size(size.width * if (progress > 0f) animatedProgress.value else 1f, fill)) + Size(size.width * if (progress > 0f) progress.coerceIn(0f, 1f) else 1f, fill)) } } }, content = content) } } - -/** Target and line-kind changes cut; a held resume line interpolates even when runtime arrives late. */ -@Composable -internal fun rememberTitleProgress(identity: String?, fraction: Float, hasResumeLine: Boolean): State { - 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() -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt index ab52201a..ee5054c5 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment -import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role @@ -26,35 +25,25 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D -/** Retained sheet controls are painted but cannot receive focus, hover or clicks. */ -internal val LocalTitleControlsEnabled = compositionLocalOf { true } - /** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */ @Composable -fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, busy: Boolean = false, busyLabel: String? = null, onClick: () -> Unit) { +fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, onClick: () -> Unit) { val focusOwner = LocalTitleFocusOwner.current - val enabled = LocalTitleControlsEnabled.current val background = if (option.primary) C.fg else if (overArtwork) { if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary } else if (focused) C.buttonSecondaryFocused else C.buttonSecondary val foreground = if (option.primary) C.onFocus else C.fg val shape = RoundedCornerShape(WolfskinTokens.Radius.button) val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX) - Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus.takeIf { enabled }).focusScale(focused, WolfskinTokens.Focus.scaleControl) + Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl) .semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on } .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .background(background, shape) - .clickable(enabled = enabled && !busy) { focusOwner?.invoke(); onClick() }.then(dimensions), + .then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier) + .clickable { focusOwner?.invoke(); onClick() }.then(dimensions), horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { - if (busyLabel != null) BusyLabelSwap(busy, busyLabel, foreground, WolfskinTokens.Type.button) { - Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { - if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(L.buttonIcon)) - Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1) - } - } else { - if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon)) - if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1) - } + 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) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt index caa84a14..5b2a7c47 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -1,11 +1,5 @@ package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween -import androidx.compose.runtime.getValue -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Size import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.platform.testTag @@ -15,6 +9,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset @@ -40,11 +35,6 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) { if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return } val item = state.data.item - val factsItem = state.data.watchTarget.takeIf { it.kind == "episode" } ?: item - val readinessHeight by animateDpAsState( - if (state.data.requestedStage == null) WolfskinTokens.Shell.zero else WolfskinTokens.Motion.readinessSlot, - tween(if (reducedMotion()) 0 else WolfskinTokens.Motion.lateLineFade, easing = WolfskinTokens.Motion.easeOut), - label = "title-readiness-height") val episode = when { state.row == "episodes" -> state.data.episodes.getOrNull(state.column) state.data.watchTarget.kind == "episode" -> state.data.watchTarget @@ -66,10 +56,10 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) } if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) - if (state.data.connection != TitleConnection.SignedOut) TitleRatings(factsItem.ratings) + if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings) if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres)) } - Box(Modifier.fillMaxWidth().height(readinessHeight).clipToBounds().testTag("title-readiness-slot")) { + Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) { state.data.requestedStage?.let { stage -> val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted } Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { @@ -84,22 +74,22 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.", color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap)) } - state.data.playTarget?.takeIf { it.resumeSeconds > 0 || it.kind == "episode" }?.let { target -> + state.data.playTarget?.let { target -> Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) { if (target.resumeSeconds > 0) { - val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f - val progress = rememberTitleProgress(target.id, fraction, hasResumeLine = true) - Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress)) - .drawBehind { drawRect(C.accent, size = Size(size.width * progress.value, size.height)) }) + Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) { + val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f + Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(C.accent)) + } 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) + if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = TitleTextStyles.progress) } } Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index, Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) }, - onFocus = { state.focus("header", index) }, busy = option.id == "play" && state.playbackStart.starting == "play", - busyLabel = if (option.id == "play") "Starting…" else null) { state.click("header", index) } } + onFocus = { state.focus("header", index) }) { state.click("header", index) } } } if (state.options.any { it.icon }) { val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt deleted file mode 100644 index 080262ae..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt +++ /dev/null @@ -1,79 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.graphics.graphicsLayer -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D - -/** A compact modal keeps secondary actions reachable without growing the header row. */ -@Composable -internal fun TitleMenu(state: TitleState, shell: ShellState) { - val visible = state.sheet == TitleSheet.Menu && !shell.pickerVisible - val focus = remember { FocusRequester() } - val reduced = reducedMotion() - val opacity = remember { Animatable(0f) } - LaunchedEffect(visible, reduced) { - if (visible) focus.requestFocus() - opacity.animateTo(if (visible) 1f else 0f, - tween(motionDuration(if (visible) WolfskinTokens.Motion.sheetIn else WolfskinTokens.Motion.sheetOut, reduced), easing = WolfskinTokens.Motion.easeOut)) - } - if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) { - Box(Modifier.fillMaxSize().background(C.sheetScrim) - .clickable(enabled = visible) { state.back(shell) } - .pointerInput(visible) { - if (!visible) awaitPointerEventScope { - while (true) awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() } - } - } - .focusRequester(focus).onPreviewKeyEvent { event -> - val key = readShellKey(event) - visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) - }.focusable(enabled = visible)) { - CompositionLocalProvider(LocalTitleControlsEnabled provides visible) { - Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth) - .graphicsLayer { alpha = opacity.value } - .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) - .padding(D.receiptSide).testTag("title-menu"), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) { - Text("Menu", color = C.fg, style = WolfskinTokens.Type.rowHeading) - state.menuOptions.forEachIndexed { index, option -> - val focused = visible && state.sheetIndex == index - TitleControl(option, focused, - Modifier.fillMaxWidth(), - onFocus = { if (visible) state.sheetIndex = index }, overArtwork = false) { - if (visible) state.clickSheet(index) - } - } - } - } - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt index ab6379fc..1fb71dd9 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt @@ -63,7 +63,7 @@ data class TitleVersion( enum class TitleReadStatus { Ready, Loading, Unavailable } enum class TitleConnection { SignedIn, SignedOut, Offline } -enum class TitleSheet { Menu, Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt } +enum class TitleSheet { Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt } /** A title read includes related titles and the selected season; reads happen outside composition. */ @Immutable diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt index c1e01ab1..3a87dcdd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt @@ -1,11 +1,5 @@ package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable @@ -50,65 +44,25 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D /** Sheets sit above navigation and consume pointer and key input across the entire scrim. */ @Composable fun TitleOptionsSheet(state: TitleState, shell: ShellState) { - val visible = state.sheet != null && state.sheet != TitleSheet.Menu - val menu = state.sheet == TitleSheet.Menu - val previous = remember { arrayOf(null) } - val retainedSheet = remember { arrayOf(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() } - 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 + LaunchedEffect(state.sheet) { focus.requestFocus() } val shadow = remember { Shadow(radius = D.sheetShadowRadius, offset = DpOffset.Zero.copy(x = D.sheetShadowX), color = Color.Black.copy(alpha = D.sheetShadowOpacity)) } - Box(Modifier.fillMaxSize().sheetScrim { if (!ownsScrim) 0f else if (fromMenu) 1f else scrim.value } - .pointerInput(visible) { - awaitPointerEventScope { - while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() } - } - } - .testTag("title-sheet").semantics { stateDescription = "$sheet:${content.sheetIndex}" } + Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { } + .testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" } .focusRequester(focus).onPreviewKeyEvent { event -> val key = readShellKey(event) - visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) - }.focusable(enabled = visible)) { - CompositionLocalProvider(LocalTitleControlsEnabled provides visible, LocalTitleFocusOwner provides null) { - CssColumn(Modifier.sheetPanelFade { fade.value }.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight() - .dropShadow(RectangleShape, shadow).background(C.surface) - .verticalScroll(rememberScrollState(), enabled = visible).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop), - gap = if (sheet == TitleSheet.Tracks) D.trackGap else if (sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) { - when (sheet) { - TitleSheet.Version -> VersionChoices(content) - TitleSheet.Tracks -> TrackChoices(content) - TitleSheet.MediaInfo -> MediaInformation(content) - TitleSheet.ConfirmWatched -> WatchedConfirmation(content) - TitleSheet.HiddenReceipt -> HiddenReceipt(content) - TitleSheet.Menu, null -> Unit - } + key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) + }.focusable()) { + CssColumn(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface) + .verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop), + gap = if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) { + when (state.sheet) { + TitleSheet.Version -> VersionChoices(state) + TitleSheet.Tracks -> TrackChoices(state) + TitleSheet.MediaInfo -> MediaInformation(state) + TitleSheet.ConfirmWatched -> WatchedConfirmation(state) + TitleSheet.HiddenReceipt -> HiddenReceipt(state) + null -> Unit } } } @@ -146,12 +100,11 @@ private fun TrackChoices(state: TitleState) { @Composable private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { - val enabled = LocalTitleControlsEnabled.current val foreground = if (focused) C.onFocus else C.fg val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical) - Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus.takeIf { enabled }).focusScale(focused, WolfskinTokens.Focus.scaleRow) + Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleRow) .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) - .clickable(enabled = enabled, onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide), + .clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide), horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) { CssColumn(Modifier.weight(1f)) { Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index 9cc90fb0..cefe5064 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -28,23 +28,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.spring -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.drawscope.Stroke -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.animation.core.tween import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.focus.FocusRequester @@ -65,11 +51,10 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D /** The shared shell remains authoritative; title sheets overlay it and trap all input. */ @Composable fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { - val view = screenView(shell) + val view = shell.view LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt } Box(modifier.fillMaxSize().onPreviewKeyEvent { event -> - if (state.playbackStart.launch != null) return@onPreviewKeyEvent true - // No content target exists during an initial load; Down must not enter a skeleton. + // No content target exists during an initial load; nav Down must not enter a skeleton. if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false else when (readShellKey(event)) { ShellKey.Down -> true @@ -84,10 +69,8 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) { ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) } } - TitleMenu(state, shell) - if (!shell.pickerVisible) TitleOptionsSheet(state, shell) + if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell) if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } - PlaybackHandoffVeil(state.playbackStart) } } @@ -97,31 +80,22 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism LaunchedEffect(message) { if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() } } - RecoveryToastHost(message, modifier) + if (message != null) RecoveryToast(message, modifier) } /** One keyed vertical frame renders the exact header, season, cast and related order. */ @Composable fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { val density = LocalDensity.current - val list = screenView(shell).listState - val entryActive = LocalScreenActive.current + val list = shell.view.listState var wheelPending by remember { mutableStateOf(false) } val focus = remember { FocusRequester() } - val sheetWasOpen = remember { booleanArrayOf(state.sheet != null) } var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) } - val compactHeader = state.data.status == TitleReadStatus.Ready - val item = state.data.item - val posterFloor = if (item.kind == "movie" && item.poster != null && (item.backdrop == null || item.isPosterBackdrop)) - D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero - val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead - LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible, entryActive) { - 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 compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held && + state.data.item.kind == "tv" && state.data.item.seasons > 0 + val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead + LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) { + if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() } val displayedRows = remember(state.data) { buildList { @@ -143,24 +117,14 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val position by remember(list, rowOffsets) { derivedStateOf { rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset } } - val headerPixels = with(density) { headerHeight.roundToPx() } - val previousHeaderPixels = remember(list, item.id, density) { intArrayOf(headerPixels) } - val compensateHeader = previousHeaderPixels[0] != headerPixels && !shell.navFocused && !list.isScrollInProgress && - list.firstVisibleItemIndex == 0 && list.firstVisibleItemScrollOffset > 0 && - list.layoutInfo.visibleItemsInfo.any { it.key == state.row && kotlin.math.abs(it.offset - with(density) { L.pinnedLine.roundToPx() }) <= 1 } - // Compensate in the same remeasure; an asynchronous scroll would chase the animated header. - androidx.compose.runtime.SideEffect { - if (compensateHeader) list.requestScrollToItem(0, (list.firstVisibleItemScrollOffset + headerPixels - previousHeaderPixels[0]).coerceAtLeast(0)) - previousHeaderPixels[0] = headerPixels - } LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) { - if (compensateHeader) return@LaunchedEffect + if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab) val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0) val y = rowOffsets[index] val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() } val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f else y - with(density) { L.pinnedLine.toPx() } - if (target != position) animateFrameScroll(list, target - position) + if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec) } 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 } } @@ -179,7 +143,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie if (key != ShellKey.Ok) return@onPreviewKeyEvent true } key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) - }.focusable(enabled = entryActive)) { + }.focusable()) { // This remains visible behind the scrolled rows; it is not an off-screen header image. TitleBackdrop(state.data.item, Modifier.fillMaxSize()) Box(Modifier.fillMaxSize().drawBehind { drawRect(C.bg.copy(alpha = dim)) }) @@ -190,7 +154,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val active = state.row == id && !shell.navFocused && state.sheet == null when (id) { "header" -> Box(Modifier.height(headerHeight)) { - TitleHeader(state, active, if (compactHeader) Modifier.onSizeChanged { size -> + TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size -> measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap } else Modifier) } @@ -219,79 +183,24 @@ private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) { private fun TitleSeasons(state: TitleState, active: Boolean) { val focusOwner = LocalTitleFocusOwner.current val list = androidx.compose.foundation.lazy.rememberLazyListState() - val reduced = reducedMotion() - val selectedIndex = state.data.seasonNumbers.indexOf(state.season) - val visibleChip = if (active) state.column else selectedIndex - LaunchedEffect(visibleChip, state.data.seasonNumbers, reduced) { + val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season) + LaunchedEffect(visibleChip, state.data.seasonNumbers) { if (visibleChip >= 0) { - val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == visibleChip } - if (visible == null) list.scrollToItem(visibleChip) - else animateFrameScroll(list, visible.offset.toFloat()) + if (NetworkArtTransition.reduced) list.scrollToItem(visibleChip) else list.animateScrollToItem(visibleChip) } } - var capsuleIndex by remember { mutableIntStateOf(selectedIndex) } - val capsulePosition = remember { Animatable(0f) } - val capsuleWidth = remember { Animatable(0f) } - // A stable virtual row coordinate preserves the spring's frame time and velocity on retarget. - var capsuleTarget by remember { mutableFloatStateOf(0f) } - val selection = rememberUpdatedState(selectedIndex) - LaunchedEffect(list, reduced) { - snapshotFlow { selection.value }.collectLatest { index -> - val target = snapshotFlow { list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } }.filterNotNull().first() - val previous = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == capsuleIndex } - if (reduced || capsuleWidth.value == 0f || previous == null) { - capsulePosition.snapTo(0f) - capsuleWidth.snapTo(target.size.toFloat()) - capsuleTarget = 0f - capsuleIndex = index - } else { - val targetPosition = capsuleTarget + target.offset - previous.offset - capsuleTarget = targetPosition - capsuleIndex = index - launch { capsulePosition.animateTo(targetPosition, spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness)) } - if (capsuleWidth.targetValue != target.size.toFloat()) launch { - capsuleWidth.animateTo(target.size.toFloat(), spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness)) - } - } - } - } - androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop).drawWithCache { - val height = D.chipHeight.toPx() - val radius = WolfskinTokens.Radius.chip.toPx() - val border = D.chipBorder.toPx() - val stroke = Stroke(border) - val inset = Offset(border / 2, border / 2) - val outerCorner = CornerRadius(radius) - val innerCorner = CornerRadius(radius - border / 2) - onDrawBehind { - val layout = list.layoutInfo - val visible = layout.visibleItemsInfo - val selected = capsuleIndex - var index = 0 - while (index < visible.size) { - val chip = visible[index++] - if (chip.index != selected) continue - val topLeft = Offset(chip.offset + layout.beforeContentPadding + capsulePosition.value - capsuleTarget, 0f) - val bounds = Size(capsuleWidth.value, height) - drawRoundRect(C.accentSoft, topLeft, bounds, outerCorner) - drawRoundRect(C.accent, topLeft + inset, Size(bounds.width - border, bounds.height - border), innerCorner, style = stroke) - break - } - } - }, state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { - val seasonNumbers = state.data.seasonNumbers - val keys = seasonNumbers.map { "season-$it" }.uniqueKeys() - items(seasonNumbers.size, key = { keys[it] }, contentType = { "chip" }) { index -> - val number = seasonNumbers[index] + 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)) { + items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index -> + val number = state.data.seasonNumbers[index] val focused = active && state.column == index val selected = state.season == number val shape = RoundedCornerShape(WolfskinTokens.Radius.chip) - val label = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg) Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl) .onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) } - .background(if (focused) C.fg else if (selected) androidx.compose.ui.graphics.Color.Transparent else C.chipRest, shape) + .background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape) + .then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier) .clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) { - Text("Season $number", color = label, style = WolfskinTokens.Type.label) + Text("Season $number", color = if (focused) C.onFocus else if (selected) C.accentText else C.fg, style = WolfskinTokens.Type.label) } } } @@ -313,3 +222,4 @@ private fun TitleReadError(state: TitleState) { } } +internal val titleFrameScrollSpec get() = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index 63423a91..429a38ce 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -51,7 +51,7 @@ fun TitleCast(state: TitleState, active: Boolean) { LaunchedEffect(state.column, active, state.data.cast.size) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size) } - Box(Modifier.height(D.castHeight).testTag("title-cast")) { + Box(Modifier.height(D.castHeight)) { Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) { @@ -59,9 +59,7 @@ fun TitleCast(state: TitleState, active: Boolean) { items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) { Box(Modifier.size(L.castCircle).background(C.surface2, CircleShape)) } - } else { - val keys = state.data.cast.map { it.id }.uniqueKeys() - itemsIndexed(state.data.cast, key = { index, _ -> keys[index] }, contentType = { _, _ -> "person" }) { index, person -> + } else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person -> val focused = active && state.column == index Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f) .onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) } @@ -73,7 +71,6 @@ fun TitleCast(state: TitleState, active: Boolean) { } } } - } } } } @@ -83,11 +80,11 @@ fun TitleCast(state: TitleState, active: Boolean) { fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { val titles = if (episodes) state.data.episodes else state.data.related val id = if (episodes) "episodes" else "related" - val width = if (episodes) D.episodeCardWidth else WolfskinTokens.Card.posterW + val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH val track = rememberLazyListState() - val visible = if (episodes) ((WolfskinTokens.Shell.stageWidth - L.safeSide * 2 + WolfskinTokens.Card.tileGap) / (D.episodeCardWidth + WolfskinTokens.Card.tileGap)).toInt() else WolfskinTokens.Card.posterSlots - val pitch = with(LocalDensity.current) { (if (episodes) D.episodeCardWidth + WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterPitch).toPx() } + val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots + val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() } LaunchedEffect(state.column, active, titles.size) { if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size) } @@ -95,19 +92,16 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter) .height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading), - contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(if (episodes) WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterGap)) { + contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) { - items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { index -> - Box(Modifier.size(width, height).testTag("title-episode-slot-$index").background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) + items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { + Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) } - } else { - val keys = titles.map { it.id }.uniqueKeys() - itemsIndexed(titles, key = { index, _ -> keys[index] }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item -> - TitlePoster(item, active && state.column == index, Modifier.width(width).then(if (episodes) Modifier.testTag("title-episode-slot-$index") else Modifier) + } else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item -> + TitlePoster(item, active && state.column == index, Modifier.width(width) .onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) }, episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) } } - } } } } @@ -123,16 +117,17 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true, modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) } else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, - D.episodeCardWidth, D.episodeCardHeight, watched = item.watched, + WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched, progress = item.progress.takeIf { it < 1f } ?: 0f) { TitleTileArtwork(item) Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { - Text(item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) - if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) - CardMetadata(null, item.episodeLabel + if (item.runtimeSeconds > 0) " · ${item.runtime}" else "", item.watched && item.resumeSeconds <= 0) + Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0) } - androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut)), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut))) { + androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) { LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth()) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index d7cdba74..d8a6d560 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -20,29 +20,16 @@ class TitleState( private val centers = HashMap>() fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x } fun showSeasonLoading() { if (data.episodesStatus != TitleReadStatus.Loading) data = data.copy(episodesStatus = TitleReadStatus.Loading) } - /** Cached episodes replace the loading state on the season-selection frame. */ - fun showSeason(number: Int, episodes: List) { - season = number - data = data.copy(selectedSeason = number, episodes = episodes, episodesStatus = TitleReadStatus.Ready) - } fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) } var sheet by mutableStateOf(null) private set var sheetIndex by mutableStateOf(0) - private var mediaInfoFromMenu = false - val menuOptions: List by derivedStateOf { buildList { - if (target != null) add(TitleOption("info", "Media info", "", icon = false)) - if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) { - add(TitleOption("add", "Add for someone", "users", icon = false)) - } - } } var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1) var versionId by mutableStateOf(initial.selectedVersion) var audioIndex by mutableStateOf(initial.selectedAudio) var subtitleIndex by mutableStateOf(initial.selectedSubtitle) var watched by mutableStateOf(initial.watchTarget.watched) var toast by mutableStateOf(null) - val playbackStart = PlaybackStart() private var armedCard: String? = null private var seasonChosen = false val requestedSeason: Int? get() = season.takeIf { seasonChosen } @@ -78,7 +65,6 @@ class TitleState( if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle if (row !in rows) { row = "header"; column = 0 } column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0)) - if (sheet == TitleSheet.Menu && menuOptions.isEmpty()) sheet = null } /** Pointer focus and D-pad use the same row memory. */ @@ -121,7 +107,6 @@ class TitleState( /** Key-up can only activate the same card that armed the first key-down. */ fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean { - if (playbackStart.launch != null) return true if (key == ShellKey.Ok) { if (!down) { okHeld = false @@ -154,10 +139,8 @@ class TitleState( /** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */ fun back(shell: ShellState) { - if (playbackStart.launch != null) return - playbackStart.fail() when { - sheet != null -> closeSheet() + sheet != null -> sheet = null shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back) else -> onAction(TitleAction.Pop) } @@ -196,22 +179,22 @@ class TitleState( private fun activateOption(id: String) { when (id) { - "play", "restart" -> if (data.watchReady && playbackStart.starting == null) target?.let { onAction(TitleAction.Play(it, id == "restart")) } + "play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) } "version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version } "tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks } - "menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu } + "more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo } "watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched() "trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) } + "seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0)) "retry" -> onAction(TitleAction.Retry) "back" -> onAction(TitleAction.Pop) - "request", "add" -> onAction(TitleAction.Request(data.item, id != "request")) + "request", "other", "add" -> onAction(TitleAction.Request(data.item, id != "request")) "status" -> onAction(TitleAction.Requests(data.item)) "hide", "unhide" -> { val hidden = id == "hide" data = data.copy(hidden = hidden) onAction(TitleAction.Hide(data.item, hidden)) - if (hidden && data.hidden) { sheetIndex = 0; sheet = TitleSheet.HiddenReceipt } - else if (!hidden && !data.hidden) toast = "Back in your picks." + if (hidden) { sheetIndex = 0; sheet = TitleSheet.HiddenReceipt } else toast = "Back in your picks." } } } @@ -222,10 +205,9 @@ class TitleState( } private fun sheetKey(key: ShellKey) { - if (key == ShellKey.Back) { closeSheet(); return } + if (key == ShellKey.Back) { sheet = null; return } val current = version val size = when (sheet) { - TitleSheet.Menu -> menuOptions.size TitleSheet.Version -> data.versions.size TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2 TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2 @@ -235,16 +217,6 @@ class TitleState( ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0) ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1) ShellKey.Ok -> when (sheet) { - TitleSheet.Menu -> menuOptions.getOrNull(sheetIndex)?.let { option -> - if (option.id == "info") { - mediaInfoFromMenu = true - sheetIndex = 0 - sheet = TitleSheet.MediaInfo - } else { - sheet = null - activateOption(option.id) - } - } TitleSheet.Version -> { val selected = data.versions[sheetIndex] versionId = selected.id @@ -262,19 +234,11 @@ class TitleState( onAction(TitleAction.Hide(data.item, false)) } else onAction(TitleAction.Pop) } - else -> closeSheet() + else -> sheet = null } } } - private fun closeSheet() { - if (sheet == TitleSheet.MediaInfo && mediaInfoFromMenu) { - mediaInfoFromMenu = false - sheetIndex = 0 - sheet = TitleSheet.Menu - } else sheet = null - } - private fun selectTrack(current: TitleVersion) { when { sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index @@ -310,12 +274,14 @@ fun titleOptions(state: TitleState): List = buildList { val series = data.watchTarget.kind == "tv" add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched)) if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer")) - if (target != null || (data.connection == TitleConnection.SignedIn && data.requestsAllowed)) add(TitleOption("menu", "Menu", "more")) + if (target != null) add(TitleOption("more", "More", "more", "Media info")) + if (item.kind == "tv" && item.seasons > 0) add(TitleOption("seasons", "Seasons", "tv", icon = false)) + if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) add(TitleOption("add", "Add for someone", "users", icon = false)) } else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) { add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false)) } else { add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false)) - add(TitleOption("menu", "Menu", "more")) + add(TitleOption("other", "For someone else", "users", icon = false)) if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false)) else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false)) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt index 312c87ea..224469bc 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -20,13 +20,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S -/** The picker covers the nav; Down from any person reaches the centred Settings gear. */ +/** The picker covers the nav and exposes Settings only below the final person. */ @Composable fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) { Box(modifier.fillMaxSize().background(C.bg)) { @@ -37,23 +36,15 @@ fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) { @Composable private fun PeoplePicker(state: ShellState) { - PickerPeople(state.people, state.pickerIndex, state.gearFocused, - onPerson = { index -> state.focusPerson(index); state.key(ShellKey.Ok) }, - onGear = { state.gearFocused = true; state.key(ShellKey.Ok) }) -} - -/** Live and frozen pickers share geometry; a frozen picker has no interactive modifiers. */ -@Composable -internal fun PickerPeople(people: List, focusedIndex: Int, gearFocused: Boolean, - onPerson: ((Int) -> Unit)?, onGear: (() -> Unit)?) { Box(Modifier.fillMaxWidth().offset(y = S.pickerTitleTop), contentAlignment = Alignment.TopCenter) { Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle) } Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { - people.forEachIndexed { index, person -> - val focused = !gearFocused && focusedIndex == index - Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard) - .then(if (onPerson != null) Modifier.clickable { onPerson(index) } else Modifier), horizontalAlignment = Alignment.CenterHorizontally) { + state.people.forEachIndexed { index, person -> + val focused = !state.gearFocused && state.pickerIndex == index + Column(Modifier.width(L.pickerAvatar).focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { + state.focusPerson(index); state.key(ShellKey.Ok) + }, horizontalAlignment = Alignment.CenterHorizontally) { Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker, Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing)) Spacer(Modifier.height(S.pickerNameGap)) @@ -62,11 +53,11 @@ internal fun PickerPeople(people: List, focusedIndex: Int, gearFocu } } val left = (S.stageWidth - L.gear) / 2 - Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(gearFocused, WolfskinTokens.Focus.scaleCard) - .focusRing(gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing) - .then(if (onGear != null) Modifier.clickable(onClick = onGear) else Modifier), contentAlignment = Alignment.Center) { - Glyph("gear", if (gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) - if (gearFocused) { + Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard) + .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing) + .clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { + Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) + if (state.gearFocused) { Box(Modifier.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) } @@ -82,7 +73,7 @@ private fun RecoveryPicker(state: ShellState) { Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle) } if (loading) { - Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { + Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { repeat(state.people.size) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Box(Modifier.size(L.pickerAvatar).background(C.surface2, CircleShape)) @@ -111,20 +102,16 @@ private fun RecoveryPicker(state: ShellState) { /** Recovery actions remain focusable while profiles cannot be read. */ @Composable fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, - textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, - busy: Boolean = false, busyLabel: String? = null, onClick: () -> Unit) { + textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) { val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier) - .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable { if (!busy) onClick() } + .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick) .padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) { - val content: @Composable () -> Unit = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) { - if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) - Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle) - } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) { + if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) + Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle) } - if (busyLabel == null) content() else BusyLabelSwap(busy, busyLabel, if (primary) C.onFocus else C.fg, textStyle, content) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 9770b26d..e2103e63 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -229,8 +229,6 @@ object WolfskinTokens { const val focusMove = 180 const val captionDelay = 60 const val captionOut = 100 - const val sheetIn = 160 - const val sheetOut = 100 const val artReveal = 160 const val heroFade = 320 const val heroCopyOut = 120 @@ -267,7 +265,7 @@ object WolfskinTokens { const val heroRequestHeight = 1080 const val heroPosterRequestWidth = 420 const val heroPosterRequestHeight = 630 - const val episodeRequestWidth = 448 + const val episodeRequestWidth = 400 const val episodeRequestHeight = 280 const val castRequestWidth = 200 const val castRequestHeight = 200 @@ -321,7 +319,6 @@ object WolfskinTokens { val receiptPaddingX = 15.0.dp val receiptGap = 5.0.dp val receiptGlyph = 18.0.dp - val thumbGlyph = 20.0.dp val buttonMargin = 6.0.dp val skeletonTitleWidth = 180.0.dp val skeletonTitleHeight = 17.0.dp @@ -457,11 +454,8 @@ object WolfskinTokens { const val sheetOpacity = 0.5f } object Title { - val episodeCardWidth = 224.0.dp val episodeCardHeight = 140.0.dp - val actionMenuWidth = 260.0.dp val seriesHeaderGap = 20.0.dp - val posterCastGap = 10.0.dp val lineGap = 11.0.dp val lineLift = -2.0.dp val overviewGap = 17.0.dp @@ -595,7 +589,7 @@ object WolfskinTokens { val factsGap = 11.0.dp val overviewBottom = 15.0.dp val cardPadding = 11.0.dp - val cardOverlayTop = 18.0.dp + val cardOverlayTop = 27.0.dp val cardOverlayBottom = 9.0.dp val sectionTop = 20.0.dp val sectionAvatar = 28.0.dp diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverDetailsFocusTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverDetailsFocusTest.kt deleted file mode 100644 index 01b61484..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverDetailsFocusTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.graphics.asImageBitmap -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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -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.GraphicsMode -import org.robolectric.annotation.Config - -/** A catalogue read has no right to move the destination's focus into the root navigation. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class DiscoverDetailsFocusTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun heroDetailsRetainsDestinationFocusDuringLoadingAndLateMetadata() { - rule.mainClock.autoAdvance = false - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") } - val snapshot = discoverTestFixture().snapshot("justin") - val discover = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination) - val selected = requireNotNull(discover.hero) - val title = TitleState(TitlePageData(TitleItem(selected.key, selected.title, selected.type), status = TitleReadStatus.Loading), shell.person) {} - val art = android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap() - rule.setContent { WolfskinTheme { - CompositionLocalProvider(LocalDiscoverArt provides { art }) { - val modifier = Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight) - if (shell.route.startsWith("title/")) TitleSurface(title, shell, modifier) - else ShellSurface(shell, modifier) { DiscoverScreen(discover, it) } - } - } } - rule.mainClock.advanceTimeBy(640) - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } - repeat(6) { - rule.mainClock.advanceTimeBy(16) - rule.waitForIdle() - assertFalse("Loading cannot steal destination focus on a later frame", shell.navFocused) - } - assertEquals("title/${android.net.Uri.encode(selected.key)}", shell.route) - assertFalse("The destination owns focus even while its read is pending", shell.navFocused) - rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } - assertFalse("Down on a loading title must not enter nav or a skeleton", shell.navFocused) - rule.runOnIdle { title.update(title.data.copy(status = TitleReadStatus.Ready)) } - rule.mainClock.advanceTimeBy(640) - val before = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot - rule.runOnIdle { title.update(title.data.copy(item = title.data.item.copy(overview = "Late overview", ratings = listOf(LibraryRating("rt", 88.0))))) } - repeat(24) { - rule.mainClock.advanceTimeBy(16) - assertFalse(shell.navFocused) - assertEquals("Loaded actions cannot replay their entry or move after late metadata", before, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot) - } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt deleted file mode 100644 index 193a1348..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt +++ /dev/null @@ -1,272 +0,0 @@ -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.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import coil3.intercept.Interceptor -import coil3.request.ErrorResult -import com.github.takahirom.roborazzi.captureRoboImage -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.awaitCancellation -import org.json.JSONArray -import org.json.JSONObject -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -import java.io.File -import java.io.IOException - -/** Real Discover artwork, copy and pager are sampled on every manually advanced native frame. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class DiscoverHeroSequenceTest { - @get:Rule val rule = createAndroidComposeRule() - private val samples = JSONArray() - private lateinit var directory: File - private lateinit var state: DiscoverState - private lateinit var shell: ShellState - private val artAvailable = mutableStateOf(true) - private val reduced = mutableStateOf(false) - private var previousPixel: Int? = null - private val mounted = mutableStateOf(true) - private var lowerPixel = 0 - private var largestJump = 0 - private var previousLayers = emptyMap>() - private val previousDotWidths = mutableMapOf() - private var previousSelection: Int? = null - - private fun launch(scenario: String, failedArt: Boolean = false, settle: Boolean = true) { - directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-hero/$scenario").apply { mkdirs() } - 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 - WolfskinImages.configureNetwork(rule.activity) { add(Interceptor { chain -> - if (chain.request.data.toString().startsWith("sequence:")) { - if (failedArt) ErrorResult(image = null, request = chain.request, throwable = IOException("Controlled artwork failure")) else awaitCancellation() - } else chain.proceed() - }) } - val fixture = discoverTestFixture() - val colors = listOf(android.graphics.Color.RED, android.graphics.Color.BLUE, android.graphics.Color.GREEN) - val titles = fixture.snapshot("justin").picks.take(3).mapIndexed { index, title -> - title.copy(backdrop = "sequence:$index", poster = null, posterOnly = false, score = 100.0 - index) - } - val art = colors.map { color -> Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(color) }.asImageBitmap() } - val snapshot = fixture.snapshot("justin").copy(picks = titles) - shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") } - state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination) - rule.setContent { WolfskinTheme { - val saved = rememberSaveableStateHolder() - CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalDiscoverArt provides { path -> - path.removePrefix("sequence:").toIntOrNull()?.let { if (it != 1 || artAvailable.value) art[it] else null } - }) { - ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> - if (mounted.value) saved.SaveableStateProvider("discover") { DiscoverScreen(state, modifier) } - } - } - } } - if (settle) rule.mainClock.advanceTimeBy(640) else rule.mainClock.advanceTimeByFrame() - } - - @Test fun initialCopyEntersOnceAndDoesNotReplayOnDestinationRemount() { - launch("initial", settle = false) - sample("initial", 0) - fun copy() = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true) - .fetchSemanticsNodes().single { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") } - assertEquals(0f, copy().config[HeroLayerAlpha], .001f) - assertEquals(with(rule.density) { WolfskinTokens.Motion.copyEntryRise.toPx() }, copy().config[HeroLayerTranslation], .001f) - frames("entry", 16) - assertEquals(1f, copy().config[HeroLayerAlpha], .001f) - rule.runOnIdle { mounted.value = false } - frames("absent", 1) - rule.runOnIdle { mounted.value = true } - repeat(8) { frame -> - rule.mainClock.advanceTimeBy(16) - sample("remount", frame) - assertEquals("Returning to a saved destination cannot replay its entrance", 1f, copy().config[HeroLayerAlpha], .001f) - assertEquals(0f, copy().config[HeroLayerTranslation], .001f) - } - finish() - } - - @Test fun interruptedHeroNeverSnapsToAnIntermediateSelection() { - launch("interrupted") - sample("settled", 0) - press(Key.DirectionRight) - frames("first", 12) - press(Key.DirectionRight) - frames("interrupted", 28) - press(Key.DirectionLeft) - frames("reverse", 28) - finish() - assertSmoothPixels() - assertEquals(1, state.slide) - } - - @Test fun slowArtRetainsBothThePaintedHeroAndItsCopyUntilReady() { - launch("slow") - sample("settled", 0) - val original = previousPixel - val originalLower = lowerPixel - rule.runOnIdle { artAvailable.value = false } - press(Key.DirectionRight) - repeat(24) { frame -> - rule.mainClock.advanceTimeBy(16) - sample("pending", frame) - assertEquals("Pending art cannot expose a placeholder beneath the hero", originalLower, lowerPixel) - } - assertEquals("Pending art must not replace the painted backdrop", original, previousPixel) - assertEquals(state.heroes.first().key, copyIdentities().single()) - rule.runOnIdle { artAvailable.value = true } - frames("ready", 28) - finish() - assertSmoothPixels() - assertEquals(state.hero!!.key, copyIdentities().single()) - } - - @Test fun failedArtBlendsToItsFinalFallbackWithoutErasingThePreviousHero() { - launch("failed", failedArt = true) - sample("settled", 0) - rule.runOnIdle { artAvailable.value = false } - press(Key.DirectionRight) - frames("fallback", 28) - rule.onNodeWithTag("discover-missing-art").assertExists() - finish() - assertSmoothPixels() - assertEquals(state.hero!!.key, copyIdentities().single()) - } - - @Test fun repeatedPressesRetargetTheCurrentCompositeWithoutSnapBack() { - launch("rapid") - sample("settled", 0) - listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionLeft, Key.DirectionLeft, Key.DirectionRight, Key.DirectionRight).forEachIndexed { index, key -> - press(key) - frames("press-$index", 3) - } - repeat(48) { index -> - press(if (index % 2 == 0) Key.DirectionLeft else Key.DirectionRight) - frames("burst-$index", 1) - } - frames("settled-latest", 28) - finish() - assertSmoothPixels() - assertEquals(state.hero!!.key, copyIdentities().single()) - } - - @Test fun reducedMotionKeepsTheBlendButRemovesCopyTranslation() { - launch("reduced") - rule.runOnIdle { reduced.value = true } - press(Key.DirectionRight) - frames("reduced", (WolfskinTokens.Motion.heroFade + 15) / 16) - finish() - assertEquals(state.hero!!.key, copyIdentities().single()) - assertEquals(1, rule.onAllNodesWithTag("hero-incoming", useUnmergedTree = true).fetchSemanticsNodes().size) - assertTrue(rule.onAllNodesWithTag("hero-outgoing", useUnmergedTree = true).fetchSemanticsNodes().isEmpty()) - val moving = (0 until samples.length()).map(samples::getJSONObject).filter { row -> - val layers = row.getJSONArray("layers") - (0 until layers.length()).map(layers::getJSONObject).any { - it.getString("kind") == "hero-outgoing" && it.getDouble("alpha") > 0.0 && it.getDouble("alpha") < 1.0 - } - }.map { it.getLong("timeMs") } - assertTrue("Reduced artwork fade cannot retain the ordinary 320ms lifetime", moving.last() - moving.first() <= WolfskinTokens.Motion.reducedFade) - } - - private fun copyIdentities() = rule.onAllNodesWithTag("hero-copy", useUnmergedTree = true).fetchSemanticsNodes().map { it.config[HeroLayerIdentity] } - private fun press(key: Key) = rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(key); keyUp(key) } - private fun frames(phase: String, count: Int) = repeat(count) { index -> rule.mainClock.advanceTimeBy(16); sample(phase, index) } - private fun finish() = File(directory, "series.json").writeText(samples.toString(2)) - private fun assertSmoothPixels() = assertTrue("A 16ms hero frame cannot snap to a full intermediate selection; largest channel jump=$largestJump", largestJump <= 48) - - private fun sample(phase: String, index: Int) { - rule.waitForIdle() - val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - val pixel = try { - lowerPixel = bitmap.getPixel(1600, 800) - bitmap.getPixel(1600, 200) - } finally { bitmap.recycle() } - val channels = channels(pixel) - previousPixel?.let { old -> largestJump = maxOf(largestJump, channels.zip(channels(old)).maxOf { (a, b) -> kotlin.math.abs(a - b) }) } - previousPixel = pixel - val copy = JSONArray() - state.heroes.forEach { title -> rule.onAllNodesWithText(title.title, useUnmergedTree = true).fetchSemanticsNodes().forEach { node -> - copy.put(JSONObject().put("title", title.key).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top)) - } } - val layers = layerSamples() - val dots = JSONArray() - state.heroes.indices.forEach { dot -> rule.onAllNodesWithTag("hero-dot-$dot", useUnmergedTree = true).fetchSemanticsNodes().forEach { node -> - previousDotWidths[dot]?.let { previous -> - val range = with(rule.density) { (WolfskinTokens.Discover.dotActive - WolfskinTokens.Discover.dotSize).toPx() } - if (!reduced.value) assertTrue("Pager retarget must not snap a full dot-width step", kotlin.math.abs(node.boundsInRoot.width - previous) <= range / 2) - if (reduced.value) { - val rest = with(rule.density) { WolfskinTokens.Discover.dotSize.toPx() } - assertTrue("Reduced motion snaps pager geometry instead of moving it", node.boundsInRoot.width == rest || node.boundsInRoot.width == rest + range) - } - if (previousSelection == state.slide) { - if (dot == state.slide) assertTrue("The selected pager dot cannot shrink backwards", node.boundsInRoot.width >= previous - .01f) - else assertTrue("An outgoing pager dot cannot grow backwards", node.boundsInRoot.width <= previous + .01f) - } - } - previousDotWidths[dot] = node.boundsInRoot.width - dots.put(JSONObject().put("index", dot).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top).put("width", node.boundsInRoot.width)) - } } - previousSelection = state.slide - samples.put(JSONObject().put("phase", phase).put("frame", index).put("timeMs", rule.mainClock.currentTime) - .put("selection", state.slide).put("mounted", mounted.value).put("rgb", JSONArray(channels)).put("lowerRgb", JSONArray(channels(lowerPixel))).put("copy", copy).put("layers", layers).put("dots", dots).put("navFocused", shell.navFocused)) - } - - private fun layerSamples(): JSONArray { - val result = JSONArray() - val current = mutableMapOf>() - val nodes = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes() - val unique = nodes.groupBy { it.config[SemanticsProperties.TestTag] + ":" + it.id } - if (mounted.value) assertTrue("The artwork composite always has an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f }) - val visibleCopy = nodes.count { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") && it.config[HeroLayerAlpha] > 0f } - assertTrue("Sequential copy cannot paint overlapping titles, including interruptions", visibleCopy <= 1) - val artCount = nodes.count { !it.config[SemanticsProperties.TestTag].startsWith("hero-copy") } - assertTrue("Sustained presses cannot extend outgoing art lifetimes", artCount <= (WolfskinTokens.Motion.heroFade + 15) / 16 + 3) - unique.forEach { (key, matching) -> matching.forEach { node -> - val alpha = node.config[HeroLayerAlpha] - val translation = node.config[HeroLayerTranslation] - val tag = node.config[SemanticsProperties.TestTag] - if (matching.size == 1) { - previousLayers[key]?.let { (previousAlpha, previousTranslation) -> - if (tag == "hero-outgoing" || tag == "hero-copy-outgoing") assertTrue("Outgoing layers cannot restart at full opacity: $key", alpha <= previousAlpha + .001f) - if (tag == "hero-copy-outgoing" && alpha > 0f) assertEquals("Visible outgoing copy must fade where its entry stopped", previousTranslation, translation, .001f) - if (tag == "hero-copy") { - assertTrue("Copy alpha cannot snap backwards: $key", alpha >= previousAlpha - .001f) - if (alpha > 0f && previousAlpha > 0f) assertTrue("Visible copy translation cannot snap backwards", translation <= previousTranslation + .001f) - } - } - current[key] = alpha to translation - } - if (reduced.value) assertEquals(0f, translation, .001f) - result.put(JSONObject().put("kind", tag).put("identity", node.config[HeroLayerIdentity]).put("layerId", node.id).put("alpha", alpha) - .put("translationY", translation).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top)) - } } - previousLayers = current - return result - } - - private fun channels(pixel: Int) = listOf(android.graphics.Color.red(pixel), android.graphics.Color.green(pixel), android.graphics.Color.blue(pixel)) -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt index e2aa398b..e5bd3c3a 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt @@ -2,9 +2,6 @@ package org.justinvisser.wolfskin.design import android.app.Application -import android.graphics.Bitmap -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.graphics.asImageBitmap import android.view.KeyEvent as AndroidKeyEvent import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent @@ -22,12 +19,10 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode /** Acceptance data drives real Android events through the shell's production focus adapter. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) class DiscoverKeysTest { @get:Rule val rule = createComposeRule() private val fixture = discoverTestFixture() @@ -38,14 +33,11 @@ class DiscoverKeysTest { { shell.enterDestination("discover") }, fixture::service) private fun launch() { - val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap() rule.setContent { WolfskinTheme { - CompositionLocalProvider(LocalDiscoverArt provides { art }) { ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier -> if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier) } - } } } } @@ -59,7 +51,7 @@ class DiscoverKeysTest { assertEquals(path.getInt("presses"), keys.length()) launch() repeat(keys.length()) { index -> press(when (keys.getString(index)) { - "OK" -> Key.DirectionCenter; "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown; "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight; else -> Key.Escape + "OK" -> Key.DirectionCenter; "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown; "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight; else -> Key.Back }) } rule.waitForIdle() } @@ -144,7 +136,7 @@ class DiscoverKeysTest { @Test fun closingAnUncertainReceiptNeverTurnsReopeningIntoAnotherPost() { operations.next = RequestPhase.NotConfirmed replay("Request a pick for yourself") - press(Key.Escape, Key.DirectionCenter) + press(Key.Back, Key.DirectionCenter) assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase) operations.next = RequestPhase.Receipt press(Key.DirectionCenter) @@ -173,7 +165,7 @@ class DiscoverKeysTest { press(Key.DirectionDown, Key.DirectionCenter) press(Key.DirectionCenter) assertEquals("shared", operations.sent.single().requester) - press(Key.Escape, Key.DirectionCenter) + press(Key.Back, Key.DirectionCenter) press(Key.DirectionDown, Key.DirectionCenter) press(Key.DirectionCenter) assertEquals(1, operations.sent.size) @@ -260,73 +252,11 @@ class DiscoverKeysTest { val origin = shell.view state.openCollection(state.lanes.first { it.id == "pk-movie" }) launch() - press(Key.DirectionDown, Key.Escape) + press(Key.DirectionDown, Key.Back) assertNull(state.collection) assertSame(origin, shell.view) assertEquals(3, shell.view.row) - assertEquals(2, state.column) - } - - @Test fun firstEntryStartsLeftmostAndOnlyMovedLanesRememberTheirPosition() { - replay("Open Discover") - press(Key.DirectionDown, Key.DirectionDown) - assertEquals(0, state.column) - repeat(7) { press(Key.DirectionRight) } - val moved = state.column - assertTrue(moved > 0) - press(Key.DirectionDown) - assertEquals("An untouched lane starts at its own left edge, not the previous lane's column", 0, state.column) - press(Key.DirectionUp) - assertEquals(moved, state.column) - } - - @Test fun leavingTheTabResetsLaneMovementButPushedTitlesDoNot() { - replay("Open Discover") - press(Key.DirectionDown, Key.DirectionDown) - press(Key.DirectionDown) - repeat(4) { press(Key.DirectionRight) } - val row = state.focusedRow - val moved = state.column - press(Key.DirectionCenter) - assertNotNull("This round trip must open a title, not a service lane", state.title) - press(Key.Escape) - assertEquals(moved, state.column) - press(Key.Escape) - press(Key.DirectionLeft, Key.DirectionCenter) - assertEquals("home", shell.route) - press(Key.DirectionUp, Key.DirectionRight) - press(Key.DirectionCenter) - assertEquals("discover", shell.route) - assertEquals(row, state.focusedRow) - assertEquals(0, state.column) - assertEquals(0, state.laneFirsts[state.rowKey] ?: 0) - } - - @Test fun rebuildingDiscoverDoesNotReuseThePreviousInstancesLaneColumns() { - shell.pickerVisible = false - shell.enterDestination("discover") - state.focus(state.firstShelf, 4) - val rebuilt = DiscoverState(shell, snapshot, operations, CoroutineScope(Dispatchers.Main.immediate)) - assertEquals(0, rebuilt.column) - } - - @Test fun serviceLanesKeepIndependentPositionsAndResetWithTheRootVisit() { - shell.pickerVisible = false - shell.enterDestination("discover") - state.focus(state.firstShelf + 1, 3) - state.openService(snapshot.services[0]) - state.focus(state.firstShelf + 1, 4) - state.key(ShellKey.Back) - assertEquals("A service must not overwrite the root lane", 3, state.column) - state.openService(snapshot.services[1]) - state.key(ShellKey.Down) - assertEquals("Different services can reuse lane ids without sharing columns", 0, state.column) - state.key(ShellKey.Back) - state.openService(snapshot.services[0]) - state.key(ShellKey.Down) - assertEquals(4, state.column) - state.enterRoute("home") - assertEquals("Leaving Discover clears service positions too", 0, state.column) + assertEquals(2, shell.view.cards[state.rowKey]) } @Test diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverNetworkHeroTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverNetworkHeroTest.kt deleted file mode 100644 index a351057c..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverNetworkHeroTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -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.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import coil3.asImage -import coil3.decode.DataSource -import coil3.intercept.Interceptor -import coil3.request.SuccessResult -import com.github.takahirom.roborazzi.captureRoboImage -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import org.json.JSONArray -import org.json.JSONObject -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -import java.io.File - -/** Network-success artwork must not add Coil's independent alpha ramp beneath the hero blend. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class DiscoverNetworkHeroTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun delayedNetworkSuccessCannotDipThePaintedHeroOpacity() { - 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 - val red = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.RED) }.asImageBitmap() - val blue = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.BLUE) }.asImage() - val release = CompletableDeferred() - WolfskinImages.configureNetwork(rule.activity) { add(Interceptor { chain -> - if (chain.request.data == "network:blue") { - release.await() - SuccessResult(blue, chain.request, DataSource.NETWORK) - } else chain.proceed() - }) } - val fixture = discoverTestFixture() - val titles = fixture.snapshot("justin").picks.take(2).mapIndexed { index, title -> - title.copy(backdrop = if (index == 0) "network:red" else "network:blue", poster = null, posterOnly = false, score = 100.0 - index) - } - val snapshot = fixture.snapshot("justin").copy(picks = titles) - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") } - val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate)) - rule.setContent { WolfskinTheme { - CompositionLocalProvider(LocalDiscoverArt provides { path -> if (path == "network:red") red else null }) { - ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) } - } - } } - rule.mainClock.advanceTimeBy(640) - rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionRight); keyUp(Key.DirectionRight) } - rule.mainClock.advanceTimeBy(192) - rule.onNodeWithText(titles.first().title).assertIsDisplayed() - val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-network-hero").apply { mkdirs() } - val samples = JSONArray() - rule.runOnIdle { release.complete(Unit) } - var lastPixel = 0 - repeat(40) { index -> - rule.mainClock.advanceTimeBy(16) - val file = File(directory, "network-${index.toString().padStart(2, '0')}.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - lastPixel = try { bitmap.getPixel(1600, 200) } finally { bitmap.recycle() } - val redChannel = android.graphics.Color.red(lastPixel) - val blueChannel = android.graphics.Color.blue(lastPixel) - samples.put(JSONObject().put("timeMs", rule.mainClock.currentTime).put("red", redChannel).put("blue", blueChannel)) - assertTrue("A second network-image fade exposes the dark placeholder beneath both heroes at frame $index: red=$redChannel blue=$blueChannel", redChannel + blueChannel >= 250) - } - File(directory, "series.json").writeText(samples.toString(2)) - assertEquals(android.graphics.Color.BLUE, lastPixel) - rule.onNodeWithText(titles.last().title).assertIsDisplayed() - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverPolishTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverPolishTest.kt deleted file mode 100644 index f56f928d..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverPolishTest.kt +++ /dev/null @@ -1,242 +0,0 @@ -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() - 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() - 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() - 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) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DuplicateLazyKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DuplicateLazyKeysTest.kt deleted file mode 100644 index ea7d0977..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DuplicateLazyKeysTest.kt +++ /dev/null @@ -1,103 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.unit.dp -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** - * Lazy lists throw "Key ... was already used" in measure when two items share an id. Server lists can repeat one: - * a Jellyfin person is credited once per role, so the title page crashed on a cast with the same person twice. - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class DuplicateLazyKeysTest { - @get:Rule val rule = createComposeRule() - private val viewer = ShellPerson("justin", "Justin", "J", false) - private fun shell(route: String) = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination(route) } - private fun repeated(id: String, name: String = id) = TitleItem(id, name, "movie", held = true) - - @Test fun repeatedKeysKeepTheFirstAndSuffixTheRest() { - assertEquals(listOf("a", "b", "a#1", "a#2", "a#1#1"), listOf("a", "b", "a", "a", "a#1").uniqueKeys()) - assertEquals(listOf("a", "b"), listOf("a", "b").uniqueKeys()) - } - - @Test fun castCreditedForTwoRolesComposes() { - val state = TitleState(TitlePageData(repeated("film"), cast = listOf( - TitlePerson("person", "Actor", "Hero"), TitlePerson("other", "Other", "Friend"), TitlePerson("person", "Actor", "Villain"))), viewer) {} - rule.setContent { WolfskinTheme { TitleCast(state, active = false) } } - rule.waitForIdle() - assertEquals(2, rule.onAllNodesWithTag("title-cast-person").fetchSemanticsNodes().size) - rule.onNodeWithTag("title-cast-other").assertExists() - } - - @Test fun relatedTitleListedTwiceComposes() { - val state = TitleState(TitlePageData(repeated("film"), related = listOf(repeated("same"), repeated("other"), repeated("same"))), viewer) {} - rule.setContent { WolfskinTheme { TitleMediaShelf(state, episodes = false, active = false) } } - rule.waitForIdle() - rule.onNodeWithTag("title-shelf:related").assertExists() - } - - @Test fun episodeListedTwiceComposes() { - val series = TitleItem("series", "A series", "tv", seasons = 1, held = true) - val episode = TitleItem("episode", "Pilot", "episode", held = true, season = 1, episode = 1) - val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode, episode), connection = TitleConnection.SignedIn), viewer) {} - rule.setContent { WolfskinTheme { TitleMediaShelf(state, episodes = true, active = false) } } - rule.waitForIdle() - rule.onNodeWithTag("title-shelf:episodes").assertExists() - } - - @Test fun repeatedSeasonNumberComposes() { - val series = TitleItem("series", "A series", "tv", seasons = 2, held = true) - val state = TitleState(TitlePageData(series, seasonNumbers = listOf(1, 1, 2)), viewer) {} - rule.setContent { WolfskinTheme { TitleSurface(state, shell("title/series"), Modifier.requiredSize(960.dp, 540.dp)) } } - rule.waitForIdle() - rule.onNodeWithTag("title-shelf:episodes").assertDoesNotExist() - assertTrue(state.rows.contains("seasons")) - } - - @Test fun filmographyWithARepeatedCreditComposes() { - val state = PersonState(TitlePerson("person", "Actor"), List(WolfskinTokens.Behaviour.gridColumns.toInt() + 1) { repeated("credit") }, {}) - rule.setContent { WolfskinTheme { PersonSurface(state, shell("title/person"), Modifier.requiredSize(960.dp, 540.dp)) } } - rule.waitForIdle() - rule.onNodeWithTag("person-page").assertExists() - } - - @Test fun homeShelfWithARepeatedCardComposes() { - val card = HomeTitle("episode", "Pilot", "episode") - val snapshot = HomeSnapshot(listOf(HomeRow("cw", HomeRowKind.Shelf, "Continue watching", listOf(card, HomeTitle("other", "Other", "movie"), card), HomeCardKind.Wide)), HomeAvailability.Ready) - val shell = shell("home") - rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } } } - rule.waitForIdle() - rule.onNodeWithTag("home-card:cw:0").assertExists() - rule.onNodeWithTag("home-card:cw:2").assertExists() - } - - @Test fun libraryGridWithARepeatedTitleComposes() { - val columns = WolfskinTokens.Behaviour.gridColumns.toInt() - val titles = List(columns * 2) { LibraryTitle("same", "Same title", "movie") } - val shell = shell("movies") - val grid = LibraryGridState("Movies", titles) - rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> LibraryScreen(shell, grid, modifier) } } } - rule.waitForIdle() - rule.onNodeWithTag("library-grid").assertExists() - } - - @Test fun requestListedTwiceComposes() { - val request = ComingInRequest("r1", "movie", 1, "A film", "justin", "Justin", null, null, ComingInStage.Waiting) - val shell = shell("settings").apply { requests.update(ComingInSnapshot(listOf(request, request.copy(id = "r2"), request), ComingInStatus.Ready, signedIn = true)); openRequests() } - rule.setContent { WolfskinTheme { ShellSurface(shell) } } - rule.waitForIdle() - rule.onNodeWithTag("wolfskin-shell").assertExists() - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusGlideTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusGlideTest.kt deleted file mode 100644 index 24fa42d3..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusGlideTest.kt +++ /dev/null @@ -1,251 +0,0 @@ -// 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() - 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() - 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) { - 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) } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt index f316001a..440c0fe3 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt @@ -13,9 +13,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onNodeWithTag @@ -62,22 +59,16 @@ class HeroMotionTest { val probe = BitmapFactory.decodeFile(probeFile.absolutePath) try { for (y in listOf(380, 400, 420, 600, 800)) { - assertEquals("Pending artwork cannot replace any part of the painted backdrop: y=$y", - Color.Red.toArgb(), probe.getPixel(100, y)) + assertEquals("The outgoing hero must stop at its 400px boundary: y=$y", + (if (y < 400) Color.Red else Color.Blue).toArgb(), probe.getPixel(100, y)) } + assertEquals(400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f) } finally { probe.recycle() } update { ready.value = true } rule.mainClock.advanceTimeBy(176) val mixed = pixel(directory, 100) assertNotEquals(Color.Red.toArgb(), mixed) assertNotEquals(Color.Blue.toArgb(), mixed) - val blendedProbe = BitmapFactory.decodeFile(probeFile.absolutePath) - try { - assertEquals("Only the outgoing blend is clipped at its 400px boundary", - 400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f) - assertEquals(Color.Blue.toArgb(), blendedProbe.getPixel(100, 400)) - assertEquals(Color.Blue.toArgb(), blendedProbe.getPixel(100, 800)) - } finally { blendedProbe.recycle() } rule.mainClock.advanceTimeBy(400) assertEquals(Color.Blue.toArgb(), pixel(directory, 100)) update { selected.value = "green"; ready.value = false } @@ -91,35 +82,6 @@ class HeroMotionTest { } finally { directory.deleteRecursively() } } - @Test fun homeHeroRetainsTheOutgoingCopyAnchorWhenTheNextTitleWraps() { - rule.mainClock.autoAdvance = false - val first = HomeTitle("first", "Short title", "movie", backdrop = "red") - val next = HomeTitle("next", "A much longer title that must wrap onto a second line in this hero", "movie", backdrop = "blue") - val selected = mutableStateOf(first) - val art = mapOf("red" to Color.Red, "blue" to Color.Blue).mapValues { (_, color) -> - android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888).apply { eraseColor(color.toArgb()) }.asImageBitmap() - } - rule.setContent { WolfskinTheme { - CompositionLocalProvider(LocalTitleBitmaps provides art) { - Box(Modifier.requiredSize(960.dp, 540.dp)) { - HomeHero(HomeRow("hero", HomeRowKind.Hero, cards = listOf(selected.value)), true, 0, emptyList(), {}, {}) - } - } - } } - rule.mainClock.advanceTimeBy(640) - val anchor = rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot - val actionAnchor = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot - update { selected.value = next } - repeat(WolfskinTokens.Motion.heroCopyOut / 16) { - rule.mainClock.advanceTimeBy(16) - assertEquals("A taller incoming copy cannot move the outgoing title", anchor, rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot) - assertEquals("Hero actions remain stationary outside the copy handoff", actionAnchor, rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot) - } - rule.mainClock.advanceTimeBy(320) - rule.onNodeWithText(first.title).assertDoesNotExist() - rule.onNodeWithText(next.title).assertIsDisplayed() - } - private fun update(block: () -> Unit) { rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } rule.mainClock.advanceTimeByFrame() diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt index 18f75917..323b52ff 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt @@ -110,12 +110,12 @@ class HomeCaptureTest { @Test fun st019_picksUnavailable() = capture("st-019", mode = "picks-unavailable", row = "trending") @Test fun st020_missingArt() = capture("st-020", person = "shared", mode = "missing", row = "lib-movies") @Test fun st096_widestTilePlate() = capture("st-096", person = "mindel", row = "new", column = 4) - @Test fun st100_episodePlate() = capture("st-100", person = "mindel", row = "cw") + @Test fun st100_episodePlate() = capture("st-100", person = "mindel", row = "nu") @Test fun st099_posterPlate() = capture("st-099", row = "lib-movies", column = 1) - @Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "cw", column = 2) + @Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2) @Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4) @Test fun st103_heroRatings() = capture("st-103") - @Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "cw") + @Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "nu") @Test fun focusHeroDetails() = capture("focus-hero-details", column = 1) @Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m") } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeGradientTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeGradientTest.kt deleted file mode 100644 index 241da584..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeGradientTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -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.background -import androidx.compose.foundation.layout.Box -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.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap -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.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Bright artwork cannot reappear between a landscape title scrim and the card's solid state edge. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class HomeGradientTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun focusedAndUnfocusedTileAndWideScrimsReachTheBottomWithoutDarkeningTheUpperImage() { - val bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.WHITE) } - val art = mapOf("solid" to bitmap.asImageBitmap()) - val title = HomeTitle("episode", "Episode", "episode", backdrop = "solid", seriesTitle = "Series") - var row by mutableStateOf(HomeRow("gradient", HomeRowKind.Shelf, cards = listOf(title), cardKind = HomeCardKind.Tile)) - var focused by mutableStateOf(false) - val memory = HomeViewMemory() - rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) { - Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) { - HomeShelf(row, memory, focused, 0, emptyList(), {}, {}) - } - } } } - val directory = kotlin.io.path.createTempDirectory("wolfskin-gradient-").toFile() - try { - for (kind in listOf(HomeCardKind.Tile, HomeCardKind.Wide)) for (selected in listOf(false, true)) { - rule.runOnIdle { row = row.copy(cardKind = kind); focused = selected } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val bounds = rule.onNodeWithTag("home-card:gradient:0").fetchSemanticsNode().boundsInRoot - val file = File(directory, "$kind-$selected.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val rendered = BitmapFactory.decodeFile(file.absolutePath) - try { - val scale = bounds.height / row.cardHeight.value - val x = (bounds.right - WolfskinTokens.Home.cardPadding.value * scale * 2).toInt() - val bottom = (bounds.bottom - WolfskinTokens.CardMetadata.bottom.value * scale - scale).toInt() - val upper = (bounds.top + bounds.height * .4f).toInt() - assertTrue("$kind/$selected image leaks below the scrim", android.graphics.Color.red(rendered.getPixel(x, bottom)) < 80) - assertTrue("$kind/$selected scrim starts too high", android.graphics.Color.red(rendered.getPixel(x, upper)) > 245) - } finally { rendered.recycle() } - } - } finally { directory.deleteRecursively() } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroActionsTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroActionsTest.kt deleted file mode 100644 index 57552aac..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroActionsTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.assertIsNotDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Fresh watch data may enable playback, but cannot move an already-visible Details action. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class HomeHeroActionsTest { - @get:Rule val rule = createComposeRule() - - @Test fun latePlaybackReadinessKeepsDetailsBoundsForResumePlayAndEpisodeLabels() { - val title = HomeTitle("arrival", "Arrival", "movie", overview = "A linguist investigates a mysterious arrival.") - val episode = title.copy(id = "episode", type = "episode", season = 3, episode = 7) - val directory = File(System.getProperty("wolfskin.captureDirectory"), "hero-actions").apply { mkdirs() } - val current = mutableStateOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(title), heroKind = "resume", watchReady = false)) - rule.setContent { WolfskinTheme { - Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { - HomeHero(current.value, false, 0, emptyList(), {}, {}) - } - } } - for ((kind, item) in listOf("resume" to episode, "new" to title, "next" to episode)) { - rule.runOnIdle { current.value = current.value.copy(cards = listOf(item), heroKind = kind, watchReady = false) } - rule.mainClock.advanceTimeBy(1000) - val before = rule.onNodeWithText("Details").fetchSemanticsNode().boundsInRoot - val primary = (if (kind == "resume") "Resume" else "Play") + if (item.isEpisode) " ${item.episodeLabel}" else "" - rule.onNodeWithText(primary).assertIsNotDisplayed() - rule.onRoot().captureRoboImage(File(directory, "$kind-pending.png").absolutePath) - rule.runOnIdle { current.value = current.value.copy(watchReady = true) } - rule.mainClock.advanceTimeBy(400) - val after = rule.onNodeWithText("Details").fetchSemanticsNode().boundsInRoot - rule.onRoot().captureRoboImage(File(directory, "$kind-ready.png").absolutePath) - assertEquals("Late $primary must not move Details", before, after) - } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroSequenceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroSequenceTest.kt deleted file mode 100644 index 899c36af..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroSequenceTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -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.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.saveable.rememberSaveableStateHolder -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import com.github.takahirom.roborazzi.captureRoboImage -import org.json.JSONArray -import org.json.JSONObject -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -import java.io.File - -/** Real Home hero entry, interrupted fade-through and saved remount sampled at native 16 ms steps. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class HomeHeroSequenceTest { - @get:Rule val rule = createAndroidComposeRule() - private val first = HomeTitle("first", "Short title", "movie", backdrop = "red") - private val next = HomeTitle("next", "A much longer title that must wrap onto a second line in this hero", "movie", backdrop = "blue") - private val third = HomeTitle("third", "The final selected title", "movie", backdrop = "green") - private val selected = mutableStateOf(first) - private val reduced = mutableStateOf(false) - private val mounted = mutableStateOf(true) - private val samples = JSONArray() - private lateinit var directory: File - private lateinit var actionBounds: Rect - - @Test fun initialSequentialBurstAndRemountKeepHomeCopyReadable() { - 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 - directory = File(System.getProperty("wolfskin.captureDirectory"), "home-hero-copy").apply { mkdirs() } - val art = mapOf("red" to android.graphics.Color.RED, "blue" to android.graphics.Color.BLUE, "green" to android.graphics.Color.GREEN).mapValues { (_, color) -> - Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(color) }.asImageBitmap() - } - rule.setContent { WolfskinTheme { - val saved = rememberSaveableStateHolder() - CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalTitleBitmaps provides art) { - Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) { - if (mounted.value) saved.SaveableStateProvider("home-hero") { - HomeHero(HomeRow("hero", HomeRowKind.Hero, cards = listOf(selected.value)), true, 0, emptyList(), {}, {}) - } - } - } - } } - rule.mainClock.advanceTimeByFrame() - actionBounds = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot - sample("initial", 0) - assertEquals(0f, copy().config[HeroLayerAlpha], .001f) - assertEquals(with(rule.density) { WolfskinTokens.Motion.copyEntryRise.toPx() }, copy().config[HeroLayerTranslation], .001f) - frames("entry", 16) - assertEquals(1f, copy().config[HeroLayerAlpha], .001f) - rule.runOnIdle { selected.value = next } - frames("handoff", 26) - assertEquals(next.id, copy().config[HeroLayerIdentity]) - assertEquals(1f, copy().config[HeroLayerAlpha], .001f) - repeat(24) { index -> - rule.runOnIdle { selected.value = if (index % 2 == 0) first else third } - frames("burst-$index", 1) - } - frames("settled", 28) - assertEquals(third.id, copy().config[HeroLayerIdentity]) - assertEquals(1f, copy().config[HeroLayerAlpha], .001f) - rule.runOnIdle { reduced.value = true; selected.value = next } - frames("reduced", 20) - assertEquals(next.id, copy().config[HeroLayerIdentity]) - assertEquals(1f, copy().config[HeroLayerAlpha], .001f) - rule.runOnIdle { mounted.value = false } - frames("absent", 1) - rule.runOnIdle { mounted.value = true } - repeat(8) { frame -> - rule.mainClock.advanceTimeBy(16) - sample("remount", frame) - assertEquals("Returning Home cannot replay the initial entrance", 1f, copy().config[HeroLayerAlpha], .001f) - assertEquals(0f, copy().config[HeroLayerTranslation], .001f) - } - File(directory, "series.json").writeText(samples.toString(2)) - } - - private fun frames(phase: String, count: Int) = repeat(count) { frame -> rule.mainClock.advanceTimeBy(16); sample(phase, frame) } - private fun copy() = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes() - .single { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") } - - private fun sample(phase: String, frame: Int) { - rule.waitForIdle() - val file = File(directory, "$phase-${frame.toString().padStart(2, '0')}.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - val pixel = try { bitmap.getPixel(1600, 200) } finally { bitmap.recycle() } - val nodes = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes() - val copyNodes = nodes.filter { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") } - assertTrue("Home must never overlap visible titles, even during bursts", copyNodes.count { it.config[HeroLayerAlpha] > 0f } <= 1) - if (mounted.value) { - assertTrue("Home artwork retains an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f }) - assertEquals("Copy motion cannot move Home's controls", actionBounds, rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot) - } - val layers = JSONArray() - nodes.forEach { node -> - val translation = node.config[HeroLayerTranslation] - if (reduced.value) assertEquals(0f, translation, .001f) - layers.put(JSONObject().put("kind", node.config[SemanticsProperties.TestTag]).put("identity", node.config[HeroLayerIdentity]) - .put("layerId", node.id).put("alpha", node.config[HeroLayerAlpha]).put("translationY", translation) - .put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top)) - } - samples.put(JSONObject().put("phase", phase).put("frame", frame).put("timeMs", rule.mainClock.currentTime) - .put("mounted", mounted.value).put("selection", selected.value.id).put("reduced", reduced.value) - .put("rgb", JSONArray(listOf(android.graphics.Color.red(pixel), android.graphics.Color.green(pixel), android.graphics.Color.blue(pixel)))) - .put("actionX", actionBounds.left).put("actionY", actionBounds.top).put("layers", layers)) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt index 9b371f74..a23faa73 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt @@ -98,22 +98,20 @@ class HomeKeysTest { var action: HomeAction? = null rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } } press("DOWN") - rule.onNodeWithTag("home-card:cw:2").performMouseInput { enter(center) } + rule.onNodeWithTag("home-card:nu:2").performMouseInput { enter(center) } rule.waitForIdle() - assertEquals("cw", snapshot.rows[shell.view.row].id) - assertEquals(2, shell.view.cards["cw"]) + assertEquals("nu", snapshot.rows[shell.view.row].id) + assertEquals(2, shell.view.cards["nu"]) assertNull(action) } - @Test fun rememberedColumnsClampToShortRowsAndTheFinalSeeAll() { + @Test fun downClampsToShortRowsAndNeverMovesPastTheFinalSeeAll() { val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false } val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards val long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards) val short = long.copy(id = "short", cards = cards.take(2), total = 2) val tail = short.copy(id = "tail", total = 20) shell.view.cards[long.id] = cards.lastIndex - shell.view.cards[short.id] = cards.lastIndex - shell.view.cards[tail.id] = 2 var action: HomeAction? = null rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } } @@ -134,18 +132,17 @@ class HomeKeysTest { assertEquals(tail.id, (action as HomeAction.SeeAll).row.id) } - @Test fun visitedMixedWidthRowsKeepColumnsAndTrackWhenMovingInsideTheMiddle() { + @Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() { val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false } val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards val posters = HomeRow(id = "posters", kind = HomeRowKind.Shelf, cards = cards) val tiles = posters.copy(id = "tiles", cardKind = HomeCardKind.Tile) shell.view.cards[posters.id] = 3 - shell.view.cards[tiles.id] = 2 rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(posters, tiles)), modifier) {} } } } press("DOWN") - assertEquals(2, shell.view.cards[tiles.id]) + assertEquals("The third tile is nearest the fourth poster's horizontal center", 2, shell.view.cards[tiles.id]) repeat(4) { press("RIGHT") } val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left press("LEFT") @@ -219,7 +216,7 @@ class HomeKeysTest { val key = when (name) { "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight - "BACK" -> Key.Escape; else -> Key.DirectionCenter + "BACK" -> Key.Back; else -> Key.DirectionCenter } rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt deleted file mode 100644 index bb96da5c..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt +++ /dev/null @@ -1,175 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -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 androidx.compose.ui.MotionDurationScale -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File - -/** Directional navigation follows stable rows through page returns and asynchronous replacement. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class HomeNavigationTest { - private val motionScale = object : MotionDurationScale { override val scaleFactor = 1f } - @get:Rule val rule = createAndroidComposeRule(effectContext = motionScale) - private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } - private val cards = listOf(HomeTitle("episode", "Episode", "episode", seriesTitle = "Series", season = 1, episode = 2)) - private val rows = listOf( - HomeRow("hero", HomeRowKind.Hero, cards = cards), - HomeRow("cw", HomeRowKind.Shelf, "Continue watching", cards, HomeCardKind.Wide), - HomeRow("nu", HomeRowKind.Shelf, "Next up", cards, HomeCardKind.Tile), - HomeRow("new", HomeRowKind.Shelf, "New in your library", cards, HomeCardKind.Tile), - HomeRow("pending", HomeRowKind.Shelf, skeleton = true), - HomeRow("section", HomeRowKind.Section), - ) + (0..5).map { HomeRow("row-$it", HomeRowKind.Shelf, "Shelf $it", cards) } - private var snapshot by mutableStateOf(HomeSnapshot(rows)) - private var title by mutableStateOf(null) - private var outgoing by mutableStateOf(null) - private var retainOutgoing = false - - @Test fun downScrollAfterBackIsMonotonicAtSixteenMillisecondFrames() { - retainOutgoing = true - mount() - press(Key.DirectionDown) - press(Key.DirectionCenter) - press(Key.Escape) - rule.runOnIdle { outgoing = null } - rule.waitForIdle() - rule.mainClock.autoAdvance = false - rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } - val directory = File(System.getProperty("wolfskin.captureDirectory"), "home-scroll-frames").apply { mkdirs() } - val series = mutableListOf("milliseconds,index,offset") - var previousIndex = shell.view.listState.firstVisibleItemIndex - var previousOffset = shell.view.listState.firstVisibleItemScrollOffset - val start = previousIndex to previousOffset - repeat((WolfskinTokens.Motion.scroll + 64) / 16) { frame -> - rule.mainClock.advanceTimeBy(16) - rule.waitForIdle() - val list = shell.view.listState - val index = list.firstVisibleItemIndex - val offset = list.firstVisibleItemScrollOffset - assertTrue("Scroll must not snap backward at frame $frame", index > previousIndex || index == previousIndex && offset >= previousOffset) - series += "${(frame + 1) * 16},$index,$offset" - rule.onRoot().captureRoboImage(File(directory, "frame-${frame.toString().padStart(2, '0')}.png").absolutePath) - previousIndex = index - previousOffset = offset - } - File(directory, "position.csv").writeText(series.joinToString("\n")) - assertNotEquals("Down must actually scroll after Back", start, previousIndex to previousOffset) - assertEquals(2, shell.view.row) - rule.mainClock.autoAdvance = true - walkDown(start = 2) - } - - @Test fun outgoingEpisodeDuringBackCannotTakeOverTheHomeScroller() { - retainOutgoing = true - mount() - press(Key.DirectionDown) - press(Key.DirectionDown) - press(Key.DirectionCenter) - rule.onNodeWithTag("title-page").assertIsDisplayed() - press(Key.Escape) - rule.runOnIdle { outgoing = null } - rule.waitForIdle() - assertEquals(2, shell.view.row) - walkDown(start = 2) - } - - @Test fun downAdvancesEveryRowAndScrollsAfterEpisodeBack() { - mount() - walkDown() - repeat(rows.count { it.focusable } - 1) { press(Key.DirectionUp) } - press(Key.DirectionDown) - val saved = shell.view - press(Key.DirectionCenter) - rule.onNodeWithTag("title-page").assertIsDisplayed() - press(Key.Escape) - assertSame(saved, shell.view) - assertEquals(1, shell.view.row) - walkDown(start = 1) - } - - @Test fun refreshReorderInsertionRemovalAndSkeletonReadyKeepDownProgress() { - mount() - press(Key.DirectionDown) - press(Key.DirectionDown) - assertEquals("nu", snapshot.rows[shell.view.row].id) - rule.runOnIdle { snapshot = snapshot.copy(rows = listOf(rows[0], rows[3], rows[1], rows[2]) + rows.drop(4)) } - rule.waitForIdle() - assertEquals("nu", snapshot.rows[shell.view.row].id) - rule.runOnIdle { snapshot = snapshot.copy(rows = snapshot.rows.map { if (it.id == "pending") it.copy(cards = cards, skeleton = false) else it }) } - press(Key.DirectionDown) - assertEquals("pending", snapshot.rows[shell.view.row].id) - rule.runOnIdle { snapshot = snapshot.copy(rows = listOf(HomeRow("insert", HomeRowKind.Shelf, cards = cards)) + snapshot.rows) } - rule.waitForIdle() - assertEquals("pending", snapshot.rows[shell.view.row].id) - rule.runOnIdle { snapshot = snapshot.copy(rows = snapshot.rows.filter { it.id != "pending" }) } - rule.waitForIdle() - assertFalse(shell.navFocused) - walkDown(start = shell.view.row) - } - - @Test fun loadingToReadyThenRefreshDoesNotTrapFocusOnPendingRows() { - snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true)) - mount() - assertTrue(shell.navFocused) - rule.runOnIdle { snapshot = HomeSnapshot(rows) } - rule.waitForIdle() - assertFalse(shell.navFocused) - walkDown() - } - - private fun mount() { - rule.setContent { WolfskinTheme { - val page = title - if (page != null) ScreenEntry(shell, "title/episode", true) { TitleSurface(page, shell) } - else ScreenEntry(shell, "home", true) { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action -> - if (action is HomeAction.Details) { - shell.enterDestination("title/episode") - title = TitleState(TitlePageData(TitleItem(action.title.id, action.title.title, "episode", held = true)), shell.person) { effect -> - if (effect == TitleAction.Pop) { - if (retainOutgoing) outgoing = title - shell.enterDestination("home") - title = null - } - } - } - } } } - outgoing?.let { ScreenEntry(shell, "title/episode", false) { TitleSurface(it, shell) } } - } } - rule.waitForIdle() - } - - private fun walkDown(start: Int = 0) { - val indices = snapshot.rows.indices.filter { it >= start && snapshot.rows[it].focusable } - for (index in indices) { - if (index != start) press(Key.DirectionDown) - assertEquals("Down must advance to ${snapshot.rows[index].id}", index, shell.view.row) - if (snapshot.rows[index].kind == HomeRowKind.Shelf) { - val bounds = rule.onNodeWithTag("home-card:${snapshot.rows[index].id}:0").fetchSemanticsNode().boundsInRoot - assertTrue("Focused card must clear navigation: $bounds", bounds.top >= WolfskinTokens.Layout.navBand.value * rule.density.density) - assertTrue("Focused card must clear screen bottom: $bounds", bounds.bottom <= WolfskinTokens.Shell.stageHeight.value * rule.density.density) - } - } - press(Key.DirectionDown) - assertEquals(indices.last(), shell.view.row) - } - - private fun press(key: Key) { - rule.onRoot().performKeyInput { pressKey(key) } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomePolishRowsTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomePolishRowsTest.kt deleted file mode 100644 index 92053eeb..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomePolishRowsTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -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() - @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, 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, 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 = 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, 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)) - } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt index 0d3aa24f..664ecba6 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt @@ -46,7 +46,7 @@ class HomeRatingsUiTest { rule.onNodeWithText("7.8").assertDoesNotExist() rule.mainClock.advanceTimeBy(450) rule.onNodeWithText("7.8").assertExists() - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Escape) } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) } rule.runOnIdle { assertTrue(shell.navFocused) } rule.mainClock.advanceTimeBy(32) rule.onNodeWithText("7.8").assertDoesNotExist() diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowEntryTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowEntryTest.kt deleted file mode 100644 index 61382594..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowEntryTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.compose.foundation.lazy.LazyListState -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Home starts new rows at zero and retains visited positions independently of other root tabs. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class HomeRowEntryTest { - private val people = listOf(ShellPerson("justin", "Justin", "J", true)) - private val cards = List(12) { HomeTitle("item-$it", "Film $it", "movie") } - - @Test fun unvisitedRowsStartLeftmostAcrossCardWidths() { - for (from in HomeCardKind.entries) for (to in HomeCardKind.entries) { - val shell = ShellState(people, "justin").apply { pickerVisible = false; navFocused = false } - val source = HomeRow("source", HomeRowKind.Shelf, cards = cards, cardKind = from) - val target = source.copy(id = "target", cardKind = to) - val focus = HomeFocus(shell, listOf(source, target)) {} - focus.focus(0, 3) - focus.key(ShellKey.Down) - assertEquals("$from to $to", target.id, focus.row?.id) - assertEquals("$from to $to must start leftmost", 0, focus.column) - assertEquals(0, shell.rememberedView("home").home.firstCards[target.id]) - } - } - - @Test fun visitedColumnsAndFirstVisiblePositionsSurviveRowChangesAndRootTabLeave() { - val shell = ShellState(people, "justin").apply { pickerVisible = false; navFocused = false } - val source = HomeRow("source", HomeRowKind.Shelf, cards = cards) - val rows = listOf(source, source.copy(id = "target", cardKind = HomeCardKind.Tile)) - val focus = HomeFocus(shell, rows) {} - focus.focus(0, 8) - focus.key(ShellKey.Left) - val view = shell.rememberedView("home") - val first = view.home.firstCards.getValue(source.id) - val track = LazyListState(first, 19) - view.home.tracks[source.id] = track - focus.key(ShellKey.Down) - focus.key(ShellKey.Up) - assertEquals(7, focus.column) - assertEquals(first, view.home.firstCards[source.id]) - - shell.focusNav(shell.tabs.indexOf("discover")); shell.key(ShellKey.Ok) - shell.focusNav(shell.tabs.indexOf("home")); shell.key(ShellKey.Ok) - val restored = HomeFocus(shell, rows) {} - assertSame(view, shell.rememberedView("home")) - assertEquals(7, restored.column) - restored.key(ShellKey.Down) - restored.key(ShellKey.Up) - assertEquals(7, restored.column) - assertEquals(first, view.home.firstCards[source.id]) - assertSame(track, view.home.tracks[source.id]) - assertEquals(first, track.firstVisibleItemIndex) - assertEquals(19, track.firstVisibleItemScrollOffset) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt index 280e4cae..9fa7e9a9 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt @@ -44,26 +44,7 @@ class HomeRowsTest { @Test fun firstReadSkeletonHasNoFocusableContent() { val rows = buildHomeRows(HomeLibrary(), continuation, loading = true) + assertEquals(listOf(HomeRowKind.Hero, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf), rows.map { it.kind }) assertTrue(rows.all { !it.focusable }) } - - @Test fun mergedWatchRowPrefersResumePerSeriesAndInterleavesByWatchRecency() { - val inProgress = next.copy(id = "resume-a", seriesId = "a", resumeSeconds = 40.0, lastPlayedAt = 30) - val olderResume = inProgress.copy(id = "older-a", lastPlayedAt = 10) - val nextSameSeries = next.copy(id = "next-a", seriesId = "a", lastPlayedAt = 50) - val nextOtherSeries = next.copy(id = "next-b", seriesId = "b", lastPlayedAt = 40) - val film = resume.copy(id = "film", resumeSeconds = 20.0, lastPlayedAt = 20) - val unknown = next.copy(id = "unknown", seriesId = "c") - val library = HomeLibrary(listOf(inProgress, olderResume, film), listOf(nextSameSeries, nextOtherSeries, nextOtherSeries, unknown)) - assertEquals(listOf("next-b", "resume-a", "film", "unknown"), continueWatching(library).map { it.id }) - val rows = buildHomeRows(library, continuation) - assertFalse(rows.any { it.id == "nu" }) - assertEquals(listOf("next-b", "film", "unknown"), rows.single { it.id == "cw" }.cards.map { it.id }) - } - - @Test fun oldCachedEpisodesWithoutSeriesIdsDeduplicateBySeriesName() { - val checkpoint = next.copy(id = "resume", seriesTitle = "Same series", resumeSeconds = 10.0) - val following = next.copy(id = "next", seriesTitle = "Same series") - assertEquals(listOf(checkpoint), continueWatching(HomeLibrary(listOf(checkpoint), listOf(following)))) - } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt index e30a9f60..fe86448f 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.test.pressKey import com.github.takahirom.roborazzi.captureRoboImage import java.io.File import org.junit.Assert.assertTrue -import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -60,15 +59,10 @@ class HomeViewportTest { repeat(row.count - 1) { press(Key.DirectionRight) } assertVisible(shell, rows) capture("viewport-${row.id}") - val viewport = shell.view.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset } - val bounds = rule.onNodeWithTag("home-card:${row.id}:${row.count - 1}").fetchSemanticsNode().boundsInRoot press(Key.DirectionCenter) rule.onNodeWithTag("title-page").assertIsDisplayed() - press(Key.Escape) + press(Key.Back) assertVisible(shell, rows) - assertEquals("Back restores the exact scroll, not only the focused card", viewport, - shell.view.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }) - assertEquals(bounds, rule.onNodeWithTag("home-card:${row.id}:${row.count - 1}").fetchSemanticsNode().boundsInRoot) } repeat(rows.lastIndex) { press(Key.DirectionUp); assertVisible(shell, rows) } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt index dcbbe296..5eae18b5 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt @@ -77,7 +77,7 @@ class LibraryKeysTest { repeat(6) { press(Key.DirectionRight) } assertEquals(5, grid.index) assertFalse(grid.inRail) - press(listOf(Key.DirectionDown, Key.Escape)) + press(listOf(Key.DirectionDown, Key.Back)) assertTrue(shell.navFocused) assertEquals(11, grid.index) press(Key.DirectionDown) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPolishTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPolishTest.kt deleted file mode 100644 index f361f7ba..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPolishTest.kt +++ /dev/null @@ -1,129 +0,0 @@ -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() - 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("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() - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt index 4a301574..12d6ddad 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot @@ -45,19 +44,20 @@ class MotionSurfaceTest { @get:Rule val rule = createAndroidComposeRule(effectContext = animatorScale) private val viewer = ShellPerson("justin", "Justin", "J", false) - @Test fun reducedMotionReadinessSnapsAndRemovingItRestoresTheCollapsedStack() { + @Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() { val shell = shell() val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview") val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview") val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {} - rule.setContent { CompositionLocalProvider(LocalReducedMotion provides true) { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } } + rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } + rule.waitForIdle() val before = bounds() - assertEquals(0f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f) - rule.runOnIdle { state.update(state.data.copy(requestedStage = "Ready", requestActor = "Mindel")) } - assertEquals(56f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f) - bounds().zip(before).forEach { (after, initial) -> assertEquals(56f, after.top - initial.top, 1f) } - rule.runOnIdle { state.update(state.data.copy(requestedStage = null)) } - assertEquals(before, bounds()) + for (stage in listOf("Ready", "Downloading", null)) { + rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Mindel", item = series.copy(ratings = listOf(LibraryRating("rt", 88.0))))) } + rule.mainClock.advanceTimeBy(3400) + rule.waitForIdle() + assertEquals("Late metadata cannot change reserved header geometry", before, bounds()) + } } @Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() { @@ -67,14 +67,8 @@ class MotionSurfaceTest { rule.setContent { WolfskinTheme { TitleSurface(state, shell) } } rule.mainClock.advanceTimeBy(2100) rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } - rule.onNodeWithTag("title-page").assertIsFocused() - rule.runOnIdle { - assertFalse(shell.navFocused) - assertFalse(shell.pickerVisible) - assertEquals("header", state.row) - assertEquals(0, state.column) - } - rule.onRoot().performKeyInput { pressKey(Key.Escape) } + rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible) } + rule.onRoot().performKeyInput { pressKey(Key.Back) } rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt deleted file mode 100644 index 5f006341..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt +++ /dev/null @@ -1,72 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -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 - -/** Every profile reaches Settings vertically without losing its horizontal return position. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class PickerNavigationTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun downFromEveryAvatarReachesSettingsAndUpReturnsToTheOrigin() { - val state = ShellState(fixturePeople(), "justin") - rule.setContent { WolfskinTheme { ShellSurface(state) } } - for (index in state.people.indices) { - rule.runOnIdle { state.pickerIndex = index; state.gearFocused = false } - press(Key.DirectionDown) - assertTrue("Down from profile $index must reach Settings", state.gearFocused) - press(Key.DirectionUp) - assertFalse(state.gearFocused) - assertEquals("Up must return to profile $index", index, state.pickerIndex) - } - } - - @Test fun settingsBackThenUpKeepsTheAvatarThatOpenedTheGear() { - val state = ShellState(fixturePeople(), "justin") - rule.setContent { WolfskinTheme { ShellSurface(state) } } - for (index in state.people.indices) { - rule.runOnIdle { state.pickerIndex = index; state.gearFocused = false } - press(Key.DirectionDown) - press(Key.DirectionCenter) - assertEquals("settings", state.route) - press(Key.Escape) - assertTrue(state.pickerVisible) - assertTrue(state.gearFocused) - press(Key.DirectionUp) - assertEquals(index, state.pickerIndex) - } - } - - @Test fun settingsGearIsCenteredUnderTheWholePeopleRowForAnyProfileCount() { - val state = ShellState(fixturePeople(), "justin") - rule.setContent { WolfskinTheme { ShellSurface(state) } } - for (count in 1..5) { - val people = (0 until count).map { ShellPerson("person-$it", "Person $it", "$it", false) } - rule.runOnIdle { state.updatePeople(people); state.gearFocused = true } - rule.mainClock.advanceTimeBy(400) - val first = rule.onNodeWithTag("picker-person:${people.first().key}").fetchSemanticsNode().boundsInRoot - val last = rule.onNodeWithTag("picker-person:${people.last().key}").fetchSemanticsNode().boundsInRoot - val gear = rule.onNodeWithTag("picker-settings").fetchSemanticsNode().boundsInRoot - assertEquals("Settings must stay centred under the whole row for $count people", (first.center.x + last.center.x) / 2, gear.center.x, .1f) - assertTrue("Settings must be below the avatars", gear.top > last.bottom) - } - } - - private fun press(key: Key) { - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } - rule.mainClock.advanceTimeBy(400) - rule.waitForIdle() - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerPolishTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerPolishTest.kt deleted file mode 100644 index 76566c98..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerPolishTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -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() - - @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 }) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryToastMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryToastMotionTest.kt deleted file mode 100644 index be875722..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryToastMotionTest.kt +++ /dev/null @@ -1,112 +0,0 @@ -// 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() - - @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(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() - 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")) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt index 5dcd1baf..f678f68e 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt @@ -76,11 +76,11 @@ class RequestsKeysTest { send(Key.DirectionCenter) assertFalse(state.requestsVisible) assertTrue(state.route.startsWith("title/")) - send(Key.Escape) + send(Key.Back) rule.onNodeWithTag("coming-row-x6").assertExists() assertEquals("x6", state.requests.focusedId) assertTrue(state.requestsVisible) - send(Key.Escape) + send(Key.Back) assertFalse(state.requestsVisible) assertEquals("settings", state.route) assertEquals(0, state.settingsRow) @@ -100,9 +100,9 @@ class RequestsKeysTest { assertEquals(1, retries) assertTrue(state.requestsVisible) rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) } - rule.onNodeWithText("New requests show up here.").assertExists() send(Key.DirectionDown, Key.DirectionCenter) - assertFalse(state.requestsVisible) + rule.onNodeWithText("Nothing coming in.").assertExists() + assertTrue(state.requestsVisible) } @Test fun cachedRatingsRenderInTheTelevisionLocale() { @@ -126,7 +126,7 @@ class RequestsKeysTest { mount(state) send(Key.DirectionCenter, Key.DirectionUp, Key.DirectionCenter) assertEquals("home", state.route) - send(Key.Escape) + send(Key.Back) assertFalse("Root Back must focus navigation, not reopen the abandoned sheet", state.requestsVisible) assertTrue(state.navFocused) } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsPolishTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsPolishTest.kt deleted file mode 100644 index 3350674d..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsPolishTest.kt +++ /dev/null @@ -1,310 +0,0 @@ -// 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() - 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) { - 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, 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 { - 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) {} } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt deleted file mode 100644 index 22168261..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt +++ /dev/null @@ -1,102 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -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 - -/** A retained outgoing title cannot steal another screen's viewport or Android input on Back. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class ScreenEntryNavigationTest { - @get:Rule val rule = createAndroidComposeRule() - private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } - private val credits = (0..35).map { TitleItem("credit-$it", "Credit $it", "movie") } - private val grid = LibraryGridState("Library", credits.map { LibraryTitle(it.id, it.name, "movie") }).apply { toolbar = false } - private val person = PersonState(TitlePerson("actor", "Actor"), credits, {}) - private val title = TitleState(TitlePageData(TitleItem("parent", "Parent", "movie"), cast = listOf(TitlePerson("actor", "Actor")), related = credits.take(10)), shell.person) {} - private val fixture = discoverTestFixture() - private val snapshot = fixture.snapshot("justin") - private val discover = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), {}, {}, fixture::service) - private var childVisible by mutableStateOf(false) - private var outgoingVisible by mutableStateOf(false) - private lateinit var child: TitleState - - @Test fun libraryBackRestoresCardAndDownReachesFinalGridRow() = verify("movies") - @Test fun discoverBackRestoresShelfAndDownReachesFinalShelf() = verify("discover") - @Test fun personBackRestoresCreditAndDownReachesFinalGridRow() = verify("person/actor") - @Test fun titleBackRestoresRowAndDownReachesRelatedShelf() = verify("title/parent") - - private fun verify(route: String) { - shell.enterDestination(route) - if (route == "discover") discover.focus(discover.firstShelf, 0) - child = TitleState(TitlePageData(TitleItem("child", "Child episode", "episode", held = true)), shell.person) { action -> - if (action == TitleAction.Pop) { - shell.enterDestination(route) - childVisible = false - outgoingVisible = true - } - } - rule.setContent { WolfskinTheme { - if (!childVisible) ScreenEntry(shell, route, true) { host(route) } - if (childVisible || outgoingVisible) ScreenEntry(shell, "title/child", childVisible) { TitleSurface(child, shell) } - } } - rule.waitForIdle() - val view = shell.view - val before = position(route) - rule.runOnIdle { shell.enterDestination("title/child"); childVisible = true } - press(Key.Escape) - rule.runOnIdle { outgoingVisible = false } - rule.waitForIdle() - assertSame(view, shell.view) - assertEquals(before, position(route)) - repeat(maxOf(credits.size, discover.rowCount)) { - val previous = position(route) - press(Key.DirectionDown) - assertTrue("$route Down cannot move backward: $previous -> ${position(route)}", position(route) >= previous) - } - val expected = when { - route == "movies" -> (grid.items.lastIndex / grid.columns) * grid.columns - route == "discover" -> discover.rowCount - 1 - route.startsWith("person/") -> (credits.lastIndex / WolfskinTokens.Behaviour.gridColumns.toInt()) * WolfskinTokens.Behaviour.gridColumns.toInt() - else -> title.rows.lastIndex - } - assertEquals("$route must reach its last row after Back", expected, position(route)) - assertTrue("$route must scroll its last row into composition", view.listState.firstVisibleItemIndex > 0) - } - - @Composable private fun host(route: String) { - when { - route == "movies" -> ShellSurface(shell) { LibraryScreen(shell, grid, it) } - route == "discover" -> ShellSurface(shell) { DiscoverScreen(discover, it) } - route.startsWith("person/") -> PersonSurface(person, shell) - else -> TitleSurface(title, shell) - } - } - - private fun position(route: String): Int = when { - route == "movies" -> grid.index - route == "discover" -> discover.focusedRow - route.startsWith("person/") -> person.index - else -> title.rows.indexOf(title.row) - } - - private fun press(key: Key) { - rule.onRoot().performKeyInput { pressKey(key) } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScrollMomentumTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScrollMomentumTest.kt deleted file mode 100644 index 3bc5b8c7..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScrollMomentumTest.kt +++ /dev/null @@ -1,144 +0,0 @@ -// 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() - - @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() - val offsets = mutableListOf() - val velocities = mutableListOf() - val repeats = mutableListOf() - 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) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt index 8002c74d..3cb4445c 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt @@ -63,7 +63,7 @@ class ShellKeysTest { } @Test fun settingsBackBackInTenPresses() { - replay(settings + listOf(Key.Escape, Key.Escape), 10) + replay(settings + listOf(Key.Back, Key.Back), 10) assertEquals("home", state.route) assertFalse(state.pickerVisible) assertTrue(state.navFocused) @@ -76,11 +76,21 @@ class ShellKeysTest { assertEquals(3, state.settingsRow) } + @Test fun onlyLastPersonReachesGearAndUpReturnsThere() { + replay(listOf(Key.DirectionDown, Key.DirectionRight, Key.DirectionDown), 3) + assertFalse(state.gearFocused) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionDown) } + assertTrue(state.gearFocused) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp) } + assertFalse(state.gearFocused) + assertEquals(2, state.pickerIndex) + } + @Test fun rootBackAndNavDownPreserveViewAndWrap() { replay(listOf(Key.DirectionCenter), 1) val view = state.view rule.runOnIdle { view.row = 4; view.cards["row-4"] = 2 } - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Escape); pressKey(Key.DirectionDown) } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back); pressKey(Key.DirectionDown) } assertSame(view, state.view) assertEquals(4, state.view.row) assertEquals(2, state.view.cards["row-4"]) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/TitleReadinessMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/TitleReadinessMotionTest.kt deleted file mode 100644 index 01bc734a..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/TitleReadinessMotionTest.kt +++ /dev/null @@ -1,100 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.MotionDurationScale -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -import java.io.File - -/** Readiness changes move real title content continuously, without reserving an empty band. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class TitleReadinessMotionTest { - private val animatorScale = object : MotionDurationScale { override var scaleFactor by mutableFloatStateOf(1f) } - @get:Rule val rule = createAndroidComposeRule(effectContext = animatorScale) - private lateinit var state: TitleState - - private fun render() { - val viewer = ShellPerson("justin", "Justin", "J", false) - val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") } - val series = TitleItem("series", "A series", "tv", seasons = 1, held = true, overview = "A series overview") - val episode = TitleItem("episode", "Pilot", "episode", held = true, season = 1, episode = 1, overview = "Episode overview") - state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {} - rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } - rule.mainClock.advanceTimeBy(500) - rule.waitForIdle() - } - - private fun positions(): List = listOf("title-readiness-slot", "title-overview", "title-actions", "title-shelf:episodes").mapIndexed { index, tag -> - val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot - if (index == 0) bounds.height else bounds.top - } - - private fun frames(stage: String?, name: String, count: Int = 22): List> { - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-readiness-motion/$name").apply { mkdirs() } - rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Justin")); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } - val values = List(count) { index -> - rule.waitForIdle() - positions().also { - rule.onRoot().captureRoboImage(File(directory, "${index.toString().padStart(2, '0')}.png").absolutePath) - rule.mainClock.advanceTimeBy(16) - rule.waitForIdle() - } - } - File(directory, "samples.csv").writeText("timeMs,slotHeightPx,overviewTopPx,actionsTopPx,episodesTopPx\n" + values.mapIndexed { index, row -> "${index * 16},${row.joinToString(",")}" }.joinToString("\n")) - return values - } - - @Test fun lateReadinessExpansionAndRemovalStayMonotonicAndMoveTheWholeStack() { - render() - val collapsed = positions() - assertEquals(0f, collapsed[0], 0.1f) - rule.mainClock.autoAdvance = false - val opening = frames("Ready", "expand") - val expanded = opening.last() - assertEquals(56f, expanded[0], 0.1f) - expanded.drop(1).zip(collapsed.drop(1)).forEach { (after, before) -> assertEquals(56f, after - before, 1f) } - val closing = frames(null, "collapse") - assertEquals(collapsed, closing.last()) - for ((samples, direction) in listOf(opening to 1f, closing to -1f)) { - samples.zipWithNext().forEach { (before, after) -> before.zip(after).forEach { (first, second) -> - assertTrue("Readiness content must not reverse or overshoot: $samples", (second - first) * direction >= -0.1f) - assertTrue("Height change must not jump in one frame", kotlin.math.abs(second - first) < 56f) - } } - samples.forEach { assertTrue(it[0] in 0f..56f) } - } - assertFalse(state.options.any { it.id == "seasons" }) - rule.mainClock.autoAdvance = true - } - - @Test fun aPinnedEpisodeShelfDoesNotBounceWhenReadinessChangesAboveIt() { - render() - rule.runOnIdle { state.focus("episodes", 0) } - rule.mainClock.advanceTimeBy(500) - rule.waitForIdle() - val pinned = positions().last() - rule.mainClock.autoAdvance = false - for (samples in listOf(frames("Ready", "pinned", count = 34), frames(null, "pinned-collapse", count = 34))) { - samples.forEach { assertEquals("A header change cannot bounce a pinned focused shelf", pinned, it.last(), 1f) } - } - assertEquals("episodes", state.row) - rule.mainClock.autoAdvance = true - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt index a027f92f..dfefe549 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt @@ -18,7 +18,7 @@ class WolfskinImagesTest { Surface("grid/Home/Discover/person poster", 256, 384, "w342"), Surface("Home tile", 400, 225, "w780"), Surface("Home wide", 544, 306, "w780"), - Surface("episode still", 448, 280, "w780"), + Surface("episode still", 400, 280, "w780"), Surface("cast portrait", 200, 200, "w342"), Surface("person portrait", 240, 240, "w342"), Surface("hero poster", 420, 630, "w342", true), diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index 99f15d01..309913d4 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -1,10 +1,7 @@ { "title": { - "episodeCardWidth": {"dp": 224, "px": 448, "source": "tv-round2: episode width +12%"}, - "episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: preserve taller episode cards"}, - "actionMenuWidth": {"dp": 260, "px": 520, "source": "tv-round2: compact title action menu"}, + "episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: episode art height +24.4%"}, "seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"}, - "posterCastGap": {"dp": 10, "px": 20, "source": "tv-round2: minimum poster-to-cast clearance"}, "lineGap": {"dp": 11, "px": 22, "source": "index.html:214"}, "lineLift": {"dp": -2, "px": -4, "source": "index.html:214"}, "overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index 463122e5..1c06609a 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -142,8 +142,6 @@ --motion-focusMove: 180ms; --motion-captionDelay: 60ms; --motion-captionOut: 100ms; - --motion-sheetIn: 160ms; - --motion-sheetOut: 100ms; --motion-artReveal: 160ms; --motion-heroFade: 320ms; --motion-heroCopyOut: 120ms; @@ -194,7 +192,6 @@ --requests-receiptPaddingX: 30.0px; --requests-receiptGap: 10.0px; --requests-receiptGlyph: 36.0px; - --requests-thumbGlyph: 40.0px; --requests-buttonMargin: 12.0px; --requests-skeletonTitleWidth: 360.0px; --requests-skeletonTitleHeight: 34.0px; @@ -296,11 +293,8 @@ --lift-heroY: 30.0px; --lift-sheetBlur: 80.0px; --lift-sheetX: -30.0px; - --title-episodeCardWidth: 448.0px; --title-episodeCardHeight: 280.0px; - --title-actionMenuWidth: 520.0px; --title-seriesHeaderGap: 40.0px; - --title-posterCastGap: 20.0px; --title-lineGap: 22.0px; --title-lineLift: -4.0px; --title-overviewGap: 34.0px; @@ -420,7 +414,7 @@ --home-factsGap: 22.0px; --home-overviewBottom: 30.0px; --home-cardPadding: 22.0px; - --home-cardOverlayTop: 36.0px; + --home-cardOverlayTop: 54.0px; --home-cardOverlayBottom: 18.0px; --home-sectionTop: 40.0px; --home-sectionAvatar: 56.0px; diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index f84690cc..b66734ad 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1408,8 +1408,6 @@ "focusMove": { "$type": "duration", "$value": "180ms" }, "captionDelay": { "$type": "duration", "$value": "60ms" }, "captionOut": { "$type": "duration", "$value": "100ms" }, - "sheetIn": { "$type": "duration", "$value": "160ms", "source": "ui-polish P06, approved 2026-11-07: side panel and its scrim fade in, easeOut" }, - "sheetOut": { "$type": "duration", "$value": "100ms", "source": "ui-polish P06, approved 2026-11-07: side panel and its scrim fade out, easeOut" }, "artReveal": { "$type": "duration", "$value": "160ms" }, "heroFade": { "$type": "duration", "$value": "320ms" }, "heroCopyOut": { "$type": "duration", "$value": "120ms" }, @@ -1548,9 +1546,9 @@ "source": "feedback-2026-11-06: largest upright hero poster slot" }, "episodeRequest": { - "px": [448, 280], + "px": [400, 280], "decode": "ARGB_8888", - "source": "tv-round2: wider episode cards at their preserved height" + "source": "feedback-2026-11-06: taller episode cards" }, "castRequest": { "px": [ diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 59039c0f..2fd24fdd 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,9 +1,9 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json 993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py -72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json -9562bd6e6d8fb4d6ef380380e621d6c32401387560437eb4f999ebecbcda160e shell-tokens.json -68a94b81bb0300aa05d241f11d88ff4843caa0b1b16e0ed09f62674b336e69fe src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json -e93d868d6fdf9ad939f1fd18c452732fb3afa76bbba8fe6fdd0c1007aeff3b01 tokens.css -e729efd2efd6bc52a35480038d64ff0199ce166e6e794332e9cfa7dc84ed98c9 tokens.json +eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json +192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json +8efafb988e507e367630212db4af4f40b529be55d0f90577a7be6dd98a86b4d4 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json +5f3b6d52c7ecfe49db59d2bd74acc1781ff7196db62bae3827d8f52b50d65aea tokens.css +3ed41f38aac01a608fb3e61c836180cf1ad76fe7cd1aac8b126f0a4adc0b2db1 tokens.json 19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json