Round 2 release candidate: Home, Discover, trailer, crash fix and stock screens #64

Merged
justinzeus merged 25 commits from fm/tv-round2-rc into fm/tv-round2 2026-11-07 21:14:15 +01:00
41 changed files with 671 additions and 307 deletions
Showing only changes of commit 12c201dc55 - Show all commits

View file

@ -29,7 +29,7 @@ sealed interface 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
@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
@SerialName("NextUp")

View file

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

View file

@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.google.common.cache.CacheBuilder
import dagger.hilt.android.qualifiers.ActivityContext
@ -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.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import timber.log.Timber
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import java.time.LocalDateTime
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import timber.log.Timber
/**
* Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row.
@ -46,50 +47,21 @@ class DatePlayedService
.expireAfterWrite(2, TimeUnit.HOURS)
.build<SeriesItemId, Deferred<LocalDateTime>>()
private fun getLastPlayed(
seriesId: UUID,
item: BaseItem,
): Deferred<LocalDateTime> {
val request =
GetEpisodesRequest(
seriesId = seriesId,
adjacentTo = item.id,
limit = 1,
)
return scope.async(WholphinDispatchers.IO) {
val premiereDate = item.data.premiereDate
private fun readLastPlayed(seriesId: UUID): Deferred<LocalDateTime> =
scope.async(WholphinDispatchers.IO) {
try {
val result =
GetEpisodesRequestHandler
.execute(
api,
request,
).content.items
.firstOrNull()
?.userData
?.lastPlayedDate ?: LocalDateTime.MIN
if (premiereDate != null && result.isBefore(premiereDate)) {
premiereDate
} else {
result
}
api.itemsApi.getItems(parentId = seriesId, recursive = true,
includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true,
sortBy = listOf(ItemSortBy.DATE_PLAYED), sortOrder = listOf(SortOrder.DESCENDING), limit = 1)
.content.items.firstOrNull()?.userData?.lastPlayedDate ?: LocalDateTime.MIN
} catch (ex: InvalidStatusException) {
Timber.w(
"Error fetching series=%s, item=%s: %s",
seriesId,
item.id,
ex.localizedMessage,
)
LocalDateTime.MIN.coerceAtLeast(premiereDate ?: LocalDateTime.MIN)
Timber.w(ex, "Error fetching series=%s", seriesId)
LocalDateTime.MIN
}
}
}
/**
* Get the logical timestamp when the item was last played.
*
* This is calculated using lastest of the actual last played timestamp of the item,
* the previous episode's last played timestamp, and the episode's premiere date
* Get the series' latest actual watch activity, never an upcoming episode's premiere date.
*/
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
withContext(WholphinDispatchers.IO) {
@ -97,7 +69,7 @@ class DatePlayedService
return@withContext if (seriesId != null) {
datePlayedCache
.get(SeriesItemId(seriesId, item.id)) {
getLastPlayed(seriesId, item)
readLastPlayed(seriesId)
}.await()
} else {
null

View file

@ -92,44 +92,8 @@ class HomeDataService
usePaging: Boolean = false,
): HomeRowLoadingState =
when (row) {
is HomeRowConfig.ContinueWatching -> {
val resume =
latestNextUpService.getResume(
userDto.id,
limit,
true,
row.viewOptions.useSeries,
)
Success(
title = ResStringProvider(R.string.continue_watching),
items = resume,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = resume.size >= limit,
)
}
is HomeRowConfig.NextUp -> {
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
Success(
title = ResStringProvider(R.string.next_up),
items = nextUp,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = nextUp.size >= limit,
)
}
is HomeRowConfig.ContinueWatching,
is HomeRowConfig.NextUp,
is HomeRowConfig.ContinueWatchingCombined -> {
val resume =
latestNextUpService.getResume(

View file

@ -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.JellyfinUser
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards
@ -159,7 +160,7 @@ class HomeSettingsService
null
}
}.orEmpty()
return HomePageSettings(rows, version)
return HomePageSettings(mergeHomeWatchRows(rows), version)
}
private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? =
@ -191,7 +192,7 @@ class HomeSettingsService
)
return settings?.let {
val resolvedRows =
settings.rows.mapIndexed { index, config ->
mergeHomeWatchRows(settings.rows).mapIndexed { index, config ->
resolve(index, config)
}
HomePageResolvedSettings(userId, resolvedSource, resolvedRows)
@ -261,7 +262,7 @@ class HomeSettingsService
settings: HomePageSettings,
) {
val resolvedRows =
settings.rows.mapIndexed { index, config ->
mergeHomeWatchRows(settings.rows).mapIndexed { index, config ->
resolve(index, config)
}
currentSettings.update { it.copy(rows = resolvedRows) }
@ -313,7 +314,7 @@ class HomeSettingsService
listOf(
HomeRowConfigDisplay(
id = includedIds.size + 1,
title = ResStringProvider(R.string.combine_continue_next),
title = ResStringProvider(R.string.continue_watching),
config = HomeRowConfig.ContinueWatchingCombined(),
),
)
@ -434,7 +435,8 @@ class HomeSettingsService
null
}
}.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 {
null
}
@ -457,19 +459,13 @@ class HomeSettingsService
)
}
is HomeRowConfig.ContinueWatching -> {
HomeRowConfigDisplay(
id,
ResStringProvider(R.string.continue_watching),
config,
)
}
is HomeRowConfig.ContinueWatching,
is HomeRowConfig.NextUp,
is HomeRowConfig.ContinueWatchingCombined -> {
HomeRowConfigDisplay(
id,
ResStringProvider(R.string.combine_continue_next),
config,
ResStringProvider(R.string.continue_watching),
HomeRowConfig.ContinueWatchingCombined(config.viewOptions),
)
}
@ -495,14 +491,6 @@ class HomeSettingsService
HomeRowConfigDisplay(id, StringStringProvider(config.name), config)
}
is HomeRowConfig.NextUp -> {
HomeRowConfigDisplay(
id,
ResStringProvider(R.string.next_up),
config,
)
}
is HomeRowConfig.RecentlyAdded -> {
val title = getItemName(R.string.recently_added_in, config.parentId)
HomeRowConfigDisplay(

View file

@ -158,7 +158,7 @@ class LatestNextUpService
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
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
Timber.v("buildCombined took %s", duration)
return@withContext result

View file

@ -1,7 +0,0 @@
package com.github.damontecres.wholphin.ui.main.settings
sealed interface HomeRowConfigAction {
data object Combine : HomeRowConfigAction
data object Split : HomeRowConfigAction
}

View file

@ -60,7 +60,6 @@ fun HomeRowSettings(
viewOptions: HomeRowViewOptions,
onViewOptionsChange: (HomeRowViewOptions) -> Unit,
onConfigChange: (HomeRowConfig) -> Unit,
onConfigAction: (HomeRowConfigAction) -> Unit,
onApplyApplyAll: () -> Unit,
modifier: Modifier = Modifier,
defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(),
@ -126,7 +125,6 @@ fun HomeRowSettings(
addAdditionalConfig(
config = config,
onConfigChange = onConfigChange,
onConfigAction = onConfigAction,
)
}
}
@ -135,29 +133,8 @@ fun HomeRowSettings(
private fun LazyListScope.addAdditionalConfig(
config: HomeRowConfig,
onConfigChange: (HomeRowConfig) -> Unit,
onConfigAction: (HomeRowConfigAction) -> Unit,
) {
when (config) {
is HomeRowConfig.ContinueWatchingCombined -> {
item {
ClickPreference(
title = stringResource(R.string.split_into_separate_rows),
onClick = { onConfigAction.invoke(HomeRowConfigAction.Split) },
)
}
}
is HomeRowConfig.ContinueWatching,
is HomeRowConfig.NextUp,
-> {
item {
ClickPreference(
title = stringResource(R.string.combine_continue_next),
onClick = { onConfigAction.invoke(HomeRowConfigAction.Combine) },
)
}
}
is HomeRowConfig.ByParent -> {
item {
val sortAndDirection =

View file

@ -35,8 +35,6 @@ fun HomeSettingsAddRow(
) {
itemsIndexed(
listOf(
MetaRowType.CONTINUE_WATCHING,
MetaRowType.NEXT_UP,
MetaRowType.COMBINED_CONTINUE_WATCHING,
),
) { index, type ->
@ -94,9 +92,7 @@ fun HomeSettingsAddRow(
enum class MetaRowType(
@param:StringRes val stringId: Int,
) {
CONTINUE_WATCHING(R.string.continue_watching),
NEXT_UP(R.string.next_up),
COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next),
COMBINED_CONTINUE_WATCHING(R.string.continue_watching),
FAVORITES(R.string.favorites),
DISCOVER(R.string.discover),
COLLECTION(R.string.collection),

View file

@ -157,8 +157,6 @@ fun HomeSettingsPage(
onClick = { backStack.add(ChooseRowType(it)) },
onClickMeta = {
when (it) {
MetaRowType.CONTINUE_WATCHING,
MetaRowType.NEXT_UP,
MetaRowType.COMBINED_CONTINUE_WATCHING,
-> {
addRow { viewModel.addRow(it) }
@ -246,18 +244,6 @@ fun HomeSettingsPage(
onConfigChange = {
viewModel.onConfigChange(row, it)
},
onConfigAction = {
viewModel.onConfigAction(row, it)
when (it) {
HomeRowConfigAction.Combine,
HomeRowConfigAction.Split,
-> {
backStack.removeAt(
backStack.lastIndex,
)
}
}
},
)
}

View file

@ -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.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.HomeDataService
@ -249,26 +250,10 @@ class HomeSettingsViewModel
val id = idCounter++
val newRow =
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 -> {
HomeRowConfigDisplay(
id = id,
title = ResStringProvider(R.string.combine_continue_next),
title = ResStringProvider(R.string.continue_watching),
config = ContinueWatchingCombined(),
)
}
@ -813,7 +798,7 @@ class HomeSettingsViewModel
if (result != null) {
Timber.v("Got remote settings")
val newRows =
result.rows.mapIndexed { index, config ->
mergeHomeWatchRows(result.rows).mapIndexed { index, config ->
homeSettingsService.resolve(index, config)
}
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(
row: HomeRowConfigDisplay,
config: HomeRowConfig,

View file

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

View file

@ -17,7 +17,7 @@ import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
class HomeLibraryCacheTest {
@Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() {
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 library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84))
HomeLibraryCache(context).write("server:person", library, 12345L)
@ -26,4 +26,19 @@ class HomeLibraryCacheTest {
assertEquals(12345L, saved.second)
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)
}
}

View file

@ -121,7 +121,7 @@ class WatchReadTest {
runCurrent()
assertFalse(loading.isCompleted)
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)
loading.join()
assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001)
@ -132,4 +132,28 @@ class WatchReadTest {
WholphinDispatchers.reset()
}
}
@Test fun mergedHomeUsesSeriesWatchActivityNotAnUnwatchedNextEpisodeDate() = runTest {
val otherSeries = UUID.fromString("00000000-0000-0000-0000-000000000004")
val checkpoint = decode(resumeWire).let { it.copy(seriesId = seriesId, seriesName = "Series A",
userData = it.userData!!.copy(lastPlayedDate = java.time.LocalDateTime.parse("2026-11-05T12:00:00"))) }
val hero = checkpoint.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000005"), type = BaseItemKind.MOVIE, seriesId = null, seriesName = null)
val next = decode(nextWire).let { it.copy(seriesId = seriesId, seriesName = "Series A",
userData = it.userData!!.copy(playbackPositionTicks = 0, lastPlayedDate = null)) }
val otherNext = next.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000006"), seriesId = otherSeries, seriesName = "Series B")
coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(hero, checkpoint))
coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next, otherNext))
for ((id, date) in listOf(seriesId to "2026-11-04T12:00:00", otherSeries to "2026-11-07T12:00:00")) {
coEvery { library.getItem(userId = userId, itemId = id) } returns successResponse(BaseItemDto(id = id, type = BaseItemKind.SERIES))
val watched = checkpoint.copy(userData = checkpoint.userData!!.copy(lastPlayedDate = java.time.LocalDateTime.parse(date)))
coEvery { items.getItems(userId = userId, parentId = id, recursive = true,
includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true,
sortBy = listOf(org.jellyfin.sdk.model.api.ItemSortBy.DATE_PLAYED),
sortOrder = listOf(org.jellyfin.sdk.model.api.SortOrder.DESCENDING), limit = 1) } returns successQueryResult(listOf(watched))
}
val rows = buildHomeRows(HomeReadFacade(api, images, mockk()).readWatch(userId), emptyList())
val watching = rows.single { it.id == "cw" }.cards
assertEquals(listOf(otherNext.id.toString(), checkpoint.id.toString()), watching.map { it.id })
assertEquals(96.078, watching.last().resumeSeconds, .0001)
}
}

View file

@ -317,6 +317,7 @@ fun WolfskinApp(
LaunchedEffect(destination, state, isCurrent) {
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
}
org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) {
if (destination.fullScreen) {
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
} else {
@ -342,6 +343,7 @@ fun WolfskinApp(
}
}
}
}
}
},
)

View file

@ -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("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt)
put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId)
put("seriesId", title.seriesId); put("lastPlayedAt", title.lastPlayedAt)
}) }
}
@ -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"),
string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"),
List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"),
seriesId = string("seriesId"), lastPlayedAt = if (item.isNull("lastPlayedAt")) null else item.getLong("lastPlayedAt"),
)
}

View file

@ -3,7 +3,12 @@ package org.justinvisser.wolfskin.tv.read
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.ImageUrlService
import java.util.UUID
import java.time.ZoneOffset
import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
@ -27,18 +32,39 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
suspend fun readWatch(userId: UUID): HomeLibrary {
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id ->
try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() }
catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
null
}
val seriesIds = buildSet {
resumeItems.forEach { it.seriesId?.let(::add) }
nextItems.forEach { it.seriesId?.let(::add) }
}
val series = coroutineScope {
val limiter = Semaphore(WATCH_READ_CONCURRENCY)
seriesIds.associateWith { id -> async { limiter.withPermit { readSeriesWatch(userId, id) } } }
.mapValues { it.value.await() }
}
val resume = resumeItems.map { title(it, series) }
val next = nextItems.map { title(it, series) }
return HomeLibrary(resume = resume, nextUp = next)
}
private suspend fun readSeriesWatch(userId: UUID, id: UUID): SeriesWatch {
val tmdb = try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() }
catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
null
}
// Next-up episodes are unwatched; date the series by its latest actually watched episode.
val playedAt = try {
api.itemsApi.getItems(userId = userId, parentId = id, recursive = true,
includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true,
sortBy = listOf(ItemSortBy.DATE_PLAYED), sortOrder = listOf(SortOrder.DESCENDING), limit = 1)
.content.items.firstOrNull()?.userData?.lastPlayedDate?.toInstant(ZoneOffset.UTC)?.toEpochMilli()
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
null
}
return SeriesWatch(tmdb, playedAt)
}
/** Library failures do not discard an already successful watch read. */
suspend fun readLibrary(userId: UUID): HomeLibrary {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
@ -66,7 +92,7 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
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)
return HomeTitle(
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,
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true,
tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] 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 {
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)
}
}

View file

@ -14,6 +14,17 @@ The focused card and its ring remain fully inside the viewport, with at least th
Library and Person grids retain six columns and the existing Level 2 row spacing. The final Person column keeps the full poster width; horizontal padding must not shrink it.
## Home continuation and navigation — round 2 amendment
Home has one **Continue watching** lane for in-progress films/episodes and next-up episodes. Use the existing landscape-tile 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.
Landscape-card bottom gradients extend through the reserved state-line area, ending at the solid bottom backing with no exposed artwork band. The title-gradient top padding is **36 px / 18 dp**, down from 54 px / 27 dp; the approved 92% bottom opacity is unchanged. Captions and state lines retain their existing geometry.
## 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.

View file

@ -330,7 +330,10 @@ attribution and project URL. The check does not depend on incremental merge file
## Wolfskin Home
Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero,
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
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
@ -339,11 +342,13 @@ remains the later-slice target.
All Home components and immutable screen models live in `:wolfskin-design`.
The shell's remembered view owns vertical position, each row's selected card and
actual horizontal `LazyListState`, and the bounded six-hour ratings session cache.
Composing destinations bind `rememberedView(route)` before navigation effects run, so a
new See all grid cannot borrow or clamp Home's scroller. Re-entry restores the
remembered track before the page with `scrollToItem`, preserving exact indices and
pixel offsets rather than animating back. Android focus stays with the shell;
cards use its logical focus, without requests against uncomposed lazy items.
Every `NavEntry` installs `ScreenEntry` with its own `rememberedView(route)` and
active state before navigation effects run. Home, Library, Discover, title and
person surfaces consume that entry's viewport; an outgoing page cannot borrow
Home's scroller during Back. Only the active entry registers logical key handling
or acquires Android focus. Re-entry restores the remembered horizontal track
with `scrollToItem`, preserving exact indices and pixel offsets. Cards use the
shell's logical focus without requests against uncomposed lazy items.
Back preserves the viewport; returning to Home does not re-run focus-follow over
a manually scrolled saved view. The next arrow re-enters visible content when
scrolling moved focus off-screen.
@ -356,10 +361,23 @@ The initial skeleton parks focus on the nav. When the first content arrives,
an untouched fresh entry focuses hero button zero; intervening nav input prevents
that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus`
release/repeat guard and Back ladder.
Landscape title scrims include the reserved state-line area inside their gradient
rather than leaving an exposed image strip below it. Their top padding is the
shared Home token (18 dp); opacity remains the approved 92%.
The legacy opt-out Home and its presets also use one combined watch row. Saved
local/server/web/plugin split rows migrate at their first position, preserving
that row's display options and all unrelated rows. Old serialized row names stay
decodable; settings no longer offer separate lanes or a Split action.
`HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up
publish independently of slow or failed library shelves; saved metadata still
paints immediately, but a saved hero offers Details until watch data revalidates.
Series chronology reads the latest actually watched episode, not the next-up
episode's unwatched user data or premiere date. Enrichment is bounded to three
concurrent series reads; absent history leaves the date unknown rather than
inventing a timestamp. The disk projection preserves series IDs and watch dates,
and still accepts older cache entries without either field.
Home and title Play actions resolve the current Jellyfin item and its current
checkpoint at the click, preserving millisecond positions. Series actions use
live NextUp with resumable episodes enabled; an explicitly opened episode stays

View file

@ -50,9 +50,9 @@
},
"cardOverlayTop": {
"$type": "dimension",
"px": 54,
"dp": 27.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
"px": 36,
"dp": 18.0,
"source": "TV round 2: lower card bottom gradient; cover the state-line reserve"
},
"cardOverlayBottom": {
"$type": "dimension",

View file

@ -99,6 +99,8 @@ class HomeFixture(context: Context) {
resumeSeconds = item.optDouble("resumeSeconds", 0.0).takeIf { it.isFinite() } ?: 0.0,
played = item.optBoolean("played"), addedAt = item.optString("addedAt", ""), genres = genres(item).toList(), ratings = ratings(item),
held = item.optString("availability") == "in_library", tmdbId = item.intOrNull("tmdbId"),
seriesId = item.stringOrNull("seriesId"),
lastPlayedAt = item.stringOrNull("lastPlayedAt")?.let { java.time.Instant.parse(it).toEpochMilli() },
)
private fun ratings(item: JSONObject): List<HomeRating> {

View file

@ -44,12 +44,14 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@Composable
fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
val shell = state.shell
val view = screenView(shell)
val entryActive = LocalScreenActive.current
val focus = remember(state) { object : ShellContentFocus by state {} }
DisposableEffect(focus) {
shell.contentFocus = focus
DisposableEffect(focus, entryActive) {
if (entryActive) shell.contentFocus = focus
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 } }
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
val ratingsReader = LocalScreenRatings.current
@ -78,7 +80,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
return
}
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 heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) {
buildList {

View file

@ -28,6 +28,8 @@ data class HomeTitle(
val held: Boolean = false,
val otherOwner: String? = null,
val tmdbId: Int? = null,
val seriesId: String? = null,
val lastPlayedAt: Long? = null,
) {
val isEpisode: Boolean get() = type == "episode"
val displayTitle: String get() = seriesTitle ?: title

View file

@ -18,12 +18,13 @@ fun buildHomeRows(
): List<HomeRow> = buildList {
if (loading) {
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))
}
return@buildList
}
val choice = when {
// Hero precedence remains independent of the merged shelf's recency order.
library.resume.isNotEmpty() -> "resume" to library.resume.first()
library.nextUp.isNotEmpty() -> "next" to library.nextUp.first()
library.newest.isNotEmpty() -> "new" to library.newest.first()
@ -34,10 +35,10 @@ fun buildHomeRows(
if (!libraryAvailable) {
add(HomeRow("jf-down", HomeRowKind.State, "Your library can’t be reached", text = "Jellyfin isn’t answering, so nothing can play right now."))
} else {
listOf(Triple("cw", "Continue watching", library.resume), Triple("nu", "Next up", library.nextUp), Triple("new", "New in your library", library.newest)).forEach { (id, title, source) ->
val cards = source.filter { it.id != hero?.id }
if (cards.isNotEmpty()) add(HomeRow(id, HomeRowKind.Shelf, title, cards, if (id == "cw") HomeCardKind.Wide else HomeCardKind.Tile))
}
val watching = continueWatching(library).filter { it.watchIdentity != hero?.watchIdentity }
if (watching.isNotEmpty()) add(HomeRow("cw", HomeRowKind.Shelf, "Continue watching", watching, HomeCardKind.Tile))
val newest = library.newest.filter { it.id != hero?.id }
if (newest.isNotEmpty()) add(HomeRow("new", HomeRowKind.Shelf, "New in your library", newest, HomeCardKind.Tile))
listOf("movies", "shows", "anime").forEach { destination ->
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))
@ -47,6 +48,27 @@ fun buildHomeRows(
addAll(continuation)
}
/** Prefer a resumable episode per series, then sort the chosen items by actual watch activity. */
internal fun continueWatching(library: HomeLibrary): List<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. */
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)

View file

@ -29,11 +29,12 @@ fun HomeScreen(
onAction: (HomeAction) -> Unit,
) {
val view = shell.rememberedView("home")
val active = LocalScreenActive.current
LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt }
val action by rememberUpdatedState(onAction)
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
DisposableEffect(shell, focus) {
shell.contentFocus = focus
DisposableEffect(shell, focus, active) {
if (active) shell.contentFocus = focus
onDispose { if (shell.contentFocus === focus) shell.contentFocus = null }
}
LaunchedEffect(snapshot.rows, view) {

View file

@ -86,9 +86,10 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
private fun TileOverlay(title: HomeTitle) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
val metadata = WolfskinTokens.CardMetadata
Column(Modifier.padding(bottom = metadata.bottom + metadata.separator + metadata.resumeLine).fillMaxWidth()
.background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f))))
.padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) {
Column(Modifier.fillMaxWidth()
.background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = WolfskinTokens.Title.cardOverlayOpacity))))
.padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop,
bottom = H.cardOverlayBottom + metadata.bottom + metadata.separator + metadata.resumeLine)) {
Text(title.displayTitle, modifier = Modifier.exactLineBox(metadata.titleFocus), color = C.fg, style = metadata.titleFocus, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}

View file

@ -39,10 +39,11 @@ fun LibraryScreen(
cachedRatings: Set<String> = emptySet(),
ratingsReachable: Boolean = true,
offlineArt: ((String) -> ImageBitmap?)? = null,
view: RememberedView = shell.view,
view: RememberedView = screenView(shell),
registerFocus: Boolean = true,
) {
val density = LocalDensity.current
val entryActive = LocalScreenActive.current
val context = LocalContext.current
val active = !shell.navFocused && !shell.pickerVisible
val items = grid.items
@ -50,8 +51,8 @@ fun LibraryScreen(
val count = if (grid.catalogue) items.size else grid.source.size
var plateKey by remember { mutableStateOf<String?>(null) }
val ratingsReader = LocalScreenRatings.current
DisposableEffect(shell, grid, registerFocus) {
if (registerFocus) shell.contentFocus = grid
DisposableEffect(shell, grid, registerFocus, entryActive) {
if (registerFocus && entryActive) shell.contentFocus = grid
onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null }
}
LaunchedEffect(grid) {

View file

@ -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. */
@Composable
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 }
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)) {
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
ShellSurface(shell, handlesContentKeys = false) { PersonPage(state, shell, it) }
@ -101,7 +102,8 @@ fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Mo
@Composable
private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
val density = LocalDensity.current
val list = shell.view.listState
val list = screenView(shell).listState
val entryActive = LocalScreenActive.current
var wheelPending by remember { mutableStateOf(false) }
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
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
}
key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false
}.focusable()) {
}.focusable(enabled = entryActive)) {
LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
item(key = "person-header", contentType = "header") {
Box(Modifier.height(L.row_phead)) {

View file

@ -0,0 +1,20 @@
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 }
/** 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

View file

@ -116,6 +116,9 @@ class ShellState(
fun rememberedView(destination: String): RememberedView =
views[person.key to destination] ?: RememberedView()
/** Register an entry before composition; rememberedView also serves read-only snapshot observers. */
internal fun prepareDestinationView(destination: String) { retainView(destination) }
/** External intents and upstream pops remain authoritative over the screen. */
fun enterDestination(destination: String) {
retainView(destination)

View file

@ -61,6 +61,8 @@ fun ShellSurface(
) {
val focus = remember { FocusRequester() }
val contentFocus = remember { FocusRequester() }
val active = LocalScreenActive.current
val view = screenView(state)
val focusManager = LocalFocusManager.current
LaunchedEffect(state.toast) {
val message = state.toast
@ -69,30 +71,32 @@ fun ShellSurface(
if (state.toast == message) state.toast = null
}
}
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
else contentFocus.requestFocus()
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys, active) {
if (active) {
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
else contentFocus.requestFocus()
}
}
Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell")
.semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" }
.focusRequester(focus)
.onPreviewKeyEvent {
if (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 {
if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
if (active && it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
if (!focusManager.moveFocus(FocusDirection.Up)) {
state.navFocused = true
state.navIndex = state.currentTab
}
true
} else false
}.focusable()) {
}.focusable(enabled = active)) {
if (state.pickerVisible) {
WhosWatching(state)
} else {
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)
overlay()
}

View file

@ -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. */
@Composable
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 }
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
// No content target exists during an initial load; nav Down must not enter a skeleton.
@ -87,15 +87,16 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism
@Composable
fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
val density = LocalDensity.current
val list = shell.view.listState
val list = screenView(shell).listState
val entryActive = LocalScreenActive.current
var wheelPending by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() }
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
state.data.item.kind == "tv" && state.data.item.seasons > 0
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible, entryActive) {
if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
}
val displayedRows = remember(state.data) {
buildList {
@ -143,7 +144,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
}
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable()) {
}.focusable(enabled = entryActive)) {
// This remains visible behind the scrolled rows; it is not an off-screen header image.
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().drawBehind { drawRect(C.bg.copy(alpha = dim)) })

View file

@ -586,7 +586,7 @@ object WolfskinTokens {
val factsGap = 11.0.dp
val overviewBottom = 15.0.dp
val cardPadding = 11.0.dp
val cardOverlayTop = 27.0.dp
val cardOverlayTop = 18.0.dp
val cardOverlayBottom = 9.0.dp
val sectionTop = 20.0.dp
val sectionAvatar = 28.0.dp

View file

@ -110,12 +110,12 @@ class HomeCaptureTest {
@Test fun st019_picksUnavailable() = capture("st-019", mode = "picks-unavailable", row = "trending")
@Test fun st020_missingArt() = capture("st-020", person = "shared", mode = "missing", row = "lib-movies")
@Test fun st096_widestTilePlate() = capture("st-096", person = "mindel", row = "new", column = 4)
@Test fun st100_episodePlate() = capture("st-100", person = "mindel", row = "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 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 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 xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m")
}

View file

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

View file

@ -98,10 +98,10 @@ class HomeKeysTest {
var action: HomeAction? = null
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } }
press("DOWN")
rule.onNodeWithTag("home-card:nu:2").performMouseInput { enter(center) }
rule.onNodeWithTag("home-card:cw:2").performMouseInput { enter(center) }
rule.waitForIdle()
assertEquals("nu", snapshot.rows[shell.view.row].id)
assertEquals(2, shell.view.cards["nu"])
assertEquals("cw", snapshot.rows[shell.view.row].id)
assertEquals(2, shell.view.cards["cw"])
assertNull(action)
}

View file

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

View file

@ -44,7 +44,26 @@ class HomeRowsTest {
@Test fun firstReadSkeletonHasNoFocusableContent() {
val rows = buildHomeRows(HomeLibrary(), continuation, loading = true)
assertEquals(listOf(HomeRowKind.Hero, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf), rows.map { it.kind })
assertTrue(rows.all { !it.focusable })
}
@Test fun mergedWatchRowPrefersResumePerSeriesAndInterleavesByWatchRecency() {
val inProgress = next.copy(id = "resume-a", seriesId = "a", resumeSeconds = 40.0, lastPlayedAt = 30)
val olderResume = inProgress.copy(id = "older-a", lastPlayedAt = 10)
val nextSameSeries = next.copy(id = "next-a", seriesId = "a", lastPlayedAt = 50)
val nextOtherSeries = next.copy(id = "next-b", seriesId = "b", lastPlayedAt = 40)
val film = resume.copy(id = "film", resumeSeconds = 20.0, lastPlayedAt = 20)
val unknown = next.copy(id = "unknown", seriesId = "c")
val library = HomeLibrary(listOf(inProgress, olderResume, film), listOf(nextSameSeries, nextOtherSeries, nextOtherSeries, unknown))
assertEquals(listOf("next-b", "resume-a", "film", "unknown"), continueWatching(library).map { it.id })
val rows = buildHomeRows(library, continuation)
assertFalse(rows.any { it.id == "nu" })
assertEquals(listOf("next-b", "film", "unknown"), rows.single { it.id == "cw" }.cards.map { it.id })
}
@Test fun oldCachedEpisodesWithoutSeriesIdsDeduplicateBySeriesName() {
val checkpoint = next.copy(id = "resume", seriesTitle = "Same series", resumeSeconds = 10.0)
val following = next.copy(id = "next", seriesTitle = "Same series")
assertEquals(listOf(checkpoint), continueWatching(HomeLibrary(listOf(checkpoint), listOf(following))))
}
}

View file

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

View file

@ -411,7 +411,7 @@
--home-factsGap: 22.0px;
--home-overviewBottom: 30.0px;
--home-cardPadding: 22.0px;
--home-cardOverlayTop: 54.0px;
--home-cardOverlayTop: 36.0px;
--home-cardOverlayBottom: 18.0px;
--home-sectionTop: 40.0px;
--home-sectionAvatar: 56.0px;

View file

@ -1,8 +1,8 @@
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
695e086fd771ee463411a28e8f241637c3c86d13e9474995abaea27a1feff1b7 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css
71905adcd89991f8ce41859cce7714fcf382a19de25538e5272a84e88f070b07 tokens.css
ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json