From 0c623bbcc0c60b2456944f66e7b62eaff862d190 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Sun, 9 Aug 2026 08:56:42 +1200 Subject: [PATCH] Complete genre browsing for v0.2.34 --- CLAUDE.md | 7 + .../memby/data/EmbyRepository.kt | 28 +- .../memby/data/remote/GatewayApi.kt | 1 + .../ponzischeme89/memby/ui/MainActivity.kt | 67 +++- .../memby/ui/genre/GenreBrowseScreen.kt | 337 ++++++++++++++++++ .../memby/ui/genre/GenreBrowseViewModel.kt | 145 ++++++++ .../memby/ui/genre/GenreBrowseTest.kt | 16 + server/internal/api/genres.go | 24 +- server/internal/api/genres_test.go | 19 + 9 files changed, 635 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index b0cb38c..0fa3d0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -510,6 +510,13 @@ at a time. Things to preserve: focused. - **Episodes are excluded on both paths.** An episode inherits its series' genres, so including them fills a page with twenty entries of one comedy and buries the rest. +- **Movies and TV Series have their own full-width genre browser.** The Genres launcher is + the first focus target on both destinations and `ui/genre/` reuses the same paged + repository call in a poster grid. Its `type=Movie` / `type=Series` query is enforced by + the gateway and on the direct Emby path, so opening Drama from Movies can never mix a + television series into the grid (and vice versa). The picker derives its catalogue from + the unfiltered server rows as well as the visible destination rows: hiding a home shelf + must not make that genre disappear from Browse. - **The gateway path degrades to a keyword search on the *first* page only**, so a set on a new build talking to a gateway that predates the route still shows something. A later page does not: a gateway that answered page one and failed on page two is having trouble, not diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index d30d76e..6183918 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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(), diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index 99d1f16..7de49f3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -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") diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 3eb8a4c..699cf1a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -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(null) } var removingProfileId by remember { mutableStateOf(null) } var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } + var genreBrowseItemType by remember { mutableStateOf(null) } var navigationExpanded by rememberSaveable { mutableStateOf(false) } var restoreRailAfterSettings by remember { mutableStateOf(false) } var detailsItem by remember { mutableStateOf(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 { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt new file mode 100644 index 0000000..98c09fc --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt @@ -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, + 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), + ) + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt new file mode 100644 index 0000000..fc3d3c7 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt @@ -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 = emptyList(), + val selectedGenre: String? = null, + val items: List = 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 = _state.asStateFlow() + private var pageJob: Job? = null + + fun setGenres(genres: List) { + 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): List = 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 create(modelClass: Class): T = + GenreBrowseViewModel(repository, itemType) as T +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt new file mode 100644 index 0000000..4196f2f --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt @@ -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", " "), + ), + ) + } +} diff --git a/server/internal/api/genres.go b/server/internal/api/genres.go index 220989d..171ec0e 100644 --- a/server/internal/api/genres.go +++ b/server/internal/api/genres.go @@ -53,8 +53,13 @@ func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess s } limit := queryInt(r, "limit", genrePageSize, genrePageMax) offset := queryOffset(r, "offset") + itemType, ok := genreItemType(r.URL.Query().Get("type")) + if !ok { + writeError(w, http.StatusBadRequest, "type must be Movie or Series") + return + } - key := cache.UserKey(sess.EmbyUserID, "genre:"+genre+":"+itoa(offset)+":"+itoa(limit)) + key := cache.UserKey(sess.EmbyUserID, "genre:"+itemType+":"+genre+":"+itoa(offset)+":"+itoa(limit)) if raw, err := s.cache.Get(ctx, key); err == nil { w.Header().Set("X-Memby-Cache", "hit") writeRaw(w, http.StatusOK, raw) @@ -63,7 +68,7 @@ func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess s params := rowParams(url.Values{ "Genres": {genre}, - "IncludeItemTypes": {"Movie,Series"}, + "IncludeItemTypes": {itemType}, "Recursive": {"true"}, "StartIndex": {itoa(offset)}, "Limit": {itoa(limit)}, @@ -117,6 +122,21 @@ func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess s writeRaw(w, http.StatusOK, body) } +// genreItemType keeps the old mixed search as the default for Search, while the Movies +// and TV Series destinations can ask for a shelf that never crosses media types. +func genreItemType(value string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "": + return "Movie,Series", true + case "movie": + return "Movie", true + case "series": + return "Series", true + default: + return "", false + } +} + // genreTotal is what the television's scroll stops on, and it has to be right in the case // where nobody counted. // diff --git a/server/internal/api/genres_test.go b/server/internal/api/genres_test.go index 72e1b3f..2530340 100644 --- a/server/internal/api/genres_test.go +++ b/server/internal/api/genres_test.go @@ -45,3 +45,22 @@ func TestQueryOffsetTreatsZeroAsAValueRatherThanAMissingOne(t *testing.T) { } } } + +func TestGenreItemTypeSeparatesMoviesAndSeries(t *testing.T) { + cases := map[string]string{ + "": "Movie,Series", + "movie": "Movie", + "Movie": "Movie", + "series": "Series", + " Series ": "Series", + } + for input, want := range cases { + got, ok := genreItemType(input) + if !ok || got != want { + t.Errorf("genreItemType(%q) = %q, %v; want %q, true", input, got, ok, want) + } + } + if got, ok := genreItemType("Episode"); ok || got != "" { + t.Fatalf("genreItemType(Episode) = %q, %v; want empty, false", got, ok) + } +}