diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 39b82bc8..9a6fba35 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -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") diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeWatchRows.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeWatchRows.kt new file mode 100644 index 00000000..3e2fdb5f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeWatchRows.kt @@ -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): List = 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) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt index ceb84695..b44dc024 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt @@ -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>() - private fun getLastPlayed( - seriesId: UUID, - item: BaseItem, - ): Deferred { - 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 = + 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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt index fe462b98..c529b5cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 1c05640e..f0f6c00e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index dbaa219e..f58bacaa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -158,7 +158,7 @@ class LatestNextUpService val timestamps = mutableMapOf() 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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt deleted file mode 100644 index 2a44c8f4..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -sealed interface HomeRowConfigAction { - data object Combine : HomeRowConfigAction - - data object Split : HomeRowConfigAction -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt index 48901b0f..dcd46216 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt @@ -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 = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt index 439b2505..1055455b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt @@ -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), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 0bd6b7db..01ef4cb1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -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, - ) - } - } - }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 1082a23f..4c74fc44 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -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, diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/HomeWatchRowsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/HomeWatchRowsTest.kt new file mode 100644 index 00000000..7b8c5655 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/HomeWatchRowsTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt index 49fc269f..fcf22467 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt @@ -17,7 +17,7 @@ import org.justinvisser.wolfskin.tv.read.HomeLibraryCache class HomeLibraryCacheTest { @Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() { val context = ApplicationProvider.getApplicationContext() - 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() + 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) + } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt index a7901727..fdcaa710 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt @@ -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) + } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index aed8ec7b..2eb874eb 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -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( } } } + } } }, ) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt index 10422c09..796c0b29 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt @@ -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"), ) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt index bb86e68a..109a7a0a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt @@ -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 = emptyMap()): HomeTitle { + private fun title(item: BaseItemDto, series: Map = 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) } } diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 913f31f5..a67848fd 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -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. diff --git a/docker/README.md b/docker/README.md index 5cd6f6fa..edf99eb4 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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 diff --git a/wolfskin-design/home-tokens.json b/wolfskin-design/home-tokens.json index 7cceeaa7..8a903933 100644 --- a/wolfskin-design/home-tokens.json +++ b/wolfskin-design/home-tokens.json @@ -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", diff --git a/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt b/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt index c046c290..f56c3459 100644 --- a/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt +++ b/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt @@ -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 { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index e3137e45..bff191bd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -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 { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt index 8bf7d6e2..45ddf9d4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt @@ -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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt index 4c7f8beb..b92c4386 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt @@ -18,12 +18,13 @@ fun buildHomeRows( ): List = 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 { 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 { + val chosen = linkedMapOf() + fun choose(source: List) { + 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) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index 11725f4a..f6cc248f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -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) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt index d1827758..4f29d5f1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -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) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 93c55c85..322437be 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -39,10 +39,11 @@ fun LibraryScreen( cachedRatings: Set = 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(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) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index 7da2fba8..3f5be1e8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -86,10 +86,11 @@ class PersonState(initialPerson: TitlePerson, initialCredits: List, 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)) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt new file mode 100644 index 00000000..2947bdd3 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt @@ -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 { 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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index c770a3ab..ecae94c8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -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) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index 603942b2..f7dbfb1d 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -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() } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index cefe5064..6a604daf 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -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)) }) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 6c014755..842cd98d 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -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 diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt index 323b52ff..18f75917 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt @@ -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") } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeGradientTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeGradientTest.kt new file mode 100644 index 00000000..241da584 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeGradientTest.kt @@ -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() + + @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() } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt index a23faa73..50f40ac0 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt @@ -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) } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt new file mode 100644 index 00000000..f3ed1662 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt @@ -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(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(null) + private var outgoing by mutableStateOf(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("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() + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt index 9fa7e9a9..280e4cae 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt @@ -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)))) + } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt new file mode 100644 index 00000000..7504b73f --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt @@ -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() + 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() + } +} diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index cb81f2a5..50c24868 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -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; diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index a45b7f17..0dbf3324 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -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