Fix Home navigation ownership and merge watch lanes
This commit is contained in:
parent
c2f7ccc438
commit
12c201dc55
41 changed files with 671 additions and 307 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
/** Saved split rows become one watch row at their first position, retaining its presentation choices. */
|
||||
fun mergeHomeWatchRows(rows: List<HomeRowConfig>): List<HomeRowConfig> = buildList {
|
||||
var watchRowSeen = false
|
||||
for (row in rows) {
|
||||
when (row) {
|
||||
is HomeRowConfig.ContinueWatching, is HomeRowConfig.NextUp, is HomeRowConfig.ContinueWatchingCombined -> {
|
||||
if (!watchRowSeen) add(HomeRowConfig.ContinueWatchingCombined(row.viewOptions))
|
||||
watchRowSeen = true
|
||||
}
|
||||
else -> add(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
|||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
sealed interface HomeRowConfigAction {
|
||||
data object Combine : HomeRowConfigAction
|
||||
|
||||
data object Split : HomeRowConfigAction
|
||||
}
|
||||
|
|
@ -60,7 +60,6 @@ fun HomeRowSettings(
|
|||
viewOptions: HomeRowViewOptions,
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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))))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue