Compare commits
16 commits
main
...
fm/tv-r2-h
| Author | SHA1 | Date | |
|---|---|---|---|
| f375d88030 | |||
| 7c67596cb6 | |||
| a27e922675 | |||
| c5dbe91076 | |||
| 5811ab0555 | |||
| 33cca64eba | |||
| 9cfe9362ee | |||
| 2b142af8d1 | |||
| e6ddf1d7e5 | |||
| 174e1ae5c9 | |||
| febcded034 | |||
| ae613216d0 | |||
| 5046727b2a | |||
| a159c835c5 | |||
| 12c201dc55 | |||
| 6d7513bd0e |
72 changed files with 2056 additions and 470 deletions
|
|
@ -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" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,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) {
|
||||||
|
|
@ -97,7 +100,8 @@ class ConductarrTitleSurfaceTest {
|
||||||
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
|
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
|
||||||
}
|
}
|
||||||
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" })
|
||||||
|
assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" })
|
||||||
} finally { viewModel.viewModelScope.cancel() }
|
} finally { viewModel.viewModelScope.cancel() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,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 +170,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 +219,10 @@ 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.")) })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import 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
|
||||||
|
|
||||||
|
/** A loaded outgoing destination remains composed after pop but cannot consume the incoming Home Back. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
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)
|
||||||
|
|
||||||
|
private fun verifyOutgoingBack(personPage: Boolean) {
|
||||||
|
val activity = rule.activity
|
||||||
|
val navigation = NavigationManager()
|
||||||
|
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
|
val shell = ShellState(listOf(viewer), viewer.key) {
|
||||||
|
if (it == ShellAction.Exit) navigation.requestExit()
|
||||||
|
}.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))
|
||||||
|
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 {
|
||||||
|
rememberWolfskinBackHandler(shell, navigation)
|
||||||
|
val current = navigation.backStack.last()
|
||||||
|
LaunchedEffect(current) { if (current is Destination.Home) shell.enterDestination("home") }
|
||||||
|
Box {
|
||||||
|
ScreenEntry(shell, "home", current is Destination.Home) { HomeScreen(shell, home) {} }
|
||||||
|
// Retain the real outgoing destination just as NavDisplay does during its exit animation.
|
||||||
|
ScreenEntry(shell, route, current == destination) {
|
||||||
|
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.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
|
||||||
|
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())
|
||||||
|
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
assertTrue("Only the following Back from Home nav should exit", activity.isFinishing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,9 +14,6 @@ import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
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 +33,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)
|
||||||
|
|
@ -284,15 +280,6 @@ 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) })
|
||||||
|
|
@ -315,26 +302,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() {
|
||||||
|
|
|
||||||
|
|
@ -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.Back)
|
||||||
|
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.Back)
|
||||||
|
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.Back)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,184 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.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 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], 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 }
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) }
|
||||||
|
assertTrue(shell.navFocused)
|
||||||
|
assertFalse(activity.isFinishing)
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.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))
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import androidx.compose.ui.input.key.type
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
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
|
||||||
|
|
@ -339,7 +340,7 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen
|
||||||
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) {
|
||||||
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 ->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -201,6 +200,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) }
|
||||||
|
|
@ -271,7 +271,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(
|
||||||
|
|
@ -285,6 +287,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) {
|
||||||
|
|
@ -300,7 +303,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(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
||||||
|
|
@ -317,10 +320,10 @@ fun WolfskinApp(
|
||||||
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
|
||||||
|
|
@ -342,6 +345,7 @@ fun WolfskinApp(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
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.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) {
|
||||||
|
{
|
||||||
|
if (navigation.backStack.lastOrNull()?.fullScreen == true) navigation.goBack()
|
||||||
|
else shell.key(ShellKey.Back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
return onBack
|
||||||
|
}
|
||||||
|
|
@ -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,15 @@
|
||||||
|
|
||||||
## 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”.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 +22,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.
|
||||||
|
|
@ -74,7 +99,7 @@ Row reveals are grouped rather than the browser prototype's per-card staging: th
|
||||||
|
|
||||||
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. 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 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 loading keeps nav focus and Back still pops the title/person route.
|
||||||
|
|
||||||
Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range.
|
Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range.
|
||||||
|
|
||||||
|
|
|
||||||
116
docker/README.md
116
docker/README.md
|
|
@ -43,6 +43,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
|
||||||
|
|
@ -228,6 +234,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
|
||||||
|
|
@ -236,6 +248,23 @@ 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”.
|
||||||
|
|
||||||
|
`rememberWolfskinBackHandler` owns Android Back at the app root, including a
|
||||||
|
single-entry `NavDisplay`; full-screen hand-offs use the same `NavigationManager`
|
||||||
|
exit event rather than exposing the parent setup stack. `WolfskinRootExitTest`
|
||||||
|
mounts actual Wolfskin Home/Discover/library surfaces and covers picker,
|
||||||
|
hardware Back and Coming-in sheet precedence. `WolfskinBackStackTest` mounts
|
||||||
|
the real Updates and Home Settings pages to cover parent pops and unsaved edits.
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -346,7 +375,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
|
||||||
|
|
@ -355,27 +387,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
|
||||||
|
|
@ -637,24 +687,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
|
||||||
|
|
@ -706,6 +763,28 @@ 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -730,7 +809,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.
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,14 @@ 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.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 +80,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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -46,10 +47,21 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
||||||
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -86,9 +86,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -285,7 +290,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
|
||||||
|
|
@ -303,17 +308,13 @@ 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) {
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,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
|
||||||
|
|
@ -69,30 +71,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 (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
|
if (active) {
|
||||||
else contentFocus.requestFocus()
|
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
|
||||||
|
else contentFocus.requestFocus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell")
|
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) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) 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,6 +32,7 @@ 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
|
||||||
|
|
@ -110,7 +111,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))
|
||||||
|
|
|
||||||
|
|
@ -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,7 +51,7 @@ 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; nav Down must not enter a skeleton.
|
||||||
|
|
@ -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,7 +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 (compensateHeader) return@LaunchedEffect
|
||||||
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
||||||
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]
|
||||||
|
|
@ -143,7 +158,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 +169,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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
@ -80,11 +80,11 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
||||||
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,13 +92,13 @@ 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 itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
|
||||||
TitlePoster(item, active && state.column == index, Modifier.width(width)
|
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) }
|
||||||
}
|
}
|
||||||
|
|
@ -117,17 +117,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,13 +190,12 @@ 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"
|
||||||
|
|
@ -205,9 +212,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 +225,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 +252,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 +300,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))
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,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
|
||||||
|
|
@ -451,8 +451,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
|
||||||
|
|
@ -586,7 +589,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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import androidx.compose.ui.MotionDurationScale
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Directional navigation follows stable rows through page returns and asynchronous replacement. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
class HomeNavigationTest {
|
||||||
|
private val motionScale = object : MotionDurationScale { override val scaleFactor = 1f }
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = motionScale)
|
||||||
|
private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
|
||||||
|
private val cards = listOf(HomeTitle("episode", "Episode", "episode", seriesTitle = "Series", season = 1, episode = 2))
|
||||||
|
private val rows = listOf(
|
||||||
|
HomeRow("hero", HomeRowKind.Hero, cards = cards),
|
||||||
|
HomeRow("cw", HomeRowKind.Shelf, "Continue watching", cards, HomeCardKind.Wide),
|
||||||
|
HomeRow("nu", HomeRowKind.Shelf, "Next up", cards, HomeCardKind.Tile),
|
||||||
|
HomeRow("new", HomeRowKind.Shelf, "New in your library", cards, HomeCardKind.Tile),
|
||||||
|
HomeRow("pending", HomeRowKind.Shelf, skeleton = true),
|
||||||
|
HomeRow("section", HomeRowKind.Section),
|
||||||
|
) + (0..5).map { HomeRow("row-$it", HomeRowKind.Shelf, "Shelf $it", cards) }
|
||||||
|
private var snapshot by mutableStateOf(HomeSnapshot(rows))
|
||||||
|
private var title by mutableStateOf<TitleState?>(null)
|
||||||
|
private var outgoing by mutableStateOf<TitleState?>(null)
|
||||||
|
private var retainOutgoing = false
|
||||||
|
|
||||||
|
@Test fun downScrollAfterBackIsMonotonicAtSixteenMillisecondFrames() {
|
||||||
|
retainOutgoing = true
|
||||||
|
mount()
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
press(Key.Back)
|
||||||
|
rule.runOnIdle { outgoing = null }
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||||
|
val directory = File(System.getProperty("wolfskin.captureDirectory"), "home-scroll-frames").apply { mkdirs() }
|
||||||
|
val series = mutableListOf<String>("milliseconds,index,offset")
|
||||||
|
var previousIndex = shell.view.listState.firstVisibleItemIndex
|
||||||
|
var previousOffset = shell.view.listState.firstVisibleItemScrollOffset
|
||||||
|
val start = previousIndex to previousOffset
|
||||||
|
repeat((WolfskinTokens.Motion.scroll + 64) / 16) { frame ->
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
rule.waitForIdle()
|
||||||
|
val list = shell.view.listState
|
||||||
|
val index = list.firstVisibleItemIndex
|
||||||
|
val offset = list.firstVisibleItemScrollOffset
|
||||||
|
assertTrue("Scroll must not snap backward at frame $frame", index > previousIndex || index == previousIndex && offset >= previousOffset)
|
||||||
|
series += "${(frame + 1) * 16},$index,$offset"
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "frame-${frame.toString().padStart(2, '0')}.png").absolutePath)
|
||||||
|
previousIndex = index
|
||||||
|
previousOffset = offset
|
||||||
|
}
|
||||||
|
File(directory, "position.csv").writeText(series.joinToString("\n"))
|
||||||
|
assertNotEquals("Down must actually scroll after Back", start, previousIndex to previousOffset)
|
||||||
|
assertEquals(2, shell.view.row)
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
walkDown(start = 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun outgoingEpisodeDuringBackCannotTakeOverTheHomeScroller() {
|
||||||
|
retainOutgoing = true
|
||||||
|
mount()
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
rule.onNodeWithTag("title-page").assertIsDisplayed()
|
||||||
|
press(Key.Back)
|
||||||
|
rule.runOnIdle { outgoing = null }
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals(2, shell.view.row)
|
||||||
|
walkDown(start = 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun downAdvancesEveryRowAndScrollsAfterEpisodeBack() {
|
||||||
|
mount()
|
||||||
|
walkDown()
|
||||||
|
repeat(rows.count { it.focusable } - 1) { press(Key.DirectionUp) }
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
val saved = shell.view
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
rule.onNodeWithTag("title-page").assertIsDisplayed()
|
||||||
|
press(Key.Back)
|
||||||
|
assertSame(saved, shell.view)
|
||||||
|
assertEquals(1, shell.view.row)
|
||||||
|
walkDown(start = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun refreshReorderInsertionRemovalAndSkeletonReadyKeepDownProgress() {
|
||||||
|
mount()
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertEquals("nu", snapshot.rows[shell.view.row].id)
|
||||||
|
rule.runOnIdle { snapshot = snapshot.copy(rows = listOf(rows[0], rows[3], rows[1], rows[2]) + rows.drop(4)) }
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals("nu", snapshot.rows[shell.view.row].id)
|
||||||
|
rule.runOnIdle { snapshot = snapshot.copy(rows = snapshot.rows.map { if (it.id == "pending") it.copy(cards = cards, skeleton = false) else it }) }
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertEquals("pending", snapshot.rows[shell.view.row].id)
|
||||||
|
rule.runOnIdle { snapshot = snapshot.copy(rows = listOf(HomeRow("insert", HomeRowKind.Shelf, cards = cards)) + snapshot.rows) }
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals("pending", snapshot.rows[shell.view.row].id)
|
||||||
|
rule.runOnIdle { snapshot = snapshot.copy(rows = snapshot.rows.filter { it.id != "pending" }) }
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
walkDown(start = shell.view.row)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun loadingToReadyThenRefreshDoesNotTrapFocusOnPendingRows() {
|
||||||
|
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true))
|
||||||
|
mount()
|
||||||
|
assertTrue(shell.navFocused)
|
||||||
|
rule.runOnIdle { snapshot = HomeSnapshot(rows) }
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
walkDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mount() {
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
val page = title
|
||||||
|
if (page != null) ScreenEntry(shell, "title/episode", true) { TitleSurface(page, shell) }
|
||||||
|
else ScreenEntry(shell, "home", true) { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action ->
|
||||||
|
if (action is HomeAction.Details) {
|
||||||
|
shell.enterDestination("title/episode")
|
||||||
|
title = TitleState(TitlePageData(TitleItem(action.title.id, action.title.title, "episode", held = true)), shell.person) { effect ->
|
||||||
|
if (effect == TitleAction.Pop) {
|
||||||
|
if (retainOutgoing) outgoing = title
|
||||||
|
shell.enterDestination("home")
|
||||||
|
title = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} } }
|
||||||
|
outgoing?.let { ScreenEntry(shell, "title/episode", false) { TitleSurface(it, shell) } }
|
||||||
|
} }
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun walkDown(start: Int = 0) {
|
||||||
|
val indices = snapshot.rows.indices.filter { it >= start && snapshot.rows[it].focusable }
|
||||||
|
for (index in indices) {
|
||||||
|
if (index != start) press(Key.DirectionDown)
|
||||||
|
assertEquals("Down must advance to ${snapshot.rows[index].id}", index, shell.view.row)
|
||||||
|
if (snapshot.rows[index].kind == HomeRowKind.Shelf) {
|
||||||
|
val bounds = rule.onNodeWithTag("home-card:${snapshot.rows[index].id}:0").fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue("Focused card must clear navigation: $bounds", bounds.top >= WolfskinTokens.Layout.navBand.value * rule.density.density)
|
||||||
|
assertTrue("Focused card must clear screen bottom: $bounds", bounds.bottom <= WolfskinTokens.Shell.stageHeight.value * rule.density.density)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertEquals(indices.last(), shell.view.row)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun press(key: Key) {
|
||||||
|
rule.onRoot().performKeyInput { pressKey(key) }
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Home starts new rows at zero and retains visited positions independently of other root tabs. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class HomeRowEntryTest {
|
||||||
|
private val people = listOf(ShellPerson("justin", "Justin", "J", true))
|
||||||
|
private val cards = List(12) { HomeTitle("item-$it", "Film $it", "movie") }
|
||||||
|
|
||||||
|
@Test fun unvisitedRowsStartLeftmostAcrossCardWidths() {
|
||||||
|
for (from in HomeCardKind.entries) for (to in HomeCardKind.entries) {
|
||||||
|
val shell = ShellState(people, "justin").apply { pickerVisible = false; navFocused = false }
|
||||||
|
val source = HomeRow("source", HomeRowKind.Shelf, cards = cards, cardKind = from)
|
||||||
|
val target = source.copy(id = "target", cardKind = to)
|
||||||
|
val focus = HomeFocus(shell, listOf(source, target)) {}
|
||||||
|
focus.focus(0, 3)
|
||||||
|
focus.key(ShellKey.Down)
|
||||||
|
assertEquals("$from to $to", target.id, focus.row?.id)
|
||||||
|
assertEquals("$from to $to must start leftmost", 0, focus.column)
|
||||||
|
assertEquals(0, shell.rememberedView("home").home.firstCards[target.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun visitedColumnsAndFirstVisiblePositionsSurviveRowChangesAndRootTabLeave() {
|
||||||
|
val shell = ShellState(people, "justin").apply { pickerVisible = false; navFocused = false }
|
||||||
|
val source = HomeRow("source", HomeRowKind.Shelf, cards = cards)
|
||||||
|
val rows = listOf(source, source.copy(id = "target", cardKind = HomeCardKind.Tile))
|
||||||
|
val focus = HomeFocus(shell, rows) {}
|
||||||
|
focus.focus(0, 8)
|
||||||
|
focus.key(ShellKey.Left)
|
||||||
|
val view = shell.rememberedView("home")
|
||||||
|
val first = view.home.firstCards.getValue(source.id)
|
||||||
|
val track = LazyListState(first, 19)
|
||||||
|
view.home.tracks[source.id] = track
|
||||||
|
focus.key(ShellKey.Down)
|
||||||
|
focus.key(ShellKey.Up)
|
||||||
|
assertEquals(7, focus.column)
|
||||||
|
assertEquals(first, view.home.firstCards[source.id])
|
||||||
|
|
||||||
|
shell.focusNav(shell.tabs.indexOf("discover")); shell.key(ShellKey.Ok)
|
||||||
|
shell.focusNav(shell.tabs.indexOf("home")); shell.key(ShellKey.Ok)
|
||||||
|
val restored = HomeFocus(shell, rows) {}
|
||||||
|
assertSame(view, shell.rememberedView("home"))
|
||||||
|
assertEquals(7, restored.column)
|
||||||
|
restored.key(ShellKey.Down)
|
||||||
|
restored.key(ShellKey.Up)
|
||||||
|
assertEquals(7, restored.column)
|
||||||
|
assertEquals(first, view.home.firstCards[source.id])
|
||||||
|
assertSame(track, view.home.tracks[source.id])
|
||||||
|
assertEquals(first, track.firstVisibleItemIndex)
|
||||||
|
assertEquals(19, track.firstVisibleItemScrollOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,7 +44,26 @@ class HomeRowsTest {
|
||||||
|
|
||||||
@Test fun firstReadSkeletonHasNoFocusableContent() {
|
@Test fun firstReadSkeletonHasNoFocusableContent() {
|
||||||
val rows = buildHomeRows(HomeLibrary(), continuation, loading = true)
|
val rows = buildHomeRows(HomeLibrary(), continuation, loading = true)
|
||||||
assertEquals(listOf(HomeRowKind.Hero, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf), rows.map { it.kind })
|
|
||||||
assertTrue(rows.all { !it.focusable })
|
assertTrue(rows.all { !it.focusable })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun mergedWatchRowPrefersResumePerSeriesAndInterleavesByWatchRecency() {
|
||||||
|
val inProgress = next.copy(id = "resume-a", seriesId = "a", resumeSeconds = 40.0, lastPlayedAt = 30)
|
||||||
|
val olderResume = inProgress.copy(id = "older-a", lastPlayedAt = 10)
|
||||||
|
val nextSameSeries = next.copy(id = "next-a", seriesId = "a", lastPlayedAt = 50)
|
||||||
|
val nextOtherSeries = next.copy(id = "next-b", seriesId = "b", lastPlayedAt = 40)
|
||||||
|
val film = resume.copy(id = "film", resumeSeconds = 20.0, lastPlayedAt = 20)
|
||||||
|
val unknown = next.copy(id = "unknown", seriesId = "c")
|
||||||
|
val library = HomeLibrary(listOf(inProgress, olderResume, film), listOf(nextSameSeries, nextOtherSeries, nextOtherSeries, unknown))
|
||||||
|
assertEquals(listOf("next-b", "resume-a", "film", "unknown"), continueWatching(library).map { it.id })
|
||||||
|
val rows = buildHomeRows(library, continuation)
|
||||||
|
assertFalse(rows.any { it.id == "nu" })
|
||||||
|
assertEquals(listOf("next-b", "film", "unknown"), rows.single { it.id == "cw" }.cards.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun oldCachedEpisodesWithoutSeriesIdsDeduplicateBySeriesName() {
|
||||||
|
val checkpoint = next.copy(id = "resume", seriesTitle = "Same series", resumeSeconds = 10.0)
|
||||||
|
val following = next.copy(id = "next", seriesTitle = "Same series")
|
||||||
|
assertEquals(listOf(checkpoint), continueWatching(HomeLibrary(listOf(checkpoint), listOf(following))))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,20 +44,19 @@ class MotionSurfaceTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
|
||||||
private val viewer = ShellPerson("justin", "Justin", "J", false)
|
private val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
|
|
||||||
@Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() {
|
@Test fun reducedMotionReadinessSnapsAndRemovingItRestoresTheCollapsedStack() {
|
||||||
val shell = shell()
|
val shell = shell()
|
||||||
val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview")
|
val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview")
|
||||||
val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview")
|
val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview")
|
||||||
val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
|
val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
|
||||||
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides true) { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } }
|
||||||
rule.waitForIdle()
|
|
||||||
val before = bounds()
|
val before = bounds()
|
||||||
for (stage in listOf("Ready", "Downloading", null)) {
|
assertEquals(0f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
|
||||||
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Mindel", item = series.copy(ratings = listOf(LibraryRating("rt", 88.0))))) }
|
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Ready", requestActor = "Mindel")) }
|
||||||
rule.mainClock.advanceTimeBy(3400)
|
assertEquals(56f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
|
||||||
rule.waitForIdle()
|
bounds().zip(before).forEach { (after, initial) -> assertEquals(56f, after.top - initial.top, 1f) }
|
||||||
assertEquals("Late metadata cannot change reserved header geometry", before, bounds())
|
rule.runOnIdle { state.update(state.data.copy(requestedStage = null)) }
|
||||||
}
|
assertEquals(before, bounds())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {
|
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Every profile reaches Settings vertically without losing its horizontal return position. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class PickerNavigationTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun downFromEveryAvatarReachesSettingsAndUpReturnsToTheOrigin() {
|
||||||
|
val state = ShellState(fixturePeople(), "justin")
|
||||||
|
rule.setContent { WolfskinTheme { ShellSurface(state) } }
|
||||||
|
for (index in state.people.indices) {
|
||||||
|
rule.runOnIdle { state.pickerIndex = index; state.gearFocused = false }
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertTrue("Down from profile $index must reach Settings", state.gearFocused)
|
||||||
|
press(Key.DirectionUp)
|
||||||
|
assertFalse(state.gearFocused)
|
||||||
|
assertEquals("Up must return to profile $index", index, state.pickerIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun settingsBackThenUpKeepsTheAvatarThatOpenedTheGear() {
|
||||||
|
val state = ShellState(fixturePeople(), "justin")
|
||||||
|
rule.setContent { WolfskinTheme { ShellSurface(state) } }
|
||||||
|
for (index in state.people.indices) {
|
||||||
|
rule.runOnIdle { state.pickerIndex = index; state.gearFocused = false }
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals("settings", state.route)
|
||||||
|
press(Key.Back)
|
||||||
|
assertTrue(state.pickerVisible)
|
||||||
|
assertTrue(state.gearFocused)
|
||||||
|
press(Key.DirectionUp)
|
||||||
|
assertEquals(index, state.pickerIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun settingsGearIsCenteredUnderTheWholePeopleRowForAnyProfileCount() {
|
||||||
|
val state = ShellState(fixturePeople(), "justin")
|
||||||
|
rule.setContent { WolfskinTheme { ShellSurface(state) } }
|
||||||
|
for (count in 1..5) {
|
||||||
|
val people = (0 until count).map { ShellPerson("person-$it", "Person $it", "$it", false) }
|
||||||
|
rule.runOnIdle { state.updatePeople(people); state.gearFocused = true }
|
||||||
|
rule.mainClock.advanceTimeBy(400)
|
||||||
|
val first = rule.onNodeWithTag("picker-person:${people.first().key}").fetchSemanticsNode().boundsInRoot
|
||||||
|
val last = rule.onNodeWithTag("picker-person:${people.last().key}").fetchSemanticsNode().boundsInRoot
|
||||||
|
val gear = rule.onNodeWithTag("picker-settings").fetchSemanticsNode().boundsInRoot
|
||||||
|
assertEquals("Settings must stay centred under the whole row for $count people", (first.center.x + last.center.x) / 2, gear.center.x, .1f)
|
||||||
|
assertTrue("Settings must be below the avatars", gear.top > last.bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun press(key: Key) {
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||||
|
rule.mainClock.advanceTimeBy(400)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** A retained outgoing title cannot steal another screen's viewport or Android input on Back. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
class ScreenEntryNavigationTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
|
||||||
|
private val credits = (0..35).map { TitleItem("credit-$it", "Credit $it", "movie") }
|
||||||
|
private val grid = LibraryGridState("Library", credits.map { LibraryTitle(it.id, it.name, "movie") }).apply { toolbar = false }
|
||||||
|
private val person = PersonState(TitlePerson("actor", "Actor"), credits, {})
|
||||||
|
private val title = TitleState(TitlePageData(TitleItem("parent", "Parent", "movie"), cast = listOf(TitlePerson("actor", "Actor")), related = credits.take(10)), shell.person) {}
|
||||||
|
private val fixture = discoverTestFixture()
|
||||||
|
private val snapshot = fixture.snapshot("justin")
|
||||||
|
private val discover = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), {}, {}, fixture::service)
|
||||||
|
private var childVisible by mutableStateOf(false)
|
||||||
|
private var outgoingVisible by mutableStateOf(false)
|
||||||
|
private lateinit var child: TitleState
|
||||||
|
|
||||||
|
@Test fun libraryBackRestoresCardAndDownReachesFinalGridRow() = verify("movies")
|
||||||
|
@Test fun discoverBackRestoresShelfAndDownReachesFinalShelf() = verify("discover")
|
||||||
|
@Test fun personBackRestoresCreditAndDownReachesFinalGridRow() = verify("person/actor")
|
||||||
|
@Test fun titleBackRestoresRowAndDownReachesRelatedShelf() = verify("title/parent")
|
||||||
|
|
||||||
|
private fun verify(route: String) {
|
||||||
|
shell.enterDestination(route)
|
||||||
|
if (route == "discover") discover.focus(discover.firstShelf, 0)
|
||||||
|
child = TitleState(TitlePageData(TitleItem("child", "Child episode", "episode", held = true)), shell.person) { action ->
|
||||||
|
if (action == TitleAction.Pop) {
|
||||||
|
shell.enterDestination(route)
|
||||||
|
childVisible = false
|
||||||
|
outgoingVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
if (!childVisible) ScreenEntry(shell, route, true) { host(route) }
|
||||||
|
if (childVisible || outgoingVisible) ScreenEntry(shell, "title/child", childVisible) { TitleSurface(child, shell) }
|
||||||
|
} }
|
||||||
|
rule.waitForIdle()
|
||||||
|
val view = shell.view
|
||||||
|
val before = position(route)
|
||||||
|
rule.runOnIdle { shell.enterDestination("title/child"); childVisible = true }
|
||||||
|
press(Key.Back)
|
||||||
|
rule.runOnIdle { outgoingVisible = false }
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertSame(view, shell.view)
|
||||||
|
assertEquals(before, position(route))
|
||||||
|
repeat(maxOf(credits.size, discover.rowCount)) {
|
||||||
|
val previous = position(route)
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertTrue("$route Down cannot move backward: $previous -> ${position(route)}", position(route) >= previous)
|
||||||
|
}
|
||||||
|
val expected = when {
|
||||||
|
route == "movies" -> (grid.items.lastIndex / grid.columns) * grid.columns
|
||||||
|
route == "discover" -> discover.rowCount - 1
|
||||||
|
route.startsWith("person/") -> (credits.lastIndex / WolfskinTokens.Behaviour.gridColumns.toInt()) * WolfskinTokens.Behaviour.gridColumns.toInt()
|
||||||
|
else -> title.rows.lastIndex
|
||||||
|
}
|
||||||
|
assertEquals("$route must reach its last row after Back", expected, position(route))
|
||||||
|
assertTrue("$route must scroll its last row into composition", view.listState.firstVisibleItemIndex > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable private fun host(route: String) {
|
||||||
|
when {
|
||||||
|
route == "movies" -> ShellSurface(shell) { LibraryScreen(shell, grid, it) }
|
||||||
|
route == "discover" -> ShellSurface(shell) { DiscoverScreen(discover, it) }
|
||||||
|
route.startsWith("person/") -> PersonSurface(person, shell)
|
||||||
|
else -> TitleSurface(title, shell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun position(route: String): Int = when {
|
||||||
|
route == "movies" -> grid.index
|
||||||
|
route == "discover" -> discover.focusedRow
|
||||||
|
route.startsWith("person/") -> person.index
|
||||||
|
else -> title.rows.indexOf(title.row)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun press(key: Key) {
|
||||||
|
rule.onRoot().performKeyInput { pressKey(key) }
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,16 +60,6 @@ class ShellKeysTest {
|
||||||
assertEquals(2, state.settingsRow)
|
assertEquals(2, state.settingsRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {
|
|
||||||
replay(listOf(Key.DirectionDown, Key.DirectionRight, Key.DirectionDown), 3)
|
|
||||||
assertFalse(state.gearFocused)
|
|
||||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionDown) }
|
|
||||||
assertTrue(state.gearFocused)
|
|
||||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp) }
|
|
||||||
assertFalse(state.gearFocused)
|
|
||||||
assertEquals(2, state.pickerIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun rootBackAndNavDownPreserveViewAndWrap() {
|
@Test fun rootBackAndNavDownPreserveViewAndWrap() {
|
||||||
replay(listOf(Key.DirectionCenter), 1)
|
replay(listOf(Key.DirectionCenter), 1)
|
||||||
val view = state.view
|
val view = state.view
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.MotionDurationScale
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Readiness changes move real title content continuously, without reserving an empty band. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class TitleReadinessMotionTest {
|
||||||
|
private val animatorScale = object : MotionDurationScale { override var scaleFactor by mutableFloatStateOf(1f) }
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
|
||||||
|
private lateinit var state: TitleState
|
||||||
|
|
||||||
|
private fun render() {
|
||||||
|
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
|
val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") }
|
||||||
|
val series = TitleItem("series", "A series", "tv", seasons = 1, held = true, overview = "A series overview")
|
||||||
|
val episode = TitleItem("episode", "Pilot", "episode", held = true, season = 1, episode = 1, overview = "Episode overview")
|
||||||
|
state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
|
||||||
|
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
||||||
|
rule.mainClock.advanceTimeBy(500)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun positions(): List<Float> = listOf("title-readiness-slot", "title-overview", "title-actions", "title-shelf:episodes").mapIndexed { index, tag ->
|
||||||
|
val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
|
||||||
|
if (index == 0) bounds.height else bounds.top
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun frames(stage: String?, name: String, count: Int = 22): List<List<Float>> {
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-readiness-motion/$name").apply { mkdirs() }
|
||||||
|
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Justin")); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
|
val values = List(count) { index ->
|
||||||
|
rule.waitForIdle()
|
||||||
|
positions().also {
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "${index.toString().padStart(2, '0')}.png").absolutePath)
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
File(directory, "samples.csv").writeText("timeMs,slotHeightPx,overviewTopPx,actionsTopPx,episodesTopPx\n" + values.mapIndexed { index, row -> "${index * 16},${row.joinToString(",")}" }.joinToString("\n"))
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun lateReadinessExpansionAndRemovalStayMonotonicAndMoveTheWholeStack() {
|
||||||
|
render()
|
||||||
|
val collapsed = positions()
|
||||||
|
assertEquals(0f, collapsed[0], 0.1f)
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val opening = frames("Ready", "expand")
|
||||||
|
val expanded = opening.last()
|
||||||
|
assertEquals(56f, expanded[0], 0.1f)
|
||||||
|
expanded.drop(1).zip(collapsed.drop(1)).forEach { (after, before) -> assertEquals(56f, after - before, 1f) }
|
||||||
|
val closing = frames(null, "collapse")
|
||||||
|
assertEquals(collapsed, closing.last())
|
||||||
|
for ((samples, direction) in listOf(opening to 1f, closing to -1f)) {
|
||||||
|
samples.zipWithNext().forEach { (before, after) -> before.zip(after).forEach { (first, second) ->
|
||||||
|
assertTrue("Readiness content must not reverse or overshoot: $samples", (second - first) * direction >= -0.1f)
|
||||||
|
assertTrue("Height change must not jump in one frame", kotlin.math.abs(second - first) < 56f)
|
||||||
|
} }
|
||||||
|
samples.forEach { assertTrue(it[0] in 0f..56f) }
|
||||||
|
}
|
||||||
|
assertFalse(state.options.any { it.id == "seasons" })
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aPinnedEpisodeShelfDoesNotBounceWhenReadinessChangesAboveIt() {
|
||||||
|
render()
|
||||||
|
rule.runOnIdle { state.focus("episodes", 0) }
|
||||||
|
rule.mainClock.advanceTimeBy(500)
|
||||||
|
rule.waitForIdle()
|
||||||
|
val pinned = positions().last()
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
for (samples in listOf(frames("Ready", "pinned", count = 34), frames(null, "pinned-collapse", count = 34))) {
|
||||||
|
samples.forEach { assertEquals("A header change cannot bounce a pinned focused shelf", pinned, it.last(), 1f) }
|
||||||
|
}
|
||||||
|
assertEquals("episodes", state.row)
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ class WolfskinImagesTest {
|
||||||
Surface("grid/Home/Discover/person poster", 256, 384, "w342"),
|
Surface("grid/Home/Discover/person poster", 256, 384, "w342"),
|
||||||
Surface("Home tile", 400, 225, "w780"),
|
Surface("Home tile", 400, 225, "w780"),
|
||||||
Surface("Home wide", 544, 306, "w780"),
|
Surface("Home wide", 544, 306, "w780"),
|
||||||
Surface("episode still", 400, 280, "w780"),
|
Surface("episode still", 448, 280, "w780"),
|
||||||
Surface("cast portrait", 200, 200, "w342"),
|
Surface("cast portrait", 200, 200, "w342"),
|
||||||
Surface("person portrait", 240, 240, "w342"),
|
Surface("person portrait", 240, 240, "w342"),
|
||||||
Surface("hero poster", 420, 630, "w342", true),
|
Surface("hero poster", 420, 630, "w342", true),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
{
|
{
|
||||||
"title": {
|
"title": {
|
||||||
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: episode art height +24.4%"},
|
"episodeCardWidth": {"dp": 224, "px": 448, "source": "tv-round2: episode width +12%"},
|
||||||
|
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: preserve taller episode cards"},
|
||||||
|
"actionMenuWidth": {"dp": 260, "px": 520, "source": "tv-round2: compact title action menu"},
|
||||||
"seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"},
|
"seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"},
|
||||||
|
"posterCastGap": {"dp": 10, "px": 20, "source": "tv-round2: minimum poster-to-cast clearance"},
|
||||||
"lineGap": {"dp": 11, "px": 22, "source": "index.html:214"},
|
"lineGap": {"dp": 11, "px": 22, "source": "index.html:214"},
|
||||||
"lineLift": {"dp": -2, "px": -4, "source": "index.html:214"},
|
"lineLift": {"dp": -2, "px": -4, "source": "index.html:214"},
|
||||||
"overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"},
|
"overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"},
|
||||||
|
|
|
||||||
|
|
@ -290,8 +290,11 @@
|
||||||
--lift-heroY: 30.0px;
|
--lift-heroY: 30.0px;
|
||||||
--lift-sheetBlur: 80.0px;
|
--lift-sheetBlur: 80.0px;
|
||||||
--lift-sheetX: -30.0px;
|
--lift-sheetX: -30.0px;
|
||||||
|
--title-episodeCardWidth: 448.0px;
|
||||||
--title-episodeCardHeight: 280.0px;
|
--title-episodeCardHeight: 280.0px;
|
||||||
|
--title-actionMenuWidth: 520.0px;
|
||||||
--title-seriesHeaderGap: 40.0px;
|
--title-seriesHeaderGap: 40.0px;
|
||||||
|
--title-posterCastGap: 20.0px;
|
||||||
--title-lineGap: 22.0px;
|
--title-lineGap: 22.0px;
|
||||||
--title-lineLift: -4.0px;
|
--title-lineLift: -4.0px;
|
||||||
--title-overviewGap: 34.0px;
|
--title-overviewGap: 34.0px;
|
||||||
|
|
@ -411,7 +414,7 @@
|
||||||
--home-factsGap: 22.0px;
|
--home-factsGap: 22.0px;
|
||||||
--home-overviewBottom: 30.0px;
|
--home-overviewBottom: 30.0px;
|
||||||
--home-cardPadding: 22.0px;
|
--home-cardPadding: 22.0px;
|
||||||
--home-cardOverlayTop: 54.0px;
|
--home-cardOverlayTop: 36.0px;
|
||||||
--home-cardOverlayBottom: 18.0px;
|
--home-cardOverlayBottom: 18.0px;
|
||||||
--home-sectionTop: 40.0px;
|
--home-sectionTop: 40.0px;
|
||||||
--home-sectionAvatar: 56.0px;
|
--home-sectionAvatar: 56.0px;
|
||||||
|
|
|
||||||
|
|
@ -1543,9 +1543,9 @@
|
||||||
"source": "feedback-2026-11-06: largest upright hero poster slot"
|
"source": "feedback-2026-11-06: largest upright hero poster slot"
|
||||||
},
|
},
|
||||||
"episodeRequest": {
|
"episodeRequest": {
|
||||||
"px": [400, 280],
|
"px": [448, 280],
|
||||||
"decode": "ARGB_8888",
|
"decode": "ARGB_8888",
|
||||||
"source": "feedback-2026-11-06: taller episode cards"
|
"source": "tv-round2: wider episode cards at their preserved height"
|
||||||
},
|
},
|
||||||
"castRequest": {
|
"castRequest": {
|
||||||
"px": [
|
"px": [
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
||||||
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
|
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
|
||||||
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
|
72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json
|
||||||
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
|
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
|
||||||
3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
8c47b082670b4805c9c961addee6ef8b567d874bf93d8f8dc4b160eef6d18b6a src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
|
98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json
|
||||||
d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css
|
f188d525547d20d5751688573d8a81f8a2cb6c050dfb0141b8378842a7ff8a9c tokens.css
|
||||||
ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json
|
b1cf8f8caea9bfacb7994e0ba39e71ffab33452f6f5227a524ba71874b045b4d tokens.json
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue