Complete genre browsing for v0.2.34

This commit is contained in:
ponzischeme89
2026-08-09 08:56:42 +12:00
parent fdd9e6cab2
commit 0c623bbcc0
9 changed files with 635 additions and 9 deletions
@@ -696,11 +696,29 @@ class EmbyRepository(private val settings: SettingsStore) {
* on this build talking to a gateway that predates the route still shows a viewer
* *something* when they press a genre — which is exactly what it did before.
*/
suspend fun browseGenre(genre: String, offset: Int = 0, limit: Int = GENRE_PAGE_SIZE): GenrePage {
suspend fun browseGenre(
genre: String,
offset: Int = 0,
limit: Int = GENRE_PAGE_SIZE,
itemType: String? = null,
): GenrePage {
val trimmed = genre.trim()
if (trimmed.isEmpty()) return GenrePage(emptyList(), offset, 0)
val embyItemType = when (itemType?.trim()?.lowercase()) {
null, "" -> "Movie,Series"
"movie" -> "Movie"
"series" -> "Series"
else -> "Movie,Series"
}
if (ServerConfig.isGateway) {
runCatching { requireGateway().genreItems(trimmed, offset, limit) }
runCatching {
requireGateway().genreItems(
trimmed,
offset,
limit,
embyItemType.takeUnless { it == "Movie,Series" },
)
}
.onSuccess { page ->
return GenrePage(page.items, offset, page.total)
}
@@ -710,14 +728,16 @@ class EmbyRepository(private val settings: SettingsStore) {
// failed on page two is having trouble, not missing the route, and a
// search's results pasted onto the end of a genre would be nonsense.
if (offset > 0) throw error
val items = search(trimmed, limit)
val items = search(trimmed, limit).filter { candidate ->
embyItemType == "Movie,Series" || candidate.type.equals(embyItemType, ignoreCase = true)
}
return GenrePage(items, offset, items.size)
}
}
val items = getHomeItems(
params = mapOf(
"Genres" to trimmed,
"IncludeItemTypes" to "Movie,Series",
"IncludeItemTypes" to embyItemType,
"Recursive" to "true",
"StartIndex" to offset.toString(),
"Limit" to limit.toString(),
@@ -82,6 +82,7 @@ interface GatewayApi {
@Path("genre") genre: String,
@Query("offset") offset: Int,
@Query("limit") limit: Int,
@Query("type") itemType: String? = null,
): com.ponzischeme89.memby.data.model.GatewayGenrePage
@POST("v1/search/history")
@@ -133,6 +133,8 @@ import com.ponzischeme89.memby.ui.alerts.MyAlertsPage
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
import com.ponzischeme89.memby.ui.genre.GenreBrowseLauncher
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.performance.PerformanceMonitor
import com.ponzischeme89.memby.ui.search.SearchScreen
@@ -1369,6 +1371,7 @@ private fun HomeScreen(
var switchingProfileId by remember { mutableStateOf<String?>(null) }
var removingProfileId by remember { mutableStateOf<String?>(null) }
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
var genreBrowseItemType by remember { mutableStateOf<String?>(null) }
var navigationExpanded by rememberSaveable { mutableStateOf(false) }
var restoreRailAfterSettings by remember { mutableStateOf(false) }
var detailsItem by remember { mutableStateOf<BaseItem?>(null) }
@@ -1641,6 +1644,7 @@ private fun HomeScreen(
navigationExpanded = it
},
onDestinationSelected = { destination ->
genreBrowseItemType = null
when (destination) {
BrowseDestination.SETTINGS -> {
restoreRailAfterSettings = true
@@ -1761,6 +1765,43 @@ private fun HomeScreen(
return@BoxWithConstraints
}
genreBrowseItemType?.let { itemType ->
val genres = remember(homeContent.rows, rows, itemType) {
// Use the unfiltered server rows as well as what this destination
// currently draws. Hiding a shelf must not remove its genre from
// the catalogue-wide browser.
(homeContent.rows.asSequence().flatMap { it.items.asSequence() } +
rows.asSequence().flatMap { it.items.asSequence() })
.filter { item -> item.type.equals(itemType, ignoreCase = true) }
.flatMap { it.genres.asSequence() }
.map(String::trim)
.filter(String::isNotEmpty)
.distinctBy { it.lowercase() }
.sortedWith(String.CASE_INSENSITIVE_ORDER)
.toList()
}
GenreBrowseScreen(
itemType = itemType,
genres = genres,
navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester,
onItemFocused = homeViewModel::focusItem,
onItemSelected = { item ->
homeViewModel.focusItem(item)
detailsAiringNotice = null
detailsItem = item
},
onClose = {
genreBrowseItemType = null
scope.launch {
kotlinx.coroutines.delay(16L)
runCatching { contentFocusRequester.requestFocus() }
}
},
)
return@BoxWithConstraints
}
FocusedHomeBackdrop(homeViewModel)
val verticalState = verticalStates.getOrPut(selectedDestination.name) {
LazyListState()
@@ -1799,7 +1840,9 @@ private fun HomeScreen(
val firstPopulatedRowId = remember(rows) {
rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id
}
val leadingItemCount = (if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
val leadingItemCount =
(if (selectedDestination == BrowseDestination.MOVIES || selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
(if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
if (
selectedDestination == BrowseDestination.FAVORITES &&
recentSearches.isNotEmpty()
@@ -1891,6 +1934,21 @@ private fun HomeScreen(
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
if (
selectedDestination == BrowseDestination.MOVIES ||
selectedDestination == BrowseDestination.SHOWS
) {
item(key = "genre-browser", contentType = "genre-browser") {
val itemType = if (selectedDestination == BrowseDestination.SHOWS) "Series" else "Movie"
GenreBrowseLauncher(
mediaLabel = if (itemType == "Series") "TV series" else "movie",
navigationFocusRequester = navigationFocusRequester,
onFocused = { navigationExpanded = false },
onOpen = { genreBrowseItemType = itemType },
modifier = Modifier.focusRequester(contentFocusRequester),
)
}
}
if (selectedDestination == BrowseDestination.SHOWS) {
item(key = "my-shows", contentType = "my-shows") {
MyShowsStrip(
@@ -1899,7 +1957,9 @@ private fun HomeScreen(
availableWidth = contentWidth,
density = settings.homeCardDensity,
navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester.takeIf { myShows.isNotEmpty() },
// The Genres launcher above is the destination's entry
// target. One requester cannot be attached to both.
contentFocusRequester = null,
onShowSelected = { selectedMyShow = it },
onContentFocused = { navigationExpanded = false },
)
@@ -1957,7 +2017,8 @@ private fun HomeScreen(
navigationFocusRequester = navigationFocusRequester,
contentEntryFocusRequester = contentFocusRequester.takeIf {
!hasHomeHero &&
(selectedDestination != BrowseDestination.SHOWS || myShows.isEmpty()) &&
selectedDestination != BrowseDestination.MOVIES &&
selectedDestination != BrowseDestination.SHOWS &&
row.id == firstPopulatedRowId
},
heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf {
@@ -0,0 +1,337 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package com.ponzischeme89.memby.ui.genre
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
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.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.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Category
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
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.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator
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.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun GenreBrowseLauncher(
mediaLabel: String,
navigationFocusRequester: FocusRequester,
onFocused: () -> Unit,
onOpen: () -> Unit,
modifier: Modifier = Modifier,
) {
FocusScaleContainer(
onFocused = onFocused,
onClick = onOpen,
contentDescription = "Browse ${if (mediaLabel == "movie") "movies" else mediaLabel} by genre",
modifier = modifier
.padding(horizontal = 36.dp, vertical = 4.dp)
.fillMaxWidth()
.height(70.dp)
.focusProperties { left = navigationFocusRequester }
.background(MembySurfaceRaised, RoundedCornerShape(12.dp))
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(12.dp)),
) { focused ->
Row(
modifier = Modifier
.fillMaxSize()
.background(
if (focused) MembyAccent.copy(alpha = 0.22f) else Color.Transparent,
RoundedCornerShape(12.dp),
)
.padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Category,
contentDescription = null,
tint = if (focused) Color.White else MembyAccent,
modifier = Modifier.size(27.dp),
)
Spacer(Modifier.width(14.dp))
Column {
Text("Genres", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold)
Text(
"Browse every $mediaLabel title in a full poster grid",
color = MembyMutedText,
fontSize = 13.sp,
)
}
}
}
}
@Composable
fun GenreBrowseScreen(
itemType: String,
genres: List<String>,
navigationFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val mediaLabel = if (itemType.equals("Series", ignoreCase = true)) "TV series" else "movies"
val browseViewModel: GenreBrowseViewModel = viewModel(
key = "genre-browse-${itemType.lowercase()}",
factory = remember(itemType) {
GenreBrowseViewModelFactory(ServiceLocator.repository, itemType)
},
)
val state by browseViewModel.state.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState()
LaunchedEffect(genres) { browseViewModel.setGenres(genres) }
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
}
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 by genre",
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
state.selectedGenre ?: "Choose a genre",
color = MembyMutedText,
fontSize = 14.sp,
)
}
}
Spacer(Modifier.height(16.dp))
LazyRow(
contentPadding = PaddingValues(horizontal = horizontalPadding),
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
rowItemsIndexed(state.genres, key = { _, genre -> genre }) { index, genre ->
val selected = genre == state.selectedGenre
FocusScaleContainer(
onFocused = {},
onClick = {
if (!selected) {
browseViewModel.selectGenre(genre)
}
},
contentDescription = "Browse $genre $mediaLabel",
modifier = Modifier
.then(if (index == 0) Modifier.focusRequester(contentFocusRequester) else Modifier)
.focusProperties { if (index == 0) left = navigationFocusRequester }
.background(
if (selected) MembyAccent.copy(alpha = 0.24f) else MembySurfaceRaised,
RoundedCornerShape(10.dp),
)
.border(
1.dp,
if (selected) MembyAccent else Color.White.copy(alpha = 0.08f),
RoundedCornerShape(10.dp),
),
) { focused ->
Text(
genre,
color = if (focused || selected) Color.White else MembyMutedText,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
modifier = Modifier
.background(
if (focused) Color.White.copy(alpha = 0.12f) else Color.Transparent,
RoundedCornerShape(10.dp),
)
.padding(horizontal = 16.dp, vertical = 10.dp),
)
}
}
}
Spacer(Modifier.height(18.dp))
when {
state.genres.isEmpty() -> GenreMessage("No genres are available for $mediaLabel yet.")
state.isLoading && state.items.isEmpty() -> GenreMessage("Loading ${state.selectedGenre.orEmpty()} $mediaLabel")
state.errorMessage != null && state.items.isEmpty() -> GenreRetry(
message = state.errorMessage.orEmpty(),
onRetry = browseViewModel::retry,
)
state.items.isEmpty() -> GenreMessage("No ${state.selectedGenre.orEmpty()} $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 ->
PosterGridCard(
item = item,
width = cardWidth,
onFocused = { onItemFocused(item) },
onClick = { onItemSelected(item) },
onLongClick = { onItemSelected(item) },
modifier = Modifier.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,
)
}
}
}
}
}
}
}
}
@Composable
private fun GenreMessage(message: String) {
Box(Modifier.fillMaxWidth().padding(36.dp), contentAlignment = Alignment.Center) {
Text(message, color = MembyMutedText, fontSize = 15.sp)
}
}
@Composable
private fun GenreRetry(message: String, onRetry: () -> Unit) {
Column(
Modifier.fillMaxWidth().padding(36.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(message, color = MembyMutedText, fontSize = 15.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
FocusScaleContainer(
onFocused = {},
onClick = onRetry,
contentDescription = "Try loading the genre again",
modifier = Modifier
.background(MembyAccent, RoundedCornerShape(9.dp))
.semantics { contentDescription = "Try again" },
) { focused ->
Text(
"Try again",
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
)
}
}
}
@@ -0,0 +1,145 @@
package com.ponzischeme89.memby.ui.genre
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.GENRE_PAGE_SIZE
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.hasMoreGenreItems
import com.ponzischeme89.memby.data.model.BaseItem
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class GenreBrowseUiState(
val genres: List<String> = emptyList(),
val selectedGenre: String? = null,
val items: List<BaseItem> = emptyList(),
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val canLoadMore: Boolean = false,
val errorMessage: String? = null,
)
/** A paged, media-type-specific genre shelf used by the Movies and TV Series pages. */
class GenreBrowseViewModel(
private val repository: EmbyRepository,
private val itemType: String,
) : ViewModel() {
private val _state = MutableStateFlow(GenreBrowseUiState())
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
private var pageJob: Job? = null
fun setGenres(genres: List<String>) {
val normalised = normaliseGenres(genres)
if (normalised == state.value.genres) return
if (normalised.isEmpty()) {
pageJob?.cancel()
_state.value = GenreBrowseUiState()
return
}
val selected = state.value.selectedGenre?.takeIf { current ->
normalised.any { it.equals(current, ignoreCase = true) }
} ?: normalised.firstOrNull()
_state.update { it.copy(genres = normalised) }
if (selected != null && selected != state.value.selectedGenre) selectGenre(selected)
}
fun selectGenre(genre: String) {
val selected = genre.trim()
if (selected.isEmpty()) return
pageJob?.cancel()
_state.update {
it.copy(
selectedGenre = selected,
items = emptyList(),
isLoading = true,
isLoadingMore = false,
canLoadMore = false,
errorMessage = null,
)
}
pageJob = viewModelScope.launch { loadPage(selected, 0) }
}
fun loadMore() {
val current = state.value
val genre = current.selectedGenre ?: return
if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return
_state.update { it.copy(isLoadingMore = true) }
pageJob = viewModelScope.launch { loadPage(genre, current.items.size) }
}
fun retry() {
val current = state.value
val genre = current.selectedGenre ?: return
val offset = current.items.size
_state.update {
it.copy(
isLoading = offset == 0,
isLoadingMore = offset > 0,
errorMessage = null,
)
}
pageJob?.cancel()
pageJob = viewModelScope.launch { loadPage(genre, offset) }
}
private suspend fun loadPage(genre: String, offset: Int) {
runCatching {
repository.browseGenre(
genre = genre,
offset = offset,
limit = GENRE_PAGE_SIZE,
itemType = itemType,
)
}.onSuccess { page ->
_state.update { current ->
if (current.selectedGenre != genre || current.items.size != page.offset) return@update current
val items = (current.items + page.items).distinctBy(BaseItem::id)
current.copy(
items = items,
isLoading = false,
isLoadingMore = false,
canLoadMore = hasMoreGenreItems(
loaded = items.size,
total = page.total,
lastPageSize = page.items.size,
pageSize = GENRE_PAGE_SIZE,
),
errorMessage = null,
)
}
}.onFailure { error ->
_state.update { current ->
if (current.selectedGenre != genre) current else current.copy(
isLoading = false,
isLoadingMore = false,
canLoadMore = false,
errorMessage = friendlyEmbyError(error),
)
}
}
}
}
fun normaliseGenres(genres: List<String>): List<String> = genres
.asSequence()
.map(String::trim)
.filter(String::isNotEmpty)
.distinctBy { it.lowercase() }
.sortedWith(String.CASE_INSENSITIVE_ORDER)
.toList()
class GenreBrowseViewModelFactory(
private val repository: EmbyRepository,
private val itemType: String,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
GenreBrowseViewModel(repository, itemType) as T
}
@@ -0,0 +1,16 @@
package com.ponzischeme89.memby.ui.genre
import org.junit.Assert.assertEquals
import org.junit.Test
class GenreBrowseTest {
@Test
fun `genre picker trims deduplicates and sorts names`() {
assertEquals(
listOf("Action", "comedy", "Science Fiction"),
normaliseGenres(
listOf(" Science Fiction ", "comedy", "", "Action", "Comedy", " "),
),
)
}
}