Compare commits
48 commits
main
...
fm/tv-roun
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f5b5ffd54 | |||
| 2f3cdf2339 | |||
| 2c5e39261c | |||
| 27b7554fc1 | |||
| 4af1898fd2 | |||
| d73796742c | |||
| 49d8ffc892 | |||
|
|
408aff3db7 | ||
|
|
2eeacdc531 | ||
| 1dee89975e | |||
|
|
ed103bcb96 | ||
|
|
d739a201ee | ||
| a268cdd7b8 | |||
| 985ebc53a7 | |||
| 5f759b8fef | |||
| b51dced5fe | |||
|
|
5fb37886bb | ||
| 64432b0032 | |||
|
|
502d43f271 | ||
|
|
8b6edf5a96 | ||
|
|
abebd52850 | ||
| a5b5e92017 | |||
| 6f85b9c611 | |||
| 8be769b67c | |||
| f375d88030 | |||
| 7c67596cb6 | |||
| a27e922675 | |||
| c5dbe91076 | |||
| 79f64f6e18 | |||
| 5811ab0555 | |||
| 33cca64eba | |||
| 9cfe9362ee | |||
| 7bfa431350 | |||
| aa31199b4f | |||
| 2b142af8d1 | |||
| f9bd630ea7 | |||
| e6ddf1d7e5 | |||
| 98dd54e03b | |||
| 9988a193de | |||
| 174e1ae5c9 | |||
| febcded034 | |||
| ae613216d0 | |||
| 60b0c307aa | |||
| 12e908389f | |||
| 5046727b2a | |||
| a159c835c5 | |||
| 12c201dc55 | |||
| 6d7513bd0e |
117 changed files with 5006 additions and 658 deletions
|
|
@ -491,6 +491,8 @@ dependencies {
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
dependsOn(":wolfskin-design:testDebugUnitTest")
|
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")
|
||||||
systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath)
|
systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath)
|
||||||
systemProperty("robolectric.offline", "true")
|
systemProperty("robolectric.offline", "true")
|
||||||
systemProperty("roborazzi.test.record", "true")
|
systemProperty("roborazzi.test.record", "true")
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.RestoredSession
|
import com.github.damontecres.wholphin.data.RestoredSession
|
||||||
|
import org.justinvisser.wolfskin.tv.recoverWolfskinSession
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||||
|
|
@ -479,11 +480,13 @@ class MainActivityViewModel
|
||||||
fun appResume() {
|
fun appResume() {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
|
val expected = serverRepository.current.value
|
||||||
try {
|
try {
|
||||||
Timber.v("Updating userDto")
|
Timber.v("Updating userDto")
|
||||||
serverRepository.updateUserDto()
|
serverRepository.updateUserDto()
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.w(ex, "Error during appResume")
|
Timber.w(ex, "Error during appResume")
|
||||||
|
recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -590,6 +593,7 @@ class MainActivityViewModel
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
Timber.w(exception, "Saved launch session revalidation failed")
|
Timber.w(exception, "Saved launch session revalidation failed")
|
||||||
|
recoverWolfskinSession(context, exception, restoredSession.currentUser, serverRepository, setupNavigationManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
||||||
import org.justinvisser.wolfskin.tv.WolfskinApp
|
import org.justinvisser.wolfskin.tv.WolfskinApp
|
||||||
|
import org.justinvisser.wolfskin.tv.WolfskinSetupContent
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||||
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
||||||
|
|
@ -48,6 +49,7 @@ fun MainContent(
|
||||||
backdropService: BackdropService,
|
backdropService: BackdropService,
|
||||||
screensaverService: ScreensaverService,
|
screensaverService: ScreensaverService,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
|
||||||
) {
|
) {
|
||||||
val preferences by rememberUpdatedState(userPreferences)
|
val preferences by rememberUpdatedState(userPreferences)
|
||||||
Surface(
|
Surface(
|
||||||
|
|
@ -72,6 +74,7 @@ fun MainContent(
|
||||||
entryProvider = { key ->
|
entryProvider = { key ->
|
||||||
key as SetupDestination
|
key as SetupDestination
|
||||||
NavEntry(key) {
|
NavEntry(key) {
|
||||||
|
if (setupInterceptor(key, Modifier.fillMaxSize())) return@NavEntry
|
||||||
when (key) {
|
when (key) {
|
||||||
SetupDestination.Loading -> {
|
SetupDestination.Loading -> {
|
||||||
Box(
|
Box(
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ sealed interface HomeRowConfig {
|
||||||
fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig
|
fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Continue watching media that the user has started but not finished
|
* Legacy serialized resume row, migrated to ContinueWatchingCombined when settings are read.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("ContinueWatching")
|
@SerialName("ContinueWatching")
|
||||||
|
|
@ -40,7 +40,7 @@ sealed interface HomeRowConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next up row for next episodes in a series the user has started
|
* Legacy serialized next-up row, migrated to ContinueWatchingCombined when settings are read.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("NextUp")
|
@SerialName("NextUp")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
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<HomeRowConfig>): List<HomeRowConfig> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import com.google.common.cache.CacheBuilder
|
import com.google.common.cache.CacheBuilder
|
||||||
import dagger.hilt.android.qualifiers.ActivityContext
|
import dagger.hilt.android.qualifiers.ActivityContext
|
||||||
|
|
@ -21,13 +20,15 @@ import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
import timber.log.Timber
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
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.
|
* Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row.
|
||||||
|
|
@ -46,50 +47,21 @@ class DatePlayedService
|
||||||
.expireAfterWrite(2, TimeUnit.HOURS)
|
.expireAfterWrite(2, TimeUnit.HOURS)
|
||||||
.build<SeriesItemId, Deferred<LocalDateTime>>()
|
.build<SeriesItemId, Deferred<LocalDateTime>>()
|
||||||
|
|
||||||
private fun getLastPlayed(
|
private fun readLastPlayed(seriesId: UUID): Deferred<LocalDateTime> =
|
||||||
seriesId: UUID,
|
scope.async(WholphinDispatchers.IO) {
|
||||||
item: BaseItem,
|
|
||||||
): Deferred<LocalDateTime> {
|
|
||||||
val request =
|
|
||||||
GetEpisodesRequest(
|
|
||||||
seriesId = seriesId,
|
|
||||||
adjacentTo = item.id,
|
|
||||||
limit = 1,
|
|
||||||
)
|
|
||||||
return scope.async(WholphinDispatchers.IO) {
|
|
||||||
val premiereDate = item.data.premiereDate
|
|
||||||
try {
|
try {
|
||||||
val result =
|
api.itemsApi.getItems(parentId = seriesId, recursive = true,
|
||||||
GetEpisodesRequestHandler
|
includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true,
|
||||||
.execute(
|
sortBy = listOf(ItemSortBy.DATE_PLAYED), sortOrder = listOf(SortOrder.DESCENDING), limit = 1)
|
||||||
api,
|
.content.items.firstOrNull()?.userData?.lastPlayedDate ?: LocalDateTime.MIN
|
||||||
request,
|
|
||||||
).content.items
|
|
||||||
.firstOrNull()
|
|
||||||
?.userData
|
|
||||||
?.lastPlayedDate ?: LocalDateTime.MIN
|
|
||||||
if (premiereDate != null && result.isBefore(premiereDate)) {
|
|
||||||
premiereDate
|
|
||||||
} else {
|
|
||||||
result
|
|
||||||
}
|
|
||||||
} catch (ex: InvalidStatusException) {
|
} catch (ex: InvalidStatusException) {
|
||||||
Timber.w(
|
Timber.w(ex, "Error fetching series=%s", seriesId)
|
||||||
"Error fetching series=%s, item=%s: %s",
|
LocalDateTime.MIN
|
||||||
seriesId,
|
|
||||||
item.id,
|
|
||||||
ex.localizedMessage,
|
|
||||||
)
|
|
||||||
LocalDateTime.MIN.coerceAtLeast(premiereDate ?: LocalDateTime.MIN)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the logical timestamp when the item was last played.
|
* Get the series' latest actual watch activity, never an upcoming episode's premiere date.
|
||||||
*
|
|
||||||
* 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? =
|
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
|
||||||
withContext(WholphinDispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
|
|
@ -97,7 +69,7 @@ class DatePlayedService
|
||||||
return@withContext if (seriesId != null) {
|
return@withContext if (seriesId != null) {
|
||||||
datePlayedCache
|
datePlayedCache
|
||||||
.get(SeriesItemId(seriesId, item.id)) {
|
.get(SeriesItemId(seriesId, item.id)) {
|
||||||
getLastPlayed(seriesId, item)
|
readLastPlayed(seriesId)
|
||||||
}.await()
|
}.await()
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
|
||||||
|
|
@ -92,44 +92,8 @@ class HomeDataService
|
||||||
usePaging: Boolean = false,
|
usePaging: Boolean = false,
|
||||||
): HomeRowLoadingState =
|
): HomeRowLoadingState =
|
||||||
when (row) {
|
when (row) {
|
||||||
is HomeRowConfig.ContinueWatching -> {
|
is HomeRowConfig.ContinueWatching,
|
||||||
val resume =
|
is HomeRowConfig.NextUp,
|
||||||
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 -> {
|
is HomeRowConfig.ContinueWatchingCombined -> {
|
||||||
val resume =
|
val resume =
|
||||||
latestNextUpService.getResume(
|
latestNextUpService.getResume(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
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.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.preferences.DefaultUserConfiguration
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
|
|
@ -159,7 +160,7 @@ class HomeSettingsService
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}.orEmpty()
|
}.orEmpty()
|
||||||
return HomePageSettings(rows, version)
|
return HomePageSettings(mergeHomeWatchRows(rows), version)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? =
|
private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? =
|
||||||
|
|
@ -191,7 +192,7 @@ class HomeSettingsService
|
||||||
)
|
)
|
||||||
return settings?.let {
|
return settings?.let {
|
||||||
val resolvedRows =
|
val resolvedRows =
|
||||||
settings.rows.mapIndexed { index, config ->
|
mergeHomeWatchRows(settings.rows).mapIndexed { index, config ->
|
||||||
resolve(index, config)
|
resolve(index, config)
|
||||||
}
|
}
|
||||||
HomePageResolvedSettings(userId, resolvedSource, resolvedRows)
|
HomePageResolvedSettings(userId, resolvedSource, resolvedRows)
|
||||||
|
|
@ -261,7 +262,7 @@ class HomeSettingsService
|
||||||
settings: HomePageSettings,
|
settings: HomePageSettings,
|
||||||
) {
|
) {
|
||||||
val resolvedRows =
|
val resolvedRows =
|
||||||
settings.rows.mapIndexed { index, config ->
|
mergeHomeWatchRows(settings.rows).mapIndexed { index, config ->
|
||||||
resolve(index, config)
|
resolve(index, config)
|
||||||
}
|
}
|
||||||
currentSettings.update { it.copy(rows = resolvedRows) }
|
currentSettings.update { it.copy(rows = resolvedRows) }
|
||||||
|
|
@ -313,7 +314,7 @@ class HomeSettingsService
|
||||||
listOf(
|
listOf(
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id = includedIds.size + 1,
|
id = includedIds.size + 1,
|
||||||
title = ResStringProvider(R.string.combine_continue_next),
|
title = ResStringProvider(R.string.continue_watching),
|
||||||
config = HomeRowConfig.ContinueWatchingCombined(),
|
config = HomeRowConfig.ContinueWatchingCombined(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -434,7 +435,8 @@ class HomeSettingsService
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}.flatten()
|
}.flatten()
|
||||||
HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs)
|
val merged = mergeHomeWatchRows(rowConfigs.map { it.config }).mapIndexed { index, config -> resolve(index, config) }
|
||||||
|
HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, merged)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
@ -457,19 +459,13 @@ class HomeSettingsService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.ContinueWatching -> {
|
is HomeRowConfig.ContinueWatching,
|
||||||
HomeRowConfigDisplay(
|
is HomeRowConfig.NextUp,
|
||||||
id,
|
|
||||||
ResStringProvider(R.string.continue_watching),
|
|
||||||
config,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is HomeRowConfig.ContinueWatchingCombined -> {
|
is HomeRowConfig.ContinueWatchingCombined -> {
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id,
|
id,
|
||||||
ResStringProvider(R.string.combine_continue_next),
|
ResStringProvider(R.string.continue_watching),
|
||||||
config,
|
HomeRowConfig.ContinueWatchingCombined(config.viewOptions),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -495,14 +491,6 @@ class HomeSettingsService
|
||||||
HomeRowConfigDisplay(id, StringStringProvider(config.name), config)
|
HomeRowConfigDisplay(id, StringStringProvider(config.name), config)
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.NextUp -> {
|
|
||||||
HomeRowConfigDisplay(
|
|
||||||
id,
|
|
||||||
ResStringProvider(R.string.next_up),
|
|
||||||
config,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is HomeRowConfig.RecentlyAdded -> {
|
is HomeRowConfig.RecentlyAdded -> {
|
||||||
val title = getItemName(R.string.recently_added_in, config.parentId)
|
val title = getItemName(R.string.recently_added_in, config.parentId)
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ class LatestNextUpService
|
||||||
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
|
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
|
||||||
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
|
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
|
||||||
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
|
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
|
||||||
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] }
|
val result = (resume + nextUp).distinctBy { it.data.seriesId ?: it.id }.sortedByDescending { timestamps[it.id] }
|
||||||
val duration = (System.currentTimeMillis() - start).milliseconds
|
val duration = (System.currentTimeMillis() - start).milliseconds
|
||||||
Timber.v("buildCombined took %s", duration)
|
Timber.v("buildCombined took %s", duration)
|
||||||
return@withContext result
|
return@withContext result
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -15,6 +17,11 @@ class NavigationManager
|
||||||
@Inject
|
@Inject
|
||||||
constructor() {
|
constructor() {
|
||||||
var backStack: MutableList<Destination> = NavBackStack(Destination.Home())
|
var backStack: MutableList<Destination> = NavBackStack(Destination.Home())
|
||||||
|
private val exits = Channel<Unit>(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]
|
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
|
||||||
|
|
@ -41,12 +48,14 @@ class NavigationManager
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Go to the previous page
|
* Go to the previous page, or exit when there is no parent.
|
||||||
*/
|
*/
|
||||||
fun goBack() {
|
fun goBack() {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (backStack.size > 1) {
|
if (backStack.size > 1) {
|
||||||
backStack.removeLastOrNull()
|
backStack.removeLastOrNull()
|
||||||
|
} else {
|
||||||
|
requestExit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log()
|
log()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package com.github.damontecres.wholphin.ui.main.settings
|
|
||||||
|
|
||||||
sealed interface HomeRowConfigAction {
|
|
||||||
data object Combine : HomeRowConfigAction
|
|
||||||
|
|
||||||
data object Split : HomeRowConfigAction
|
|
||||||
}
|
|
||||||
|
|
@ -60,7 +60,6 @@ fun HomeRowSettings(
|
||||||
viewOptions: HomeRowViewOptions,
|
viewOptions: HomeRowViewOptions,
|
||||||
onViewOptionsChange: (HomeRowViewOptions) -> Unit,
|
onViewOptionsChange: (HomeRowViewOptions) -> Unit,
|
||||||
onConfigChange: (HomeRowConfig) -> Unit,
|
onConfigChange: (HomeRowConfig) -> Unit,
|
||||||
onConfigAction: (HomeRowConfigAction) -> Unit,
|
|
||||||
onApplyApplyAll: () -> Unit,
|
onApplyApplyAll: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||||
|
|
@ -126,7 +125,6 @@ fun HomeRowSettings(
|
||||||
addAdditionalConfig(
|
addAdditionalConfig(
|
||||||
config = config,
|
config = config,
|
||||||
onConfigChange = onConfigChange,
|
onConfigChange = onConfigChange,
|
||||||
onConfigAction = onConfigAction,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -135,29 +133,8 @@ fun HomeRowSettings(
|
||||||
private fun LazyListScope.addAdditionalConfig(
|
private fun LazyListScope.addAdditionalConfig(
|
||||||
config: HomeRowConfig,
|
config: HomeRowConfig,
|
||||||
onConfigChange: (HomeRowConfig) -> Unit,
|
onConfigChange: (HomeRowConfig) -> Unit,
|
||||||
onConfigAction: (HomeRowConfigAction) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
when (config) {
|
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 -> {
|
is HomeRowConfig.ByParent -> {
|
||||||
item {
|
item {
|
||||||
val sortAndDirection =
|
val sortAndDirection =
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,6 @@ fun HomeSettingsAddRow(
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
listOf(
|
listOf(
|
||||||
MetaRowType.CONTINUE_WATCHING,
|
|
||||||
MetaRowType.NEXT_UP,
|
|
||||||
MetaRowType.COMBINED_CONTINUE_WATCHING,
|
MetaRowType.COMBINED_CONTINUE_WATCHING,
|
||||||
),
|
),
|
||||||
) { index, type ->
|
) { index, type ->
|
||||||
|
|
@ -94,9 +92,7 @@ fun HomeSettingsAddRow(
|
||||||
enum class MetaRowType(
|
enum class MetaRowType(
|
||||||
@param:StringRes val stringId: Int,
|
@param:StringRes val stringId: Int,
|
||||||
) {
|
) {
|
||||||
CONTINUE_WATCHING(R.string.continue_watching),
|
COMBINED_CONTINUE_WATCHING(R.string.continue_watching),
|
||||||
NEXT_UP(R.string.next_up),
|
|
||||||
COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next),
|
|
||||||
FAVORITES(R.string.favorites),
|
FAVORITES(R.string.favorites),
|
||||||
DISCOVER(R.string.discover),
|
DISCOVER(R.string.discover),
|
||||||
COLLECTION(R.string.collection),
|
COLLECTION(R.string.collection),
|
||||||
|
|
|
||||||
|
|
@ -157,8 +157,6 @@ fun HomeSettingsPage(
|
||||||
onClick = { backStack.add(ChooseRowType(it)) },
|
onClick = { backStack.add(ChooseRowType(it)) },
|
||||||
onClickMeta = {
|
onClickMeta = {
|
||||||
when (it) {
|
when (it) {
|
||||||
MetaRowType.CONTINUE_WATCHING,
|
|
||||||
MetaRowType.NEXT_UP,
|
|
||||||
MetaRowType.COMBINED_CONTINUE_WATCHING,
|
MetaRowType.COMBINED_CONTINUE_WATCHING,
|
||||||
-> {
|
-> {
|
||||||
addRow { viewModel.addRow(it) }
|
addRow { viewModel.addRow(it) }
|
||||||
|
|
@ -246,18 +244,6 @@ fun HomeSettingsPage(
|
||||||
onConfigChange = {
|
onConfigChange = {
|
||||||
viewModel.onConfigChange(row, it)
|
viewModel.onConfigChange(row, it)
|
||||||
},
|
},
|
||||||
onConfigAction = {
|
|
||||||
viewModel.onConfigAction(row, it)
|
|
||||||
when (it) {
|
|
||||||
HomeRowConfigAction.Combine,
|
|
||||||
HomeRowConfigAction.Split,
|
|
||||||
-> {
|
|
||||||
backStack.removeAt(
|
|
||||||
backStack.lastIndex,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ 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.HomeRowConfig.TvPrograms
|
||||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
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.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.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.HomeDataService
|
import com.github.damontecres.wholphin.services.HomeDataService
|
||||||
|
|
@ -249,26 +250,10 @@ class HomeSettingsViewModel
|
||||||
val id = idCounter++
|
val id = idCounter++
|
||||||
val newRow =
|
val newRow =
|
||||||
when (type) {
|
when (type) {
|
||||||
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 -> {
|
MetaRowType.COMBINED_CONTINUE_WATCHING -> {
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id = id,
|
id = id,
|
||||||
title = ResStringProvider(R.string.combine_continue_next),
|
title = ResStringProvider(R.string.continue_watching),
|
||||||
config = ContinueWatchingCombined(),
|
config = ContinueWatchingCombined(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -813,7 +798,7 @@ class HomeSettingsViewModel
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
Timber.v("Got remote settings")
|
Timber.v("Got remote settings")
|
||||||
val newRows =
|
val newRows =
|
||||||
result.rows.mapIndexed { index, config ->
|
mergeHomeWatchRows(result.rows).mapIndexed { index, config ->
|
||||||
homeSettingsService.resolve(index, config)
|
homeSettingsService.resolve(index, config)
|
||||||
}
|
}
|
||||||
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||||
|
|
@ -839,76 +824,6 @@ 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(
|
fun onConfigChange(
|
||||||
row: HomeRowConfigDisplay,
|
row: HomeRowConfigDisplay,
|
||||||
config: HomeRowConfig,
|
config: HomeRowConfig,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -70,7 +70,7 @@ class ConductarrTitlePagesTest {
|
||||||
assertEquals(TitleConnection.SignedOut, revoked.connection)
|
assertEquals(TitleConnection.SignedOut, revoked.connection)
|
||||||
assertTrue(revoked.item.ratings.isEmpty())
|
assertTrue(revoked.item.ratings.isEmpty())
|
||||||
assertTrue(revoked.playTarget!!.ratings.isEmpty())
|
assertTrue(revoked.playTarget!!.ratings.isEmpty())
|
||||||
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" })
|
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.menuOptions.any { it.id == "add" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -85,4 +85,36 @@ class ConductarrTitlePagesTest {
|
||||||
assertTrue(emptyList<LibraryRating>().withEpisode(8.0, null).isEmpty())
|
assertTrue(emptyList<LibraryRating>().withEpisode(8.0, null).isEmpty())
|
||||||
assertTrue(emptyList<LibraryRating>().withEpisode(8.0, 0).isEmpty())
|
assertTrue(emptyList<LibraryRating>().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)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.json.*
|
import kotlinx.serialization.json.*
|
||||||
|
|
@ -49,7 +50,8 @@ class ConductarrTitleSurfaceTest {
|
||||||
runBlocking { session.authenticate() }
|
runBlocking { session.authenticate() }
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.findHeld(any(), any()) } returns null
|
coEvery { facade.findHeld(any(), any()) } returns null
|
||||||
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true))
|
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||||
|
val revokedLookup = CompletableDeferred<java.util.UUID?>()
|
||||||
try {
|
try {
|
||||||
val route = titleRoute("movie:42")
|
val route = titleRoute("movie:42")
|
||||||
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply {
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply {
|
||||||
|
|
@ -76,6 +78,9 @@ class ConductarrTitleSurfaceTest {
|
||||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
||||||
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath)
|
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath)
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
|
||||||
|
rule.mainClock.advanceTimeBy(300)
|
||||||
|
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
|
||||||
rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
|
rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
|
||||||
rule.waitUntil(30_000) {
|
rule.waitUntil(30_000) {
|
||||||
|
|
@ -90,15 +95,26 @@ class ConductarrTitleSurfaceTest {
|
||||||
assertFalse(body.containsKey("actor"))
|
assertFalse(body.containsKey("actor"))
|
||||||
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-receipt.png").absolutePath)
|
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-receipt.png").absolutePath)
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
coEvery { facade.findHeld(any(), any()) } coAnswers { revokedLookup.await() }
|
||||||
fake.ratingsStatus = 401
|
fake.ratingsStatus = 401
|
||||||
runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) }
|
runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) }
|
||||||
rule.waitUntil(30_000) {
|
rule.waitUntil(30_000) {
|
||||||
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
|
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())
|
assertTrue(viewModel.title!!.data.item.ratings.isEmpty())
|
||||||
assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") })
|
assertFalse(viewModel.title!!.options.any { it.id == "request" })
|
||||||
} finally { viewModel.viewModelScope.cancel() }
|
assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" })
|
||||||
|
} finally { viewModel.viewModelScope.cancel(); revokedLookup.complete(null) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
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.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<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun loadedCatalogueRefreshKeepsReadyActionsOpaqueAndStationary() {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
fake.localArt = true
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
runBlocking { session.authenticate() }
|
||||||
|
val facade = mockk<TitleReadFacade>()
|
||||||
|
coEvery { facade.findHeld(any(), any()) } returns null
|
||||||
|
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
|
||||||
|
val 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<java.util.UUID?>()
|
||||||
|
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)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
} 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))
|
||||||
|
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)
|
||||||
|
} 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<TitleReadFacade>()
|
||||||
|
coEvery { facade.readSaved(any(), any()) } returns null
|
||||||
|
coEvery { facade.read(any(), any(), any()) } returns held
|
||||||
|
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
|
||||||
|
val 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<TitlePageData>()
|
||||||
|
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" })
|
||||||
|
} 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 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 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,7 +43,7 @@ class DiscoverSkeletonKeysTest {
|
||||||
}
|
}
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
rule.runOnIdle { assertEquals(1, state.focusedRow); assertNull(state.cardKey) }
|
rule.runOnIdle { assertEquals(1, state.focusedRow); assertNull(state.cardKey) }
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible); assertEquals("discover", shell.route) }
|
rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible); assertEquals("discover", shell.route) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ class DiscoverTitleTimingTest {
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.findHeld(any(), any()) } returns null
|
coEvery { facade.findHeld(any(), any()) } returns null
|
||||||
val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) }
|
val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) }
|
||||||
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true))
|
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true))
|
||||||
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
|
||||||
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +84,7 @@ class DiscoverTitleTimingTest {
|
||||||
assertEquals("related", model.title!!.row)
|
assertEquals("related", model.title!!.row)
|
||||||
rule.onNodeWithText("More like this").assertIsDisplayed()
|
rule.onNodeWithText("More like this").assertIsDisplayed()
|
||||||
rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath)
|
rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath)
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.Back) }
|
rule.onRoot().performKeyInput { pressKey(Key.Escape) }
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertNull(discover.title)
|
assertNull(discover.title)
|
||||||
assertEquals("discover", shell.route)
|
assertEquals("discover", shell.route)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||||
class HomeLibraryCacheTest {
|
class HomeLibraryCacheTest {
|
||||||
@Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() {
|
@Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
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 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 film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43)
|
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))
|
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)
|
HomeLibraryCache(context).write("server:person", library, 12345L)
|
||||||
|
|
@ -26,4 +26,19 @@ class HomeLibraryCacheTest {
|
||||||
assertEquals(12345L, saved.second)
|
assertEquals(12345L, saved.second)
|
||||||
assertNull(HomeLibraryCache(context).read("server:someone-else"))
|
assertNull(HomeLibraryCache(context).read("server:someone-else"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun preMergeCacheWithoutWatchIdentityOrTimestampStillReopens() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ class HomeSeeAllNavigationTest {
|
||||||
try {
|
try {
|
||||||
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||||
exerciseGrid(grid)
|
exerciseGrid(grid)
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertTrue(navigation.backStack.last() is Destination.Home)
|
assertTrue(navigation.backStack.last() is Destination.Home)
|
||||||
assertEquals(row.id, snapshot.rows[shell.view.row].id)
|
assertEquals(row.id, snapshot.rows[shell.view.row].id)
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@ class PersonSwitchThreadingTest {
|
||||||
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
|
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
|
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
|
||||||
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository)
|
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true))
|
||||||
try {
|
try {
|
||||||
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)
|
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)
|
||||||
conductarr.authenticate()
|
conductarr.authenticate()
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,12 @@ class SavedPeoplePickerTest {
|
||||||
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
viewModel = model, homeContent = { _, _, _, _ -> })
|
viewModel = model, homeContent = { _, _, _, _ -> })
|
||||||
}
|
}
|
||||||
rule.waitUntil(5_000) { entered.get() && session.state?.people?.size == 3 }
|
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
|
||||||
|
}
|
||||||
users.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() }
|
users.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() }
|
||||||
rule.runOnIdle {
|
rule.runOnIdle {
|
||||||
assertTrue(session.state!!.pickerVisible)
|
assertTrue(session.state!!.pickerVisible)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.performKeyInput
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
|
@ -14,6 +15,7 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
|
@ -46,6 +48,7 @@ import java.util.UUID
|
||||||
class TitleAsyncFocusTest {
|
class TitleAsyncFocusTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
private lateinit var viewModel: WolfskinTitleViewModel
|
private lateinit var viewModel: WolfskinTitleViewModel
|
||||||
|
private val navigation = NavigationManager()
|
||||||
|
|
||||||
@After fun dispose() {
|
@After fun dispose() {
|
||||||
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
|
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
|
||||||
|
|
@ -57,9 +60,10 @@ class TitleAsyncFocusTest {
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.readSaved(id, any()) } returns null
|
coEvery { facade.readSaved(id, any()) } returns null
|
||||||
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
|
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
|
||||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
|
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||||
val person = ShellPerson("justin", "Justin", "J", false)
|
val person = ShellPerson("justin", "Justin", "J", false)
|
||||||
val route = titleRoute(id.toString())
|
val route = titleRoute(id.toString())
|
||||||
|
navigation.navigateTo(Destination.Wolfskin(route))
|
||||||
val shell = ShellState(listOf(person), person.key).apply {
|
val shell = ShellState(listOf(person), person.key).apply {
|
||||||
pickerVisible = false
|
pickerVisible = false
|
||||||
pickerLaunch = false
|
pickerLaunch = false
|
||||||
|
|
@ -69,9 +73,12 @@ class TitleAsyncFocusTest {
|
||||||
rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } }
|
rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } }
|
||||||
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
|
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertTrue(shell.navFocused)
|
assertFalse("Loading retains destination focus instead of focusing the root nav", shell.navFocused)
|
||||||
|
rule.onNodeWithTag("title-page").assertIsFocused()
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused)
|
assertFalse("Down cannot transfer focus to nav or an initial skeleton", shell.navFocused)
|
||||||
|
assertEquals("header", viewModel.title!!.row)
|
||||||
|
assertEquals(0, viewModel.title!!.column)
|
||||||
|
|
||||||
val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList())
|
val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList())
|
||||||
rule.runOnIdle {
|
rule.runOnIdle {
|
||||||
|
|
@ -96,9 +103,10 @@ class TitleAsyncFocusTest {
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.readPersonSaved(id) } returns null
|
coEvery { facade.readPersonSaved(id) } returns null
|
||||||
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
|
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
|
||||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
|
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
val route = personRoute(id.toString())
|
val route = personRoute(id.toString())
|
||||||
|
navigation.navigateTo(Destination.Wolfskin(route))
|
||||||
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
||||||
pickerVisible = false
|
pickerVisible = false
|
||||||
pickerLaunch = false
|
pickerLaunch = false
|
||||||
|
|
@ -109,9 +117,12 @@ class TitleAsyncFocusTest {
|
||||||
} }
|
} }
|
||||||
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
|
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertTrue(shell.navFocused)
|
assertFalse(shell.navFocused)
|
||||||
|
rule.onNodeWithTag("title-page").assertIsFocused()
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
assertTrue(shell.navFocused)
|
assertFalse("Down cannot enter a pending person skeleton", shell.navFocused)
|
||||||
|
assertEquals("header", viewModel.title!!.row)
|
||||||
|
assertEquals(0, viewModel.title!!.column)
|
||||||
rule.runOnIdle {
|
rule.runOnIdle {
|
||||||
reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead(
|
reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead(
|
||||||
org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"),
|
org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"),
|
||||||
|
|
@ -121,7 +132,7 @@ class TitleAsyncFocusTest {
|
||||||
rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable }
|
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)
|
assertNotNull("A real person reply must populate the performer destination", viewModel.person)
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused)
|
assertFalse("The loaded person retains destination focus on its first credit", shell.navFocused)
|
||||||
assertEquals(0, viewModel.person!!.index)
|
assertEquals(0, viewModel.person!!.index)
|
||||||
press(Key.DirectionRight)
|
press(Key.DirectionRight)
|
||||||
assertEquals(1, viewModel.person!!.index)
|
assertEquals(1, viewModel.person!!.index)
|
||||||
|
|
@ -139,9 +150,10 @@ class TitleAsyncFocusTest {
|
||||||
reads.incrementAndGet()
|
reads.incrementAndGet()
|
||||||
throw java.io.IOException("offline")
|
throw java.io.IOException("offline")
|
||||||
}
|
}
|
||||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
|
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
val route = titleRoute(id.toString())
|
val route = titleRoute(id.toString())
|
||||||
|
navigation.navigateTo(Destination.Wolfskin(route))
|
||||||
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
||||||
pickerVisible = false; pickerLaunch = false; enterDestination(route)
|
pickerVisible = false; pickerLaunch = false; enterDestination(route)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import org.json.JSONObject
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.justinvisser.wolfskin.design.LocalTitleBitmaps
|
import org.justinvisser.wolfskin.design.LocalTitleBitmaps
|
||||||
|
|
@ -87,7 +89,7 @@ class TitleCaptureTest {
|
||||||
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
||||||
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
||||||
data.episodes.forEach { item ->
|
data.episodes.forEach { item ->
|
||||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) }
|
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt())) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
||||||
|
|
@ -170,7 +172,10 @@ class TitleCaptureTest {
|
||||||
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
|
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
|
||||||
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
|
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
|
||||||
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
|
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
|
||||||
@Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") }
|
@Test fun st047_mediaInfo() = capture(47) { state, shell ->
|
||||||
|
open(state, shell, "menu")
|
||||||
|
state.clickSheet(0)
|
||||||
|
}
|
||||||
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
|
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
|
||||||
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
|
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
|
||||||
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
|
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
|
||||||
|
|
@ -216,4 +221,33 @@ class TitleCaptureTest {
|
||||||
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
||||||
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
||||||
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
|
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
|
||||||
|
@Test fun round2Menu() = capture(120) { state, shell -> open(state, shell, "menu") }
|
||||||
|
@Test fun round2MussoliniResume() = capture(121, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) })
|
||||||
|
@Test fun round2Episodes() = capture(122, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) { state, _ -> state.focus("episodes", 0) }
|
||||||
|
@Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days")
|
||||||
|
@Test fun round2HeldPosterFallback() = capture(124, "The Girl Who Leapt Through Time", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
|
||||||
|
@Test fun round2NotHeldShortOverview() = capture(125, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
|
||||||
|
|
||||||
|
@Test fun watchedRingBelongsOnlyToFocus() {
|
||||||
|
lateinit var state: TitleState
|
||||||
|
capture(126) { current, _ -> state = current; state.watched = false; option(state, "tracks") }
|
||||||
|
val edges = mutableListOf<List<Int>>()
|
||||||
|
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])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
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.runtime.LaunchedEffect
|
||||||
|
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.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 io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
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-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class TitleEntryBackTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private lateinit var viewModel: WolfskinTitleViewModel
|
||||||
|
private lateinit var conductarr: ConductarrSession
|
||||||
|
|
||||||
|
@After fun dispose() {
|
||||||
|
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
|
||||||
|
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 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<NavigationManager, ShellState> {
|
||||||
|
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<TitleReadFacade>()
|
||||||
|
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<TitleFixtureProvider>(), navigation, conductarr,
|
||||||
|
mockk<ServerRepository>(relaxed = true), 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,11 +12,9 @@ import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performMouseInput
|
import androidx.compose.ui.test.performMouseInput
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.hasClickAction
|
import androidx.compose.ui.test.hasClickAction
|
||||||
import androidx.compose.ui.test.hasText
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.performSemanticsAction
|
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
@ -36,7 +34,6 @@ import org.justinvisser.wolfskin.design.TitleSurface
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
|
||||||
|
|
||||||
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
|
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
|
@ -147,7 +144,7 @@ class TitleKeysTest {
|
||||||
assertEquals("Version", state.options[state.column].label)
|
assertEquals("Version", state.options[state.column].label)
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
assertEquals(1, state.sheetIndex)
|
assertEquals(1, state.sheetIndex)
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
assertNull(state.sheet)
|
assertNull(state.sheet)
|
||||||
assertEquals("Version", state.options[state.column].label)
|
assertEquals("Version", state.options[state.column].label)
|
||||||
assertFalse(shell.navFocused)
|
assertFalse(shell.navFocused)
|
||||||
|
|
@ -164,7 +161,7 @@ class TitleKeysTest {
|
||||||
assertEquals(2, state.subtitleIndex)
|
assertEquals(2, state.subtitleIndex)
|
||||||
press(Key.DirectionUp); press(Key.DirectionCenter)
|
press(Key.DirectionUp); press(Key.DirectionCenter)
|
||||||
assertEquals(-1, state.subtitleIndex)
|
assertEquals(-1, state.subtitleIndex)
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
assertNull(state.sheet)
|
assertNull(state.sheet)
|
||||||
assertEquals("tracks", state.options[state.column].id)
|
assertEquals("tracks", state.options[state.column].id)
|
||||||
}
|
}
|
||||||
|
|
@ -284,24 +281,19 @@ class TitleKeysTest {
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
assertEquals(expected, state.row)
|
assertEquals(expected, state.row)
|
||||||
}
|
}
|
||||||
@Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() {
|
|
||||||
render("Andor")
|
|
||||||
val layouts = mutableListOf<TextLayoutResult>()
|
|
||||||
rule.onNodeWithText("Add for someone", useUnmergedTree = true)
|
|
||||||
.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
|
|
||||||
val layout = layouts.single()
|
|
||||||
assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth)
|
|
||||||
assertEquals(1, layout.lineCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
|
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
|
||||||
render(transform = { it.copy(status = TitleReadStatus.Loading) })
|
render(transform = { it.copy(status = TitleReadStatus.Loading) })
|
||||||
rule.onNodeWithText("Cast").assertDoesNotExist()
|
rule.onNodeWithText("Cast").assertDoesNotExist()
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
assertTrue(shell.navFocused)
|
assertFalse(shell.navFocused)
|
||||||
|
rule.onNodeWithTag("title-page").assertIsFocused()
|
||||||
assertEquals("header", state.row)
|
assertEquals("header", state.row)
|
||||||
|
assertEquals(0, state.column)
|
||||||
|
assertFalse(shell.pickerVisible)
|
||||||
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
|
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
|
||||||
assertEquals(listOf(TitleAction.Pop), effects)
|
assertEquals(listOf(TitleAction.Pop), effects)
|
||||||
|
assertFalse(shell.pickerVisible)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -315,26 +307,6 @@ class TitleKeysTest {
|
||||||
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
|
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
||||||
@Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() {
|
|
||||||
render()
|
|
||||||
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) }
|
|
||||||
press(Key.DirectionCenter)
|
|
||||||
val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
|
||||||
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
|
||||||
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
|
|
||||||
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
|
|
||||||
val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
|
||||||
assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
||||||
@Test fun filmHeaderKeepsItsFixedCssStack() {
|
|
||||||
render()
|
|
||||||
assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f)
|
|
||||||
assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f)
|
|
||||||
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ class TitlePlaybackNavigationTest {
|
||||||
episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor")))
|
episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor")))
|
||||||
}
|
}
|
||||||
val session = ConductarrSession(MemoryCacheStore())
|
val session = ConductarrSession(MemoryCacheStore())
|
||||||
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true))
|
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||||
fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true)
|
fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true)
|
||||||
try {
|
try {
|
||||||
navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString())))
|
navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString())))
|
||||||
|
|
@ -109,7 +109,7 @@ class TitlePlaybackNavigationTest {
|
||||||
coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE,
|
coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE,
|
||||||
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
|
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
|
||||||
val session = ConductarrSession(MemoryCacheStore())
|
val session = ConductarrSession(MemoryCacheStore())
|
||||||
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true))
|
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||||
try {
|
try {
|
||||||
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
|
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
// 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<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun missingCatalogueIdentityShowsRecoveryAndCannotProduceAFakeHideReceipt() {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
runBlocking { session.authenticate() }
|
||||||
|
val facade = mockk<TitleReadFacade>()
|
||||||
|
coEvery { facade.findHeld(any(), any()) } returns null
|
||||||
|
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
|
||||||
|
val 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Native title interactions keep secondary actions, focus and section geometry intact. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class TitleRound2Test {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val effects = mutableListOf<TitleAction>()
|
||||||
|
private lateinit var state: TitleState
|
||||||
|
private lateinit var shell: ShellState
|
||||||
|
|
||||||
|
private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||||
|
private fun render(title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }) {
|
||||||
|
val fixture = fixture()
|
||||||
|
shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/test") }
|
||||||
|
state = TitleState(transform(fixture.title(title, person)), shell.person, effects::add)
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
BackHandler { state.back(shell) }
|
||||||
|
TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp))
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
private fun press(key: Key) = rule.onRoot().performKeyInput { pressKey(key) }
|
||||||
|
private fun menu() {
|
||||||
|
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "menu" }) }
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
rule.mainClock.advanceTimeBy(300)
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun menuBackRestoresItsButtonAndKeepsMediaInfoAndRequestReachable() {
|
||||||
|
render()
|
||||||
|
menu()
|
||||||
|
val opener = state.column
|
||||||
|
press(Key.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")), "title-menu-info-motion").apply { mkdirs() }
|
||||||
|
rule.runOnIdle { state.clickSheet(0) }
|
||||||
|
var waited = 0
|
||||||
|
while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
rule.waitForIdle()
|
||||||
|
waited += 16
|
||||||
|
}
|
||||||
|
val records = mutableListOf<String>()
|
||||||
|
fun frame(phase: String, index: Int): Pair<Int, Int> {
|
||||||
|
val path = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||||
|
rule.onRoot().captureRoboImage(path.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
|
||||||
|
fun brightness(x: Int, y: Int): Int {
|
||||||
|
val pixel = bitmap.getPixel(x, y)
|
||||||
|
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
(brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") }
|
||||||
|
} finally { bitmap.recycle() }
|
||||||
|
}
|
||||||
|
val info = List(14) { index ->
|
||||||
|
rule.waitForIdle()
|
||||||
|
frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
|
||||||
|
}
|
||||||
|
info.forEach { assertEquals("No extra scrim or disappearing overlay may darken or pop over Media info", info.last(), it) }
|
||||||
|
rule.runOnIdle { state.back(shell) }
|
||||||
|
repeat(14) { index ->
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals("Returning to Menu keeps exactly one scrim", info.last().second, frame("back", index).second)
|
||||||
|
}
|
||||||
|
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertExists()
|
||||||
|
File(directory, "samples.csv").writeText("phase,timeMs,sheetBrightness,leftBrightness\n" + records.joinToString("\n"))
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun menuFadeFramesStayMonotonicAndNeverTranslate() {
|
||||||
|
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-motion").apply { mkdirs() }
|
||||||
|
rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "menu" }) }
|
||||||
|
var waited = 0
|
||||||
|
while (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
rule.waitForIdle()
|
||||||
|
waited += 16
|
||||||
|
}
|
||||||
|
val bounds = rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||||
|
val samples = mutableListOf<String>()
|
||||||
|
fun frame(phase: String, index: Int): Int {
|
||||||
|
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val image = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
val brightness = try {
|
||||||
|
val pixel = image.getPixel((bounds.left + 40).toInt(), (bounds.top + 6).toInt())
|
||||||
|
android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||||
|
} finally { image.recycle() }
|
||||||
|
samples.add("$phase,${index * 16},$brightness,${bounds.left},${bounds.top}")
|
||||||
|
return brightness
|
||||||
|
}
|
||||||
|
fun assertMonotonic(values: List<Int>) {
|
||||||
|
assertNotEquals("Fade must visibly change the menu pixel", values.first(), values.last())
|
||||||
|
val direction = if (values.last() > values.first()) 1 else -1
|
||||||
|
values.zipWithNext().forEach { (before, after) ->
|
||||||
|
assertTrue("Menu alpha must not flicker or snap back: $values", (after - before) * direction >= -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val entering = List(14) { index ->
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals(bounds, rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot)
|
||||||
|
frame("open", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
|
||||||
|
}
|
||||||
|
assertMonotonic(entering)
|
||||||
|
rule.runOnIdle { state.back(shell) }
|
||||||
|
val leaving = mutableListOf<Int>()
|
||||||
|
repeat(9) { index ->
|
||||||
|
rule.waitForIdle()
|
||||||
|
if (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) leaving.add(frame("close", index))
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
}
|
||||||
|
assertMonotonic(leaving)
|
||||||
|
rule.onNodeWithTag("title-menu").assertDoesNotExist()
|
||||||
|
File(directory, "samples.csv").writeText("phase,timeMs,brightness,leftPx,topPx\n" + samples.joinToString("\n"))
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals(TitleSheet.Menu, state.sheet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
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.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<ComponentActivity>()
|
||||||
|
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<TitleItem?>(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<String>(), playback.events)
|
||||||
|
rule.runOnIdle {
|
||||||
|
item.value = movie
|
||||||
|
preferences.value = preferences(ThemeSongVolume.DISABLED)
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals(emptyList<String>(), 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<String>(), 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<TitleReadFacade>()
|
||||||
|
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).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.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<ThemeSongPlayer>()
|
||||||
|
val events = mutableListOf<String>()
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,21 +19,23 @@ import org.robolectric.annotation.Config
|
||||||
@Config(sdk = [34], application = Application::class)
|
@Config(sdk = [34], application = Application::class)
|
||||||
class TitleTrailerTest {
|
class TitleTrailerTest {
|
||||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
private val url = "https://www.youtube.com/watch?v=abcdefghijk"
|
private val url = "https://www.youtube.com/watch?v=ab_-defghij"
|
||||||
|
|
||||||
@Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() {
|
@Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() {
|
||||||
register(Intent(Intent.ACTION_VIEW, Uri.parse(url)), "test.browser")
|
register(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/watch?v=ab_-defghij")), "test.browser")
|
||||||
assertFalse(TitleTrailer(context).open(url))
|
assertFalse(TitleTrailer(context).open(url))
|
||||||
assertNull(shadowOf(context as Application).nextStartedActivity)
|
assertNull(shadowOf(context as Application).nextStartedActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() {
|
@Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() {
|
||||||
register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:abcdefghijk")), "test.youtube")
|
register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")), "test.youtube")
|
||||||
assertTrue(TitleTrailer(context).open(url))
|
assertTrue(TitleTrailer(context).open(url))
|
||||||
val launched = shadowOf(context as Application).nextStartedActivity
|
val launched = shadowOf(context as Application).nextStartedActivity
|
||||||
|
assertEquals(Intent.ACTION_VIEW, launched.action)
|
||||||
assertEquals("test.youtube", launched.`package`)
|
assertEquals("test.youtube", launched.`package`)
|
||||||
assertEquals("vnd.youtube", launched.data?.scheme)
|
assertEquals("vnd.youtube", launched.data?.scheme)
|
||||||
assertEquals("abcdefghijk", launched.data?.schemeSpecificPart)
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() {
|
@Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() {
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ class UpstreamContentFocusTest {
|
||||||
val model = mockk<WolfskinShellViewModel>(relaxed = true)
|
val model = mockk<WolfskinShellViewModel>(relaxed = true)
|
||||||
every { model.session } returns WolfskinSession().also { it.state = state }
|
every { model.session } returns WolfskinSession().also { it.state = state }
|
||||||
every { model.conductarr } returns ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())
|
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()
|
every { model.libraryDestinations } returns emptyMap()
|
||||||
// An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads.
|
// An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads.
|
||||||
every { model.signedInPerson } returns "unavailable"
|
every { model.signedInPerson } returns "unavailable"
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ class WatchReadTest {
|
||||||
runCurrent()
|
runCurrent()
|
||||||
assertFalse(loading.isCompleted)
|
assertFalse(loading.isCompleted)
|
||||||
assertTrue(model.snapshot.rows.first().watchReady)
|
assertTrue(model.snapshot.rows.first().watchReady)
|
||||||
assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001)
|
assertEquals(1136.274, model.snapshot.rows.single { it.id == "cw" }.cards.single().resumeSeconds, .0001)
|
||||||
releaseLibrary.complete(Unit)
|
releaseLibrary.complete(Unit)
|
||||||
loading.join()
|
loading.join()
|
||||||
assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001)
|
assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001)
|
||||||
|
|
@ -132,4 +132,28 @@ class WatchReadTest {
|
||||||
WholphinDispatchers.reset()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
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<ComponentActivity>()
|
||||||
|
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<HomeSettingsViewModel>(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<ImageUrlService>(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<UpdateViewModel>(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
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<ComponentActivity>()
|
||||||
|
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<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
|
||||||
|
val libraries = mockk<LibraryReadFacade>(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())
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
// 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 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<ComponentActivity>()
|
||||||
|
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
|
||||||
|
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token")
|
||||||
|
private val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
private val navigation = NavigationManager()
|
||||||
|
private val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||||
|
private val stack = mutableStateListOf<SetupDestination>(SetupDestination.Loading)
|
||||||
|
private val conductarr = ConductarrSession(MemoryCacheStore())
|
||||||
|
private val model = WolfskinSetupViewModel(repository, navigation, setup, WolfskinSession(), conductarr)
|
||||||
|
private val preferences = mockk<DataStore<AppPreferences>>()
|
||||||
|
private val intentService = mockk<IntentService>()
|
||||||
|
private var previousScreens = true
|
||||||
|
private var previousFixtures = false
|
||||||
|
|
||||||
|
@Before fun configure() {
|
||||||
|
WolfskinScreenPreference.initialize(rule.activity)
|
||||||
|
previousScreens = WolfskinScreenPreference.enabled
|
||||||
|
WolfskinScreenPreference.enabled = true
|
||||||
|
ConductarrPreference.initialize(rule.activity)
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
every { repository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
|
coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(server)
|
||||||
|
every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
|
||||||
|
every { repository.serverDao.getServers() } returns listOf(JellyfinServerUsers(server, listOf(user)))
|
||||||
|
every { setup.navigateTo(any()) } answers { stack[0] = firstArg() }
|
||||||
|
every { preferences.data } returns MutableStateFlow(AppPreferences.getDefaultInstance())
|
||||||
|
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun close() {
|
||||||
|
conductarr.close()
|
||||||
|
WolfskinScreenPreference.enabled = previousScreens
|
||||||
|
ConductarrPreference.updateFixtures(previousFixtures)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mount() {
|
||||||
|
val screensaver = mockk<ScreensaverService>(relaxed = true)
|
||||||
|
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||||
|
rule.setContent {
|
||||||
|
MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
|
mockk(relaxed = true), screensaver, Modifier,
|
||||||
|
setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appModel() = MainActivityViewModel(rule.activity, preferences, repository, setup, navigation,
|
||||||
|
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intentService)
|
||||||
|
|
||||||
|
private fun awaitStage(stage: WolfskinSetupViewModel.Stage) {
|
||||||
|
rule.waitUntil(5_000) {
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
model.stage == stage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertOwnPicker() {
|
||||||
|
awaitStage(WolfskinSetupViewModel.Stage.Picker)
|
||||||
|
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
|
||||||
|
rule.onNodeWithText("Justin").assertIsDisplayed()
|
||||||
|
rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist()
|
||||||
|
rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun coldStartLegacySignOutDrawerSwitchAndIntentFailureNeverComposeStockSelectors() {
|
||||||
|
runBlocking { appModel().handleAppStart() }
|
||||||
|
mount()
|
||||||
|
assertOwnPicker()
|
||||||
|
// The legacy sign-out-equivalent erased the recent preference, but Room still has the household.
|
||||||
|
coEvery { repository.getMostRecentServer() } returns RestoredSession.None
|
||||||
|
rule.runOnIdle { setup.navigateTo(SetupDestination.ServerList) }
|
||||||
|
assertOwnPicker()
|
||||||
|
rule.runOnIdle { setup.navigateTo(SetupDestination.UserList(server)) }
|
||||||
|
assertOwnPicker()
|
||||||
|
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Cannot restore sign-in")
|
||||||
|
rule.runOnIdle { rule.activity.lifecycleScope.launch { appModel().handleIntent(Intent()) } }
|
||||||
|
rule.waitUntil(5_000) { stack.last() == SetupDestination.ServerList }
|
||||||
|
assertOwnPicker()
|
||||||
|
coVerify(exactly = 0) { repository.switchServerOrUser() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Test fun 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)
|
||||||
|
runBlocking { appModel().handleAppStart() }
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.MainContent
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
|
import com.github.damontecres.wholphin.services.ScreensaverState
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.test.TestActivity
|
||||||
|
import com.github.damontecres.wholphin.ui.TestModule
|
||||||
|
import com.github.damontecres.wholphin.ui.successResponse
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
|
import com.github.damontecres.wholphin.util.configure
|
||||||
|
import com.github.damontecres.wholphin.util.reset
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import dagger.hilt.android.testing.HiltTestApplication
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
|
import org.jellyfin.sdk.api.operations.QuickConnectApi
|
||||||
|
import org.jellyfin.sdk.api.operations.SystemApi
|
||||||
|
import org.jellyfin.sdk.api.operations.UserApi
|
||||||
|
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TestWatcher
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows.shadowOf
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** The rollout switch must still expose both real upstream selectors, not a second forked copy. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltAndroidTest
|
||||||
|
@Config(application = HiltTestApplication::class, sdk = [34])
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class WolfskinSetupRollbackTest {
|
||||||
|
@get:Rule(order = 0) val hilt = HiltAndroidRule(this)
|
||||||
|
@get:Rule(order = 1) val registerActivity = object : TestWatcher() {
|
||||||
|
override fun starting(description: Description?) {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
shadowOf(context.packageManager).addOrUpdateActivity(ActivityInfo().apply {
|
||||||
|
name = TestActivity::class.java.name
|
||||||
|
packageName = context.packageName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@get:Rule(order = 2) val rule = createAndroidComposeRule<TestActivity>()
|
||||||
|
@Inject lateinit var api: ApiClient
|
||||||
|
@Inject lateinit var jellyfin: Jellyfin
|
||||||
|
private var previousScreens = true
|
||||||
|
|
||||||
|
@Before fun setup() {
|
||||||
|
hilt.inject()
|
||||||
|
WholphinDispatchers.configure(TestModule.testDispatcher)
|
||||||
|
WolfskinScreenPreference.initialize(rule.activity)
|
||||||
|
previousScreens = WolfskinScreenPreference.enabled
|
||||||
|
WolfskinScreenPreference.enabled = false
|
||||||
|
val users = mockk<UserApi>()
|
||||||
|
val system = mockk<SystemApi>()
|
||||||
|
val quickConnect = mockk<QuickConnectApi>()
|
||||||
|
every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api
|
||||||
|
every { api.userApi } returns users
|
||||||
|
every { api.systemApi } returns system
|
||||||
|
every { api.quickConnectApi } returns quickConnect
|
||||||
|
coEvery { users.getPublicUsers() } returns successResponse(emptyList())
|
||||||
|
coEvery { system.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(version = "10.11.11"))
|
||||||
|
coEvery { quickConnect.getQuickConnectEnabled() } returns successResponse(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun restore() {
|
||||||
|
WolfskinScreenPreference.enabled = previousScreens
|
||||||
|
WholphinDispatchers.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun rollbackRendersBothStockSelectors() {
|
||||||
|
val stack = mutableStateListOf<SetupDestination>(SetupDestination.ServerList)
|
||||||
|
val screensaver = mockk<ScreensaverService>()
|
||||||
|
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||||
|
rule.setContent {
|
||||||
|
MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
|
mockk(relaxed = true), screensaver)
|
||||||
|
}
|
||||||
|
fun awaitHeading(resource: Int) {
|
||||||
|
rule.waitUntil(5_000) {
|
||||||
|
TestModule.testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
rule.onAllNodes(androidx.compose.ui.test.hasText(rule.activity.getString(resource))).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
rule.onNodeWithText(rule.activity.getString(resource)).assertIsDisplayed()
|
||||||
|
rule.onNodeWithText("Who’s watching?").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
awaitHeading(R.string.select_server)
|
||||||
|
rule.runOnIdle { stack[0] = SetupDestination.UserList(JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)) }
|
||||||
|
awaitHeading(R.string.select_user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.data.AppDatabase
|
||||||
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
|
import com.github.damontecres.wholphin.data.MostRecentServer
|
||||||
|
import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.successResponse
|
||||||
|
import com.github.damontecres.wholphin.ui.TestModule
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
|
import org.jellyfin.sdk.api.operations.SystemApi
|
||||||
|
import org.jellyfin.sdk.api.operations.UserApi
|
||||||
|
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||||
|
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||||
|
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||||
|
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||||
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.tv.WolfskinSetupViewModel.Stage
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Setup preserves saved credentials, rejects protected-profile bypasses and recovers real auth failures. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class WolfskinSetupTest {
|
||||||
|
private val context get() = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
private val database by lazy { Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).allowMainThreadQueries().build() }
|
||||||
|
private val recent by lazy { MostRecentServerSharedPreferences(context) }
|
||||||
|
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
|
||||||
|
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "saved-test-token")
|
||||||
|
private val api = mockk<ApiClient>(relaxed = true)
|
||||||
|
private val usersApi = mockk<UserApi>()
|
||||||
|
private val systemApi = mockk<SystemApi>()
|
||||||
|
private val jellyfin by lazy { TestModule.jellyfin(mockk(), context, ClientInfo("Wolfskin test", "test"), DeviceInfo("setup-test", "Setup test")) }
|
||||||
|
private val navigation = NavigationManager()
|
||||||
|
private val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||||
|
private val session = WolfskinSession()
|
||||||
|
private val conductarr = ConductarrSession(MemoryCacheStore())
|
||||||
|
private val repository by lazy { ServerRepository(context, jellyfin, database.serverDao(), api, Dispatchers.IO, recent) }
|
||||||
|
private val model by lazy { WolfskinSetupViewModel(repository, navigation, setup, session, conductarr) }
|
||||||
|
private val dto get() = UserDto(id = user.id, name = user.name, hasPassword = true, hasConfiguredPassword = true, hasConfiguredEasyPassword = false)
|
||||||
|
private var previousFixtures = false
|
||||||
|
|
||||||
|
@Before fun saveHousehold(): Unit = runBlocking {
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
recent.clear()
|
||||||
|
database.serverDao().addOrUpdateServer(server)
|
||||||
|
user = database.serverDao().addOrUpdateUser(user)
|
||||||
|
recent.save(CurrentUser(server, user))
|
||||||
|
val client = jellyfin
|
||||||
|
every { api.userApi } returns usersApi
|
||||||
|
every { api.systemApi } returns systemApi
|
||||||
|
every { client.createApi(any(), any(), any(), any(), any()) } returns api
|
||||||
|
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
|
||||||
|
coEvery { systemApi.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(id = server.id.toString(), serverName = server.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun close() {
|
||||||
|
conductarr.close()
|
||||||
|
database.close()
|
||||||
|
ConductarrPreference.updateFixtures(previousFixtures)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun enteringPickerPreservesServerAndUserAndRecoversErasedPreferenceFromRoom() = runBlocking {
|
||||||
|
val saved = recent.get()
|
||||||
|
model.load(null)
|
||||||
|
assertEquals(Stage.Picker, model.stage)
|
||||||
|
assertEquals(user.id.toString(), model.picker!!.person.jellyfinUserId)
|
||||||
|
assertEquals(saved, recent.get())
|
||||||
|
recent.clear()
|
||||||
|
model.load(null)
|
||||||
|
assertEquals(server, model.server)
|
||||||
|
assertEquals(Stage.Picker, model.stage)
|
||||||
|
assertEquals(MostRecentServer.None, recent.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun expiredTokenRequiresCredentialsAndSuccessfulLoginRestoresSessionWithoutSecondPicker() = runBlocking {
|
||||||
|
model.load(server)
|
||||||
|
coEvery { usersApi.getCurrentUser() } throws InvalidStatusException(401)
|
||||||
|
model.select(model.picker!!.person)
|
||||||
|
assertEquals(Stage.Login, model.stage)
|
||||||
|
assertEquals(user, model.loginUser)
|
||||||
|
assertNull(repository.current.value)
|
||||||
|
assertTrue(recent.get() is MostRecentServer.ServerAndUser)
|
||||||
|
coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(user = dto, serverId = server.id.toString(), accessToken = "renewed-test-token"))
|
||||||
|
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
|
||||||
|
model.login(user.name!!, "test-password")
|
||||||
|
assertEquals("renewed-test-token", repository.current.value!!.user.accessToken)
|
||||||
|
assertEquals(user.id, (recent.get() as MostRecentServer.ServerAndUser).userId)
|
||||||
|
assertFalse(session.state!!.pickerVisible)
|
||||||
|
assertEquals(user.id.toString(), session.state!!.person.jellyfinUserId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun unreachableServerOffersRetryAndSavedPersonCanSignInWhenItReturns() = runTest {
|
||||||
|
model.load(server)
|
||||||
|
val saved = recent.get()
|
||||||
|
coEvery { usersApi.getCurrentUser() } throws IOException("offline")
|
||||||
|
model.select(model.picker!!.person)
|
||||||
|
assertEquals(Stage.Unavailable, model.stage)
|
||||||
|
assertEquals(saved, recent.get())
|
||||||
|
val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() }
|
||||||
|
assertEquals(Stage.Loading, model.stage)
|
||||||
|
retry.await()
|
||||||
|
assertEquals(Stage.Picker, model.stage)
|
||||||
|
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
|
||||||
|
model.select(model.picker!!.person)
|
||||||
|
assertEquals(user.id, repository.current.value!!.user.id)
|
||||||
|
assertFalse(session.state!!.pickerVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun retryCanRemainUnavailableWithoutErasingHousehold() = runTest {
|
||||||
|
database.serverDao().deleteUser(server.id, user.id)
|
||||||
|
val saved = recent.get()
|
||||||
|
coEvery { usersApi.getPublicUsers() } throws IOException("offline")
|
||||||
|
model.load(null)
|
||||||
|
assertEquals(Stage.Unavailable, model.stage)
|
||||||
|
val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() }
|
||||||
|
assertEquals(Stage.Loading, model.stage)
|
||||||
|
retry.await()
|
||||||
|
assertEquals(Stage.Unavailable, model.stage)
|
||||||
|
assertEquals(server, model.server)
|
||||||
|
assertEquals(saved, recent.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun pinAndRequireLoginCannotReuseSavedTokenWithoutUnlocking() = runBlocking {
|
||||||
|
user = user.copy(pin = "URDL")
|
||||||
|
database.serverDao().updateUser(user)
|
||||||
|
model.load(server)
|
||||||
|
model.select(model.picker!!.person)
|
||||||
|
assertEquals(user, model.pinUser)
|
||||||
|
assertTrue(model.picker!!.pickerVisible)
|
||||||
|
model.enterPin("WRONG")
|
||||||
|
assertNull(repository.current.value)
|
||||||
|
model.enterPin("URDL")
|
||||||
|
assertEquals(user.id, repository.current.value!!.user.id)
|
||||||
|
user = user.copy(pin = null, requireLogin = true)
|
||||||
|
database.serverDao().updateUser(user)
|
||||||
|
model.load(server)
|
||||||
|
model.select(model.picker!!.person)
|
||||||
|
assertEquals(Stage.Login, model.stage)
|
||||||
|
assertEquals(user, model.loginUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun unavailablePublicProfilesKeepHouseholdAndAllowCredentialRecovery() = runBlocking {
|
||||||
|
database.serverDao().deleteUser(server.id, user.id)
|
||||||
|
coEvery { usersApi.getPublicUsers() } throws IOException("offline")
|
||||||
|
model.load(null)
|
||||||
|
assertEquals(Stage.Unavailable, model.stage)
|
||||||
|
assertEquals(server, model.server)
|
||||||
|
model.showLogin(null)
|
||||||
|
coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(
|
||||||
|
user = dto, serverId = server.id.toString(), accessToken = "new-test-token"))
|
||||||
|
model.login(user.name!!, "test-password")
|
||||||
|
assertEquals(user.id, repository.current.value!!.user.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun expiredLaunchRecoveryIgnoresOutagesLateIdentitiesAndRollbackMode() = runBlocking {
|
||||||
|
WolfskinScreenPreference.initialize(context)
|
||||||
|
val previous = WolfskinScreenPreference.enabled
|
||||||
|
try {
|
||||||
|
WolfskinScreenPreference.enabled = true
|
||||||
|
val expected = (repository.restoreSavedSession() as com.github.damontecres.wholphin.data.RestoredSession.Success).currentUser
|
||||||
|
var destination: SetupDestination = SetupDestination.AppContent(expected)
|
||||||
|
every { setup.navigateTo(any()) } answers { destination = firstArg() }
|
||||||
|
recoverWolfskinSession(context, IOException("offline"), expected, repository, setup)
|
||||||
|
assertTrue(destination is SetupDestination.AppContent)
|
||||||
|
WolfskinScreenPreference.enabled = false
|
||||||
|
recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup)
|
||||||
|
assertTrue(destination is SetupDestination.AppContent)
|
||||||
|
WolfskinScreenPreference.enabled = true
|
||||||
|
val other = database.serverDao().addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel"))
|
||||||
|
recent.save(CurrentUser(server, other))
|
||||||
|
repository.restoreSavedSession()
|
||||||
|
recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup)
|
||||||
|
assertTrue(destination is SetupDestination.AppContent)
|
||||||
|
recoverWolfskinSession(context, InvalidStatusException(401), repository.current.value, repository, setup)
|
||||||
|
model.load((destination as SetupDestination.UserList).server)
|
||||||
|
assertEquals(Stage.Picker, model.stage)
|
||||||
|
assertTrue(model.picker!!.people.any { it.jellyfinUserId == other.id.toString() })
|
||||||
|
assertEquals(other.id, (recent.get() as MostRecentServer.ServerAndUser).userId)
|
||||||
|
} finally { WolfskinScreenPreference.enabled = previous }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun firstRunDiscoversAndPersistsHouseholdBeforeFetchingPublicProfiles() = runBlocking {
|
||||||
|
database.serverDao().deleteUser(server.id, user.id)
|
||||||
|
database.serverDao().deleteServer(server.id)
|
||||||
|
recent.clear()
|
||||||
|
model.load(null)
|
||||||
|
assertEquals(Stage.Unconfigured, model.stage)
|
||||||
|
coEvery { jellyfin.discovery.getRecommendedServers("bad-url") } returns emptyList()
|
||||||
|
model.connect("bad-url")
|
||||||
|
assertEquals(Stage.Unconfigured, model.stage)
|
||||||
|
assertTrue(database.serverDao().getServers().isEmpty())
|
||||||
|
coEvery { jellyfin.discovery.getRecommendedServers(server.url) } returns listOf(RecommendedServerInfo(
|
||||||
|
address = server.url, responseTime = 1, score = RecommendedServerInfoScore.GREAT, issues = emptyList(),
|
||||||
|
systemInfo = Result.success(PublicSystemInfo(id = server.id.toString(), serverName = server.name, startupWizardCompleted = true))))
|
||||||
|
model.connect(server.url)
|
||||||
|
assertEquals(server.id, database.serverDao().getServers().single().server.id)
|
||||||
|
coEvery { usersApi.getPublicUsers() } returns successResponse(listOf(dto))
|
||||||
|
model.load(model.server)
|
||||||
|
assertEquals(Stage.Picker, model.stage)
|
||||||
|
model.select(model.picker!!.person)
|
||||||
|
assertEquals(Stage.Login, model.stage)
|
||||||
|
assertNull(repository.current.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -104,10 +104,10 @@ class UpdatesKeysTest {
|
||||||
assertTrue(shell.contentOverlayVisible)
|
assertTrue(shell.contentOverlayVisible)
|
||||||
assertFalse(shell.navFocused)
|
assertFalse(shell.navFocused)
|
||||||
assertTrue(controller.autoCheck.value)
|
assertTrue(controller.autoCheck.value)
|
||||||
press(listOf(Key.Back))
|
press(listOf(Key.Escape))
|
||||||
assertFalse(shell.contentOverlayVisible)
|
assertFalse(shell.contentOverlayVisible)
|
||||||
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
press(listOf(Key.Back))
|
press(listOf(Key.Escape))
|
||||||
assertEquals("settings", shell.route)
|
assertEquals("settings", shell.route)
|
||||||
assertEquals(1, shell.settingsRow)
|
assertEquals(1, shell.settingsRow)
|
||||||
assertEquals(0, shell.view.listState.firstVisibleItemIndex)
|
assertEquals(0, shell.view.listState.firstVisibleItemIndex)
|
||||||
|
|
@ -120,7 +120,7 @@ class UpdatesKeysTest {
|
||||||
assertTrue(range.value() > 0f)
|
assertTrue(range.value() > 0f)
|
||||||
assertFalse(shell.navFocused)
|
assertFalse(shell.navFocused)
|
||||||
assertFalse((shell.contentFocus as UpdateFocus).autoFocused)
|
assertFalse((shell.contentFocus as UpdateFocus).autoFocused)
|
||||||
press(listOf(Key.Back))
|
press(listOf(Key.Escape))
|
||||||
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,10 +115,16 @@ private fun Title.titleItem(provider: ConductarrProvider): TitleItem = TitleItem
|
||||||
otherOwners = provider.holders(this).toList(), tmdbId = tmdbId,
|
otherOwners = provider.holders(this).toList(), tmdbId = tmdbId,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun TitlePageData.requestTitle(viewer: ShellPerson): DiscoverTitle = DiscoverTitle(
|
internal fun TitlePageData.requestTitle(viewer: ShellPerson): DiscoverTitle? {
|
||||||
item.tmdbId ?: item.id.substringAfter(':').toLongOrNull() ?: error("Request needs a catalogue identity"), if (item.kind == "movie") "movie" else "tv", item.name, item.year,
|
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,
|
genres = item.genres, overview = item.overview, poster = item.poster, backdrop = item.backdrop,
|
||||||
runtimeSeconds = item.runtimeSeconds.toInt(), seasonCount = item.seasons.takeIf { it > 0 },
|
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) },
|
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(),
|
heldBy = item.otherOwners.toSet() + if (item.held) setOf(viewer.key) else emptySet(),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -14,15 +15,19 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import org.justinvisser.wolfskin.design.DiscoverOverlay
|
import org.justinvisser.wolfskin.design.DiscoverOverlay
|
||||||
import org.justinvisser.wolfskin.design.ShellKey
|
import org.justinvisser.wolfskin.design.ShellKey
|
||||||
|
import org.justinvisser.wolfskin.design.isScreenEntryActive
|
||||||
import org.justinvisser.wolfskin.design.readShellKey
|
import org.justinvisser.wolfskin.design.readShellKey
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
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.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
|
@ -65,6 +70,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
private val navigation: NavigationManager,
|
private val navigation: NavigationManager,
|
||||||
val conductarr: ConductarrSession,
|
val conductarr: ConductarrSession,
|
||||||
private val repository: ServerRepository,
|
private val repository: ServerRepository,
|
||||||
|
val themeSongPlayer: ThemeSongPlayer,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
var title by mutableStateOf<TitleState?>(null)
|
var title by mutableStateOf<TitleState?>(null)
|
||||||
private set
|
private set
|
||||||
|
|
@ -89,10 +95,11 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
/** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */
|
/** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */
|
||||||
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
||||||
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
||||||
if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key &&
|
val sameTitle = route == destination && ::viewer.isInitialized && viewer.key == selected.key && !destination.startsWith("person/")
|
||||||
title?.data?.status == TitleReadStatus.Ready && title?.data?.item?.held == true &&
|
val readyTitle = sameTitle && title?.data?.status == TitleReadStatus.Ready
|
||||||
(title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) &&
|
val fixtureRefresh = title?.takeIf { force && readyTitle && fixture != null && fixtures && BuildConfig.DEBUG }
|
||||||
!destination.startsWith("person/") && fixture == null) {
|
if (force && readyTitle && fixture == null &&
|
||||||
|
(title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true)) {
|
||||||
loadJob?.cancel()
|
loadJob?.cancel()
|
||||||
preferences = userPreferences
|
preferences = userPreferences
|
||||||
title?.let { it.update(it.data.copy(watchReady = false)) }
|
title?.let { it.update(it.data.copy(watchReady = false)) }
|
||||||
|
|
@ -102,14 +109,18 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
route = destination
|
route = destination
|
||||||
loadJob?.cancel()
|
loadJob?.cancel()
|
||||||
person = null
|
person = null
|
||||||
fixture = null
|
if (fixtureRefresh == null) fixture = null
|
||||||
personReadPending = false
|
personReadPending = false
|
||||||
personReadFinished = false
|
personReadFinished = false
|
||||||
performerPage = 1
|
performerPage = 1
|
||||||
viewer = selected
|
viewer = selected
|
||||||
preferences = userPreferences
|
preferences = userPreferences
|
||||||
val id = Uri.decode(destination.substringAfter('/'))
|
val id = Uri.decode(destination.substringAfter('/'))
|
||||||
title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = TitleReadStatus.Loading), viewer, ::handle)
|
if (fixtureRefresh == null) {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
loadJob = viewModelScope.launch {
|
loadJob = viewModelScope.launch {
|
||||||
if (fixtures && BuildConfig.DEBUG && id.toUuidOrNull() == null) {
|
if (fixtures && BuildConfig.DEBUG && id.toUuidOrNull() == null) {
|
||||||
val source = fixtureProvider.read()
|
val source = fixtureProvider.read()
|
||||||
|
|
@ -121,13 +132,16 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
val data = withContext(Dispatchers.IO) { source.title(id, selected.key) }
|
val data = withContext(Dispatchers.IO) { source.title(id, selected.key) }
|
||||||
val key = "${viewer.key}:${data.item.id}"
|
val key = "${viewer.key}:${data.item.id}"
|
||||||
val watchKey = "${viewer.key}:${data.watchTarget.id}"
|
val watchKey = "${viewer.key}:${data.watchTarget.id}"
|
||||||
title = TitleState(data.copy(
|
val restored = data.copy(
|
||||||
selectedVersion = fixtureSelections.getString("$key:version", null),
|
selectedVersion = fixtureSelections.getString("$key:version", null),
|
||||||
selectedAudio = if (fixtureSelections.contains("$key:audio")) fixtureSelections.getInt("$key:audio", -1) else null,
|
selectedAudio = if (fixtureSelections.contains("$key:audio")) fixtureSelections.getInt("$key:audio", -1) else null,
|
||||||
selectedSubtitle = fixtureSelections.getInt("$key:subtitle", -1),
|
selectedSubtitle = fixtureSelections.getInt("$key:subtitle", -1),
|
||||||
watchTarget = data.watchTarget.copy(watched = fixtureSelections.getBoolean("$watchKey:watched", data.watchTarget.watched)),
|
watchTarget = data.watchTarget.copy(watched = fixtureSelections.getBoolean("$watchKey:watched", data.watchTarget.watched)),
|
||||||
hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden),
|
hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden),
|
||||||
), viewer, ::handle)
|
)
|
||||||
|
if (fixtureRefresh != null) {
|
||||||
|
if (title === fixtureRefresh) fixtureRefresh.update(restored)
|
||||||
|
} else title = TitleState(restored, viewer, ::handle)
|
||||||
}
|
}
|
||||||
} else if (destination.startsWith("person/")) readPerson(id) else readTitle(id)
|
} else if (destination.startsWith("person/")) readPerson(id) else readTitle(id)
|
||||||
}
|
}
|
||||||
|
|
@ -231,6 +245,12 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List<TitleItem>) =
|
private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List<TitleItem>) =
|
||||||
PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } }
|
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) {
|
private fun handle(action: TitleAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
TitleAction.Pop -> navigation.goBack()
|
TitleAction.Pop -> navigation.goBack()
|
||||||
|
|
@ -250,7 +270,10 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
restart = action.restart)
|
restart = action.restart)
|
||||||
}
|
}
|
||||||
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true)
|
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true)
|
||||||
else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer."
|
else {
|
||||||
|
themeSongPlayer.stop()
|
||||||
|
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.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
||||||
is TitleAction.Season -> {
|
is TitleAction.Season -> {
|
||||||
loadJob?.cancel()
|
loadJob?.cancel()
|
||||||
|
|
@ -289,6 +312,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
||||||
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
||||||
if (title !== current || watchScope != playbackScope) return@launch
|
if (title !== current || watchScope != playbackScope) return@launch
|
||||||
|
themeSongPlayer.stop()
|
||||||
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
|
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
|
|
@ -334,14 +358,19 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen
|
||||||
val watchRefresh = rememberWatchRefresh(viewModel.watchScope)
|
val watchRefresh = rememberWatchRefresh(viewModel.watchScope)
|
||||||
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
||||||
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
|
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
|
||||||
|
TitleThemeMusic(title?.data?.item, preferences, viewModel.themeSongPlayer,
|
||||||
|
active = isScreenEntryActive() && 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
|
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
|
||||||
LaunchedEffect(title?.data?.status) {
|
LaunchedEffect(title?.data?.status) {
|
||||||
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
||||||
}
|
}
|
||||||
val person = viewModel.person.takeIf { title != null }
|
val person = viewModel.person.takeIf { title != null }
|
||||||
BackHandler(enabled = title != null) {
|
BackHandler(enabled = isScreenEntryActive() && title != null) {
|
||||||
|
viewModel.handleBack(route, shell) {
|
||||||
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
|
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Box(modifier.onPreviewKeyEvent { event ->
|
Box(modifier.onPreviewKeyEvent { event ->
|
||||||
if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let {
|
if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let {
|
||||||
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it)
|
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it)
|
||||||
|
|
@ -354,4 +383,19 @@ 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 }
|
private fun String.toUuidOrNull(): UUID? = try { UUID.fromString(this) } catch (_: IllegalArgumentException) { null }
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ class TitleFixture(private val fixture: JSONObject) {
|
||||||
seriesName = source.optString("seriesTitle").takeIf { it.isNotEmpty() }, season = source.optInt("seasonNumber"), episode = source.optInt("episodeNumber"),
|
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") },
|
ratings = ratings(source), otherOwners = owners.filter { it.getString("key") != person }.map { it.getString("name") },
|
||||||
isPosterBackdrop = source.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop",
|
isPosterBackdrop = source.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop",
|
||||||
|
tmdbId = source.optLong("tmdbId").takeIf { it > 0 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,17 +24,20 @@ internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: She
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SideEffect { if (!fixtures) requests?.updateRequesters(requesters) }
|
SideEffect { if (!fixtures) requests?.updateRequesters(requesters) }
|
||||||
LaunchedEffect(requests, title?.data) {
|
val requestItem = remember(fixtures, title?.data, shell.person.key) {
|
||||||
|
if (fixtures) null else title?.data?.requestTitle(shell.person)
|
||||||
|
}
|
||||||
|
LaunchedEffect(requests, title?.data, requestItem) {
|
||||||
val data = title?.data ?: return@LaunchedEffect
|
val data = title?.data ?: return@LaunchedEffect
|
||||||
if (!fixtures && requests != null && data.item.tmdbId != null) {
|
val item = requestItem ?: return@LaunchedEffect
|
||||||
val item = data.requestTitle(shell.person)
|
if (requests != null) {
|
||||||
requests.updateSnapshot(DiscoverSnapshot(picks = if (data.pick) listOf(item) else emptyList(),
|
requests.updateSnapshot(DiscoverSnapshot(picks = if (data.pick) listOf(item) else emptyList(),
|
||||||
hidden = if (data.hidden) listOf(item) else emptyList(),
|
hidden = if (data.hidden) listOf(item) else emptyList(),
|
||||||
status = if (data.connection == TitleConnection.SignedOut) DiscoverStatus.NeedsSignIn else DiscoverStatus.Ready,
|
status = if (data.connection == TitleConnection.SignedOut) DiscoverStatus.NeedsSignIn else DiscoverStatus.Ready,
|
||||||
savedAt = data.savedAt, reachable = data.connection == TitleConnection.SignedIn, requestsAllowed = data.requestsAllowed))
|
savedAt = data.savedAt, reachable = data.connection == TitleConnection.SignedIn, requestsAllowed = data.requestsAllowed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val hidden = title?.data?.takeIf { it.item.tmdbId != null }?.let { data -> requests?.isHidden(data.requestTitle(shell.person)) }
|
val hidden = requestItem?.let { requests?.isHidden(it) }
|
||||||
LaunchedEffect(requests, hidden, requests?.toast) {
|
LaunchedEffect(requests, hidden, requests?.toast) {
|
||||||
val data = title?.data ?: return@LaunchedEffect
|
val data = title?.data ?: return@LaunchedEffect
|
||||||
if (!fixtures && hidden != null && data.hidden != hidden) title.update(data.copy(hidden = hidden))
|
if (!fixtures && hidden != null && data.hidden != hidden) title.update(data.copy(hidden = hidden))
|
||||||
|
|
@ -49,13 +52,22 @@ internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: She
|
||||||
viewModel.conductarrAction = { action ->
|
viewModel.conductarrAction = { action ->
|
||||||
val current = viewModel.title
|
val current = viewModel.title
|
||||||
when (action) {
|
when (action) {
|
||||||
is TitleAction.Request -> if (current != null && requests != null) requests.openRequest(action.forSomeoneElse, current.data.requestTitle(shell.person))
|
is TitleAction.Request -> if (current != null) {
|
||||||
is TitleAction.Requests -> shell.openRequests()
|
|
||||||
is TitleAction.Hide -> if (current != null && requests != null) {
|
|
||||||
val item = current.data.requestTitle(shell.person)
|
val item = current.data.requestTitle(shell.person)
|
||||||
|
if (item == null || requests == null) current.toast = RecoveryMessage.TitleActionUnavailable
|
||||||
|
else requests.openRequest(action.forSomeoneElse, item)
|
||||||
|
}
|
||||||
|
is TitleAction.Requests -> shell.openRequests()
|
||||||
|
is TitleAction.Hide -> if (current != 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)
|
if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false)
|
||||||
requests.overlay = null // TitleState already owns the native hide receipt and Undo.
|
requests.overlay = null // TitleState already owns the native hide receipt and Undo.
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else -> error("Not a Conductarr title action")
|
else -> error("Not a Conductarr title action")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ class TitleTrailer(private val context: Context) {
|
||||||
?: source.pathSegments.takeIf { it.firstOrNull() in setOf("shorts", "embed") }?.getOrNull(1)
|
?: source.pathSegments.takeIf { it.firstOrNull() in setOf("shorts", "embed") }?.getOrNull(1)
|
||||||
else -> null
|
else -> null
|
||||||
} ?: return false
|
} ?: return false
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$video")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
// 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 handler = context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
val handler = context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||||
.firstOrNull { it.activityInfo.exported } ?: return false
|
.firstOrNull { it.activityInfo.exported } ?: return false
|
||||||
intent.setPackage(handler.activityInfo.packageName)
|
intent.setPackage(handler.activityInfo.packageName)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -202,6 +201,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ShellAction.Pop -> navigation.goBack()
|
ShellAction.Pop -> navigation.goBack()
|
||||||
|
ShellAction.Exit -> navigation.requestExit()
|
||||||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
||||||
is ShellAction.Person -> selectPerson(action.person, server)
|
is ShellAction.Person -> selectPerson(action.person, server)
|
||||||
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
||||||
|
|
@ -226,7 +226,11 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
val saved = withContext(Dispatchers.IO) {
|
val saved = withContext(Dispatchers.IO) {
|
||||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
||||||
}
|
}
|
||||||
if (saved == null || saved.isProtected) return@launch
|
if (saved == null || saved.isProtected) {
|
||||||
|
if (ConductarrPreference.fixtures) return@launch
|
||||||
|
setupNavigation.navigateTo(SetupDestination.UserList(server))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
val current = repository.changeUser(server, saved)
|
val current = repository.changeUser(server, saved)
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
|
|
@ -234,7 +238,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
setupNavigation.navigateTo(SetupDestination.UserList(server))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -273,7 +277,9 @@ fun WolfskinApp(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val loading = remember(user.id) {
|
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()).apply { peopleStatus = PeopleStatus.Loading }
|
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 }
|
||||||
}
|
}
|
||||||
val state = viewModel.session.state ?: loading
|
val state = viewModel.session.state ?: loading
|
||||||
val discover = rememberDiscoverState(
|
val discover = rememberDiscoverState(
|
||||||
|
|
@ -287,6 +293,7 @@ fun WolfskinApp(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
RequestsRefresh(state, viewModel::refreshRequests)
|
RequestsRefresh(state, viewModel::refreshRequests)
|
||||||
|
val onBack = rememberWolfskinBackHandler(state, navigationManager)
|
||||||
WolfskinTheme {
|
WolfskinTheme {
|
||||||
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
|
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
|
||||||
org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) {
|
org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) {
|
||||||
|
|
@ -302,7 +309,7 @@ fun WolfskinApp(
|
||||||
}
|
}
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = navigationManager.backStack,
|
backStack = navigationManager.backStack,
|
||||||
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
|
onBack = onBack,
|
||||||
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||||
entryProvider = { destination ->
|
entryProvider = { destination ->
|
||||||
NavEntry(
|
NavEntry(
|
||||||
|
|
@ -315,20 +322,15 @@ fun WolfskinApp(
|
||||||
// Cached entries must not retain the launch shell after sign-in completes.
|
// Cached entries must not retain the launch shell after sign-in completes.
|
||||||
val state = LocalShellState.current
|
val state = LocalShellState.current
|
||||||
val discover = LocalDiscoverState.current
|
val discover = LocalDiscoverState.current
|
||||||
val route = when (destination) {
|
val route = destination.toWolfskinRoute()
|
||||||
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
|
val isCurrent = navigationManager.backStack.lastOrNull() == destination
|
||||||
LaunchedEffect(destination, state, isCurrent) {
|
LaunchedEffect(destination, state, isCurrent) {
|
||||||
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
|
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
|
||||||
}
|
}
|
||||||
|
org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) {
|
||||||
if (destination.fullScreen) {
|
if (destination.fullScreen) {
|
||||||
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
||||||
} else {
|
} else {
|
||||||
BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
|
||||||
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
||||||
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
||||||
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
||||||
|
|
@ -355,6 +357,7 @@ fun WolfskinApp(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
|
|
||||||
|
/** Only an expired current login leaves saved content; outages and late failures keep it usable. */
|
||||||
|
fun recoverWolfskinSession(
|
||||||
|
context: Context,
|
||||||
|
exception: Exception,
|
||||||
|
expected: CurrentUser?,
|
||||||
|
repository: ServerRepository,
|
||||||
|
setup: SetupNavigationManager,
|
||||||
|
) {
|
||||||
|
if (exception !is InvalidStatusException || exception.status != 401 || expected == null || repository.current.value !== expected) return
|
||||||
|
WolfskinScreenPreference.initialize(context)
|
||||||
|
if (WolfskinScreenPreference.enabled) setup.navigateTo(SetupDestination.UserList(expected.server))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.ButtonDefaults
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.ui.components.EditTextBox
|
||||||
|
import com.github.damontecres.wholphin.ui.setup.PinEntryDialog
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.justinvisser.wolfskin.design.ShellAction
|
||||||
|
import org.justinvisser.wolfskin.design.ShellSurface
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||||
|
import org.justinvisser.wolfskin.design.Wordmark
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||||
|
import org.justinvisser.wolfskin.tv.WolfskinSetupViewModel.Stage
|
||||||
|
|
||||||
|
/** Intercept both stock setup destinations before either upstream selector can be composed. */
|
||||||
|
@Composable
|
||||||
|
fun WolfskinSetupContent(destination: SetupDestination, modifier: Modifier = Modifier, model: WolfskinSetupViewModel? = null): Boolean {
|
||||||
|
if (destination != SetupDestination.ServerList && destination !is SetupDestination.UserList) return false
|
||||||
|
val context = LocalContext.current
|
||||||
|
remember(context) {
|
||||||
|
WolfskinScreenPreference.initialize(context)
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
if (!WolfskinScreenPreference.enabled) return false
|
||||||
|
WolfskinSetupScreen(destination, modifier, model ?: hiltViewModel())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saved people remain local; network failures expose retry and real server authentication. */
|
||||||
|
@Composable
|
||||||
|
internal fun WolfskinSetupScreen(
|
||||||
|
destination: SetupDestination,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
model: WolfskinSetupViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val hint = (destination as? SetupDestination.UserList)?.server
|
||||||
|
LaunchedEffect(destination) { model.load(hint) }
|
||||||
|
val retrySetup: () -> Unit = { scope.launch { model.retry() } }
|
||||||
|
model.onAction = { action ->
|
||||||
|
when (action) {
|
||||||
|
is ShellAction.Person -> scope.launch { model.select(action.person) }
|
||||||
|
is ShellAction.Destination -> model.showSettings()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BackHandler {
|
||||||
|
if (model.pinUser != null) model.dismissPin()
|
||||||
|
else model.returnToPicker()
|
||||||
|
}
|
||||||
|
WolfskinTheme {
|
||||||
|
val picker = model.picker
|
||||||
|
if (model.stage == Stage.Picker && picker != null) {
|
||||||
|
ShellSurface(picker, modifier.testTag("wolfskin-setup-picker"))
|
||||||
|
} else {
|
||||||
|
SetupPanel(model.stage, modifier) {
|
||||||
|
when (model.stage) {
|
||||||
|
Stage.Loading -> Text("Reading saved profiles…", color = C.muted, style = WolfskinTokens.Type.body)
|
||||||
|
Stage.Unconfigured -> ServerForm(model)
|
||||||
|
Stage.Login -> LoginForm(model)
|
||||||
|
Stage.Settings -> {
|
||||||
|
SetupButton("Sign in to another profile", !model.busy, initialFocus = true) { model.showLogin(null) }
|
||||||
|
SetupButton("Back to profiles", !model.busy, onClick = model::returnToPicker)
|
||||||
|
}
|
||||||
|
Stage.Unavailable -> {
|
||||||
|
Text(model.message.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
|
SetupButton("Try again", !model.busy, initialFocus = true, onClick = retrySetup)
|
||||||
|
if (model.server != null) SetupButton("Sign in", !model.busy) { model.showLogin(null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Stage.Picker -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.pinUser?.let { user ->
|
||||||
|
PinEntryDialog(
|
||||||
|
onDismissRequest = model::dismissPin,
|
||||||
|
onClickServerAuth = { model.showLogin(user) },
|
||||||
|
onTextChange = { value -> scope.launch { model.enterPin(value) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SetupPanel(stage: Stage, modifier: Modifier, content: @Composable () -> Unit) {
|
||||||
|
Box(modifier.fillMaxSize().background(C.bg).testTag("wolfskin-setup-${stage.name.lowercase()}")) {
|
||||||
|
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
|
||||||
|
Column(Modifier.align(Alignment.Center).width(S.pickerErrorPanelWidth),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(S.panelGap)) {
|
||||||
|
Text(when (stage) {
|
||||||
|
Stage.Unconfigured -> "Connect your household server"
|
||||||
|
Stage.Login -> "Sign in to your profile"
|
||||||
|
Stage.Settings -> "Settings"
|
||||||
|
else -> "Who’s watching?"
|
||||||
|
}, color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerForm(model: WolfskinSetupViewModel) {
|
||||||
|
var url by remember { mutableStateOf("") }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||||
|
Text("Enter your Jellyfin server URL once. Wolfskin will remember it on this TV.", color = C.muted, style = WolfskinTokens.Type.body)
|
||||||
|
EditTextBox(value = url, onValueChange = { url = it }, modifier = Modifier.fillMaxWidth().focusRequester(focus).testTag("household-server-url"),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
|
||||||
|
model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) }
|
||||||
|
SetupButton(if (model.busy) "Connecting…" else "Try again", !model.busy && url.isNotBlank()) {
|
||||||
|
scope.launch { model.connect(url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoginForm(model: WolfskinSetupViewModel) {
|
||||||
|
var username by remember(model.loginUser) { mutableStateOf(model.loginUser?.name.orEmpty()) }
|
||||||
|
var password by remember(model.loginUser) { mutableStateOf("") }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
val initialUsername = remember(model.loginUser) { username.isBlank() }
|
||||||
|
LaunchedEffect(model.loginUser) { focus.requestFocus() }
|
||||||
|
Text(model.server?.name ?: model.server?.url.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body)
|
||||||
|
Text("Username", color = C.fg, style = WolfskinTokens.Type.body)
|
||||||
|
EditTextBox(value = username, onValueChange = { username = it }, modifier = Modifier.fillMaxWidth()
|
||||||
|
.then(if (initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-username"))
|
||||||
|
Text("Password", color = C.fg, style = WolfskinTokens.Type.body)
|
||||||
|
EditTextBox(value = password, onValueChange = { password = it }, modifier = Modifier.fillMaxWidth()
|
||||||
|
.then(if (!initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-password"),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
||||||
|
model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) }
|
||||||
|
SetupButton(if (model.busy) "Signing in…" else "Sign in", !model.busy && username.isNotBlank()) {
|
||||||
|
val submitted = password
|
||||||
|
password = ""
|
||||||
|
scope.launch { model.login(username, submitted) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SetupButton(label: String, enabled: Boolean, initialFocus: Boolean = false, onClick: () -> Unit) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { if (initialFocus && enabled) focus.requestFocus() }
|
||||||
|
Button(onClick = onClick, enabled = enabled, modifier = Modifier.focusRequester(focus),
|
||||||
|
shape = ButtonDefaults.shape(shape = RoundedCornerShape(WolfskinTokens.Radius.button)),
|
||||||
|
colors = ButtonDefaults.colors(containerColor = C.buttonSecondary, contentColor = C.fg,
|
||||||
|
focusedContainerColor = C.fg, focusedContentColor = C.onFocus)) {
|
||||||
|
Text(label, style = WolfskinTokens.Type.button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.github.damontecres.wholphin.data.RestoredSession
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
|
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import org.justinvisser.wolfskin.design.ShellAction
|
||||||
|
import org.justinvisser.wolfskin.design.ShellPerson
|
||||||
|
import org.justinvisser.wolfskin.design.ShellState
|
||||||
|
|
||||||
|
/** Setup never clears the remembered household endpoint or login merely to show the picker. */
|
||||||
|
@HiltViewModel
|
||||||
|
class WolfskinSetupViewModel @Inject constructor(
|
||||||
|
private val repository: ServerRepository,
|
||||||
|
private val navigation: NavigationManager,
|
||||||
|
private val setup: SetupNavigationManager,
|
||||||
|
private val session: WolfskinSession,
|
||||||
|
private val conductarr: ConductarrSession,
|
||||||
|
) : ViewModel() {
|
||||||
|
internal enum class Stage { Loading, Unconfigured, Picker, Login, Settings, Unavailable }
|
||||||
|
|
||||||
|
internal var stage by mutableStateOf(Stage.Loading)
|
||||||
|
private set
|
||||||
|
internal var server by mutableStateOf<JellyfinServer?>(null)
|
||||||
|
private set
|
||||||
|
internal var picker by mutableStateOf<ShellState?>(null)
|
||||||
|
private set
|
||||||
|
internal var message by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
internal var busy by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
internal var pinUser by mutableStateOf<JellyfinUser?>(null)
|
||||||
|
private set
|
||||||
|
internal var loginUser by mutableStateOf<JellyfinUser?>(null)
|
||||||
|
private set
|
||||||
|
private var users: List<JellyfinUser> = emptyList()
|
||||||
|
internal var onAction: (ShellAction) -> Unit = {}
|
||||||
|
|
||||||
|
/** Recover Room's saved household server even if the old selector erased its preference. */
|
||||||
|
internal suspend fun load(hint: JellyfinServer?) = perform {
|
||||||
|
conductarr.close()
|
||||||
|
server = hint ?: repository.current.value?.server ?: when (val recent = repository.getMostRecentServer()) {
|
||||||
|
is RestoredSession.ServerOnly -> recent.server
|
||||||
|
is RestoredSession.Success -> recent.currentUser.server
|
||||||
|
RestoredSession.None -> withContext(Dispatchers.IO) {
|
||||||
|
repository.serverDao.getServers().maxWithOrNull(compareBy { it.server.lastUsed })?.server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val household = server
|
||||||
|
if (household == null) {
|
||||||
|
stage = Stage.Unconfigured
|
||||||
|
return@perform
|
||||||
|
}
|
||||||
|
users = withContext(Dispatchers.IO) { repository.serverDao.getServer(household.id)?.users.orEmpty() }
|
||||||
|
if (users.isEmpty()) {
|
||||||
|
users = withContext(Dispatchers.IO) {
|
||||||
|
repository.jellyfin.createApi(household.url).userApi.getPublicUsers().content.map {
|
||||||
|
JellyfinUser(id = it.id, name = it.name, serverId = household.id, accessToken = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (users.isEmpty()) {
|
||||||
|
showLogin(null)
|
||||||
|
} else {
|
||||||
|
val people = users.map { ShellPerson(it.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retry the startup server/profile recovery, never the failed saved-token request. */
|
||||||
|
internal suspend fun retry() {
|
||||||
|
if (busy) return
|
||||||
|
stage = Stage.Loading
|
||||||
|
load(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discovery and persistence use the same Jellyfin contract as upstream's add-server action. */
|
||||||
|
internal suspend fun connect(url: String) = perform {
|
||||||
|
val household = withContext(Dispatchers.IO) {
|
||||||
|
val candidate = repository.jellyfin.discovery.getRecommendedServers(url.trim())
|
||||||
|
.sortedBy { it.score }.firstOrNull { it.score != RecommendedServerInfoScore.BAD }
|
||||||
|
val info = candidate?.systemInfo?.getOrNull()
|
||||||
|
val id = info?.id?.toUUIDOrNull()
|
||||||
|
require(candidate != null && info != null && id != null && info.startupWizardCompleted == true) { "Could not connect to the household server. Check the URL and try again." }
|
||||||
|
JellyfinServer(id, info.serverName, candidate.address, info.version).also {
|
||||||
|
repository.addAndChangeServer(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server = household
|
||||||
|
setup.navigateTo(SetupDestination.UserList(household))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Protected saved users must authenticate before changeUser is allowed to run. */
|
||||||
|
internal suspend fun select(person: ShellPerson) {
|
||||||
|
picker?.pickerVisible = true
|
||||||
|
if (busy) return
|
||||||
|
val selected = users.firstOrNull { it.id.toString() == person.jellyfinUserId } ?: return
|
||||||
|
when {
|
||||||
|
selected.accessToken.isNullOrBlank() || selected.requireLogin -> showLogin(selected)
|
||||||
|
selected.hasPin -> pinUser = selected
|
||||||
|
else -> changeUser(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun showLogin(user: JellyfinUser?) {
|
||||||
|
pinUser = null
|
||||||
|
loginUser = user
|
||||||
|
message = null
|
||||||
|
stage = Stage.Login
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun dismissPin() { pinUser = null }
|
||||||
|
|
||||||
|
internal fun showSettings() {
|
||||||
|
picker?.pickerVisible = true
|
||||||
|
if (!busy) stage = Stage.Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun returnToPicker() {
|
||||||
|
if (busy || picker == null) return
|
||||||
|
picker?.pickerVisible = true
|
||||||
|
stage = Stage.Picker
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun enterPin(value: String) {
|
||||||
|
val selected = pinUser ?: return
|
||||||
|
if (value == selected.pin) {
|
||||||
|
pinUser = null
|
||||||
|
changeUser(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun changeUser(user: JellyfinUser) = perform {
|
||||||
|
try {
|
||||||
|
complete(repository.changeUser(requireNotNull(server), user))
|
||||||
|
} catch (exception: InvalidStatusException) {
|
||||||
|
if (exception.status != 401) throw exception
|
||||||
|
showLogin(user)
|
||||||
|
message = "Your saved sign-in expired. Sign in again to continue."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A password is sent only to the saved household server and never retained by this model. */
|
||||||
|
internal suspend fun login(username: String, password: String) = perform {
|
||||||
|
val household = requireNotNull(server)
|
||||||
|
val current = withContext(Dispatchers.IO) {
|
||||||
|
val result = repository.jellyfin.createApi(household.url).userApi
|
||||||
|
.authenticateUserByName(username = username, password = password).content
|
||||||
|
repository.changeUser(household.url, result, loginUser)
|
||||||
|
}
|
||||||
|
complete(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun complete(current: com.github.damontecres.wholphin.data.CurrentUser) {
|
||||||
|
if (ConductarrPreference.fixtures) {
|
||||||
|
session.state = null
|
||||||
|
} else {
|
||||||
|
val saved = users.filterNot { it.id == current.user.id } + current.user
|
||||||
|
val people = saved.map { ShellPerson(it.name.orEmpty().lowercase(), it.name.orEmpty(), it.name.orEmpty().take(1).uppercase(), false, it.id.toString()) }
|
||||||
|
session.state = ShellState(people, current.user.name.orEmpty().lowercase()) { session.onAction(it) }.apply {
|
||||||
|
pickerVisible = false
|
||||||
|
pickerLaunch = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.serverId = current.server.id.toString()
|
||||||
|
session.fixtureMode = ConductarrPreference.fixtures
|
||||||
|
navigation.reloadHome()
|
||||||
|
setup.navigateTo(SetupDestination.AppContent(current))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun perform(action: suspend () -> Unit) {
|
||||||
|
if (busy) return
|
||||||
|
busy = true
|
||||||
|
message = null
|
||||||
|
try {
|
||||||
|
action()
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is CancellationException) throw exception
|
||||||
|
message = if (exception is InvalidStatusException && exception.status == 401) "Invalid username or password. Try again."
|
||||||
|
else if (exception is InvalidStatusException && exception.status == 403) "This profile is not authorized. Choose another profile or sign in again."
|
||||||
|
else if (server == null && stage == Stage.Unconfigured) "Could not connect to the household server. Check the URL and try again."
|
||||||
|
else if (server == null) "Could not read the saved household server. Try again."
|
||||||
|
else "The household server didn’t answer. Try again or sign in with your server credentials."
|
||||||
|
if (stage != Stage.Login && stage != Stage.Unconfigured) stage = Stage.Unavailable
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,7 @@ class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Co
|
||||||
put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode)
|
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("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt)
|
||||||
put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId)
|
put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId)
|
||||||
|
put("seriesId", title.seriesId); put("lastPlayedAt", title.lastPlayedAt)
|
||||||
}) }
|
}) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,6 +60,7 @@ 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"),
|
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"),
|
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"),
|
List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"),
|
||||||
|
seriesId = string("seriesId"), lastPlayedAt = if (item.isNull("lastPlayedAt")) null else item.getLong("lastPlayedAt"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@ package org.justinvisser.wolfskin.tv.read
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.time.ZoneOffset
|
||||||
import javax.inject.Inject
|
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.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
|
|
@ -27,18 +32,39 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
||||||
suspend fun readWatch(userId: UUID): HomeLibrary {
|
suspend fun readWatch(userId: UUID): HomeLibrary {
|
||||||
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
|
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 nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
|
||||||
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id ->
|
val seriesIds = buildSet {
|
||||||
try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() }
|
resumeItems.forEach { it.seriesId?.let(::add) }
|
||||||
catch (exception: Exception) {
|
nextItems.forEach { it.seriesId?.let(::add) }
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
val series = coroutineScope {
|
||||||
|
val limiter = Semaphore(WATCH_READ_CONCURRENCY)
|
||||||
|
seriesIds.associateWith { id -> async { limiter.withPermit { readSeriesWatch(userId, id) } } }
|
||||||
|
.mapValues { it.value.await() }
|
||||||
}
|
}
|
||||||
val resume = resumeItems.map { title(it, series) }
|
val resume = resumeItems.map { title(it, series) }
|
||||||
val next = nextItems.map { title(it, series) }
|
val next = nextItems.map { title(it, series) }
|
||||||
return HomeLibrary(resume = resume, nextUp = next)
|
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. */
|
/** Library failures do not discard an already successful watch read. */
|
||||||
suspend fun readLibrary(userId: UUID): HomeLibrary {
|
suspend fun readLibrary(userId: UUID): HomeLibrary {
|
||||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
||||||
|
|
@ -66,7 +92,7 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
||||||
return readPlayback(api, userId, id)
|
return readPlayback(api, userId, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle {
|
private fun title(item: BaseItemDto, series: Map<UUID, SeriesWatch> = emptyMap()): HomeTitle {
|
||||||
val wrapped = BaseItem(item)
|
val wrapped = BaseItem(item)
|
||||||
return HomeTitle(
|
return HomeTitle(
|
||||||
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
|
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
|
||||||
|
|
@ -76,12 +102,18 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
||||||
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
|
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
|
||||||
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
|
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,
|
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true,
|
||||||
tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] else item.providerIds?.get("Tmdb")?.toIntOrNull(),
|
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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class SeriesWatch(val tmdbId: Int?, val lastPlayedAt: Long?)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TICKS_PER_SECOND = 10_000_000L
|
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)
|
private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,19 @@
|
||||||
|
|
||||||
## Who’s watching
|
## 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 still reaches it only from the final person; Up returns to that person.
|
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”.
|
||||||
|
|
||||||
## Focus visibility
|
## Focus visibility
|
||||||
|
|
||||||
|
|
@ -14,6 +26,23 @@ 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.
|
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 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.
|
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.
|
||||||
|
|
@ -54,6 +83,8 @@ 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.
|
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
|
## 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.
|
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.
|
||||||
|
|
@ -64,17 +95,20 @@ 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Initial hero/title copy | 180 ms, ease-out | 16 px rise (`copyEntryRise`); saved hero entry state suppresses replay on remount, not first appearance |
|
||||||
| 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 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 | 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 |
|
| 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 |
|
||||||
| 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 |
|
| 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 |
|
| 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 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.
|
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.
|
||||||
|
|
||||||
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen.
|
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. 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 series readiness band reserves **56 px (28 dp)** from its first content paint, including when empty or when readiness later disappears. Ratings remain inside the fixed-height facts band. The series header's measured height therefore cannot change when late ratings/readiness arrive; overview, actions, season and episode anchors retain their coordinates. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route.
|
The approved title follow-up (2026-11-07) supersedes the fixed readiness reservation: an absent readiness line occupies **0 px**, while a present line occupies **56 px (28 dp)**. Late arrival/removal animates that height over the **200 ms late-line duration with token ease-out**; reduced motion snaps. Overview, actions and following rows move monotonically with that band; an already-pinned focused row stays pinned through same-remeasure scroll compensation. Ratings remain inside the fixed-height facts band and appear once: an explicit episode page uses its opened episode's own set, including the episode score, rather than the series-only set. Series and episode pages have no redundant Seasons shortcut; Down reaches the season chips. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial 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.
|
||||||
|
|
||||||
Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range.
|
Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range.
|
||||||
|
|
||||||
|
|
@ -82,6 +116,8 @@ 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.
|
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
|
## Card metadata and bottom state line — revision 6, option B
|
||||||
|
|
||||||
|
|
@ -106,6 +142,12 @@ 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.
|
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.”
|
||||||
|
|
||||||
## Sources and verification
|
## Sources and verification
|
||||||
|
|
||||||
Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`.
|
Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`.
|
||||||
|
|
|
||||||
181
docker/README.md
181
docker/README.md
|
|
@ -200,6 +200,12 @@ execution order. Keep worker heap and parallelism unchanged between samples.
|
||||||
`PersonPickerSessionTest` must also pass late in the full app JVM, not just alone
|
`PersonPickerSessionTest` must also pass late in the full app JVM, not just alone
|
||||||
or promoted to the front by previous failures.
|
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
|
## Saved-person launch and DNS
|
||||||
|
|
||||||
Launch restores an unprotected saved Jellyfin login from local storage before
|
Launch restores an unprotected saved Jellyfin login from local storage before
|
||||||
|
|
@ -397,6 +403,12 @@ 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%.
|
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.
|
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
|
Settings groups hand off to existing upstream pages; Requests opens the household
|
||||||
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
|
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
|
||||||
rollout switch: turning it off renders upstream Home and library destinations with
|
rollout switch: turning it off renders upstream Home and library destinations with
|
||||||
|
|
@ -405,11 +417,62 @@ 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.
|
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.
|
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
|
Saved Jellyfin identities use the existing upstream session. Conductarr supplies
|
||||||
household labels, requester capabilities and library audiences after sign-in.
|
household labels, requester capabilities and library audiences after sign-in.
|
||||||
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
|
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
|
||||||
only when the Advanced debug fixture switch is enabled; release excludes it.
|
only when the Advanced debug fixture switch is enabled; release excludes it.
|
||||||
|
|
||||||
|
### Household setup and sign-in recovery
|
||||||
|
|
||||||
|
With **Wolfskin screens** enabled, `MainContent` intercepts both upstream setup
|
||||||
|
destinations before their stock server/user selectors can render. Cold start,
|
||||||
|
restart with automatic sign-in disabled, the old drawer's user action, legacy
|
||||||
|
sign-out-equivalent state, setup fallbacks and intent failures all use Wolfskin's
|
||||||
|
own picker. Entering it does not call `SwitchUserViewModel.init` or clear the
|
||||||
|
remembered server/user preference.
|
||||||
|
|
||||||
|
Setup reuses the supplied/current/recent household server, recovering the most
|
||||||
|
recently used Room server if the legacy picker erased the preference. Saved people
|
||||||
|
render without a network request. Only an install with no saved server gets the
|
||||||
|
one-time Jellyfin URL field; it uses existing Jellyfin discovery and Room
|
||||||
|
persistence, not a new compiled endpoint. Conductarr remains on its existing
|
||||||
|
configured URL and enriches the signed-in shell as before.
|
||||||
|
|
||||||
|
Selecting a protected profile still requires its PIN or server credentials.
|
||||||
|
Expired credentials during saved-session revalidation or foreground refresh
|
||||||
|
return to the picker without allowing a late failure to replace another person's
|
||||||
|
session. A failed selection offers Wolfskin **Try again** and **Sign in** actions;
|
||||||
|
hidden/unsaved profiles can use username/password sign-in through the setup
|
||||||
|
picker's Settings gear. An outage never replaces the saved household endpoint.
|
||||||
|
|
||||||
|
Turning **Wolfskin screens** off retains the unchanged upstream
|
||||||
|
`SwitchServerContent` and `SwitchUserContent` setup screens for rollback. Debug
|
||||||
|
fixtures still seed the existing fixture picker after authentication.
|
||||||
|
|
||||||
Person selection immediately clears the old Conductarr identity and Jellyfin
|
Person selection immediately clears the old Conductarr identity and Jellyfin
|
||||||
read gate before saved-login I/O. Detached clients are disposed exactly once on
|
read gate before saved-login I/O. Detached clients are disposed exactly once on
|
||||||
the application's IO scope, after the replacement (or signed-out) identity is
|
the application's IO scope, after the replacement (or signed-out) identity is
|
||||||
|
|
@ -486,21 +549,26 @@ 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
|
hero rather than activate the nav. Fixture completion does not steal a different
|
||||||
destination or nav selection.
|
destination or nav selection.
|
||||||
|
|
||||||
Native Roborazzi captures use Robolectric SDK 34 and
|
Native Roborazzi captures normally use Robolectric SDK 34 and
|
||||||
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and
|
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). System-Back smokes also run SDK 36.
|
||||||
the sampled Compose nav-spring curve to `build/wolfskin-captures/`.
|
`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/`
|
Home, Library and Discover captures use `home/`, `library/` and `discover/`
|
||||||
subdirectories so shared spec state numbers cannot overwrite another renderer's image.
|
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.
|
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
|
The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a
|
||||||
separate press-count path.
|
separate press-count path.
|
||||||
|
|
||||||
Robolectric SDK 28/30/34 runtimes resolve through Gradle's existing persistent
|
Robolectric SDK 28/30/34/36 runtimes resolve through Gradle's existing persistent
|
||||||
dependency cache, then copy to `wolfskin-design/build/robolectric-sdk/`. Test workers
|
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
|
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
|
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
|
hosts can seed the existing cache from SHA512-verified local Maven artifacts without
|
||||||
changing the wrapper or adding volumes.
|
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
|
Default-flavour launcher artwork and English, Hungarian and Polish string overrides
|
||||||
live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated
|
live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated
|
||||||
|
|
@ -515,7 +583,10 @@ attribution and project URL. The check does not depend on incremental merge file
|
||||||
## Wolfskin Home
|
## Wolfskin Home
|
||||||
|
|
||||||
Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero,
|
Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero,
|
||||||
Jellyfin shelves, per-destination "Your …" shelves and the Discover continuation.
|
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.
|
||||||
The final action opens the real merged Discover screen at its top through the
|
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
|
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
|
the complete underlying list rather than the visible shelf window. Title content
|
||||||
|
|
@ -524,27 +595,45 @@ remains the later-slice target.
|
||||||
All Home components and immutable screen models live in `:wolfskin-design`.
|
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
|
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.
|
actual horizontal `LazyListState`, and the bounded six-hour ratings session cache.
|
||||||
Composing destinations bind `rememberedView(route)` before navigation effects run, so a
|
Every `NavEntry` installs `ScreenEntry` with its own `rememberedView(route)` and
|
||||||
new See all grid cannot borrow or clamp Home's scroller. Re-entry restores the
|
active state before navigation effects run. Home, Library, Discover, title and
|
||||||
remembered track before the page with `scrollToItem`, preserving exact indices and
|
person surfaces consume that entry's viewport; an outgoing page cannot borrow
|
||||||
pixel offsets rather than animating back. Android focus stays with the shell;
|
Home's scroller during Back. Only the active entry registers logical key handling
|
||||||
cards use its logical focus, without requests against uncomposed lazy items.
|
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.
|
||||||
Back preserves the viewport; returning to Home does not re-run focus-follow over
|
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
|
a manually scrolled saved view. The next arrow re-enters visible content when
|
||||||
scrolling moved focus off-screen.
|
scrolling moved focus off-screen.
|
||||||
Pointing changes focus without activation, cards activate on key-up, and held
|
Pointing changes focus without activation, cards activate on key-up, and held
|
||||||
OK events cannot activate twice.
|
OK events cannot activate twice.
|
||||||
Between shelves, an unvisited row selects the nearest visible card center even
|
Between Home shelves, an unvisited row starts at its leftmost card. A visited row
|
||||||
when card widths differ. A visited row restores its column. Horizontal movement
|
restores its column and first-visible position, including after leaving and
|
||||||
keeps the track still while focus remains in its visible middle.
|
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.
|
||||||
The initial skeleton parks focus on the nav. When the first content arrives,
|
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
|
an untouched fresh entry focuses hero button zero; intervening nav input prevents
|
||||||
that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus`
|
that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus`
|
||||||
release/repeat guard and Back ladder.
|
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
|
`HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up
|
||||||
publish independently of slow or failed library shelves; saved metadata still
|
publish independently of slow or failed library shelves; saved metadata still
|
||||||
paints immediately, but a saved hero offers Details until watch data revalidates.
|
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
|
Home and title Play actions resolve the current Jellyfin item and its current
|
||||||
checkpoint at the click, preserving millisecond positions. Series actions use
|
checkpoint at the click, preserving millisecond positions. Series actions use
|
||||||
live NextUp with resumable episodes enabled; an explicitly opened episode stays
|
live NextUp with resumable episodes enabled; an explicitly opened episode stays
|
||||||
|
|
@ -806,24 +895,31 @@ The [revision-5 amendment](../data/tv-spec/spec.md) and
|
||||||
[native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html)
|
[native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html)
|
||||||
retain the approved frames except for the requested feedback changes:
|
retain the approved frames except for the requested feedback changes:
|
||||||
|
|
||||||
- The picker gear is centred under everyone and uses the picker-person ring.
|
- 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.
|
||||||
- Shelf tracks share second-last-slot lookahead, count clamping and settled
|
- Shelf tracks share second-last-slot lookahead, count clamping and settled
|
||||||
offsets. Home records completed frame positioning rather than an animation
|
offsets. Home records completed frame positioning rather than an animation
|
||||||
that may still be cancelled; first mount restores the intended shelf item.
|
that may still be cancelled; first mount restores the intended shelf item.
|
||||||
Discover uses all six approved poster slots. Person keeps the final column
|
Discover uses all six approved poster slots. Person keeps the final column
|
||||||
at full poster width.
|
at full poster width.
|
||||||
- Episode images choose episode Primary, then Thumb, then the already-read
|
- Episode images choose episode Primary, then Thumb, then the already-read series Backdrop.
|
||||||
series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance.
|
Title episode cards now use 448×280 px, preserving the taller approved crop.
|
||||||
Episode context uses the episode overview, with two summary lines on the
|
Focused summaries ellipsise to one line, the show-name overlay is absent, and
|
||||||
focused card. Held-series headers measure their copy/actions and leave 40 px
|
`S1 E3 · duration` plus the watched check share one line.
|
||||||
before seasons instead of reserving a fixed empty 900 px header.
|
Ready title headers measure their copy/actions and leave 40 px before the
|
||||||
|
following section. Missing readiness lines collapse completely on every title
|
||||||
|
kind; late lines expand/collapse over the 200 ms late-line token with ease-out,
|
||||||
|
or snap with reduced motion. Already-pinned rows compensate in the same
|
||||||
|
remeasure rather than bouncing through a second focus-follow animation.
|
||||||
|
Poster-only movies floor that stack so Cast circles clear the poster by at
|
||||||
|
least 20 px, including held movie fallbacks and short overviews.
|
||||||
- Every Discover heading uses a 104 px band, including a 60 px title-to-card
|
- Every Discover heading uses a 104 px band, including a 60 px title-to-card
|
||||||
gap. Home heading geometry is unchanged.
|
gap. Home heading geometry is unchanged.
|
||||||
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
|
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
|
||||||
420×630. The shared request builder replaces conflicting Jellyfin size/encoding
|
420×630. The shared request builder replaces conflicting Jellyfin size/encoding
|
||||||
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG
|
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG
|
||||||
quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and
|
quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and
|
||||||
400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
|
448×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
|
||||||
authentication query parameters remain intact. TMDB uses w342 for posters and
|
authentication query parameters remain intact. TMDB uses w342 for posters and
|
||||||
portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
|
portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
|
||||||
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
|
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
|
||||||
|
|
@ -875,6 +971,36 @@ pop a pushed title/person from content **or** nav. Initial loading has no conten
|
||||||
target: Down stays in nav, while Back can still leave the title. A missing rating
|
target: Down stays in nav, while Back can still leave the title. A missing rating
|
||||||
set emits no layout node, so it cannot reserve an invisible extra facts gap.
|
set emits no layout node, so it cannot reserve an invisible extra facts gap.
|
||||||
|
|
||||||
|
The title Menu replaces the old More/media-info icon action and contains Media
|
||||||
|
info plus Add for someone when requests are permitted. It is shared by held
|
||||||
|
movie, series and episode pages; non-held pages expose Add for someone in the
|
||||||
|
same menu while Request/status and Not interested/Show again stay in the row.
|
||||||
|
Version, tracks, watched and trailer controls keep their existing positions.
|
||||||
|
Back from Media info returns to the menu; Back from the menu restores its
|
||||||
|
opening button. The compact menu fades in for 160 ms and out for 100 ms using
|
||||||
|
the motion ease-out token, with reduced-motion fades capped at 80 ms. Only the
|
||||||
|
bounded menu panel owns an alpha layer; the scrim is static. Opening another
|
||||||
|
sheet immediately removes the menu panel and scrim, preventing double darkening.
|
||||||
|
An empty menu closes if its last permitted action disappears. Controls
|
||||||
|
use the shared 4 px straddle focus ring. Neither series nor episode details have
|
||||||
|
a redundant Seasons shortcut; Down reaches the season chips directly. Ratings
|
||||||
|
appear only in the header facts line, not again beside progress. Explicit episode
|
||||||
|
pages use the opened episode's own set, including its `Ep` score; movie and series
|
||||||
|
pages keep their own sets.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
|
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
|
||||||
person credits and playback mutations to one boundary. `ConductarrTitlePages`
|
person credits and playback mutations to one boundary. `ConductarrTitlePages`
|
||||||
adds real detail, cast, related titles, performer/filmography, readiness/receipts
|
adds real detail, cast, related titles, performer/filmography, readiness/receipts
|
||||||
|
|
@ -884,6 +1010,16 @@ votes enrich those real episode identities; no catalogue stub becomes playable.
|
||||||
Debug-only `wolfskin-titles.json` and local artwork remain behind the explicit
|
Debug-only `wolfskin-titles.json` and local artwork remain behind the explicit
|
||||||
Advanced fixture switch; release never packages or selects them.
|
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
|
Held trailers use Jellyfin remote/local trailer data. YouTube URLs target an
|
||||||
installed YouTube handler such as SmartTube, never a browser. No handler leaves
|
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
|
the title visible with a calm message. Titles nobody holds have no Trailer
|
||||||
|
|
@ -899,7 +1035,10 @@ headers, sheets, cast and poster captions without changing shared type tokens.
|
||||||
Fractional stack placement rounds only the accumulated position, preventing a
|
Fractional stack placement rounds only the accumulated position, preventing a
|
||||||
one-pixel-per-row drift. Focused controls share the canonical lift shadow.
|
one-pixel-per-row drift. Focused controls share the canonical lift shadow.
|
||||||
`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row
|
`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row
|
||||||
entry, remembered columns, long-sheet visibility and full media-table geometry.
|
entry, remembered columns and long-sheet visibility. `TitleRound2Test` covers
|
||||||
|
menu Back/focus, preserved media info and household requests, movie stacking,
|
||||||
|
the Mussolini resume example, request revocation, episode skeleton geometry,
|
||||||
|
and native menu fade frames sampled in 16 ms steps.
|
||||||
`TitleAsyncFocusTest` delays title and person replies through the real destination
|
`TitleAsyncFocusTest` delays title and person replies through the real destination
|
||||||
and view-model, then exercises content focus and hardware Back. Subtitles Off
|
and view-model, then exercises content focus and hardware Back. Subtitles Off
|
||||||
maps to the player's explicit disabled sentinel, not its unspecified default.
|
maps to the player's explicit disabled sentinel, not its unspecified default.
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ val cacheRobolectricSdk by tasks.registering(Copy::class) {
|
||||||
"9-robolectric-4913185-2-i7",
|
"9-robolectric-4913185-2-i7",
|
||||||
"11-robolectric-6757853-i7",
|
"11-robolectric-6757853-i7",
|
||||||
"14-robolectric-10818077-i7",
|
"14-robolectric-10818077-i7",
|
||||||
|
"16-robolectric-13921718-i7",
|
||||||
)
|
)
|
||||||
// One configuration would evict the older SDKs through version conflict resolution.
|
// One configuration would evict the older SDKs through version conflict resolution.
|
||||||
from(versions.map { version ->
|
from(versions.map { version ->
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,9 @@
|
||||||
},
|
},
|
||||||
"cardOverlayTop": {
|
"cardOverlayTop": {
|
||||||
"$type": "dimension",
|
"$type": "dimension",
|
||||||
"px": 54,
|
"px": 36,
|
||||||
"dp": 27.0,
|
"dp": 18.0,
|
||||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
"source": "TV round 2: lower card bottom gradient; cover the state-line reserve"
|
||||||
},
|
},
|
||||||
"cardOverlayBottom": {
|
"cardOverlayBottom": {
|
||||||
"$type": "dimension",
|
"$type": "dimension",
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,8 @@ class HomeFixture(context: Context) {
|
||||||
resumeSeconds = item.optDouble("resumeSeconds", 0.0).takeIf { it.isFinite() } ?: 0.0,
|
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),
|
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"),
|
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<HomeRating> {
|
private fun ratings(item: JSONObject): List<HomeRating> {
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,8 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
||||||
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
|
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), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
||||||
itemsIndexed(visibleRows,
|
val keys = visibleRows.map { it.id }.uniqueKeys()
|
||||||
key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item ->
|
itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { _, item ->
|
||||||
ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
|
ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
|
||||||
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
|
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
|
||||||
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
|
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
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.Discover as D
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
|
||||||
|
|
@ -50,13 +52,14 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
|
||||||
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
|
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
|
||||||
}) else null }
|
}) else null }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val request = remember(context, path, poster, dim) {
|
val heroReady = LocalHeroArtReady.current
|
||||||
|
val request = remember(context, path, poster, dim, heroReady != null) {
|
||||||
val upright = poster || dim
|
val upright = poster || dim
|
||||||
WolfskinImages.request(context, path,
|
WolfskinImages.request(context, path,
|
||||||
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
|
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
|
||||||
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
|
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) {
|
if (painter != null) {
|
||||||
androidx.compose.runtime.SideEffect { heroReady?.invoke() }
|
androidx.compose.runtime.SideEffect { heroReady?.invoke() }
|
||||||
Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -44,12 +46,17 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
@Composable
|
@Composable
|
||||||
fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
val shell = state.shell
|
val shell = state.shell
|
||||||
|
val view = screenView(shell)
|
||||||
|
val entryActive = LocalScreenActive.current
|
||||||
val focus = remember(state) { object : ShellContentFocus by state {} }
|
val focus = remember(state) { object : ShellContentFocus by state {} }
|
||||||
DisposableEffect(focus) {
|
DisposableEffect(focus, entryActive) {
|
||||||
shell.contentFocus = focus
|
if (entryActive) shell.contentFocus = focus
|
||||||
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } }
|
onDispose {
|
||||||
|
if (shell.tabs.getOrNull(shell.currentTab) != "discover") state.resetLaneMemory()
|
||||||
|
if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false }
|
||||||
}
|
}
|
||||||
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt }
|
}
|
||||||
|
LaunchedEffect(view, state.snapshot.savedAt) { view.savedAt = state.snapshot.savedAt }
|
||||||
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
|
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 focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
|
||||||
val ratingsReader = LocalScreenRatings.current
|
val ratingsReader = LocalScreenRatings.current
|
||||||
|
|
@ -78,7 +85,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val list = shell.view.listState
|
val list = view.listState
|
||||||
val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)
|
val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)
|
||||||
val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) {
|
val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) {
|
||||||
buildList {
|
buildList {
|
||||||
|
|
@ -103,6 +110,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset)
|
animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
HeroMotionScope {
|
||||||
Box(modifier.fillMaxSize().testTag("discover-screen")) {
|
Box(modifier.fillMaxSize().testTag("discover-screen")) {
|
||||||
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { title ->
|
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) }
|
HeroArtCrossfade(title.key, title, L.row_phero, Modifier.offset { IntOffset(0, -list.firstVisibleItemScrollOffset) }) { DiscoverBackdrop(it) }
|
||||||
|
|
@ -130,12 +138,14 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiscoverHero(state: DiscoverState) {
|
private fun DiscoverHero(state: DiscoverState) {
|
||||||
val title = state.hero ?: return
|
val title = state.hero ?: return
|
||||||
HeroCopyHandoff(title.key, title) { copy ->
|
|
||||||
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
|
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
|
||||||
|
HeroCopyHandoff(title.key, title) { copy ->
|
||||||
|
Column {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
|
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)
|
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
|
||||||
|
|
@ -151,17 +161,32 @@ private fun DiscoverHero(state: DiscoverState) {
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(D.heroMetaGap))
|
Spacer(Modifier.height(D.heroMetaGap))
|
||||||
Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(D.heroOverviewGap))
|
Spacer(Modifier.height(D.heroOverviewGap))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
|
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) {
|
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))
|
Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
|
||||||
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)) }
|
state.heroes.forEachIndexed { index, _ -> DiscoverHeroDot(index, index == state.slide) }
|
||||||
Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
|
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
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -40,14 +40,14 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
||||||
val height = if (services) K.entryH else K.posterH
|
val height = if (services) K.entryH else K.posterH
|
||||||
val cards = rememberLazyListState()
|
val cards = rememberLazyListState()
|
||||||
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
|
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
|
||||||
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0))
|
val column = state.laneColumn(lane.id).coerceIn(0, (count - 1).coerceAtLeast(0))
|
||||||
val active = state.contentFocused && state.focusedRow == row
|
val active = state.contentFocused && state.focusedRow == row
|
||||||
val slots = K.posterSlots
|
val slots = K.posterSlots
|
||||||
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
|
val first = state.laneFirsts[lane.id] ?: 0
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
LaunchedEffect(column, count, active) {
|
LaunchedEffect(state.service?.id, column, count, active) {
|
||||||
val target = shelfFirst(column, first, slots, count)
|
val target = shelfFirst(column, first, slots, count)
|
||||||
state.shell.view.cards["first:${lane.id}"] = target
|
state.laneFirsts[lane.id] = target
|
||||||
cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() })
|
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}")) {
|
Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
/** One person owns a Discover view; the shell retains its root list and card memory. */
|
/** Each person retains the root viewport; lane movement belongs only to this Discover visit. */
|
||||||
class DiscoverState(
|
class DiscoverState(
|
||||||
val shell: ShellState,
|
val shell: ShellState,
|
||||||
initial: DiscoverSnapshot,
|
initial: DiscoverSnapshot,
|
||||||
|
|
@ -75,6 +75,20 @@ class DiscoverState(
|
||||||
private val pendingDismissals = mutableMapOf<String, DiscoverTitle?>()
|
private val pendingDismissals = mutableMapOf<String, DiscoverTitle?>()
|
||||||
private var rootFilter = "all"
|
private var rootFilter = "all"
|
||||||
|
|
||||||
|
private val rootLaneMemory = DiscoverLaneMemory()
|
||||||
|
private val serviceLaneMemory = mutableMapOf<Long, DiscoverLaneMemory>()
|
||||||
|
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) {
|
fun updateSnapshot(value: DiscoverSnapshot) {
|
||||||
snapshot = value
|
snapshot = value
|
||||||
if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime()
|
if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime()
|
||||||
|
|
@ -139,7 +153,7 @@ class DiscoverState(
|
||||||
val rowCount: Int get() = firstShelf + lanes.size
|
val rowCount: Int get() = firstShelf + lanes.size
|
||||||
val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0))
|
val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0))
|
||||||
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
|
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
|
||||||
val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn())
|
val column: Int get() = (if (focusedRow >= firstShelf) laneColumn(rowKey) else shell.view.cards[rowKey].orZero()).coerceAtMost(maxColumn())
|
||||||
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
|
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 isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true
|
||||||
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
|
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
|
||||||
|
|
@ -152,7 +166,8 @@ class DiscoverState(
|
||||||
|
|
||||||
fun focus(row: Int, column: Int = 0) {
|
fun focus(row: Int, column: Int = 0) {
|
||||||
shell.navFocused = false; shell.view.row = row
|
shell.navFocused = false; shell.view.row = row
|
||||||
shell.view.cards[rowKey] = column.coerceIn(0, maxColumn())
|
val selected = column.coerceIn(0, maxColumn())
|
||||||
|
if (focusedRow >= firstShelf) laneColumns[rowKey] = selected else shell.view.cards[rowKey] = selected
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun moveRow(direction: Int) {
|
private fun moveRow(direction: Int) {
|
||||||
|
|
@ -161,10 +176,7 @@ class DiscoverState(
|
||||||
if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return }
|
if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return }
|
||||||
if (target >= rowCount) return
|
if (target >= rowCount) return
|
||||||
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }
|
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }
|
||||||
val nearest = if (focusedRow >= firstShelf && target >= firstShelf)
|
focus(target, if (target >= firstShelf) laneColumn(key) else shell.view.cards[key].orZero())
|
||||||
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) {
|
fun openTitle(item: DiscoverTitle) {
|
||||||
|
|
@ -225,12 +237,14 @@ class DiscoverState(
|
||||||
fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item)
|
fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item)
|
||||||
|
|
||||||
fun openService(item: DiscoverService) {
|
fun openService(item: DiscoverService) {
|
||||||
|
serviceLaneMemory.getOrPut(item.id) { DiscoverLaneMemory() }
|
||||||
rootFilter = filter; service = item; filter = "movie"; revision++
|
rootFilter = filter; service = item; filter = "movie"; revision++
|
||||||
onPush("discover-service/${item.id}")
|
onPush("discover-service/${item.id}")
|
||||||
focus(firstShelf)
|
focus(firstShelf)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun enterRoute(route: String) {
|
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 }
|
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
|
else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null
|
||||||
}
|
}
|
||||||
|
|
@ -261,6 +275,7 @@ class DiscoverState(
|
||||||
ShellKey.Left, ShellKey.Right -> {
|
ShellKey.Left, ShellKey.Right -> {
|
||||||
val direction = if (key == ShellKey.Left) -1 else 1
|
val direction = if (key == ShellKey.Left) -1 else 1
|
||||||
if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0))
|
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())
|
else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn())
|
||||||
}
|
}
|
||||||
ShellKey.Ok -> when {
|
ShellKey.Ok -> when {
|
||||||
|
|
@ -417,6 +432,12 @@ class DiscoverState(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class DiscoverLaneMemory {
|
||||||
|
val columns = mutableStateMapOf<String, Int>()
|
||||||
|
val firsts = mutableStateMapOf<String, Int>()
|
||||||
|
fun clear() { columns.clear(); firsts.clear() }
|
||||||
|
}
|
||||||
|
|
||||||
private fun Int?.orZero(): Int = this ?: 0
|
private fun Int?.orZero(): Int = this ?: 0
|
||||||
|
|
||||||
private val DISCOVER_FILTERS = listOf("all", "movie", "tv")
|
private val DISCOVER_FILTERS = listOf("all", "movie", "tv")
|
||||||
|
|
|
||||||
|
|
@ -6,95 +6,156 @@ import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.requiredSize
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
import androidx.compose.foundation.layout.wrapContentSize
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
import androidx.compose.ui.semantics.SemanticsPropertyKey
|
||||||
import kotlinx.coroutines.launch
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import kotlinx.coroutines.coroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
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 org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||||
|
|
||||||
/** Artwork success or its final fallback releases a pending hero hand-off. */
|
/** Artwork success or its final fallback releases a pending hero hand-off. */
|
||||||
internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null }
|
internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null }
|
||||||
|
internal val HeroLayerAlpha = SemanticsPropertyKey<Float>("HeroLayerAlpha")
|
||||||
|
internal val HeroLayerTranslation = SemanticsPropertyKey<Float>("HeroLayerTranslation")
|
||||||
|
internal val HeroLayerIdentity = SemanticsPropertyKey<String>("HeroLayerIdentity")
|
||||||
|
|
||||||
/** Only the upper hero region blends; the lower backdrop changes without an animated screen layer. */
|
private class HeroPresentation {
|
||||||
|
var identity by mutableStateOf<Any?>(null)
|
||||||
|
}
|
||||||
|
private val LocalHeroPresentation = compositionLocalOf<HeroPresentation?> { 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<T>(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<T>(val identity: Any, var target: T)
|
||||||
|
|
||||||
|
/** Keep the painted composite while loading; interruptions fade each retained layer from its current alpha. */
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
|
fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
|
||||||
var displayed by remember { mutableStateOf(target) }
|
val initial = remember { HeroLayer(identity, target, 1f).apply { presented = true } }
|
||||||
var displayedIdentity by remember { mutableStateOf(identity) }
|
val layers = remember { mutableStateListOf(initial) }
|
||||||
var outgoing by remember { mutableStateOf<T?>(null) }
|
val selected = remember(identity) { layers.firstOrNull { it.identity == identity && it.presented && !it.outgoing } ?: HeroLayer(identity, target, 1f) }
|
||||||
val alpha = remember { Animatable(0f) }
|
|
||||||
val reduced = reducedMotion()
|
val reduced = reducedMotion()
|
||||||
var readyIdentity by remember { mutableStateOf(identity) }
|
val presentation = LocalHeroPresentation.current
|
||||||
val latestIdentity by rememberUpdatedState(identity)
|
LaunchedEffect(selected, reduced) {
|
||||||
val onReady = remember(identity) { { if (latestIdentity == identity) readyIdentity = identity } }
|
layers.removeAll { it !== selected && !it.presented }
|
||||||
LaunchedEffect(identity, reduced) {
|
if (selected !in layers) layers.add(0, selected)
|
||||||
if (displayedIdentity != identity) {
|
snapshotFlow { selected.ready }.first { it }
|
||||||
outgoing = displayed
|
selected.presented = true
|
||||||
alpha.snapTo(1f)
|
presentation?.identity = identity
|
||||||
snapshotFlow { readyIdentity == identity }.first { it }
|
layers.forEach { if (it !== selected) it.outgoing = true }
|
||||||
displayed = target
|
|
||||||
displayedIdentity = identity
|
|
||||||
}
|
|
||||||
if (outgoing != null) {
|
|
||||||
alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut))
|
|
||||||
outgoing = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Box(modifier) {
|
Box(modifier) {
|
||||||
CompositionLocalProvider(LocalHeroArtReady provides onReady) { content(target) }
|
layers.forEach { layer -> key(layer) {
|
||||||
outgoing?.let { old ->
|
SideEffect { if (layer.identity == identity) layer.target = target }
|
||||||
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height).graphicsLayer { this.alpha = alpha.value; clip = true }.testTag("hero-outgoing")) {
|
LaunchedEffect(layer.outgoing, reduced) {
|
||||||
Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { content(old) }
|
if (layer.outgoing) {
|
||||||
|
layer.alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut))
|
||||||
|
layers.remove(layer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 bounded copy layer fades out, hands off, then rises; controls keep their logical owner. */
|
/** One copy slot fades through after artwork readiness; no two titles can be painted together. */
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
|
fun <T> HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
|
||||||
val reduced = reducedMotion()
|
var entered by rememberSaveable { mutableStateOf(false) }
|
||||||
val alpha = remember { Animatable(0f) }
|
val selected = remember(identity) { HeroCopyTarget(identity, target) }
|
||||||
val progress = remember { Animatable(0f) }
|
val currentSelected by rememberUpdatedState(selected)
|
||||||
var displayed by remember { mutableStateOf(target) }
|
var displayed by remember { mutableStateOf(selected) }
|
||||||
var previous by remember { mutableStateOf(identity) }
|
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 handingOff by remember { mutableStateOf(false) }
|
||||||
var entered by remember { mutableStateOf(false) }
|
var outgoing by remember { mutableStateOf(false) }
|
||||||
val rise = with(androidx.compose.ui.platform.LocalDensity.current) { (if (entered) M.copyRise else M.copyEntryRise).toPx() }
|
val reduced = reducedMotion()
|
||||||
LaunchedEffect(identity, reduced) {
|
val presentation = LocalHeroPresentation.current
|
||||||
if (previous != identity || handingOff) {
|
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
|
handingOff = true
|
||||||
|
outgoing = true
|
||||||
|
coroutineScope {
|
||||||
|
val fade = if (alpha.value > 0f) launch {
|
||||||
alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease))
|
alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease))
|
||||||
displayed = target
|
} else null
|
||||||
previous = identity
|
if (!reduced) kotlinx.coroutines.delay(M.heroCopyDelay.toLong())
|
||||||
|
fade?.join()
|
||||||
|
}
|
||||||
|
outgoing = false
|
||||||
|
displayed = next
|
||||||
|
initialEntry = false
|
||||||
progress.snapTo(0f)
|
progress.snapTo(0f)
|
||||||
if (!reduced) kotlinx.coroutines.delay((M.heroCopyDelay - M.heroCopyOut).toLong())
|
}
|
||||||
kotlinx.coroutines.coroutineScope {
|
if (alpha.value != 1f || progress.value != 1f) coroutineScope {
|
||||||
launch { alpha.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
|
val duration = motionDuration(if (initialEntry) M.rowEnter else M.heroCopyIn, reduced)
|
||||||
launch { progress.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
|
launch { alpha.animateTo(1f, tween(duration, easing = M.easeOut)) }
|
||||||
|
launch { progress.animateTo(1f, tween(duration, easing = M.easeOut)) }
|
||||||
}
|
}
|
||||||
handingOff = false
|
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
|
Box(modifier.graphicsLayer {
|
||||||
displayed = target
|
this.alpha = alpha.value
|
||||||
}
|
translationY = if (reduced) 0f else rise * (1f - progress.value)
|
||||||
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 {
|
||||||
content(if (handingOff || previous != identity) displayed else target)
|
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) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import kotlin.math.abs
|
|
||||||
|
|
||||||
/** Home navigation mutates the shell's per-person view, including each row's remembered column. */
|
/** Home navigation mutates the shell's per-person view, including each row's remembered column. */
|
||||||
class HomeFocus(
|
class HomeFocus(
|
||||||
private val shell: ShellState,
|
private val shell: ShellState,
|
||||||
|
|
@ -38,8 +36,7 @@ class HomeFocus(
|
||||||
while (next in rows.indices && !rows[next].focusable) next += direction
|
while (next in rows.indices && !rows[next].focusable) next += direction
|
||||||
if (next in rows.indices) {
|
if (next in rows.indices) {
|
||||||
val target = rows[next]
|
val target = rows[next]
|
||||||
val nearest = nearest(current, target)
|
focus(next, view.cards[target.id] ?: 0)
|
||||||
focus(next, view.cards[target.id] ?: nearest)
|
|
||||||
} else if (direction < 0) {
|
} else if (direction < 0) {
|
||||||
view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
|
view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
|
||||||
view.home.resetScroll++
|
view.home.resetScroll++
|
||||||
|
|
@ -77,27 +74,6 @@ class HomeFocus(
|
||||||
else -> Unit
|
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) {
|
internal val HomeRow.focusSlots: Int get() = when (cardKind) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
@ -19,6 +20,7 @@ import org.justinvisser.wolfskin.design.HomeType as T
|
||||||
/** The hero occupies one row while its backdrop extends beneath the first shelves. */
|
/** The hero occupies one row while its backdrop extends beneath the first shelves. */
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
|
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
|
||||||
|
HeroMotionScope {
|
||||||
Box(Modifier.fillMaxWidth().height(L.row_hero)) {
|
Box(Modifier.fillMaxWidth().height(L.row_hero)) {
|
||||||
if (!row.skeleton) {
|
if (!row.skeleton) {
|
||||||
val title = row.cards.first()
|
val title = row.cards.first()
|
||||||
|
|
@ -28,7 +30,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
||||||
}
|
}
|
||||||
Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
|
Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
|
||||||
Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f))))
|
Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f))))
|
||||||
HeroCopyHandoff(title.id, title, Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) { copy ->
|
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) {
|
||||||
|
HeroCopyHandoff(title.id, title) { copy ->
|
||||||
Column {
|
Column {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) {
|
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)))
|
Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f)))
|
||||||
|
|
@ -44,13 +47,25 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(H.factsBottom))
|
Spacer(Modifier.height(H.factsBottom))
|
||||||
Text(copy.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
Text(copy.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(H.overviewBottom))
|
Spacer(Modifier.height(H.overviewBottom))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
val detailsOnly = row.heroKind == "pick" || !row.watchReady
|
val detailsOnly = row.heroKind == "pick"
|
||||||
val primary = if (detailsOnly) "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
if (!detailsOnly) Layout(
|
||||||
PickerButton(primary, focused && column == 0, true, if (detailsOnly) "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
|
modifier = if (row.watchReady) Modifier.lateLineFade(title.id) else Modifier,
|
||||||
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
content = {
|
||||||
|
val primary = (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
||||||
|
PickerButton(primary, focused && row.watchReady && column == 0, true, "playback",
|
||||||
|
onFocus = { if (row.watchReady) onHover(0) }, textStyle = T.button) { if (row.watchReady) onButton(0) }
|
||||||
|
},
|
||||||
|
) { measurables, constraints ->
|
||||||
|
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -65,6 +80,7 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Discover continues in the same scroller, under the active person's identity. */
|
/** Discover continues in the same scroller, under the active person's identity. */
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ data class HomeTitle(
|
||||||
val held: Boolean = false,
|
val held: Boolean = false,
|
||||||
val otherOwner: String? = null,
|
val otherOwner: String? = null,
|
||||||
val tmdbId: Int? = null,
|
val tmdbId: Int? = null,
|
||||||
|
val seriesId: String? = null,
|
||||||
|
val lastPlayedAt: Long? = null,
|
||||||
) {
|
) {
|
||||||
val isEpisode: Boolean get() = type == "episode"
|
val isEpisode: Boolean get() = type == "episode"
|
||||||
val displayTitle: String get() = seriesTitle ?: title
|
val displayTitle: String get() = seriesTitle ?: title
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,13 @@ fun buildHomeRows(
|
||||||
): List<HomeRow> = buildList {
|
): List<HomeRow> = buildList {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
add(HomeRow("hero-sk", HomeRowKind.Hero, skeleton = true))
|
add(HomeRow("hero-sk", HomeRowKind.Hero, skeleton = true))
|
||||||
listOf(HomeCardKind.Tile, HomeCardKind.Tile, HomeCardKind.Poster, HomeCardKind.Poster).forEachIndexed { index, kind ->
|
listOf(HomeCardKind.Tile, HomeCardKind.Tile, HomeCardKind.Poster).forEachIndexed { index, kind ->
|
||||||
add(HomeRow("sk$index", HomeRowKind.Shelf, cardKind = kind, skeleton = true))
|
add(HomeRow("sk$index", HomeRowKind.Shelf, cardKind = kind, skeleton = true))
|
||||||
}
|
}
|
||||||
return@buildList
|
return@buildList
|
||||||
}
|
}
|
||||||
val choice = when {
|
val choice = when {
|
||||||
|
// Hero precedence remains independent of the merged shelf's recency order.
|
||||||
library.resume.isNotEmpty() -> "resume" to library.resume.first()
|
library.resume.isNotEmpty() -> "resume" to library.resume.first()
|
||||||
library.nextUp.isNotEmpty() -> "next" to library.nextUp.first()
|
library.nextUp.isNotEmpty() -> "next" to library.nextUp.first()
|
||||||
library.newest.isNotEmpty() -> "new" to library.newest.first()
|
library.newest.isNotEmpty() -> "new" to library.newest.first()
|
||||||
|
|
@ -34,10 +35,10 @@ fun buildHomeRows(
|
||||||
if (!libraryAvailable) {
|
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."))
|
add(HomeRow("jf-down", HomeRowKind.State, "Your library can’t be reached", text = "Jellyfin isn’t answering, so nothing can play right now."))
|
||||||
} else {
|
} else {
|
||||||
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 watching = continueWatching(library).filter { it.watchIdentity != hero?.watchIdentity }
|
||||||
val cards = source.filter { it.id != hero?.id }
|
if (watching.isNotEmpty()) add(HomeRow("cw", HomeRowKind.Shelf, "Continue watching", watching, HomeCardKind.Tile))
|
||||||
if (cards.isNotEmpty()) add(HomeRow(id, HomeRowKind.Shelf, title, cards, if (id == "cw") HomeCardKind.Wide else 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("movies", "shows", "anime").forEach { destination ->
|
listOf("movies", "shows", "anime").forEach { destination ->
|
||||||
val titles = library.destinations[destination].orEmpty().sortedWith(compareBy<HomeTitle> { it.played }.thenByDescending { it.addedAt })
|
val titles = library.destinations[destination].orEmpty().sortedWith(compareBy<HomeTitle> { 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))
|
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))
|
||||||
|
|
@ -47,6 +48,27 @@ fun buildHomeRows(
|
||||||
addAll(continuation)
|
addAll(continuation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Prefer a resumable episode per series, then sort the chosen items by actual watch activity. */
|
||||||
|
internal fun continueWatching(library: HomeLibrary): List<HomeTitle> {
|
||||||
|
val chosen = linkedMapOf<String, HomeTitle>()
|
||||||
|
fun choose(source: List<HomeTitle>) {
|
||||||
|
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. */
|
/** Authentication failure is distinct from an unreachable signed-in catalogue. */
|
||||||
fun homeDiscoverState(status: HomeAvailability): HomeRow = if (status == HomeAvailability.SignedOut) {
|
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)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,12 @@ fun HomeScreen(
|
||||||
onAction: (HomeAction) -> Unit,
|
onAction: (HomeAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
val view = shell.rememberedView("home")
|
val view = shell.rememberedView("home")
|
||||||
|
val active = LocalScreenActive.current
|
||||||
LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt }
|
LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt }
|
||||||
val action by rememberUpdatedState(onAction)
|
val action by rememberUpdatedState(onAction)
|
||||||
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
|
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
|
||||||
DisposableEffect(shell, focus) {
|
DisposableEffect(shell, focus, active) {
|
||||||
shell.contentFocus = focus
|
if (active) shell.contentFocus = focus
|
||||||
onDispose { if (shell.contentFocus === focus) shell.contentFocus = null }
|
onDispose { if (shell.contentFocus === focus) shell.contentFocus = null }
|
||||||
}
|
}
|
||||||
LaunchedEffect(snapshot.rows, view) {
|
LaunchedEffect(snapshot.rows, view) {
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,8 @@ 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))
|
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()
|
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) {
|
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) {
|
||||||
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 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 ->
|
||||||
val selected = !row.skeleton && focused && column == index
|
val selected = !row.skeleton && focused && column == index
|
||||||
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$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)
|
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
|
||||||
|
|
@ -86,9 +87,10 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
|
||||||
private fun TileOverlay(title: HomeTitle) {
|
private fun TileOverlay(title: HomeTitle) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
|
||||||
val metadata = WolfskinTokens.CardMetadata
|
val metadata = WolfskinTokens.CardMetadata
|
||||||
Column(Modifier.padding(bottom = metadata.bottom + metadata.separator + metadata.resumeLine).fillMaxWidth()
|
Column(Modifier.fillMaxWidth()
|
||||||
.background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f))))
|
.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)) {
|
.padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop,
|
||||||
|
bottom = H.cardOverlayBottom + metadata.bottom + metadata.separator + metadata.resumeLine)) {
|
||||||
Text(title.displayTitle, modifier = Modifier.exactLineBox(metadata.titleFocus), color = C.fg, style = metadata.titleFocus, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(title.displayTitle, modifier = Modifier.exactLineBox(metadata.titleFocus), color = C.fg, style = metadata.titleFocus, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
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<String>.uniqueKeys(): List<String> {
|
||||||
|
val used = HashSet<String>(size)
|
||||||
|
val repeats = HashMap<String, Int>()
|
||||||
|
return map { base ->
|
||||||
|
var key = base
|
||||||
|
while (!used.add(key)) {
|
||||||
|
val count = (repeats[base] ?: 0) + 1
|
||||||
|
repeats[base] = count
|
||||||
|
key = "$base#$count"
|
||||||
|
}
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,10 +39,11 @@ fun LibraryScreen(
|
||||||
cachedRatings: Set<String> = emptySet(),
|
cachedRatings: Set<String> = emptySet(),
|
||||||
ratingsReachable: Boolean = true,
|
ratingsReachable: Boolean = true,
|
||||||
offlineArt: ((String) -> ImageBitmap?)? = null,
|
offlineArt: ((String) -> ImageBitmap?)? = null,
|
||||||
view: RememberedView = shell.view,
|
view: RememberedView = screenView(shell),
|
||||||
registerFocus: Boolean = true,
|
registerFocus: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val entryActive = LocalScreenActive.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val active = !shell.navFocused && !shell.pickerVisible
|
val active = !shell.navFocused && !shell.pickerVisible
|
||||||
val items = grid.items
|
val items = grid.items
|
||||||
|
|
@ -50,8 +51,8 @@ fun LibraryScreen(
|
||||||
val count = if (grid.catalogue) items.size else grid.source.size
|
val count = if (grid.catalogue) items.size else grid.source.size
|
||||||
var plateKey by remember { mutableStateOf<String?>(null) }
|
var plateKey by remember { mutableStateOf<String?>(null) }
|
||||||
val ratingsReader = LocalScreenRatings.current
|
val ratingsReader = LocalScreenRatings.current
|
||||||
DisposableEffect(shell, grid, registerFocus) {
|
DisposableEffect(shell, grid, registerFocus, entryActive) {
|
||||||
if (registerFocus) shell.contentFocus = grid
|
if (registerFocus && entryActive) shell.contentFocus = grid
|
||||||
onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null }
|
onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null }
|
||||||
}
|
}
|
||||||
LaunchedEffect(grid) {
|
LaunchedEffect(grid) {
|
||||||
|
|
@ -128,10 +129,13 @@ fun LibraryScreen(
|
||||||
LibraryStatus.Unavailable -> item(key = "unavailable", contentType = "state") { LibraryRecovery(grid, active) }
|
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) }
|
LibraryStatus.Ready -> if (items.isEmpty()) item(key = "empty", contentType = "state") { LibraryRecovery(grid, active && !grid.toolbar) }
|
||||||
else {
|
else {
|
||||||
items((items.size + grid.columns - 1) / grid.columns, key = { row ->
|
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 ->
|
||||||
val title = items[row * grid.columns]
|
val title = items[row * grid.columns]
|
||||||
"grid-${if (grid.catalogue) title.catalogueKey else title.id}"
|
"grid-${if (grid.catalogue) title.catalogueKey else title.id}"
|
||||||
}, contentType = { "grid" }) { row ->
|
}.uniqueKeys()
|
||||||
|
items(rowCount, key = { rowKeys[it] }, contentType = { "grid" }) { row ->
|
||||||
Box(Modifier.fillMaxWidth().height(rowHeight)) {
|
Box(Modifier.fillMaxWidth().height(rowHeight)) {
|
||||||
Row(Modifier.padding(start = L.safeSide).rowReveal("grid-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
|
Row(Modifier.padding(start = L.safeSide).rowReveal("grid-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
|
||||||
for (column in 0 until grid.columns) {
|
for (column in 0 until grid.columns) {
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,11 @@ class PersonState(initialPerson: TitlePerson, initialCredits: List<TitleItem>, p
|
||||||
/** Portrait, biography and the full continuous grid use the shell's remembered frame. */
|
/** Portrait, biography and the full continuous grid use the shell's remembered frame. */
|
||||||
@Composable
|
@Composable
|
||||||
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||||
val view = shell.view
|
val view = screenView(shell)
|
||||||
|
val entryActive = LocalScreenActive.current
|
||||||
LaunchedEffect(view, state.savedAt) { view.savedAt = state.savedAt }
|
LaunchedEffect(view, state.savedAt) { view.savedAt = state.savedAt }
|
||||||
val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
|
val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
|
||||||
LaunchedEffect(state) { shell.navFocused = false }
|
LaunchedEffect(state, entryActive) { if (entryActive) shell.navFocused = false }
|
||||||
Box(modifier.fillMaxSize().background(C.bg)) {
|
Box(modifier.fillMaxSize().background(C.bg)) {
|
||||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
|
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
|
||||||
ShellSurface(shell, handlesContentKeys = false) { PersonPage(state, shell, it) }
|
ShellSurface(shell, handlesContentKeys = false) { PersonPage(state, shell, it) }
|
||||||
|
|
@ -101,7 +102,8 @@ fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Mo
|
||||||
@Composable
|
@Composable
|
||||||
private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val list = shell.view.listState
|
val list = screenView(shell).listState
|
||||||
|
val entryActive = LocalScreenActive.current
|
||||||
var wheelPending by remember { mutableStateOf(false) }
|
var wheelPending by remember { mutableStateOf(false) }
|
||||||
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
|
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
|
||||||
LaunchedEffect(state.index / columns, shell.navFocused) {
|
LaunchedEffect(state.index / columns, shell.navFocused) {
|
||||||
|
|
@ -127,7 +129,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
|
||||||
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
|
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
|
||||||
}
|
}
|
||||||
key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false
|
key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false
|
||||||
}.focusable()) {
|
}.focusable(enabled = entryActive)) {
|
||||||
LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
|
LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
|
||||||
item(key = "person-header", contentType = "header") {
|
item(key = "person-header", contentType = "header") {
|
||||||
Box(Modifier.height(L.row_phead)) {
|
Box(Modifier.height(L.row_phead)) {
|
||||||
|
|
@ -144,7 +146,9 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
|
||||||
item(key = "filmography", contentType = "header") {
|
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))
|
Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
|
||||||
}
|
}
|
||||||
itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
|
val rowsOfCredits = state.credits.chunked(columns)
|
||||||
|
val rowKeys = rowsOfCredits.map { it.first().id }.uniqueKeys()
|
||||||
|
itemsIndexed(rowsOfCredits, key = { index, _ -> rowKeys[index] }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
|
||||||
Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||||
row.forEachIndexed { column, item ->
|
row.forEachIndexed { column, item ->
|
||||||
val index = rowIndex * columns + column
|
val index = rowIndex * columns + column
|
||||||
|
|
|
||||||
|
|
@ -40,4 +40,5 @@ fun RecoveryToast(message: String, modifier: Modifier = Modifier) {
|
||||||
object RecoveryMessage {
|
object RecoveryMessage {
|
||||||
const val StillUnreachable = "Still can’t reach it. The last saved view stays on screen."
|
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 PlaybackUnavailable = "Can’t reach Jellyfin to play. Check the network and try again."
|
||||||
|
const val TitleActionUnavailable = "Can’t update this title right now."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
internal val LocalScreenView = compositionLocalOf<RememberedView?> { 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) }
|
||||||
|
CompositionLocalProvider(LocalScreenView provides shell.rememberedView(route), LocalScreenActive provides active, 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
|
||||||
|
|
@ -15,6 +15,7 @@ sealed interface ShellAction {
|
||||||
data class Person(val person: ShellPerson) : ShellAction
|
data class Person(val person: ShellPerson) : ShellAction
|
||||||
data class HandOff(val group: String) : ShellAction
|
data class HandOff(val group: String) : ShellAction
|
||||||
data object Pop : ShellAction
|
data object Pop : ShellAction
|
||||||
|
data object Exit : ShellAction
|
||||||
data object RetryPeople : ShellAction
|
data object RetryPeople : ShellAction
|
||||||
data object RetryRequests : ShellAction
|
data object RetryRequests : ShellAction
|
||||||
data class RequestTitle(val request: ComingInRequest) : ShellAction
|
data class RequestTitle(val request: ComingInRequest) : ShellAction
|
||||||
|
|
@ -116,6 +117,9 @@ class ShellState(
|
||||||
fun rememberedView(destination: String): RememberedView =
|
fun rememberedView(destination: String): RememberedView =
|
||||||
views[person.key to destination] ?: 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. */
|
/** External intents and upstream pops remain authoritative over the screen. */
|
||||||
fun enterDestination(destination: String) {
|
fun enterDestination(destination: String) {
|
||||||
retainView(destination)
|
retainView(destination)
|
||||||
|
|
@ -193,7 +197,7 @@ class ShellState(
|
||||||
when (key) {
|
when (key) {
|
||||||
ShellKey.Left -> pickerIndex = (pickerIndex - 1).coerceAtLeast(0)
|
ShellKey.Left -> pickerIndex = (pickerIndex - 1).coerceAtLeast(0)
|
||||||
ShellKey.Right -> pickerIndex = (pickerIndex + 1).coerceAtMost(people.lastIndex)
|
ShellKey.Right -> pickerIndex = (pickerIndex + 1).coerceAtMost(people.lastIndex)
|
||||||
ShellKey.Down -> if (pickerIndex == people.lastIndex) gearFocused = true
|
ShellKey.Down -> gearFocused = true
|
||||||
ShellKey.Ok -> choosePerson(people[pickerIndex])
|
ShellKey.Ok -> choosePerson(people[pickerIndex])
|
||||||
ShellKey.Back -> closePicker()
|
ShellKey.Back -> closePicker()
|
||||||
else -> Unit
|
else -> Unit
|
||||||
|
|
@ -217,7 +221,8 @@ class ShellState(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun closePicker() {
|
private fun closePicker() {
|
||||||
if (!pickerLaunch) { pickerVisible = false; navFocused = true; navIndex = tabs.size }
|
if (pickerLaunch) onAction(ShellAction.Exit)
|
||||||
|
else { pickerVisible = false; navFocused = true; navIndex = tabs.size }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navKey(key: ShellKey) {
|
private fun navKey(key: ShellKey) {
|
||||||
|
|
@ -289,7 +294,7 @@ class ShellState(
|
||||||
onAction(ShellAction.RequestTitle(request))
|
onAction(ShellAction.RequestTitle(request))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Back never discards a root view, including the card below a focused nav. */
|
/** Back retains root view memory; a second root Back exits instead of detouring through Home. */
|
||||||
fun back() {
|
fun back() {
|
||||||
if (requestsVisible) { closeRequests(); return }
|
if (requestsVisible) { closeRequests(); return }
|
||||||
val returnRoute = requestReturnRoute
|
val returnRoute = requestReturnRoute
|
||||||
|
|
@ -307,18 +312,14 @@ class ShellState(
|
||||||
pickerVisible = true
|
pickerVisible = true
|
||||||
pickerLaunch = false
|
pickerLaunch = false
|
||||||
gearFocused = true
|
gearFocused = true
|
||||||
pickerIndex = people.lastIndex
|
|
||||||
} else if (route !in tabs) {
|
} else if (route !in tabs) {
|
||||||
if (route == "updates") navFocused = false
|
if (route == "updates") navFocused = false
|
||||||
onAction(ShellAction.Pop)
|
onAction(ShellAction.Pop)
|
||||||
} else if (!navFocused) {
|
} else if (!navFocused) {
|
||||||
navFocused = true
|
navFocused = true
|
||||||
navIndex = currentTab
|
navIndex = currentTab
|
||||||
} else if (route != "home") {
|
} else {
|
||||||
route = "home"
|
onAction(ShellAction.Exit)
|
||||||
rootRoute = "home"
|
|
||||||
navIndex = 0
|
|
||||||
onAction(ShellAction.Destination(route))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,14 @@ import androidx.tv.material3.Text
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
|
||||||
/** Screens and the shell interpret the same Android directional and activation keys. */
|
/** Native Back belongs to the Activity dispatcher; Escape remains the local Back shortcut. */
|
||||||
fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) {
|
fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) {
|
||||||
Key.DirectionUp -> ShellKey.Up
|
Key.DirectionUp -> ShellKey.Up
|
||||||
Key.DirectionDown -> ShellKey.Down
|
Key.DirectionDown -> ShellKey.Down
|
||||||
Key.DirectionLeft -> ShellKey.Left
|
Key.DirectionLeft -> ShellKey.Left
|
||||||
Key.DirectionRight -> ShellKey.Right
|
Key.DirectionRight -> ShellKey.Right
|
||||||
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok
|
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok
|
||||||
Key.Back, Key.Escape -> ShellKey.Back
|
Key.Escape -> ShellKey.Back
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,6 +62,8 @@ fun ShellSurface(
|
||||||
) {
|
) {
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
val contentFocus = remember { FocusRequester() }
|
val contentFocus = remember { FocusRequester() }
|
||||||
|
val active = LocalScreenActive.current
|
||||||
|
val view = screenView(state)
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
LaunchedEffect(state.toast) {
|
LaunchedEffect(state.toast) {
|
||||||
val message = state.toast
|
val message = state.toast
|
||||||
|
|
@ -70,30 +72,32 @@ fun ShellSurface(
|
||||||
if (state.toast == message) state.toast = null
|
if (state.toast == message) state.toast = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
|
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys, active) {
|
||||||
|
if (active) {
|
||||||
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
|
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
|
||||||
else contentFocus.requestFocus()
|
else contentFocus.requestFocus()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell")
|
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}"}" }
|
.semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" }
|
||||||
.focusRequester(focus)
|
.focusRequester(focus)
|
||||||
.onPreviewKeyEvent {
|
.onPreviewKeyEvent {
|
||||||
if (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys) state.handleKey(it) else false
|
if (active && (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys)) state.handleKey(it) else false
|
||||||
}
|
}
|
||||||
.onKeyEvent {
|
.onKeyEvent {
|
||||||
if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
|
if (active && it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
|
||||||
if (!focusManager.moveFocus(FocusDirection.Up)) {
|
if (!focusManager.moveFocus(FocusDirection.Up)) {
|
||||||
state.navFocused = true
|
state.navFocused = true
|
||||||
state.navIndex = state.currentTab
|
state.navIndex = state.currentTab
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
} else false
|
} else false
|
||||||
}.focusable()) {
|
}.focusable(enabled = active)) {
|
||||||
if (state.pickerVisible) {
|
if (state.pickerVisible) {
|
||||||
WhosWatching(state)
|
WhosWatching(state)
|
||||||
} else {
|
} else {
|
||||||
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
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)
|
NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||||
if (state.requestsVisible) ComingInSheet(state)
|
if (state.requestsVisible) ComingInSheet(state)
|
||||||
overlay()
|
overlay()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,13 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil3.compose.AsyncImage
|
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.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
|
|
@ -54,8 +57,9 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo
|
||||||
}
|
}
|
||||||
else if (path != null) {
|
else if (path != null) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val request = remember(context, path, width, height, poster) {
|
val request = remember(context, path, width, height, poster, heroReady != null) {
|
||||||
WolfskinImages.request(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),
|
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context),
|
||||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter,
|
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter,
|
||||||
|
|
@ -110,7 +114,7 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
|
||||||
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
|
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
|
||||||
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
|
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
|
||||||
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
|
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
|
||||||
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
|
.size(L.heroSheetWidth, L.heroSheetHeight).testTag("title-poster-backdrop").dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxSize().background(horizontal))
|
Box(Modifier.fillMaxSize().background(horizontal))
|
||||||
Box(Modifier.fillMaxSize().background(vertical))
|
Box(Modifier.fillMaxSize().background(vertical))
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod
|
||||||
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
||||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||||
.background(background, shape)
|
.background(background, shape)
|
||||||
.then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
|
|
||||||
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
|
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
|
||||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
|
horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
|
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
|
@ -35,6 +39,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
|
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
|
||||||
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
|
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
|
||||||
val item = state.data.item
|
val item = state.data.item
|
||||||
|
val factsItem = state.data.watchTarget.takeIf { it.kind == "episode" } ?: item
|
||||||
|
val readinessHeight by animateDpAsState(
|
||||||
|
if (state.data.requestedStage == null) WolfskinTokens.Shell.zero else WolfskinTokens.Motion.readinessSlot,
|
||||||
|
tween(if (reducedMotion()) 0 else WolfskinTokens.Motion.lateLineFade, easing = WolfskinTokens.Motion.easeOut),
|
||||||
|
label = "title-readiness-height")
|
||||||
val episode = when {
|
val episode = when {
|
||||||
state.row == "episodes" -> state.data.episodes.getOrNull(state.column)
|
state.row == "episodes" -> state.data.episodes.getOrNull(state.column)
|
||||||
state.data.watchTarget.kind == "episode" -> state.data.watchTarget
|
state.data.watchTarget.kind == "episode" -> state.data.watchTarget
|
||||||
|
|
@ -56,10 +65,10 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) }
|
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) }
|
||||||
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||||
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
|
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(factsItem.ratings)
|
||||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres))
|
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres))
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) {
|
Box(Modifier.fillMaxWidth().height(readinessHeight).clipToBounds().testTag("title-readiness-slot")) {
|
||||||
state.data.requestedStage?.let { stage ->
|
state.data.requestedStage?.let { stage ->
|
||||||
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
||||||
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
@ -74,7 +83,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
||||||
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
||||||
}
|
}
|
||||||
state.data.playTarget?.let { target ->
|
state.data.playTarget?.takeIf { it.resumeSeconds > 0 || it.kind == "episode" }?.let { target ->
|
||||||
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (target.resumeSeconds > 0) {
|
if (target.resumeSeconds > 0) {
|
||||||
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
|
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
|
||||||
|
|
@ -83,7 +92,6 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
}
|
}
|
||||||
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress)
|
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress)
|
||||||
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress)
|
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress)
|
||||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = TitleTextStyles.progress)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
|
|
||||||
|
/** A compact modal keeps secondary actions reachable without growing the header row. */
|
||||||
|
@Composable
|
||||||
|
internal fun TitleMenu(state: TitleState, shell: ShellState) {
|
||||||
|
val visible = state.sheet == TitleSheet.Menu && !shell.pickerVisible
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
val opacity = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(visible, reduced) {
|
||||||
|
if (visible) focus.requestFocus()
|
||||||
|
opacity.animateTo(if (visible) 1f else 0f,
|
||||||
|
tween(motionDuration(if (visible) WolfskinTokens.Motion.base else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||||
|
}
|
||||||
|
if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) {
|
||||||
|
Box(Modifier.fillMaxSize().background(C.sheetScrim)
|
||||||
|
.clickable { if (visible) state.back(shell) }
|
||||||
|
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||||
|
val key = readShellKey(event)
|
||||||
|
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||||
|
}.focusable()) {
|
||||||
|
Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth)
|
||||||
|
.graphicsLayer { alpha = opacity.value }
|
||||||
|
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||||
|
.padding(D.receiptSide).testTag("title-menu"), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
|
||||||
|
Text("Menu", color = C.fg, style = WolfskinTokens.Type.rowHeading)
|
||||||
|
state.menuOptions.forEachIndexed { index, option ->
|
||||||
|
val focused = visible && state.sheetIndex == index
|
||||||
|
TitleControl(option, focused,
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
onFocus = { if (visible) state.sheetIndex = index }, overArtwork = false) {
|
||||||
|
if (visible) state.clickSheet(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -63,7 +63,7 @@ data class TitleVersion(
|
||||||
|
|
||||||
enum class TitleReadStatus { Ready, Loading, Unavailable }
|
enum class TitleReadStatus { Ready, Loading, Unavailable }
|
||||||
enum class TitleConnection { SignedIn, SignedOut, Offline }
|
enum class TitleConnection { SignedIn, SignedOut, Offline }
|
||||||
enum class TitleSheet { Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt }
|
enum class TitleSheet { Menu, Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt }
|
||||||
|
|
||||||
/** A title read includes related titles and the selected season; reads happen outside composition. */
|
/** A title read includes related titles and the selected season; reads happen outside composition. */
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
||||||
TitleSheet.MediaInfo -> MediaInformation(state)
|
TitleSheet.MediaInfo -> MediaInformation(state)
|
||||||
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
|
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
|
||||||
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
|
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
|
||||||
null -> Unit
|
TitleSheet.Menu, null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,10 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
|
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
|
||||||
@Composable
|
@Composable
|
||||||
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||||
val view = shell.view
|
val view = screenView(shell)
|
||||||
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
|
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
|
||||||
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
|
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
|
||||||
// No content target exists during an initial load; nav Down must not enter a skeleton.
|
// No content target exists during an initial load; Down must not enter a skeleton.
|
||||||
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
|
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
|
||||||
else when (readShellKey(event)) {
|
else when (readShellKey(event)) {
|
||||||
ShellKey.Down -> true
|
ShellKey.Down -> true
|
||||||
|
|
@ -69,7 +69,8 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
||||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
||||||
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
||||||
}
|
}
|
||||||
if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell)
|
if (!shell.pickerVisible && state.sheet != null && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell)
|
||||||
|
TitleMenu(state, shell)
|
||||||
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,15 +88,18 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism
|
||||||
@Composable
|
@Composable
|
||||||
fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val list = shell.view.listState
|
val list = screenView(shell).listState
|
||||||
|
val entryActive = LocalScreenActive.current
|
||||||
var wheelPending by remember { mutableStateOf(false) }
|
var wheelPending by remember { mutableStateOf(false) }
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
||||||
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
|
val compactHeader = state.data.status == TitleReadStatus.Ready
|
||||||
state.data.item.kind == "tv" && state.data.item.seasons > 0
|
val item = state.data.item
|
||||||
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
|
val posterFloor = if (item.kind == "movie" && item.poster != null && (item.backdrop == null || item.isPosterBackdrop))
|
||||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
|
D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero
|
||||||
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead
|
||||||
|
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible, entryActive) {
|
||||||
|
if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
||||||
}
|
}
|
||||||
val displayedRows = remember(state.data) {
|
val displayedRows = remember(state.data) {
|
||||||
buildList {
|
buildList {
|
||||||
|
|
@ -117,8 +121,18 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
val position by remember(list, rowOffsets) { derivedStateOf {
|
val position by remember(list, rowOffsets) { derivedStateOf {
|
||||||
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
||||||
} }
|
} }
|
||||||
|
val headerPixels = with(density) { headerHeight.roundToPx() }
|
||||||
|
val previousHeaderPixels = remember(list, item.id, density) { intArrayOf(headerPixels) }
|
||||||
|
val compensateHeader = previousHeaderPixels[0] != headerPixels && !shell.navFocused && !list.isScrollInProgress &&
|
||||||
|
list.firstVisibleItemIndex == 0 && list.firstVisibleItemScrollOffset > 0 &&
|
||||||
|
list.layoutInfo.visibleItemsInfo.any { it.key == state.row && kotlin.math.abs(it.offset - with(density) { L.pinnedLine.roundToPx() }) <= 1 }
|
||||||
|
// Compensate in the same remeasure; an asynchronous scroll would chase the animated header.
|
||||||
|
androidx.compose.runtime.SideEffect {
|
||||||
|
if (compensateHeader) list.requestScrollToItem(0, (list.firstVisibleItemScrollOffset + headerPixels - previousHeaderPixels[0]).coerceAtLeast(0))
|
||||||
|
previousHeaderPixels[0] = headerPixels
|
||||||
|
}
|
||||||
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) {
|
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) {
|
||||||
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
if (compensateHeader) return@LaunchedEffect
|
||||||
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
|
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
|
||||||
val y = rowOffsets[index]
|
val y = rowOffsets[index]
|
||||||
val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
|
val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
|
||||||
|
|
@ -143,7 +157,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
|
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
|
||||||
}
|
}
|
||||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||||
}.focusable()) {
|
}.focusable(enabled = entryActive)) {
|
||||||
// This remains visible behind the scrolled rows; it is not an off-screen header image.
|
// This remains visible behind the scrolled rows; it is not an off-screen header image.
|
||||||
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
||||||
Box(Modifier.fillMaxSize().drawBehind { drawRect(C.bg.copy(alpha = dim)) })
|
Box(Modifier.fillMaxSize().drawBehind { drawRect(C.bg.copy(alpha = dim)) })
|
||||||
|
|
@ -154,7 +168,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
val active = state.row == id && !shell.navFocused && state.sheet == null
|
val active = state.row == id && !shell.navFocused && state.sheet == null
|
||||||
when (id) {
|
when (id) {
|
||||||
"header" -> Box(Modifier.height(headerHeight)) {
|
"header" -> Box(Modifier.height(headerHeight)) {
|
||||||
TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size ->
|
TitleHeader(state, active, if (compactHeader) Modifier.onSizeChanged { size ->
|
||||||
measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap
|
measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap
|
||||||
} else Modifier)
|
} else Modifier)
|
||||||
}
|
}
|
||||||
|
|
@ -190,8 +204,10 @@ private fun TitleSeasons(state: TitleState, active: Boolean) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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)) {
|
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 seasonNumbers = state.data.seasonNumbers
|
||||||
val number = state.data.seasonNumbers[index]
|
val keys = seasonNumbers.map { "season-$it" }.uniqueKeys()
|
||||||
|
items(seasonNumbers.size, key = { keys[it] }, contentType = { "chip" }) { index ->
|
||||||
|
val number = seasonNumbers[index]
|
||||||
val focused = active && state.column == index
|
val focused = active && state.column == index
|
||||||
val selected = state.season == number
|
val selected = state.season == number
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.chip)
|
val shape = RoundedCornerShape(WolfskinTokens.Radius.chip)
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
||||||
LaunchedEffect(state.column, active, state.data.cast.size) {
|
LaunchedEffect(state.column, active, state.data.cast.size) {
|
||||||
if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size)
|
if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size)
|
||||||
}
|
}
|
||||||
Box(Modifier.height(D.castHeight)) {
|
Box(Modifier.height(D.castHeight).testTag("title-cast")) {
|
||||||
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading),
|
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading),
|
||||||
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
||||||
|
|
@ -59,7 +59,9 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
||||||
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
|
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||||
Box(Modifier.size(L.castCircle).background(C.surface2, CircleShape))
|
Box(Modifier.size(L.castCircle).background(C.surface2, CircleShape))
|
||||||
}
|
}
|
||||||
} else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person ->
|
} else {
|
||||||
|
val keys = state.data.cast.map { it.id }.uniqueKeys()
|
||||||
|
itemsIndexed(state.data.cast, key = { index, _ -> keys[index] }, contentType = { _, _ -> "person" }) { index, person ->
|
||||||
val focused = active && state.column == index
|
val focused = active && state.column == index
|
||||||
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
|
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
|
||||||
.onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) }
|
.onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) }
|
||||||
|
|
@ -74,17 +76,18 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Episode and related shelves are lazy, keyed and keep their focused card above adjacent art. */
|
/** Episode and related shelves are lazy, keyed and keep their focused card above adjacent art. */
|
||||||
@Composable
|
@Composable
|
||||||
fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
||||||
val titles = if (episodes) state.data.episodes else state.data.related
|
val titles = if (episodes) state.data.episodes else state.data.related
|
||||||
val id = if (episodes) "episodes" else "related"
|
val id = if (episodes) "episodes" else "related"
|
||||||
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
|
val width = if (episodes) D.episodeCardWidth else WolfskinTokens.Card.posterW
|
||||||
val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH
|
val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH
|
||||||
val track = rememberLazyListState()
|
val track = rememberLazyListState()
|
||||||
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
|
val visible = if (episodes) ((WolfskinTokens.Shell.stageWidth - L.safeSide * 2 + WolfskinTokens.Card.tileGap) / (D.episodeCardWidth + WolfskinTokens.Card.tileGap)).toInt() else WolfskinTokens.Card.posterSlots
|
||||||
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
|
val pitch = with(LocalDensity.current) { (if (episodes) D.episodeCardWidth + WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterPitch).toPx() }
|
||||||
LaunchedEffect(state.column, active, titles.size) {
|
LaunchedEffect(state.column, active, titles.size) {
|
||||||
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
|
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
|
||||||
}
|
}
|
||||||
|
|
@ -92,19 +95,22 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
||||||
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
|
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
|
||||||
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading),
|
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading),
|
||||||
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(if (episodes) WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterGap)) {
|
||||||
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
|
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
|
||||||
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
|
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { index ->
|
||||||
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
Box(Modifier.size(width, height).testTag("title-episode-slot-$index").background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||||
}
|
}
|
||||||
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
|
} else {
|
||||||
TitlePoster(item, active && state.column == index, Modifier.width(width)
|
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)
|
||||||
.onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) },
|
.onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) },
|
||||||
episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
|
episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Shelf captions are focus-only; filmography keeps card-width metadata visible at rest. */
|
/** Shelf captions are focus-only; filmography keeps card-width metadata visible at rest. */
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -117,17 +123,16 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
||||||
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true,
|
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true,
|
||||||
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
||||||
} else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() },
|
} else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() },
|
||||||
WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched,
|
D.episodeCardWidth, D.episodeCardHeight, watched = item.watched,
|
||||||
progress = item.progress.takeIf { it < 1f } ?: 0f) {
|
progress = item.progress.takeIf { it < 1f } ?: 0f) {
|
||||||
TitleTileArtwork(item)
|
TitleTileArtwork(item)
|
||||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
||||||
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
||||||
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
CardMetadata(null, item.episodeLabel + if (item.runtimeSeconds > 0) " · ${item.runtime}" else "", item.watched && item.resumeSeconds <= 0)
|
||||||
if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0)
|
|
||||||
}
|
}
|
||||||
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) {
|
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut)), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut))) {
|
||||||
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,13 @@ class TitleState(
|
||||||
var sheet by mutableStateOf<TitleSheet?>(null)
|
var sheet by mutableStateOf<TitleSheet?>(null)
|
||||||
private set
|
private set
|
||||||
var sheetIndex by mutableStateOf(0)
|
var sheetIndex by mutableStateOf(0)
|
||||||
|
private var mediaInfoFromMenu = false
|
||||||
|
val menuOptions: List<TitleOption> by derivedStateOf { buildList {
|
||||||
|
if (target != null) add(TitleOption("info", "Media info", "", icon = false))
|
||||||
|
if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) {
|
||||||
|
add(TitleOption("add", "Add for someone", "users", icon = false))
|
||||||
|
}
|
||||||
|
} }
|
||||||
var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
|
var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
|
||||||
var versionId by mutableStateOf(initial.selectedVersion)
|
var versionId by mutableStateOf(initial.selectedVersion)
|
||||||
var audioIndex by mutableStateOf(initial.selectedAudio)
|
var audioIndex by mutableStateOf(initial.selectedAudio)
|
||||||
|
|
@ -65,6 +72,7 @@ class TitleState(
|
||||||
if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
|
if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
|
||||||
if (row !in rows) { row = "header"; column = 0 }
|
if (row !in rows) { row = "header"; column = 0 }
|
||||||
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
|
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
|
||||||
|
if (sheet == TitleSheet.Menu && menuOptions.isEmpty()) sheet = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pointer focus and D-pad use the same row memory. */
|
/** Pointer focus and D-pad use the same row memory. */
|
||||||
|
|
@ -140,7 +148,7 @@ class TitleState(
|
||||||
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
||||||
fun back(shell: ShellState) {
|
fun back(shell: ShellState) {
|
||||||
when {
|
when {
|
||||||
sheet != null -> sheet = null
|
sheet != null -> closeSheet()
|
||||||
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
||||||
else -> onAction(TitleAction.Pop)
|
else -> onAction(TitleAction.Pop)
|
||||||
}
|
}
|
||||||
|
|
@ -182,19 +190,19 @@ class TitleState(
|
||||||
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
||||||
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
||||||
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
||||||
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo }
|
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
|
||||||
"watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched()
|
"watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched()
|
||||||
"trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) }
|
"trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) }
|
||||||
"seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0))
|
|
||||||
"retry" -> onAction(TitleAction.Retry)
|
"retry" -> onAction(TitleAction.Retry)
|
||||||
"back" -> onAction(TitleAction.Pop)
|
"back" -> onAction(TitleAction.Pop)
|
||||||
"request", "other", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
|
"request", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
|
||||||
"status" -> onAction(TitleAction.Requests(data.item))
|
"status" -> onAction(TitleAction.Requests(data.item))
|
||||||
"hide", "unhide" -> {
|
"hide", "unhide" -> {
|
||||||
val hidden = id == "hide"
|
val hidden = id == "hide"
|
||||||
data = data.copy(hidden = hidden)
|
data = data.copy(hidden = hidden)
|
||||||
onAction(TitleAction.Hide(data.item, hidden))
|
onAction(TitleAction.Hide(data.item, hidden))
|
||||||
if (hidden) { sheetIndex = 0; sheet = TitleSheet.HiddenReceipt } else toast = "Back in your picks."
|
if (hidden && data.hidden) { sheetIndex = 0; sheet = TitleSheet.HiddenReceipt }
|
||||||
|
else if (!hidden && !data.hidden) toast = "Back in your picks."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -205,9 +213,10 @@ class TitleState(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sheetKey(key: ShellKey) {
|
private fun sheetKey(key: ShellKey) {
|
||||||
if (key == ShellKey.Back) { sheet = null; return }
|
if (key == ShellKey.Back) { closeSheet(); return }
|
||||||
val current = version
|
val current = version
|
||||||
val size = when (sheet) {
|
val size = when (sheet) {
|
||||||
|
TitleSheet.Menu -> menuOptions.size
|
||||||
TitleSheet.Version -> data.versions.size
|
TitleSheet.Version -> data.versions.size
|
||||||
TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2
|
TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2
|
||||||
TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2
|
TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2
|
||||||
|
|
@ -217,6 +226,16 @@ class TitleState(
|
||||||
ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0)
|
ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0)
|
||||||
ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1)
|
ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1)
|
||||||
ShellKey.Ok -> when (sheet) {
|
ShellKey.Ok -> when (sheet) {
|
||||||
|
TitleSheet.Menu -> menuOptions.getOrNull(sheetIndex)?.let { option ->
|
||||||
|
if (option.id == "info") {
|
||||||
|
mediaInfoFromMenu = true
|
||||||
|
sheetIndex = 0
|
||||||
|
sheet = TitleSheet.MediaInfo
|
||||||
|
} else {
|
||||||
|
sheet = null
|
||||||
|
activateOption(option.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
TitleSheet.Version -> {
|
TitleSheet.Version -> {
|
||||||
val selected = data.versions[sheetIndex]
|
val selected = data.versions[sheetIndex]
|
||||||
versionId = selected.id
|
versionId = selected.id
|
||||||
|
|
@ -234,11 +253,19 @@ class TitleState(
|
||||||
onAction(TitleAction.Hide(data.item, false))
|
onAction(TitleAction.Hide(data.item, false))
|
||||||
} else onAction(TitleAction.Pop)
|
} else onAction(TitleAction.Pop)
|
||||||
}
|
}
|
||||||
else -> sheet = null
|
else -> closeSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun closeSheet() {
|
||||||
|
if (sheet == TitleSheet.MediaInfo && mediaInfoFromMenu) {
|
||||||
|
mediaInfoFromMenu = false
|
||||||
|
sheetIndex = 0
|
||||||
|
sheet = TitleSheet.Menu
|
||||||
|
} else sheet = null
|
||||||
|
}
|
||||||
|
|
||||||
private fun selectTrack(current: TitleVersion) {
|
private fun selectTrack(current: TitleVersion) {
|
||||||
when {
|
when {
|
||||||
sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index
|
sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index
|
||||||
|
|
@ -274,14 +301,12 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
|
||||||
val series = data.watchTarget.kind == "tv"
|
val series = data.watchTarget.kind == "tv"
|
||||||
add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched))
|
add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched))
|
||||||
if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer"))
|
if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer"))
|
||||||
if (target != null) add(TitleOption("more", "More", "more", "Media info"))
|
if (target != null || (data.connection == TitleConnection.SignedIn && data.requestsAllowed)) add(TitleOption("menu", "Menu", "more"))
|
||||||
if (item.kind == "tv" && item.seasons > 0) add(TitleOption("seasons", "Seasons", "tv", icon = false))
|
|
||||||
if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) add(TitleOption("add", "Add for someone", "users", icon = false))
|
|
||||||
} else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) {
|
} else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) {
|
||||||
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
||||||
} else {
|
} else {
|
||||||
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false))
|
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false))
|
||||||
add(TitleOption("other", "For someone else", "users", icon = false))
|
add(TitleOption("menu", "Menu", "more"))
|
||||||
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false))
|
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false))
|
||||||
else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false))
|
else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||||
|
|
||||||
/** The picker covers the nav and exposes Settings only below the final person. */
|
/** The picker covers the nav; Down from any person reaches the centred Settings gear. */
|
||||||
@Composable
|
@Composable
|
||||||
fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) {
|
fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) {
|
||||||
Box(modifier.fillMaxSize().background(C.bg)) {
|
Box(modifier.fillMaxSize().background(C.bg)) {
|
||||||
|
|
@ -42,7 +43,7 @@ private fun PeoplePicker(state: ShellState) {
|
||||||
Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
|
Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
|
||||||
state.people.forEachIndexed { index, person ->
|
state.people.forEachIndexed { index, person ->
|
||||||
val focused = !state.gearFocused && state.pickerIndex == index
|
val focused = !state.gearFocused && state.pickerIndex == index
|
||||||
Column(Modifier.width(L.pickerAvatar).focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable {
|
Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable {
|
||||||
state.focusPerson(index); state.key(ShellKey.Ok)
|
state.focusPerson(index); state.key(ShellKey.Ok)
|
||||||
}, horizontalAlignment = Alignment.CenterHorizontally) {
|
}, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
|
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
|
||||||
|
|
@ -53,7 +54,7 @@ private fun PeoplePicker(state: ShellState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val left = (S.stageWidth - L.gear) / 2
|
val left = (S.stageWidth - L.gear) / 2
|
||||||
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
|
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
|
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
|
||||||
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
|
.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))
|
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
|
||||||
|
|
|
||||||
|
|
@ -265,7 +265,7 @@ object WolfskinTokens {
|
||||||
const val heroRequestHeight = 1080
|
const val heroRequestHeight = 1080
|
||||||
const val heroPosterRequestWidth = 420
|
const val heroPosterRequestWidth = 420
|
||||||
const val heroPosterRequestHeight = 630
|
const val heroPosterRequestHeight = 630
|
||||||
const val episodeRequestWidth = 400
|
const val episodeRequestWidth = 448
|
||||||
const val episodeRequestHeight = 280
|
const val episodeRequestHeight = 280
|
||||||
const val castRequestWidth = 200
|
const val castRequestWidth = 200
|
||||||
const val castRequestHeight = 200
|
const val castRequestHeight = 200
|
||||||
|
|
@ -454,8 +454,11 @@ object WolfskinTokens {
|
||||||
const val sheetOpacity = 0.5f
|
const val sheetOpacity = 0.5f
|
||||||
}
|
}
|
||||||
object Title {
|
object Title {
|
||||||
|
val episodeCardWidth = 224.0.dp
|
||||||
val episodeCardHeight = 140.0.dp
|
val episodeCardHeight = 140.0.dp
|
||||||
|
val actionMenuWidth = 260.0.dp
|
||||||
val seriesHeaderGap = 20.0.dp
|
val seriesHeaderGap = 20.0.dp
|
||||||
|
val posterCastGap = 10.0.dp
|
||||||
val lineGap = 11.0.dp
|
val lineGap = 11.0.dp
|
||||||
val lineLift = -2.0.dp
|
val lineLift = -2.0.dp
|
||||||
val overviewGap = 17.0.dp
|
val overviewGap = 17.0.dp
|
||||||
|
|
@ -589,7 +592,7 @@ object WolfskinTokens {
|
||||||
val factsGap = 11.0.dp
|
val factsGap = 11.0.dp
|
||||||
val overviewBottom = 15.0.dp
|
val overviewBottom = 15.0.dp
|
||||||
val cardPadding = 11.0.dp
|
val cardPadding = 11.0.dp
|
||||||
val cardOverlayTop = 27.0.dp
|
val cardOverlayTop = 18.0.dp
|
||||||
val cardOverlayBottom = 9.0.dp
|
val cardOverlayBottom = 9.0.dp
|
||||||
val sectionTop = 20.0.dp
|
val sectionTop = 20.0.dp
|
||||||
val sectionAvatar = 28.0.dp
|
val sectionAvatar = 28.0.dp
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
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<ComponentActivity>()
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
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<ComponentActivity>()
|
||||||
|
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<String, Pair<Float, Float>>()
|
||||||
|
private val previousDotWidths = mutableMapOf<Int, Float>()
|
||||||
|
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<String, Pair<Float, Float>>()
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import android.app.Application
|
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 android.view.KeyEvent as AndroidKeyEvent
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEvent
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
|
@ -19,10 +22,12 @@ import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
/** Acceptance data drives real Android events through the shell's production focus adapter. */
|
/** Acceptance data drives real Android events through the shell's production focus adapter. */
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
class DiscoverKeysTest {
|
class DiscoverKeysTest {
|
||||||
@get:Rule val rule = createComposeRule()
|
@get:Rule val rule = createComposeRule()
|
||||||
private val fixture = discoverTestFixture()
|
private val fixture = discoverTestFixture()
|
||||||
|
|
@ -33,14 +38,17 @@ class DiscoverKeysTest {
|
||||||
{ shell.enterDestination("discover") }, fixture::service)
|
{ shell.enterDestination("discover") }, fixture::service)
|
||||||
|
|
||||||
private fun launch() {
|
private fun launch() {
|
||||||
|
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
WolfskinTheme {
|
WolfskinTheme {
|
||||||
|
CompositionLocalProvider(LocalDiscoverArt provides { art }) {
|
||||||
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
|
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)
|
if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun press(first: Key, second: Key? = null) = listOfNotNull(first, second).forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
|
private fun press(first: Key, second: Key? = null) = listOfNotNull(first, second).forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
|
||||||
|
|
||||||
|
|
@ -51,7 +59,7 @@ class DiscoverKeysTest {
|
||||||
assertEquals(path.getInt("presses"), keys.length())
|
assertEquals(path.getInt("presses"), keys.length())
|
||||||
launch()
|
launch()
|
||||||
repeat(keys.length()) { index -> press(when (keys.getString(index)) {
|
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.Back
|
"OK" -> Key.DirectionCenter; "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown; "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight; else -> Key.Escape
|
||||||
}) }
|
}) }
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +144,7 @@ class DiscoverKeysTest {
|
||||||
@Test fun closingAnUncertainReceiptNeverTurnsReopeningIntoAnotherPost() {
|
@Test fun closingAnUncertainReceiptNeverTurnsReopeningIntoAnotherPost() {
|
||||||
operations.next = RequestPhase.NotConfirmed
|
operations.next = RequestPhase.NotConfirmed
|
||||||
replay("Request a pick for yourself")
|
replay("Request a pick for yourself")
|
||||||
press(Key.Back, Key.DirectionCenter)
|
press(Key.Escape, Key.DirectionCenter)
|
||||||
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||||
operations.next = RequestPhase.Receipt
|
operations.next = RequestPhase.Receipt
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
|
|
@ -165,7 +173,7 @@ class DiscoverKeysTest {
|
||||||
press(Key.DirectionDown, Key.DirectionCenter)
|
press(Key.DirectionDown, Key.DirectionCenter)
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
assertEquals("shared", operations.sent.single().requester)
|
assertEquals("shared", operations.sent.single().requester)
|
||||||
press(Key.Back, Key.DirectionCenter)
|
press(Key.Escape, Key.DirectionCenter)
|
||||||
press(Key.DirectionDown, Key.DirectionCenter)
|
press(Key.DirectionDown, Key.DirectionCenter)
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
assertEquals(1, operations.sent.size)
|
assertEquals(1, operations.sent.size)
|
||||||
|
|
@ -252,11 +260,73 @@ class DiscoverKeysTest {
|
||||||
val origin = shell.view
|
val origin = shell.view
|
||||||
state.openCollection(state.lanes.first { it.id == "pk-movie" })
|
state.openCollection(state.lanes.first { it.id == "pk-movie" })
|
||||||
launch()
|
launch()
|
||||||
press(Key.DirectionDown, Key.Back)
|
press(Key.DirectionDown, Key.Escape)
|
||||||
assertNull(state.collection)
|
assertNull(state.collection)
|
||||||
assertSame(origin, shell.view)
|
assertSame(origin, shell.view)
|
||||||
assertEquals(3, shell.view.row)
|
assertEquals(3, shell.view.row)
|
||||||
assertEquals(2, shell.view.cards[state.rowKey])
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
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<ComponentActivity>()
|
||||||
|
|
||||||
|
@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<Unit>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
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.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
|
@ -59,16 +62,22 @@ class HeroMotionTest {
|
||||||
val probe = BitmapFactory.decodeFile(probeFile.absolutePath)
|
val probe = BitmapFactory.decodeFile(probeFile.absolutePath)
|
||||||
try {
|
try {
|
||||||
for (y in listOf(380, 400, 420, 600, 800)) {
|
for (y in listOf(380, 400, 420, 600, 800)) {
|
||||||
assertEquals("The outgoing hero must stop at its 400px boundary: y=$y",
|
assertEquals("Pending artwork cannot replace any part of the painted backdrop: y=$y",
|
||||||
(if (y < 400) Color.Red else Color.Blue).toArgb(), probe.getPixel(100, y))
|
Color.Red.toArgb(), probe.getPixel(100, y))
|
||||||
}
|
}
|
||||||
assertEquals(400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f)
|
|
||||||
} finally { probe.recycle() }
|
} finally { probe.recycle() }
|
||||||
update { ready.value = true }
|
update { ready.value = true }
|
||||||
rule.mainClock.advanceTimeBy(176)
|
rule.mainClock.advanceTimeBy(176)
|
||||||
val mixed = pixel(directory, 100)
|
val mixed = pixel(directory, 100)
|
||||||
assertNotEquals(Color.Red.toArgb(), mixed)
|
assertNotEquals(Color.Red.toArgb(), mixed)
|
||||||
assertNotEquals(Color.Blue.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)
|
rule.mainClock.advanceTimeBy(400)
|
||||||
assertEquals(Color.Blue.toArgb(), pixel(directory, 100))
|
assertEquals(Color.Blue.toArgb(), pixel(directory, 100))
|
||||||
update { selected.value = "green"; ready.value = false }
|
update { selected.value = "green"; ready.value = false }
|
||||||
|
|
@ -82,6 +91,35 @@ class HeroMotionTest {
|
||||||
} finally { directory.deleteRecursively() }
|
} 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) {
|
private fun update(block: () -> Unit) {
|
||||||
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
rule.mainClock.advanceTimeByFrame()
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
|
|
||||||
|
|
@ -110,12 +110,12 @@ class HomeCaptureTest {
|
||||||
@Test fun st019_picksUnavailable() = capture("st-019", mode = "picks-unavailable", row = "trending")
|
@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 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 st096_widestTilePlate() = capture("st-096", person = "mindel", row = "new", column = 4)
|
||||||
@Test fun st100_episodePlate() = capture("st-100", person = "mindel", row = "nu")
|
@Test fun st100_episodePlate() = capture("st-100", person = "mindel", row = "cw")
|
||||||
@Test fun st099_posterPlate() = capture("st-099", row = "lib-movies", column = 1)
|
@Test fun st099_posterPlate() = capture("st-099", row = "lib-movies", column = 1)
|
||||||
@Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2)
|
@Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "cw", column = 2)
|
||||||
@Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4)
|
@Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4)
|
||||||
@Test fun st103_heroRatings() = capture("st-103")
|
@Test fun st103_heroRatings() = capture("st-103")
|
||||||
@Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "nu")
|
@Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "cw")
|
||||||
@Test fun focusHeroDetails() = capture("focus-hero-details", column = 1)
|
@Test fun focusHeroDetails() = capture("focus-hero-details", column = 1)
|
||||||
@Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m")
|
@Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
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<ComponentActivity>()
|
||||||
|
|
||||||
|
@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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
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<ComponentActivity>()
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -98,20 +98,22 @@ class HomeKeysTest {
|
||||||
var action: HomeAction? = null
|
var action: HomeAction? = null
|
||||||
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } }
|
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } }
|
||||||
press("DOWN")
|
press("DOWN")
|
||||||
rule.onNodeWithTag("home-card:nu:2").performMouseInput { enter(center) }
|
rule.onNodeWithTag("home-card:cw:2").performMouseInput { enter(center) }
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertEquals("nu", snapshot.rows[shell.view.row].id)
|
assertEquals("cw", snapshot.rows[shell.view.row].id)
|
||||||
assertEquals(2, shell.view.cards["nu"])
|
assertEquals(2, shell.view.cards["cw"])
|
||||||
assertNull(action)
|
assertNull(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun downClampsToShortRowsAndNeverMovesPastTheFinalSeeAll() {
|
@Test fun rememberedColumnsClampToShortRowsAndTheFinalSeeAll() {
|
||||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false }
|
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 cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards
|
||||||
val long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards)
|
val long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards)
|
||||||
val short = long.copy(id = "short", cards = cards.take(2), total = 2)
|
val short = long.copy(id = "short", cards = cards.take(2), total = 2)
|
||||||
val tail = short.copy(id = "tail", total = 20)
|
val tail = short.copy(id = "tail", total = 20)
|
||||||
shell.view.cards[long.id] = cards.lastIndex
|
shell.view.cards[long.id] = cards.lastIndex
|
||||||
|
shell.view.cards[short.id] = cards.lastIndex
|
||||||
|
shell.view.cards[tail.id] = 2
|
||||||
var action: HomeAction? = null
|
var action: HomeAction? = null
|
||||||
rule.setContent { WolfskinTheme {
|
rule.setContent { WolfskinTheme {
|
||||||
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } }
|
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } }
|
||||||
|
|
@ -132,17 +134,18 @@ class HomeKeysTest {
|
||||||
assertEquals(tail.id, (action as HomeAction.SeeAll).row.id)
|
assertEquals(tail.id, (action as HomeAction.SeeAll).row.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() {
|
@Test fun visitedMixedWidthRowsKeepColumnsAndTrackWhenMovingInsideTheMiddle() {
|
||||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false }
|
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 cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards
|
||||||
val posters = HomeRow(id = "posters", kind = HomeRowKind.Shelf, cards = cards)
|
val posters = HomeRow(id = "posters", kind = HomeRowKind.Shelf, cards = cards)
|
||||||
val tiles = posters.copy(id = "tiles", cardKind = HomeCardKind.Tile)
|
val tiles = posters.copy(id = "tiles", cardKind = HomeCardKind.Tile)
|
||||||
shell.view.cards[posters.id] = 3
|
shell.view.cards[posters.id] = 3
|
||||||
|
shell.view.cards[tiles.id] = 2
|
||||||
rule.setContent { WolfskinTheme {
|
rule.setContent { WolfskinTheme {
|
||||||
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(posters, tiles)), modifier) {} }
|
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(posters, tiles)), modifier) {} }
|
||||||
} }
|
} }
|
||||||
press("DOWN")
|
press("DOWN")
|
||||||
assertEquals("The third tile is nearest the fourth poster's horizontal center", 2, shell.view.cards[tiles.id])
|
assertEquals(2, shell.view.cards[tiles.id])
|
||||||
repeat(4) { press("RIGHT") }
|
repeat(4) { press("RIGHT") }
|
||||||
val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left
|
val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left
|
||||||
press("LEFT")
|
press("LEFT")
|
||||||
|
|
@ -216,7 +219,7 @@ class HomeKeysTest {
|
||||||
val key = when (name) {
|
val key = when (name) {
|
||||||
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown
|
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown
|
||||||
"LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight
|
"LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight
|
||||||
"BACK" -> Key.Back; else -> Key.DirectionCenter
|
"BACK" -> Key.Escape; else -> Key.DirectionCenter
|
||||||
}
|
}
|
||||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue