Fix Home navigation ownership and merge watch lanes

This commit is contained in:
justinzeus 2026-11-07 15:23:45 +01:00
parent c2f7ccc438
commit 12c201dc55
41 changed files with 671 additions and 307 deletions

View file

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

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.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

View file

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

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.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(

View file

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

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, 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 =

View file

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

View file

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

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.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,

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

View file

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

View file

@ -317,6 +317,7 @@ 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 {
@ -343,6 +344,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("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"),
) )
} }

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

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. 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 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.

View file

@ -330,7 +330,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
@ -339,11 +342,13 @@ 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.
@ -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 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

View file

@ -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",

View file

@ -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> {

View file

@ -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 {

View file

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

View file

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

View file

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

View file

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

View file

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

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. */ /** 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)) {

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 = 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)

View file

@ -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 (active) {
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus() if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
else contentFocus.requestFocus() else contentFocus.requestFocus()
} }
}
Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell") Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell")
.semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" } .semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" }
.focusRequester(focus) .focusRequester(focus)
.onPreviewKeyEvent { .onPreviewKeyEvent {
if (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys) state.handleKey(it) else false if (active && (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys)) state.handleKey(it) else false
} }
.onKeyEvent { .onKeyEvent {
if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) { if (active && it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
if (!focusManager.moveFocus(FocusDirection.Up)) { if (!focusManager.moveFocus(FocusDirection.Up)) {
state.navFocused = true state.navFocused = true
state.navIndex = state.currentTab state.navIndex = state.currentTab
} }
true true
} else false } else false
}.focusable()) { }.focusable(enabled = active)) {
if (state.pickerVisible) { if (state.pickerVisible) {
WhosWatching(state) WhosWatching(state)
} else { } else {
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) 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()
} }

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. */ /** 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.
@ -87,15 +87,16 @@ 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 compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
state.data.item.kind == "tv" && state.data.item.seasons > 0 state.data.item.kind == "tv" && state.data.item.seasons > 0
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) { LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible, entryActive) {
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
} }
val displayedRows = remember(state.data) { val displayedRows = remember(state.data) {
buildList { buildList {
@ -143,7 +144,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)) })

View file

@ -586,7 +586,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

View file

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

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

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

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-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;

View file

@ -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 695e086fd771ee463411a28e8f241637c3c86d13e9474995abaea27a1feff1b7 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json 062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css 71905adcd89991f8ce41859cce7714fcf382a19de25538e5272a84e88f070b07 tokens.css
ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json