0.2.73
This commit is contained in:
@@ -46,7 +46,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.72"
|
||||
val defaultVersionName = "0.2.73"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -869,30 +869,47 @@ class EmbyRepository internal constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* One stable, paged slice of all films or all series.
|
||||
* One stable, paged slice of all films, all series, or both.
|
||||
*
|
||||
* This deliberately mirrors [browseGenre] so switching between All and a genre never
|
||||
* changes the grid's ordering, user-data fields, or infinite-scroll behaviour.
|
||||
* changes the grid's ordering, user-data fields, or infinite-scroll behaviour — down to
|
||||
* how the two read [itemType], which is why the resolution below is the same rule.
|
||||
* The Genres destination is the mixed case: its "All genres" entry is the catalogue
|
||||
* itself, where the Movies and TV Series pages name a type and never cross media types.
|
||||
*/
|
||||
suspend fun browseLibrary(
|
||||
offset: Int = 0,
|
||||
limit: Int = GENRE_PAGE_SIZE,
|
||||
itemType: String,
|
||||
itemType: String?,
|
||||
): GenrePage {
|
||||
val embyItemType = if (itemType.trim().equals("Series", ignoreCase = true)) "Series" else "Movie"
|
||||
val embyItemType = when (itemType?.trim()?.lowercase()) {
|
||||
"movie" -> "Movie"
|
||||
"series" -> "Series"
|
||||
else -> "Movie,Series"
|
||||
}
|
||||
if (ServerConfig.isGateway) {
|
||||
runCatching { requireGateway().libraryItems(offset, limit, embyItemType) }
|
||||
runCatching {
|
||||
// The mixed shelf is the route's own default, so it is asked for by saying
|
||||
// nothing rather than by naming both types.
|
||||
requireGateway().libraryItems(
|
||||
offset,
|
||||
limit,
|
||||
embyItemType.takeUnless { it == "Movie,Series" }.orEmpty(),
|
||||
)
|
||||
}
|
||||
.onSuccess { page -> return GenrePage(page.items, offset, page.total) }
|
||||
.onFailure { error ->
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
if (offset > 0) throw error
|
||||
// An older gateway has no whole-library route. Its home response is a
|
||||
// useful first shelf and, crucially, is not claimed to be pageable.
|
||||
// An older gateway has no whole-library route, and one older still
|
||||
// refuses the mixed type. Its home response is a useful first shelf
|
||||
// and, crucially, is not claimed to be pageable.
|
||||
val home = getHome(limit)
|
||||
val types = embyItemType.split(',')
|
||||
val items = (home.rows.asSequence().flatMap { it.items.asSequence() } +
|
||||
home.continueWatching.asSequence() + home.nextUp.asSequence() +
|
||||
home.favorites.asSequence() + home.latestMovies.asSequence())
|
||||
.filter { it.type.equals(embyItemType, ignoreCase = true) }
|
||||
.filter { candidate -> types.any { candidate.type.equals(it, ignoreCase = true) } }
|
||||
.distinctBy(BaseItem::id)
|
||||
.take(limit)
|
||||
.toList()
|
||||
|
||||
@@ -63,6 +63,10 @@ data class NavigationLabels(
|
||||
val search: String,
|
||||
val movies: String,
|
||||
val tvShows: String,
|
||||
// Defaulted, unlike its neighbours: a configuration document published before the
|
||||
// Genres destination existed carries no such key, and a required field would make an
|
||||
// otherwise valid document fail to decode and take every other label down with it.
|
||||
val genres: String = "Genres",
|
||||
val tvCalendar: String,
|
||||
val favourites: String,
|
||||
val user: String,
|
||||
@@ -103,6 +107,7 @@ object BundledRemoteConfig {
|
||||
search = "Search",
|
||||
movies = "Movies",
|
||||
tvShows = "TV Shows",
|
||||
genres = "Genres",
|
||||
tvCalendar = "TV Calendar",
|
||||
favourites = "Favourites",
|
||||
user = "User",
|
||||
@@ -233,6 +238,7 @@ internal fun validateRemoteConfig(document: MembyRemoteConfig, appVersion: Strin
|
||||
copy.navigation.search,
|
||||
copy.navigation.movies,
|
||||
copy.navigation.tvShows,
|
||||
copy.navigation.genres,
|
||||
copy.navigation.tvCalendar,
|
||||
copy.navigation.favourites,
|
||||
copy.navigation.user,
|
||||
|
||||
@@ -93,6 +93,7 @@ import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.LiveTv
|
||||
@@ -173,6 +174,9 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
|
||||
SEARCH("Search", Icons.Default.Search),
|
||||
MOVIES("Movies", Icons.Default.Movie),
|
||||
SHOWS("TV Shows", Icons.Default.Tv),
|
||||
// The catalogue, browsed by genre rather than by shelf. Hidden unless the gateway has
|
||||
// the genre browser on — see [TvNavigationRail]'s genresEnabled.
|
||||
GENRES("Genres", Icons.Default.GridView),
|
||||
// Sonarr's schedule, a month at a time. Hidden unless the gateway says the household
|
||||
// has one — see [TvNavigationRail]'s calendarEnabled.
|
||||
CALENDAR("TV Calendar", Icons.Default.CalendarMonth),
|
||||
@@ -185,11 +189,15 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
|
||||
* The user switcher is a launcher action, not a browsing destination. Keep it pinned above
|
||||
* Home while the destinations below it may change with server capabilities.
|
||||
*/
|
||||
internal fun navigationRailItems(calendarEnabled: Boolean): List<BrowseDestination> =
|
||||
internal fun navigationRailItems(
|
||||
calendarEnabled: Boolean,
|
||||
genresEnabled: Boolean = true,
|
||||
): List<BrowseDestination> =
|
||||
listOf(BrowseDestination.PROFILES) + BrowseDestination.entries.filter {
|
||||
it != BrowseDestination.PROFILES &&
|
||||
it != BrowseDestination.SETTINGS &&
|
||||
(it != BrowseDestination.CALENDAR || calendarEnabled)
|
||||
(it != BrowseDestination.CALENDAR || calendarEnabled) &&
|
||||
(it != BrowseDestination.GENRES || genresEnabled)
|
||||
}
|
||||
|
||||
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
|
||||
@@ -264,6 +272,7 @@ fun TvNavigationRail(
|
||||
activeUsername: String = "",
|
||||
activeProfileInitials: String = "",
|
||||
calendarEnabled: Boolean = false,
|
||||
genresEnabled: Boolean = true,
|
||||
) {
|
||||
var railHasFocus by remember { mutableStateOf(false) }
|
||||
val logoScale = remember { Animatable(0.72f) }
|
||||
@@ -334,12 +343,16 @@ fun TvNavigationRail(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Memby's own icon rather than Emby's mark: the rail is Memby's chrome, and
|
||||
// the one place the app names itself should not be somebody else's logo.
|
||||
// It carries its own colour and is deliberately not tinted — this is the
|
||||
// app's icon, the same one the launcher on the television shows, so a
|
||||
// seasonal palette repainting it would make it a different mark.
|
||||
Image(
|
||||
painter = painterResource(R.drawable.emby_logo),
|
||||
painter = painterResource(R.drawable.memby_mark),
|
||||
contentDescription = "Memby",
|
||||
modifier = Modifier
|
||||
.width(32.dp)
|
||||
.height(27.dp)
|
||||
.size(30.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = logoScale.value
|
||||
scaleY = logoScale.value
|
||||
@@ -371,7 +384,9 @@ fun TvNavigationRail(
|
||||
// A destination with nothing behind it is worse than one fewer: the calendar
|
||||
// needs the gateway and a Sonarr, and a household with neither would otherwise
|
||||
// carry a rail item that only ever opens an apology.
|
||||
val destinations = remember(calendarEnabled) { navigationRailItems(calendarEnabled) }
|
||||
val destinations = remember(calendarEnabled, genresEnabled) {
|
||||
navigationRailItems(calendarEnabled, genresEnabled)
|
||||
}
|
||||
// Up and Down are explicit because the profile entry is an action while every
|
||||
// item beneath it changes the current destination. Leaving this to spatial
|
||||
// search allowed content behind the expanded rail to win occasionally.
|
||||
@@ -449,6 +464,7 @@ private fun configuredNavigationLabel(
|
||||
BrowseDestination.SEARCH -> labels.search
|
||||
BrowseDestination.MOVIES -> labels.movies
|
||||
BrowseDestination.SHOWS -> labels.tvShows
|
||||
BrowseDestination.GENRES -> labels.genres
|
||||
BrowseDestination.CALENDAR -> labels.tvCalendar
|
||||
BrowseDestination.FAVORITES -> labels.favourites
|
||||
BrowseDestination.PROFILES -> labels.user
|
||||
|
||||
@@ -2034,9 +2034,19 @@ private fun HomeScreen(
|
||||
}
|
||||
}
|
||||
LaunchedEffect(genreBrowserEnabled) {
|
||||
if (!genreBrowserEnabled && genreBrowseItemType != null) {
|
||||
if (genreBrowserEnabled) return@LaunchedEffect
|
||||
// A set standing on the Genres destination when the operator switches it off is
|
||||
// moved to Home, the stance the calendar takes: the rail entry goes with the
|
||||
// feature, so leaving it there would strand the viewer on a page nothing can
|
||||
// navigate back to.
|
||||
val strandedOnDestination = selectedDestination == BrowseDestination.GENRES
|
||||
if (genreBrowseItemType != null || strandedOnDestination) {
|
||||
genreBrowseItemType = null
|
||||
genreBrowseInitialCategoryId = null
|
||||
if (strandedOnDestination) {
|
||||
selectedDestination = BrowseDestination.HOME
|
||||
railFocusDestination = BrowseDestination.HOME
|
||||
}
|
||||
kotlinx.coroutines.delay(16L)
|
||||
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
|
||||
}
|
||||
@@ -2558,6 +2568,7 @@ private fun HomeScreen(
|
||||
activeUsername = settings.username.orEmpty(),
|
||||
activeProfileInitials = settings.profileInitials,
|
||||
calendarEnabled = tvCalendarEnabled,
|
||||
genresEnabled = genreBrowserEnabled,
|
||||
)
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
@@ -2677,6 +2688,48 @@ private fun HomeScreen(
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
|
||||
// The rail's own Genres destination browses the catalogue, films and shows
|
||||
// together — a household browses "Comedy", not "comedy films". The Movies
|
||||
// and TV Series pages open the same screen through genreBrowseItemType
|
||||
// below, naming a type so neither of those grids can cross media types.
|
||||
if (selectedDestination == BrowseDestination.GENRES) {
|
||||
GenreBrowseScreen(
|
||||
itemType = com.ponzischeme89.memby.ui.genre.ALL_MEDIA_ITEM_TYPE,
|
||||
initialCategoryId = com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID,
|
||||
favouriteStates = favoriteChanges,
|
||||
playedStates = playedChanges,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
returnFocusItemId = returnItemId.takeIf { returnRowId == GENRE_BROWSER_ROW_ID },
|
||||
returnFocusRequester = cardReturnFocusRequester,
|
||||
onItemFocused = homeViewModel::focusItem,
|
||||
onItemSelected = { item ->
|
||||
returnRowId = GENRE_BROWSER_ROW_ID
|
||||
returnItemId = item.id
|
||||
destinationFocus[BrowseDestination.GENRES] =
|
||||
GENRE_BROWSER_ROW_ID to item.id
|
||||
homeViewModel.focusItem(item)
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "genres",
|
||||
feature = "genre_browse", source = "genre_results", target = "details",
|
||||
itemName = item.name, itemType = item.type,
|
||||
)
|
||||
detailsAiringNotice = null
|
||||
detailsItem = item
|
||||
},
|
||||
onContentFocused = { navigationExpanded = false },
|
||||
onClose = {
|
||||
selectedDestination = BrowseDestination.HOME
|
||||
railFocusDestination = BrowseDestination.HOME
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(16L)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
},
|
||||
)
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
|
||||
genreBrowseItemType?.let { itemType ->
|
||||
GenreBrowseScreen(
|
||||
itemType = itemType,
|
||||
@@ -2701,6 +2754,7 @@ private fun HomeScreen(
|
||||
detailsAiringNotice = null
|
||||
detailsItem = item
|
||||
},
|
||||
onContentFocused = { navigationExpanded = false },
|
||||
onClose = {
|
||||
genreBrowseItemType = null
|
||||
genreBrowseInitialCategoryId = null
|
||||
@@ -4541,6 +4595,7 @@ internal fun homeRowsFor(
|
||||
// Search draws its own pane; the rail destinations that open an overlay have no
|
||||
// rows of their own either.
|
||||
BrowseDestination.SEARCH -> emptyList()
|
||||
BrowseDestination.GENRES -> emptyList()
|
||||
BrowseDestination.CALENDAR -> emptyList()
|
||||
BrowseDestination.PROFILES -> emptyList()
|
||||
BrowseDestination.SETTINGS -> emptyList()
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
package com.ponzischeme89.memby.ui.genre
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -13,22 +16,25 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
@@ -48,19 +54,32 @@ import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -72,11 +91,16 @@ import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||
import com.ponzischeme89.memby.ui.PosterGridCard
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun GenreDiscoveryStrip(
|
||||
@@ -183,6 +207,36 @@ private fun genreVisual(icon: GenreCategoryIcon): GenreVisual = when (icon) {
|
||||
GenreCategoryIcon.REALITY -> GenreVisual(Icons.Default.LiveTv, Color(0xFFC2410C))
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a genre may sit under focus before it is asked for.
|
||||
*
|
||||
* Focus is selection in this rail, and a held D-pad travels it several items a second.
|
||||
* Without this every genre passed through would be a request, and the one the viewer
|
||||
* actually stopped on would queue behind fifteen answers nobody is waiting for. It is
|
||||
* deliberately short: at this length a deliberate press still reads as instant, and by
|
||||
* the time the wait is over a neighbour has usually been warmed anyway.
|
||||
*/
|
||||
private const val GENRE_SELECT_DEBOUNCE_MS = 130L
|
||||
|
||||
/** How many rows of placeholder cards stand in for a genre that has not answered yet. */
|
||||
private const val GENRE_PLACEHOLDER_ROWS = 3
|
||||
|
||||
internal val GenreRailWidth = 214.dp
|
||||
|
||||
/**
|
||||
* The genres destination: a secondary navigation rail beside the launcher's own.
|
||||
*
|
||||
* The hierarchy is *primary rail → genre rail → genre content*, and each level is one
|
||||
* D-pad press from the one beside it. Changing genre is a change of the pane on the right
|
||||
* and never a navigation: the screen is not rebuilt, the rail keeps its place, and the
|
||||
* grid a viewer comes back to is the grid they left.
|
||||
*
|
||||
* Focus is selection in the genre rail, the stance the detail page's tab strip takes. A
|
||||
* remote has no hover, so a rail that highlighted one genre while a different one stayed
|
||||
* open would need a second press to mean anything and would show content contradicting
|
||||
* the highlight — and browsing a catalogue is exactly the case where pressing twice per
|
||||
* genre is what stops somebody browsing.
|
||||
*/
|
||||
@Composable
|
||||
fun GenreBrowseScreen(
|
||||
itemType: String,
|
||||
@@ -197,221 +251,247 @@ fun GenreBrowseScreen(
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onContentFocused: () -> Unit = {},
|
||||
) {
|
||||
val mediaLabel = if (itemType.equals("Series", ignoreCase = true)) "TV shows" else "movies"
|
||||
val mediaLabel = remember(itemType) { genreMediaLabel(itemType) }
|
||||
val browseViewModel: GenreBrowseViewModel = viewModel(
|
||||
key = "genre-browse-${itemType.lowercase()}",
|
||||
key = "genre-browse-${itemType.lowercase().ifEmpty { "all" }}",
|
||||
factory = remember(itemType) {
|
||||
GenreBrowseViewModelFactory(ServiceLocator.repository, itemType)
|
||||
},
|
||||
)
|
||||
val state by browseViewModel.state.collectAsStateWithLifecycle()
|
||||
val gridState = rememberLazyGridState()
|
||||
val tabState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val selectedCategory = remember(state.selectedCategoryId, itemType) {
|
||||
genreCategory(itemType, state.selectedCategoryId)
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(initialCategoryId) { browseViewModel.selectCategory(initialCategoryId) }
|
||||
// The genre the remote is on, kept apart from the view model's selection so the rail's
|
||||
// own marker and the entry FocusRequester are never waiting on a request. The pane
|
||||
// follows the selection instead, because it labels the grid rather than the remote.
|
||||
var activeCategoryId by remember(initialCategoryId) {
|
||||
mutableStateOf(state.selectedCategoryId ?: initialCategoryId)
|
||||
}
|
||||
var contentHasFocus by remember { mutableStateOf(false) }
|
||||
|
||||
// One grid state per genre, so returning to a genre returns to where it was left
|
||||
// rather than to the top of it. Bounded by the catalogue, which is a fixed list.
|
||||
val gridStates = remember(itemType) { mutableMapOf<String, LazyGridState>() }
|
||||
val focusedCardIndexes = remember(itemType) { mutableMapOf<String, Int>() }
|
||||
val gridEntryFocusRequester = remember { FocusRequester() }
|
||||
// Which card [gridEntryFocusRequester] is attached to. It is state rather than a value
|
||||
// derived from the map above, because the card a genre was left on changes as the
|
||||
// viewer travels the grid and a requester pinned to where they *entered* would send
|
||||
// the next press back to the top-left corner. Written by the press that uses it, so
|
||||
// travelling the grid costs no recomposition of it.
|
||||
var gridEntryIndex by remember(itemType) { mutableStateOf(0) }
|
||||
|
||||
val shownCategoryId = state.selectedCategoryId
|
||||
val selectedCategory = remember(shownCategoryId, itemType) {
|
||||
genreCategory(itemType, shownCategoryId)
|
||||
}
|
||||
val gridState = gridStates.getOrPut(shownCategoryId ?: initialCategoryId) { LazyGridState() }
|
||||
|
||||
LaunchedEffect(activeCategoryId) {
|
||||
// See [GENRE_SELECT_DEBOUNCE_MS]: travelling past a genre must not ask for it.
|
||||
// Only a *change* is a candidate for that — the genre the page opens on is the one
|
||||
// thing somebody is definitely waiting for, so it is asked for at once.
|
||||
if (state.selectedCategoryId != null) kotlinx.coroutines.delay(GENRE_SELECT_DEBOUNCE_MS)
|
||||
browseViewModel.selectCategory(activeCategoryId)
|
||||
}
|
||||
LaunchedEffect(favouriteStates) { browseViewModel.applyFavouriteStates(favouriteStates) }
|
||||
LaunchedEffect(playedStates) { browseViewModel.applyPlayedStates(playedStates) }
|
||||
LaunchedEffect(state.selectedCategoryId) {
|
||||
val index = state.categories.indexOfFirst { it.id == state.selectedCategoryId }.coerceAtLeast(0)
|
||||
if (state.selectedCategoryId != null) {
|
||||
gridState.scrollToItem(0)
|
||||
tabState.scrollToItem(index)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(32L)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
BackHandler(onBack = onClose)
|
||||
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(MembySurface)) {
|
||||
val columns = when {
|
||||
maxWidth >= 1080.dp -> 7
|
||||
maxWidth >= 860.dp -> 6
|
||||
maxWidth >= 680.dp -> 5
|
||||
else -> 4
|
||||
// Back steps out of the grid before it steps off the page — one press per level, the
|
||||
// rule the search pane and the calendar already follow.
|
||||
BackHandler {
|
||||
if (contentHasFocus) {
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
val spacing = 16.dp
|
||||
val horizontalPadding = 36.dp
|
||||
val cardWidth = ((maxWidth - horizontalPadding * 2 - spacing * (columns - 1)) / columns)
|
||||
.coerceAtLeast(112.dp)
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = 24.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = horizontalPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = onClose,
|
||||
contentDescription = "Back to $mediaLabel",
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.focusProperties { left = navigationFocusRequester }
|
||||
.background(MembySurfaceRaised, RoundedCornerShape(10.dp)),
|
||||
) { focused ->
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (focused) Color.White else Color.Transparent, RoundedCornerShape(10.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = if (focused) MembySurface else Color.White,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column {
|
||||
Text(
|
||||
"Browse $mediaLabel",
|
||||
color = Color.White,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
selectedCategory.label,
|
||||
color = MembyMutedText,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
/** Moves focus into the grid, at the card this genre was left on. */
|
||||
fun enterGrid(): Boolean {
|
||||
val items = state.items
|
||||
if (items.isEmpty()) return false
|
||||
// Where this genre was left comes first, and the card a detail page was opened
|
||||
// from is the fallback. The other order goes stale: returnFocusItemId is not
|
||||
// cleared once its restore is done, so it would keep pulling every later press
|
||||
// back to a card the viewer has long since scrolled past.
|
||||
val remembered = focusedCardIndexes[shownCategoryId]?.takeIf { it in items.indices }
|
||||
?: returnFocusItemId?.let { id -> items.indexOfFirst { it.id == id }.takeIf { i -> i >= 0 } }
|
||||
?: 0
|
||||
gridEntryIndex = remembered
|
||||
scope.launch {
|
||||
// A card outside the composed window cannot be focused, so the grid is put
|
||||
// back where it was before its card is asked for — and the requester has to
|
||||
// have moved to that card first, which is one recomposition away.
|
||||
runCatching { gridState.scrollToItem(remembered) }
|
||||
repeat(3) {
|
||||
kotlinx.coroutines.delay(16L)
|
||||
if (runCatching { gridEntryFocusRequester.requestFocus() }.isSuccess) return@launch
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
LazyRow(
|
||||
state = tabState,
|
||||
contentPadding = PaddingValues(horizontal = horizontalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
Box(modifier.fillMaxSize().background(MembySurface)) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
GenreRail(
|
||||
categories = state.categories,
|
||||
activeCategoryId = activeCategoryId,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
activeFocusRequester = contentFocusRequester,
|
||||
onCategoryFocused = { id ->
|
||||
onContentFocused()
|
||||
activeCategoryId = id
|
||||
},
|
||||
onEnterContent = ::enterGrid,
|
||||
)
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.onFocusChanged { contentHasFocus = it.hasFocus },
|
||||
) {
|
||||
rowItemsIndexed(state.categories, key = { _, category -> category.id }) { index, category ->
|
||||
val selected = category.id == state.selectedCategoryId
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = {
|
||||
if (!selected) {
|
||||
browseViewModel.selectCategory(category.id)
|
||||
}
|
||||
},
|
||||
contentDescription = "Browse ${category.label}",
|
||||
modifier = Modifier
|
||||
.then(if (selected) Modifier.focusRequester(contentFocusRequester) else Modifier)
|
||||
.focusProperties { if (index == 0) left = navigationFocusRequester }
|
||||
.background(
|
||||
when {
|
||||
selected -> MembyAccent
|
||||
else -> MembySurfaceRaised.copy(alpha = 0.72f)
|
||||
},
|
||||
RoundedCornerShape(18.dp),
|
||||
)
|
||||
.border(
|
||||
1.dp,
|
||||
if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.06f),
|
||||
RoundedCornerShape(18.dp),
|
||||
),
|
||||
) { focused ->
|
||||
val horizontalPadding = 40.dp
|
||||
val spacing = 18.dp
|
||||
val columns = when {
|
||||
maxWidth >= 1000.dp -> 6
|
||||
maxWidth >= 820.dp -> 5
|
||||
maxWidth >= 620.dp -> 4
|
||||
else -> 3
|
||||
}
|
||||
val cardWidth = ((maxWidth - horizontalPadding * 2 - spacing * (columns - 1)) / columns)
|
||||
.coerceAtLeast(104.dp)
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Spacer(Modifier.height(44.dp))
|
||||
Column(Modifier.padding(horizontal = horizontalPadding)) {
|
||||
Text(
|
||||
category.label,
|
||||
color = when {
|
||||
focused -> MembySurface
|
||||
selected -> Color.White
|
||||
else -> MembyMutedText
|
||||
},
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
if (focused) Color.White else Color.Transparent,
|
||||
RoundedCornerShape(18.dp),
|
||||
)
|
||||
.padding(horizontal = 17.dp, vertical = 9.dp),
|
||||
selectedCategory.label,
|
||||
color = Color.White,
|
||||
fontSize = 34.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
mediaLabel,
|
||||
color = MembyQuietText,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Spacer(Modifier.height(18.dp))
|
||||
|
||||
when {
|
||||
state.selectedCategoryId == null -> GenreMessage("Loading $mediaLabel…")
|
||||
state.isLoading && state.items.isEmpty() -> GenreMessage(
|
||||
"Loading ${selectedCategory.label}…",
|
||||
)
|
||||
state.errorMessage != null && state.items.isEmpty() -> GenreRetry(
|
||||
message = state.errorMessage.orEmpty(),
|
||||
onRetry = browseViewModel::retry,
|
||||
)
|
||||
state.items.isEmpty() -> GenreMessage(
|
||||
if (selectedCategory.genres.isEmpty()) "No $mediaLabel were found."
|
||||
else "No ${selectedCategory.label.lowercase()} $mediaLabel were found.",
|
||||
)
|
||||
else -> {
|
||||
LaunchedEffect(gridState, state.items.size, state.canLoadMore) {
|
||||
snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }
|
||||
.distinctUntilChanged()
|
||||
.collect { last ->
|
||||
if (state.canLoadMore && last >= state.items.size - columns * 2) {
|
||||
browseViewModel.loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
state = gridState,
|
||||
contentPadding = PaddingValues(
|
||||
start = horizontalPadding,
|
||||
end = horizontalPadding,
|
||||
bottom = 72.dp,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
itemsIndexed(
|
||||
state.items,
|
||||
key = { _, item -> item.id },
|
||||
contentType = { _, _ -> "genre-poster" },
|
||||
) { index, item ->
|
||||
val favourite = favouriteStates[item.id]
|
||||
val displayedItem = if (favourite == null || favourite == item.isFavorite) {
|
||||
item
|
||||
val gridPadding = PaddingValues(
|
||||
start = horizontalPadding,
|
||||
end = horizontalPadding,
|
||||
bottom = 72.dp,
|
||||
)
|
||||
when {
|
||||
// Placeholders rather than a spinner over the page: the heading,
|
||||
// the rail and the shape of the grid all stay exactly where they
|
||||
// are, so nothing under the viewer's thumb moves when the answer
|
||||
// lands.
|
||||
state.isLoading && state.items.isEmpty() -> GenrePlaceholderGrid(
|
||||
columns = columns,
|
||||
cardWidth = cardWidth,
|
||||
spacing = spacing,
|
||||
contentPadding = gridPadding,
|
||||
)
|
||||
state.errorMessage != null && state.items.isEmpty() -> GenreRetry(
|
||||
message = state.errorMessage.orEmpty(),
|
||||
onRetry = browseViewModel::retry,
|
||||
navigationFocusRequester = contentFocusRequester,
|
||||
)
|
||||
state.items.isEmpty() -> GenreMessage(
|
||||
if (selectedCategory.genres.isEmpty()) {
|
||||
"No ${mediaLabel.lowercase()} were found."
|
||||
} else {
|
||||
item.withFavourite(favourite)
|
||||
"No ${selectedCategory.label.lowercase()} ${mediaLabel.lowercase()} were found."
|
||||
},
|
||||
)
|
||||
else -> {
|
||||
LaunchedEffect(gridState, state.items.size, state.canLoadMore) {
|
||||
snapshotFlow {
|
||||
gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { last ->
|
||||
if (state.canLoadMore && last >= state.items.size - columns * 2) {
|
||||
browseViewModel.loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
PosterGridCard(
|
||||
item = displayedItem,
|
||||
width = cardWidth,
|
||||
onFocused = { onItemFocused(displayedItem) },
|
||||
onClick = { onItemSelected(displayedItem) },
|
||||
onLongClick = { onItemSelected(displayedItem) },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (item.id == returnFocusItemId) {
|
||||
Modifier.focusRequester(returnFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
state = gridState,
|
||||
contentPadding = gridPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||
verticalArrangement = Arrangement.spacedBy(22.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
itemsIndexed(
|
||||
state.items,
|
||||
key = { _, item -> item.id },
|
||||
contentType = { _, _ -> "genre-poster" },
|
||||
) { index, item ->
|
||||
val favourite = favouriteStates[item.id]
|
||||
val displayedItem = if (favourite == null || favourite == item.isFavorite) {
|
||||
item
|
||||
} else {
|
||||
item.withFavourite(favourite)
|
||||
}
|
||||
PosterGridCard(
|
||||
item = displayedItem,
|
||||
width = cardWidth,
|
||||
onFocused = {
|
||||
onContentFocused()
|
||||
shownCategoryId?.let { focusedCardIndexes[it] = index }
|
||||
onItemFocused(displayedItem)
|
||||
},
|
||||
onClick = { onItemSelected(displayedItem) },
|
||||
onLongClick = { onItemSelected(displayedItem) },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (item.id == returnFocusItemId) {
|
||||
Modifier.focusRequester(returnFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.then(
|
||||
if (index == gridEntryIndex) {
|
||||
Modifier.focusRequester(gridEntryFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.focusProperties {
|
||||
// Left out of the first column is the way
|
||||
// back to the genre this grid belongs to,
|
||||
// which is where that requester is attached.
|
||||
if (index % columns == 0) left = contentFocusRequester
|
||||
},
|
||||
)
|
||||
.focusProperties {
|
||||
if (index % columns == 0) left = navigationFocusRequester
|
||||
},
|
||||
)
|
||||
}
|
||||
if (state.isLoadingMore) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
GenreMessage("Loading more…")
|
||||
}
|
||||
} else if (state.errorMessage != null) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
GenreRetry(
|
||||
message = state.errorMessage.orEmpty(),
|
||||
onRetry = browseViewModel::retry,
|
||||
)
|
||||
}
|
||||
if (state.isLoadingMore) {
|
||||
items(GENRE_PLACEHOLDER_ROWS * columns) {
|
||||
GenrePlaceholderCard(cardWidth)
|
||||
}
|
||||
} else if (state.errorMessage != null) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
GenreRetry(
|
||||
message = state.errorMessage.orEmpty(),
|
||||
onRetry = browseViewModel::retry,
|
||||
navigationFocusRequester = contentFocusRequester,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,17 +501,246 @@ fun GenreBrowseScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The secondary rail.
|
||||
*
|
||||
* Its separation from the launcher's own rail is tonal rather than structural — a raised
|
||||
* translucent surface fading into the content background, with a hairline where the two
|
||||
* meet. That is enough to read the hierarchy at three metres without a second black
|
||||
* column making the screen look like two applications side by side.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GenreRail(
|
||||
categories: List<GenreCategory>,
|
||||
activeCategoryId: String,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
activeFocusRequester: FocusRequester,
|
||||
onCategoryFocused: (String) -> Unit,
|
||||
onEnterContent: () -> Boolean,
|
||||
/**
|
||||
* Draws one genre as though the remote were on it.
|
||||
*
|
||||
* Robolectric's window never takes focus, and the difference between the genre in
|
||||
* force and the genre under the thumb is the whole of what this rail has to say — a
|
||||
* capture that could only ever photograph the first would prove nothing.
|
||||
*/
|
||||
focusForCapture: String? = null,
|
||||
) {
|
||||
val railState = rememberLazyListState()
|
||||
// Only on arrival: once the viewer is in the rail, the lazy list scrolls itself as
|
||||
// focus travels, and a second scroll chasing the selection fights the D-pad.
|
||||
LaunchedEffect(Unit) {
|
||||
val index = categories.indexOfFirst { it.id == activeCategoryId }
|
||||
if (index > 0) runCatching { railState.scrollToItem(index) }
|
||||
}
|
||||
val itemFocusRequesters = remember(categories) {
|
||||
categories.associate { it.id to FocusRequester() }
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.width(GenreRailWidth)
|
||||
.fillMaxHeight()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(MembySurfaceRaised.copy(alpha = 0.62f), MembySurface.copy(alpha = 0f)),
|
||||
),
|
||||
)
|
||||
.drawBehind {
|
||||
// The hairline the two rails meet on. Drawn rather than a bordered Box:
|
||||
// it is one line and must not cost the rail a layout node.
|
||||
drawRect(
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(size.width - 1f, 0f),
|
||||
size = androidx.compose.ui.geometry.Size(1f, size.height),
|
||||
)
|
||||
}
|
||||
.focusGroup()
|
||||
.onKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||
// Right belongs to the rail as a whole: whichever genre holds focus, the
|
||||
// press means "into the grid", and it has to scroll the remembered card
|
||||
// back into composition before anything can be focused. A focusProperties
|
||||
// target could not do either.
|
||||
if (event.key == Key.DirectionRight) onEnterContent() else false
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
"GENRES",
|
||||
color = MembyQuietText,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.6.sp,
|
||||
modifier = Modifier.padding(start = 22.dp, top = 46.dp, bottom = 14.dp),
|
||||
)
|
||||
LazyColumn(
|
||||
state = railState,
|
||||
contentPadding = PaddingValues(start = 12.dp, end = 14.dp, bottom = 48.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
rowItemsIndexed(categories, key = { _, category -> category.id }) { index, category ->
|
||||
val requester = itemFocusRequesters.getValue(category.id)
|
||||
GenreRailItem(
|
||||
category = category,
|
||||
active = category.id == activeCategoryId,
|
||||
focusedForCapture = category.id == focusForCapture,
|
||||
onFocused = { onCategoryFocused(category.id) },
|
||||
onClick = { onCategoryFocused(category.id) },
|
||||
modifier = Modifier
|
||||
.focusRequester(requester)
|
||||
// A second requester on the same node: the screen's entry target
|
||||
// is the genre in force, and it is also what Left out of the
|
||||
// grid's first column names.
|
||||
.then(
|
||||
if (category.id == activeCategoryId) {
|
||||
Modifier.focusRequester(activeFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.focusProperties {
|
||||
// Explicit, so vertical travel can never leave the rail. Left
|
||||
// to spatial search, Up from the first genre lands on whichever
|
||||
// item of the launcher's rail happens to be beside it.
|
||||
left = navigationFocusRequester
|
||||
up = if (index == 0) {
|
||||
FocusRequester.Cancel
|
||||
} else {
|
||||
itemFocusRequesters.getValue(categories[index - 1].id)
|
||||
}
|
||||
down = if (index == categories.lastIndex) {
|
||||
FocusRequester.Cancel
|
||||
} else {
|
||||
itemFocusRequesters.getValue(categories[index + 1].id)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One genre.
|
||||
*
|
||||
* Focus and the genre in force are marked separately, the stance the player's subtitle
|
||||
* menu takes: the option under the thumb is the only fill on the rail (accent), and the
|
||||
* genre the grid beside it is showing wears a quiet plate with a bar in the accent. One
|
||||
* causes the other while the viewer is in the rail, and they come apart the moment the
|
||||
* viewer presses Right — which is the case the distinction exists for.
|
||||
*/
|
||||
@Composable
|
||||
private fun GenreRailItem(
|
||||
category: GenreCategory,
|
||||
active: Boolean,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
focusedForCapture: Boolean = false,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = "Browse ${category.label}",
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) { hasFocus ->
|
||||
val focused = hasFocus || focusedForCapture
|
||||
// Read only inside drawBehind. The rail is travelled fast, and an animated value
|
||||
// read in a composable body would recompose the item on every frame of its own
|
||||
// focus animation — which on a weak box is most of what a held D-pad costs.
|
||||
val emphasis = animateFloatAsState(
|
||||
targetValue = if (focused) 1f else 0f,
|
||||
animationSpec = tween(140),
|
||||
label = "genre-rail-emphasis",
|
||||
)
|
||||
val plate = when {
|
||||
focused -> MembyAccent
|
||||
active -> MembyControlSurface
|
||||
else -> Color.Transparent
|
||||
}
|
||||
val bar = if (focused) MembyAccentInk.copy(alpha = 0.45f) else MembyAccent
|
||||
val barVisible = focused || active
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.drawBehind {
|
||||
drawRoundRect(color = plate, cornerRadius = CornerRadius(MembyChipCorner.toPx()))
|
||||
// The marker grows with focus rather than appearing, so travelling
|
||||
// the rail reads as one moving indicator instead of a column of
|
||||
// flashing bars.
|
||||
val height = size.height * (0.34f + 0.42f * emphasis.value)
|
||||
drawRoundRect(
|
||||
color = bar,
|
||||
topLeft = androidx.compose.ui.geometry.Offset(0f, (size.height - height) / 2f),
|
||||
size = androidx.compose.ui.geometry.Size(3.dp.toPx(), height),
|
||||
cornerRadius = CornerRadius(2.dp.toPx()),
|
||||
alpha = if (barVisible) 1f else 0f,
|
||||
)
|
||||
}
|
||||
.padding(start = 15.dp, end = 12.dp, top = 11.dp, bottom = 11.dp),
|
||||
) {
|
||||
Text(
|
||||
category.label,
|
||||
color = when {
|
||||
focused -> MembyAccentInk
|
||||
active -> Color.White
|
||||
else -> MembyMutedText
|
||||
},
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (focused || active) FontWeight.Bold else FontWeight.Medium,
|
||||
// Wrapped rather than ellipsised: "Family & Animation" is a real entry and
|
||||
// a rail that hid half of its own labels would be unreadable at distance.
|
||||
maxLines = 2,
|
||||
lineHeight = 18.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenrePlaceholderGrid(
|
||||
columns: Int,
|
||||
cardWidth: Dp,
|
||||
spacing: Dp,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
contentPadding = contentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||
verticalArrangement = Arrangement.spacedBy(22.dp),
|
||||
userScrollEnabled = false,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(GENRE_PLACEHOLDER_ROWS * columns) { GenrePlaceholderCard(cardWidth) }
|
||||
}
|
||||
}
|
||||
|
||||
/** A card-shaped hole. Never focusable: it stands for something that is not there yet. */
|
||||
@Composable
|
||||
private fun GenrePlaceholderCard(width: Dp) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(width)
|
||||
.aspectRatio(2f / 3f)
|
||||
.background(MembySurfaceRaised.copy(alpha = 0.45f), RoundedCornerShape(MembyCardCorner)),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreMessage(message: String) {
|
||||
Box(Modifier.fillMaxWidth().padding(36.dp), contentAlignment = Alignment.Center) {
|
||||
Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) {
|
||||
Text(message, color = MembyMutedText, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreRetry(message: String, onRetry: () -> Unit) {
|
||||
private fun GenreRetry(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(36.dp),
|
||||
Modifier.fillMaxWidth().padding(40.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
@@ -442,11 +751,14 @@ private fun GenreRetry(message: String, onRetry: () -> Unit) {
|
||||
contentDescription = "Try loading the genre again",
|
||||
modifier = Modifier
|
||||
.background(MembyAccent, RoundedCornerShape(9.dp))
|
||||
// Left off the only control on an otherwise empty pane has to go somewhere,
|
||||
// and the genre it failed for is what is beside it.
|
||||
.focusProperties { left = navigationFocusRequester }
|
||||
.semantics { contentDescription = "Try again" },
|
||||
) { focused ->
|
||||
) { _ ->
|
||||
Text(
|
||||
"Try again",
|
||||
color = Color.White,
|
||||
color = MembyAccentInk,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
)
|
||||
|
||||
@@ -47,12 +47,26 @@ class GenreBrowseViewModel(
|
||||
private var pageJob: Job? = null
|
||||
private val categoryPages = mutableMapOf<String, CachedCategoryPage>()
|
||||
|
||||
/**
|
||||
* First pages being warmed for the categories either side of the selected one.
|
||||
*
|
||||
* Kept apart from [pageJob] because they are cancelled by opposite events: moving to
|
||||
* another genre makes the shelf's own in-flight page obsolete, and is precisely what
|
||||
* a warm exists to have finished before. Keyed so that travelling the rail joins a
|
||||
* warm already running rather than starting a second one for the same genre.
|
||||
*/
|
||||
private val warmJobs = mutableMapOf<String, Job>()
|
||||
|
||||
fun selectCategory(categoryId: String) {
|
||||
val selected = categories.firstOrNull { it.id == categoryId } ?: categories.first()
|
||||
val current = state.value
|
||||
if (current.selectedCategoryId == selected.id && (current.items.isNotEmpty() || current.isLoading)) return
|
||||
val cached = categoryPages[selected.id]
|
||||
// The page in flight was for the genre being left. Its answer can no longer be
|
||||
// shown, and holding the connection open would only slow the one now being asked
|
||||
// for. A warm for this genre is deliberately not cancelled — it is adopted below.
|
||||
pageJob?.cancel()
|
||||
val warming = warmJobs[selected.id]?.isActive == true
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedCategoryId = selected.id,
|
||||
@@ -64,7 +78,30 @@ class GenreBrowseViewModel(
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
if (cached == null) pageJob = viewModelScope.launch { loadPage(selected, 0) }
|
||||
if (cached == null && !warming) {
|
||||
pageJob = viewModelScope.launch { loadPage(selected, 0) }
|
||||
} else if (cached == null) {
|
||||
pageJob = warmJobs[selected.id]
|
||||
}
|
||||
warmNeighbours(selected.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the first page of the categories either side of the selection.
|
||||
*
|
||||
* Vertical travel is the only way a genre is reached from the rail, so these two are
|
||||
* the next thing that will be asked for. It costs a request each per session — the
|
||||
* result is cached in [categoryPages] like any other page — and it is what makes
|
||||
* moving down the rail land on a shelf that is already there rather than on a row of
|
||||
* placeholders.
|
||||
*/
|
||||
private fun warmNeighbours(selectedId: String) {
|
||||
adjacentCategoryIds(categories, selectedId).forEach { id ->
|
||||
if (categoryPages.containsKey(id)) return@forEach
|
||||
if (warmJobs[id]?.isActive == true) return@forEach
|
||||
val category = categories.firstOrNull { it.id == id } ?: return@forEach
|
||||
warmJobs[id] = viewModelScope.launch { loadPage(category, 0) }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
@@ -147,19 +184,24 @@ class GenreBrowseViewModel(
|
||||
)
|
||||
}
|
||||
}.onSuccess { page ->
|
||||
val read = page.offset + page.items.size
|
||||
val existing = if (page.offset == 0) emptyList() else categoryPages[category.id]?.items.orEmpty()
|
||||
val items = (existing + page.items).distinctBy(BaseItem::id)
|
||||
val canLoadMore = hasMoreGenreItems(
|
||||
loaded = read,
|
||||
total = page.total,
|
||||
lastPageSize = page.items.size,
|
||||
pageSize = GENRE_PAGE_SIZE,
|
||||
)
|
||||
// Filed under its genre whether or not that genre is the one on screen. A warm
|
||||
// for the category below the selection has no state to write and exists only to
|
||||
// leave this behind; keeping the cache write inside the state guard is what made
|
||||
// it fetch a page and then throw it away.
|
||||
categoryPages[category.id] = CachedCategoryPage(items, read, canLoadMore)
|
||||
_state.update { current ->
|
||||
if (current.selectedCategoryId != category.id || current.readOffset != page.offset) {
|
||||
return@update current
|
||||
}
|
||||
val read = page.offset + page.items.size
|
||||
val items = (current.items + page.items).distinctBy(BaseItem::id)
|
||||
val canLoadMore = hasMoreGenreItems(
|
||||
loaded = read,
|
||||
total = page.total,
|
||||
lastPageSize = page.items.size,
|
||||
pageSize = GENRE_PAGE_SIZE,
|
||||
)
|
||||
categoryPages[category.id] = CachedCategoryPage(items, read, canLoadMore)
|
||||
current.copy(
|
||||
items = items,
|
||||
readOffset = read,
|
||||
|
||||
@@ -44,6 +44,15 @@ enum class GenreCategoryIcon {
|
||||
|
||||
const val ALL_MEDIA_CATEGORY_ID = "all"
|
||||
|
||||
/**
|
||||
* The mixed shelf: films and series together.
|
||||
*
|
||||
* The Movies and TV Series pages name a type, so no category of theirs can mix the two
|
||||
* grids. The Genres destination is deliberately the other thing — a household browses
|
||||
* "Comedy", not "comedy films" — and passes this instead.
|
||||
*/
|
||||
const val ALL_MEDIA_ITEM_TYPE = ""
|
||||
|
||||
private val allMediaCategory = GenreCategory(
|
||||
id = ALL_MEDIA_CATEGORY_ID,
|
||||
label = "All",
|
||||
@@ -87,8 +96,9 @@ private val realityCategory = GenreCategory(
|
||||
)
|
||||
|
||||
fun genreCategories(itemType: String): List<GenreCategory> =
|
||||
if (itemType.equals("Series", ignoreCase = true)) coreGenreCategories + realityCategory
|
||||
else coreGenreCategories
|
||||
if (itemType.equals("Movie", ignoreCase = true)) coreGenreCategories
|
||||
// Reality is a television shelf, so it is offered wherever series can appear.
|
||||
else coreGenreCategories + realityCategory
|
||||
|
||||
fun genreCategoryTabs(itemType: String): List<GenreCategory> =
|
||||
listOf(allMediaCategory.copy(label = allMediaLabel(itemType))) + genreCategories(itemType)
|
||||
@@ -96,8 +106,35 @@ fun genreCategoryTabs(itemType: String): List<GenreCategory> =
|
||||
fun genreCategory(itemType: String, id: String?): GenreCategory =
|
||||
genreCategoryTabs(itemType).firstOrNull { it.id == id } ?: genreCategoryTabs(itemType).first()
|
||||
|
||||
fun allMediaLabel(itemType: String): String =
|
||||
if (itemType.equals("Series", ignoreCase = true)) "All TV shows" else "All Movies"
|
||||
fun allMediaLabel(itemType: String): String = when {
|
||||
itemType.equals("Series", ignoreCase = true) -> "All TV shows"
|
||||
itemType.equals("Movie", ignoreCase = true) -> "All Movies"
|
||||
else -> "All genres"
|
||||
}
|
||||
|
||||
/** What this shelf is made of, for the heading under a genre's name. */
|
||||
fun genreMediaLabel(itemType: String): String = when {
|
||||
itemType.equals("Series", ignoreCase = true) -> "TV shows"
|
||||
itemType.equals("Movie", ignoreCase = true) -> "Movies"
|
||||
else -> "Movies & TV shows"
|
||||
}
|
||||
|
||||
/**
|
||||
* The categories either side of the selected one, which is what the shelf warms.
|
||||
*
|
||||
* Vertical travel through the rail is the only way a genre is reached, so the neighbours
|
||||
* are the only two candidates worth the request — and the pair is what makes travelling
|
||||
* through the rail feel like it has already loaded. Deliberately not the whole list: a
|
||||
* household's Emby would answer sixteen genre requests for the fifteen nobody visited.
|
||||
*/
|
||||
fun adjacentCategoryIds(categories: List<GenreCategory>, selectedId: String?): List<String> {
|
||||
val index = categories.indexOfFirst { it.id == selectedId }
|
||||
if (index < 0) return emptyList()
|
||||
return listOfNotNull(
|
||||
categories.getOrNull(index - 1)?.id,
|
||||
categories.getOrNull(index + 1)?.id,
|
||||
)
|
||||
}
|
||||
|
||||
fun BaseItem.withFavourite(favourite: Boolean): BaseItem = copy(
|
||||
userData = (userData ?: UserItemData()).copy(isFavorite = favourite),
|
||||
|
||||
@@ -580,6 +580,12 @@ class PlayerActivity : ComponentActivity() {
|
||||
?: intent.getBooleanExtra(EXTRA_SKIP_INTRO, false)
|
||||
endCreditsAvailable = savedInstanceState?.getBoolean(STATE_END_CREDITS)
|
||||
?: intent.getBooleanExtra(EXTRA_END_CREDITS, false)
|
||||
// Carried across the recreate a Magic press causes, so the button keeps its memory
|
||||
// of what it has already put in front of this viewer.
|
||||
savedInstanceState?.getStringArrayList(STATE_MAGIC_OFFERED)?.let {
|
||||
magicOffered.clear()
|
||||
magicOffered.addAll(it)
|
||||
}
|
||||
Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}")
|
||||
|
||||
setContentView(R.layout.activity_player)
|
||||
@@ -862,7 +868,13 @@ class PlayerActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
}
|
||||
val restoredId = itemId?.takeIf(String::isNotBlank).takeIf { savedInstanceState != null }
|
||||
// Only a genuine recreation of *this* programme can have left a stop worker behind
|
||||
// to cancel. A relaunch for a new intent — a Magic press — also arrives with a
|
||||
// bundle, but its item came off the intent, and waiting on WorkManager for a
|
||||
// session that was never enqueued is delay in front of the first frame.
|
||||
val restoredId = itemId
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.takeIf { it == savedInstanceState?.getString(STATE_ITEM_ID) }
|
||||
if (restoredId != null) {
|
||||
showPlaybackLoading()
|
||||
val restoredSession = playbackSession(restoredId)
|
||||
@@ -3112,6 +3124,12 @@ class PlayerActivity : ComponentActivity() {
|
||||
// A film is a new subject, not the next step of this one, so it goes through the
|
||||
// ordinary launch rather than through playNext: a fresh player, a fresh pre-roll
|
||||
// decision and a fresh session, exactly as pressing Play on its detail page gives.
|
||||
//
|
||||
// Deliberately no finish() after it. This activity is singleTask, so the launch is
|
||||
// delivered to *this* instance as onNewIntent, which recreates it against the new
|
||||
// programme — and finishing here raced that recreate and won, which is why a press
|
||||
// named a film in a toast and then dropped the viewer back on the launcher with
|
||||
// nothing playing.
|
||||
startActivity(
|
||||
intent(
|
||||
this@PlayerActivity,
|
||||
@@ -3126,7 +3144,6 @@ class PlayerActivity : ComponentActivity() {
|
||||
backdropUrl = pick.backdropUrl,
|
||||
),
|
||||
)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4980,6 +4997,12 @@ class PlayerActivity : ComponentActivity() {
|
||||
outState.putString(STATE_TRAILER_REQUEST, playerJson.encodeToString(it))
|
||||
}
|
||||
}
|
||||
// Outside the block above deliberately: a Magic press *is* a relaunch for a new
|
||||
// intent, and what this button has already offered is exactly what the incoming
|
||||
// instance needs so a second press is a second film.
|
||||
if (magicOffered.isNotEmpty()) {
|
||||
outState.putStringArrayList(STATE_MAGIC_OFFERED, ArrayList(magicOffered))
|
||||
}
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
@@ -5315,6 +5338,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
private const val STATE_EPISODE_CODE = "state_episode_code"
|
||||
private const val STATE_RUNTIME_MS = "state_runtime_ms"
|
||||
private const val STATE_TRAILER_REQUEST = "state_trailer_request"
|
||||
private const val STATE_MAGIC_OFFERED = "state_magic_offered"
|
||||
private const val PLAYER_PREFERENCES = "player_preferences"
|
||||
private const val SUBTITLE_SIZE_KEY = "subtitle_size"
|
||||
private const val PICTURE_MODE_KEY = "picture_mode"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,114 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPalette
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.applyMembyPalette
|
||||
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
|
||||
|
||||
/**
|
||||
* Renders the navigation rail to PNGs under `build/screenshots/left-rail/`.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*LeftRailScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* It exists for the mark at the top of it. Memby's icon is drawn at 30dp on a near-black
|
||||
* surface, which is the size at which a tile-and-letterform either reads or turns to mush,
|
||||
* and no unit test can answer that.
|
||||
*
|
||||
* The themed capture is the other half of the same judgement. The icon deliberately keeps
|
||||
* its own green while everything around it repaints, so this is where that is checked to
|
||||
* still look intentional rather than like a mark the theme failed to reach.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class LeftRailScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
/** How the rail is drawn for most of a session: collapsed, Home selected. */
|
||||
@Test
|
||||
fun `collapsed rail`() {
|
||||
capture("left-rail-collapsed", expanded = false)
|
||||
}
|
||||
|
||||
/** The mark beside the wordmark, which is the one place the two are read together. */
|
||||
@Test
|
||||
fun `expanded rail`() {
|
||||
capture("left-rail-expanded", expanded = true)
|
||||
}
|
||||
|
||||
/** Every destination a fully capable household carries, so nothing is cut off the end. */
|
||||
@Test
|
||||
fun `expanded rail with every destination`() {
|
||||
capture(
|
||||
"left-rail-expanded-full",
|
||||
expanded = true,
|
||||
selected = BrowseDestination.MOVIES,
|
||||
calendarEnabled = true,
|
||||
alertCount = 3,
|
||||
)
|
||||
}
|
||||
|
||||
/** The mark takes its colour from the palette; a theme that could not reach it shows here. */
|
||||
@Test
|
||||
fun `under a themed palette`() {
|
||||
applyMembyPalette(
|
||||
MembyPalette(
|
||||
surface = Color(0xFF120A16),
|
||||
surfaceRaised = Color(0xFF1D1224),
|
||||
accent = Color(0xFFE0803A),
|
||||
),
|
||||
)
|
||||
try {
|
||||
capture("left-rail-themed", expanded = true)
|
||||
} finally {
|
||||
applyMembyPalette(MembyPalette())
|
||||
}
|
||||
}
|
||||
|
||||
private fun capture(
|
||||
name: String,
|
||||
expanded: Boolean,
|
||||
selected: BrowseDestination = BrowseDestination.HOME,
|
||||
calendarEnabled: Boolean = false,
|
||||
alertCount: Int = 0,
|
||||
) {
|
||||
compose.setContent {
|
||||
Box(Modifier.fillMaxSize().background(MembySurface)) {
|
||||
TvNavigationRail(
|
||||
selected = selected,
|
||||
expanded = expanded,
|
||||
navigationFocusRequester = remembered(),
|
||||
onRailFocusChanged = {},
|
||||
onDestinationSelected = {},
|
||||
alertCount = alertCount,
|
||||
activeUsername = "Matt",
|
||||
calendarEnabled = calendarEnabled,
|
||||
genresEnabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/left-rail/$name.png")
|
||||
}
|
||||
|
||||
@androidx.compose.runtime.Composable
|
||||
private fun remembered(): FocusRequester =
|
||||
androidx.compose.runtime.remember { FocusRequester() }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NavigationRailTest {
|
||||
@@ -26,4 +27,24 @@ class NavigationRailTest {
|
||||
assertEquals(BrowseDestination.entries.size - 1, items.size)
|
||||
assertFalse(navigationRailItems(calendarEnabled = false).contains(BrowseDestination.CALENDAR))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `genres is a rail entry only while the server has the browser on`() {
|
||||
// The stance the calendar takes: a destination with nothing behind it is worse
|
||||
// than one fewer, because it only ever opens an apology.
|
||||
assertFalse(
|
||||
navigationRailItems(calendarEnabled = true, genresEnabled = false)
|
||||
.contains(BrowseDestination.GENRES),
|
||||
)
|
||||
assertTrue(
|
||||
navigationRailItems(calendarEnabled = false, genresEnabled = true)
|
||||
.contains(BrowseDestination.GENRES),
|
||||
)
|
||||
// Whichever capabilities the household has, the user switcher stays pinned above
|
||||
// Home — nothing added below it may push those two apart.
|
||||
assertEquals(
|
||||
listOf(BrowseDestination.PROFILES, BrowseDestination.HOME),
|
||||
navigationRailItems(calendarEnabled = false, genresEnabled = false).take(2),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,39 @@ class GenreBrowseTest {
|
||||
assertEquals("reality", genreCategories("Series").last().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the genres destination browses films and shows together`() {
|
||||
// The Movies and TV Series pages name a type so neither grid can cross media
|
||||
// types. The rail's own destination is deliberately the other thing.
|
||||
assertEquals("All genres", allMediaLabel(ALL_MEDIA_ITEM_TYPE))
|
||||
assertEquals("Movies & TV shows", genreMediaLabel(ALL_MEDIA_ITEM_TYPE))
|
||||
assertEquals("Movies", genreMediaLabel("Movie"))
|
||||
assertEquals("TV shows", genreMediaLabel("Series"))
|
||||
// Reality is a television shelf, so the mixed catalogue offers it too.
|
||||
assertEquals("reality", genreCategories(ALL_MEDIA_ITEM_TYPE).last().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the genres either side of the selection are warmed`() {
|
||||
val categories = genreCategoryTabs("Movie")
|
||||
|
||||
assertEquals(
|
||||
listOf(categories[0].id, categories[2].id),
|
||||
adjacentCategoryIds(categories, categories[1].id),
|
||||
)
|
||||
// The ends have one neighbour each rather than wrapping: the rail does not, and a
|
||||
// warm for the genre at the far end is one nothing is about to ask for.
|
||||
assertEquals(listOf(categories[1].id), adjacentCategoryIds(categories, categories.first().id))
|
||||
assertEquals(
|
||||
listOf(categories[categories.lastIndex - 1].id),
|
||||
adjacentCategoryIds(categories, categories.last().id),
|
||||
)
|
||||
// A selection nothing recognises warms nothing rather than warming the top of the
|
||||
// list, which is not where the viewer is.
|
||||
assertEquals(emptyList<String>(), adjacentCategoryIds(categories, "not-a-genre"))
|
||||
assertEquals(emptyList<String>(), adjacentCategoryIds(categories, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `favourite overrides update a paged card copy immediately`() {
|
||||
val item = BaseItem(id = "film", name = "Film", type = "Movie")
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.ponzischeme89.memby.ui.genre
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPalette
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.applyMembyPalette
|
||||
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
|
||||
|
||||
/**
|
||||
* Renders the genre rail beside a stand-in grid to PNGs under
|
||||
* `build/screenshots/genre-rail/`.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*GenreRailScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* The thing worth looking at is the *hierarchy*: three columns — the launcher's rail, the
|
||||
* genre rail, the content — have to read as one screen with a legible order to them, and
|
||||
* the separation between the first two is tonal rather than structural. No unit test can
|
||||
* settle whether a translucent surface and a hairline are enough of a line at three
|
||||
* metres, and getting it wrong in either direction (invisible, or a second black column
|
||||
* that makes the screen look like two applications side by side) looks fine in code.
|
||||
*
|
||||
* The launcher rail is a stand-in of its real 54dp collapsed width rather than the real
|
||||
* component, which would want a service locator; what is being judged is the boundary,
|
||||
* and 54dp of near-black is exactly what sits there.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class GenreRailScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
/** The remote in the rail: the genre under the thumb is the one fill on the screen. */
|
||||
@Test
|
||||
fun `remote in the genre rail`() {
|
||||
capture("genre-rail-focused", active = "drama", focusForCapture = "drama")
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote in the grid — the state the focus/active distinction exists for. Drama
|
||||
* has to still say it is the genre on screen without wearing the focus treatment, or
|
||||
* a viewer down in the posters has nothing telling them what they are looking at.
|
||||
*/
|
||||
@Test
|
||||
fun `remote in the grid`() {
|
||||
capture("genre-rail-content-focused", active = "drama", focusForCapture = null)
|
||||
}
|
||||
|
||||
/** The entry state: the whole catalogue, nothing narrowed yet. */
|
||||
@Test
|
||||
fun `all genres`() {
|
||||
capture("genre-rail-all", active = ALL_MEDIA_CATEGORY_ID, focusForCapture = ALL_MEDIA_CATEGORY_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Under a foreign palette. The rail is drawn entirely from the tokens, so a theme that
|
||||
* could not reach its plate or its marker would show here.
|
||||
*/
|
||||
@Test
|
||||
fun `under a themed palette`() {
|
||||
applyMembyPalette(
|
||||
MembyPalette(
|
||||
surface = Color(0xFF120A16),
|
||||
surfaceRaised = Color(0xFF1D1224),
|
||||
accent = Color(0xFFE0803A),
|
||||
),
|
||||
)
|
||||
try {
|
||||
capture("genre-rail-themed", active = "horror", focusForCapture = "horror")
|
||||
} finally {
|
||||
applyMembyPalette(MembyPalette())
|
||||
}
|
||||
}
|
||||
|
||||
private fun capture(name: String, active: String, focusForCapture: String?) {
|
||||
val categories = genreCategoryTabs(ALL_MEDIA_ITEM_TYPE)
|
||||
compose.setContent {
|
||||
Row(Modifier.fillMaxSize().background(MembySurface)) {
|
||||
// The launcher's own rail, at its real collapsed footprint.
|
||||
Box(Modifier.width(54.dp).fillMaxSize().background(MembySurface))
|
||||
GenreRail(
|
||||
categories = categories,
|
||||
activeCategoryId = active,
|
||||
navigationFocusRequester = FocusRequester(),
|
||||
activeFocusRequester = FocusRequester(),
|
||||
onCategoryFocused = {},
|
||||
onEnterContent = { false },
|
||||
focusForCapture = focusForCapture,
|
||||
)
|
||||
StandInGrid(genreCategory(ALL_MEDIA_ITEM_TYPE, active).label)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/genre-rail/$name.png")
|
||||
}
|
||||
|
||||
/** Enough of the pane to judge the heading against the rail beside it. */
|
||||
@Composable
|
||||
private fun StandInGrid(heading: String) {
|
||||
Column(Modifier.fillMaxSize().padding(start = 40.dp, top = 44.dp)) {
|
||||
Text(heading, color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
genreMediaLabel(ALL_MEDIA_ITEM_TYPE),
|
||||
color = MembyQuietText,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(Modifier.height(18.dp))
|
||||
repeat(2) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
repeat(5) {
|
||||
Box(
|
||||
Modifier.width(118.dp).aspectRatio(2f / 3f)
|
||||
.clip(RoundedCornerShape(MembyCardCorner))
|
||||
.background(if (it == 0) MembyControlSurface else MembySurfaceRaised),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user