This commit is contained in:
ponzischeme89
2026-08-17 13:13:10 +12:00
parent 12b27c77c3
commit 43ab18854c
39 changed files with 1707 additions and 1528 deletions
+1 -1
View File
@@ -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))
}
}
}
}