From 0dffd594406562a730acce289b5e07786b69195f Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Sun, 9 Aug 2026 13:10:55 +1200 Subject: [PATCH] Release v0.2.37 --- CHANGELOG.md | 4 + CLAUDE.md | 18 +- app/build.gradle.kts | 2 +- .../memby/data/EmbyRepository.kt | 5 +- .../ponzischeme89/memby/ui/HomeViewModel.kt | 6 + .../ponzischeme89/memby/ui/MainActivity.kt | 39 ++- .../memby/ui/genre/GenreBrowseScreen.kt | 222 +++++++++++++----- .../memby/ui/genre/GenreBrowseViewModel.kt | 109 +++++---- .../memby/ui/genre/GenreCategories.kt | 104 ++++++++ .../memby/ui/genre/GenreBrowseTest.kt | 47 +++- 10 files changed, 411 insertions(+), 145 deletions(-) create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd68c3..bda11ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.2.37 — 2026-08-09 +- Improved: Browse films and TV shows from colourful genre cards with clearer, consistent categories. +- Improved: Genre pages now keep their place and show Favourite changes immediately after returning from a title. + ## 0.2.36 — 2026-08-09 - Improved: Browse all movies or TV shows alongside individual genres, with clearer navigation and paging. diff --git a/CLAUDE.md b/CLAUDE.md index 0fa3d0a..d20fb1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -510,13 +510,17 @@ 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. +- **Movies and TV Series have their own full-width genre browser.** A fixed row of colourful + mini cards is the first focus target on both destinations; it is deliberately a product + catalogue rather than whatever genre names happened to arrive in the home rows, so its + order and availability never jump around during refresh. Neighbouring Emby labels are + merged where they answer the same browsing intent (`Action|Adventure`, + `Science Fiction|Sci-Fi|Sci Fi|Fantasy`, `War|History`) using Emby's pipe-delimited genre + filter. `ui/genre/` keeps the resulting pages in memory, preserves the active category + behind a detail page, returns focus to the title that was opened, and consumes + `HomeViewModel.favoriteChanges` so a Favourite press and its possible rollback reach the + paged copy immediately. Its `type=Movie` / `type=Series` query is still enforced by the + gateway and on the direct Emby path, so no category can mix the two grids. - **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/build.gradle.kts b/app/build.gradle.kts index b91354f..6c752f8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,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.36" +val defaultVersionName = "0.2.37" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() 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 a8ce211..b0925ad 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -728,7 +728,10 @@ 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).filter { candidate -> + // An older gateway understands neither this route nor Emby's pipe + // syntax. Searching the first member at least leaves a useful shelf; + // sending "Action|Adventure" as prose would usually return nothing. + val items = search(trimmed.substringBefore('|'), limit).filter { candidate -> embyItemType == "Movie,Series" || candidate.type.equals(embyItemType, ignoreCase = true) } return GenrePage(items, offset, items.size) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index d7b5c72..154f8f4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -145,6 +145,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { private val _focusedItem = MutableStateFlow(initialFocusedItem(_state.value)) val focusedItem: StateFlow = _focusedItem.asStateFlow() + // Genre pages own paged copies that do not live in HomeUiState. This small mutation + // stream lets those copies reflect a heart press immediately, including a rollback + // when the server rejects it, without refreshing or losing the active category. + private val _favoriteChanges = MutableStateFlow>(emptyMap()) + val favoriteChanges: StateFlow> = _favoriteChanges.asStateFlow() private val _forYou = MutableStateFlow(ForYouUiState()) val forYou: StateFlow = _forYou.asStateFlow() private var metadataJob: Job? = null @@ -395,6 +400,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } private fun updateFavorite(item: BaseItem, favorite: Boolean) { + _favoriteChanges.update { it + (item.id to favorite) } updateUserData(item.id) { it.copy(isFavorite = favorite) } _state.update { state -> val updatedItem = item.copy( 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 699cf1a..ada8887 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -133,7 +133,7 @@ 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.GenreDiscoveryStrip import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen import com.ponzischeme89.memby.ui.player.PlayerActivity import com.ponzischeme89.memby.performance.PerformanceMonitor @@ -1354,6 +1354,7 @@ private fun HomeScreen( val homeContent by homeViewModel.content.collectAsStateWithLifecycle() val homeStatus by homeViewModel.status.collectAsStateWithLifecycle() val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle() + val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle() val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle() val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle() // Only whether there is one, not its countdown — that is collected inside the banner @@ -1372,6 +1373,7 @@ private fun HomeScreen( var removingProfileId by remember { mutableStateOf(null) } var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } var genreBrowseItemType by remember { mutableStateOf(null) } + var genreBrowseInitialCategoryId by remember { mutableStateOf(null) } var navigationExpanded by rememberSaveable { mutableStateOf(false) } var restoreRailAfterSettings by remember { mutableStateOf(false) } var detailsItem by remember { mutableStateOf(null) } @@ -1766,33 +1768,26 @@ private fun HomeScreen( } 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, + initialCategoryId = genreBrowseInitialCategoryId + ?: com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID, + favouriteStates = favoriteChanges, 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 homeViewModel.focusItem(item) detailsAiringNotice = null detailsItem = item }, onClose = { genreBrowseItemType = null + genreBrowseInitialCategoryId = null scope.launch { kotlinx.coroutines.delay(16L) runCatching { contentFocusRequester.requestFocus() } @@ -1940,12 +1935,15 @@ private fun HomeScreen( ) { 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", + GenreDiscoveryStrip( + itemType = itemType, navigationFocusRequester = navigationFocusRequester, + entryFocusRequester = contentFocusRequester, onFocused = { navigationExpanded = false }, - onOpen = { genreBrowseItemType = itemType }, - modifier = Modifier.focusRequester(contentFocusRequester), + onOpenCategory = { categoryId -> + genreBrowseInitialCategoryId = categoryId + genreBrowseItemType = itemType + }, ) } } @@ -2650,6 +2648,7 @@ private fun PlaybackLaunchOverlay(item: BaseItem, modifier: Modifier = Modifier) /** Row id under which the search grid records its return-focus target. */ private const val SEARCH_ROW_ID = "search-results" +private const val GENRE_BROWSER_ROW_ID = "genre-browser-results" @Composable private fun RecentSearchesRow( 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 index f8f770d..8d1014c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt @@ -28,7 +28,22 @@ 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.automirrored.filled.HelpOutline +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Category +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Gavel +import androidx.compose.material.icons.filled.Landscape +import androidx.compose.material.icons.filled.LiveTv +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material.icons.filled.MilitaryTech +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.SentimentVerySatisfied +import androidx.compose.material.icons.filled.SportsSoccer +import androidx.compose.material.icons.filled.TheaterComedy +import androidx.compose.material.icons.filled.VideoLibrary +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,6 +55,7 @@ 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.graphics.vector.ImageVector import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight @@ -61,58 +77,118 @@ import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import kotlinx.coroutines.flow.distinctUntilChanged @Composable -fun GenreBrowseLauncher( - mediaLabel: String, +fun GenreDiscoveryStrip( + itemType: String, navigationFocusRequester: FocusRequester, + entryFocusRequester: FocusRequester, onFocused: () -> Unit, - onOpen: () -> Unit, + onOpenCategory: (String) -> Unit, modifier: Modifier = Modifier, ) { - FocusScaleContainer( - onFocused = onFocused, - onClick = onOpen, - contentDescription = "Browse ${if (mediaLabel == "movie") "movies" else mediaLabel}", - modifier = modifier - .padding(horizontal = 36.dp, vertical = 4.dp) - .width(156.dp) - .height(52.dp) - .focusProperties { left = navigationFocusRequester } - .background(MembySurfaceRaised, RoundedCornerShape(10.dp)) - .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp)), - ) { focused -> - Row( - modifier = Modifier - .fillMaxSize() - .background( - if (focused) Color.White else Color.Transparent, - RoundedCornerShape(10.dp), - ) - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, + val categories = remember(itemType) { genreCategoryTabs(itemType) } + Column(modifier.padding(vertical = 4.dp)) { + Text( + "Browse genres", + color = Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 36.dp, vertical = 6.dp), + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 36.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon( - Icons.Default.Category, - contentDescription = null, - tint = if (focused) MembySurface else MembyAccent, - modifier = Modifier.size(22.dp), - ) - Spacer(Modifier.width(10.dp)) - Text( - "Browse", - color = if (focused) MembySurface else Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - ) + rowItemsIndexed(categories, key = { _, category -> category.id }) { index, category -> + GenreDiscoveryCard( + category = category, + onFocused = onFocused, + onClick = { onOpenCategory(category.id) }, + modifier = Modifier + .then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier) + .focusProperties { if (index == 0) left = navigationFocusRequester }, + ) + } } } } +@Composable +private fun GenreDiscoveryCard( + category: GenreCategory, + onFocused: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val visual = remember(category.icon) { genreVisual(category.icon) } + FocusScaleContainer( + onFocused = onFocused, + onClick = onClick, + contentDescription = "Browse ${category.label}", + modifier = modifier + .width(184.dp) + .height(92.dp) + .background(visual.colour, RoundedCornerShape(12.dp)) + .border(1.dp, Color.White.copy(alpha = 0.14f), RoundedCornerShape(12.dp)), + ) { focused -> + Box( + Modifier + .fillMaxSize() + .background( + if (focused) Color.White.copy(alpha = 0.16f) else Color.Transparent, + RoundedCornerShape(12.dp), + ) + .padding(14.dp), + ) { + Text( + category.label, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + lineHeight = 19.sp, + maxLines = 2, + modifier = Modifier.align(Alignment.BottomStart).width(120.dp), + ) + Icon( + visual.icon, + contentDescription = null, + tint = Color.White.copy(alpha = 0.92f), + modifier = Modifier.align(Alignment.TopEnd).size(32.dp), + ) + } + } +} + +private data class GenreVisual(val icon: ImageVector, val colour: Color) + +private fun genreVisual(icon: GenreCategoryIcon): GenreVisual = when (icon) { + GenreCategoryIcon.ALL -> GenreVisual(Icons.Default.Category, Color(0xFF4F46A5)) + GenreCategoryIcon.ACTION -> GenreVisual(Icons.Default.Bolt, Color(0xFFB45309)) + GenreCategoryIcon.COMEDY -> GenreVisual(Icons.Default.TheaterComedy, Color(0xFF15803D)) + GenreCategoryIcon.CRIME -> GenreVisual(Icons.Default.Gavel, Color(0xFF475569)) + GenreCategoryIcon.DRAMA -> GenreVisual(Icons.Default.TheaterComedy, Color(0xFF7E22CE)) + GenreCategoryIcon.HORROR -> GenreVisual(Icons.Default.LocalFireDepartment, Color(0xFF991B1B)) + GenreCategoryIcon.MYSTERY -> GenreVisual(Icons.AutoMirrored.Filled.HelpOutline, Color(0xFF4338CA)) + GenreCategoryIcon.SCI_FI -> GenreVisual(Icons.Default.AutoAwesome, Color(0xFF0369A1)) + GenreCategoryIcon.THRILLER -> GenreVisual(Icons.Default.VisibilityOff, Color(0xFF0F766E)) + GenreCategoryIcon.WAR -> GenreVisual(Icons.Default.MilitaryTech, Color(0xFF57534E)) + GenreCategoryIcon.FAMILY -> GenreVisual(Icons.Default.SentimentVerySatisfied, Color(0xFFDB2777)) + GenreCategoryIcon.DOCUMENTARY -> GenreVisual(Icons.Default.VideoLibrary, Color(0xFF0E7490)) + GenreCategoryIcon.ROMANCE -> GenreVisual(Icons.Default.Favorite, Color(0xFFBE185D)) + GenreCategoryIcon.WESTERN -> GenreVisual(Icons.Default.Landscape, Color(0xFF92400E)) + GenreCategoryIcon.MUSIC -> GenreVisual(Icons.Default.MusicNote, Color(0xFF6D28D9)) + GenreCategoryIcon.SPORT -> GenreVisual(Icons.Default.SportsSoccer, Color(0xFF047857)) + GenreCategoryIcon.REALITY -> GenreVisual(Icons.Default.LiveTv, Color(0xFFC2410C)) +} + @Composable fun GenreBrowseScreen( itemType: String, - genres: List, + initialCategoryId: String, + favouriteStates: Map, navigationFocusRequester: FocusRequester, contentFocusRequester: FocusRequester, + returnFocusItemId: String?, + returnFocusRequester: FocusRequester, onItemFocused: (BaseItem) -> Unit, onItemSelected: (BaseItem) -> Unit, onClose: () -> Unit, @@ -127,11 +203,19 @@ fun GenreBrowseScreen( ) val state by browseViewModel.state.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() - val allLabel = remember(itemType) { allMediaLabel(itemType) } + val tabState = androidx.compose.foundation.lazy.rememberLazyListState() + val selectedCategory = remember(state.selectedCategoryId, itemType) { + genreCategory(itemType, state.selectedCategoryId) + } - LaunchedEffect(genres) { browseViewModel.setGenres(genres) } - LaunchedEffect(state.selectedGenre) { - if (state.selectedGenre != null) gridState.scrollToItem(0) + LaunchedEffect(initialCategoryId) { browseViewModel.selectCategory(initialCategoryId) } + LaunchedEffect(favouriteStates) { browseViewModel.applyFavouriteStates(favouriteStates) } + 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) @@ -188,7 +272,7 @@ fun GenreBrowseScreen( fontWeight = FontWeight.SemiBold, ) Text( - state.selectedGenre?.takeIf(String::isNotEmpty) ?: allLabel, + selectedCategory.label, color = MembyMutedText, fontSize = 14.sp, ) @@ -197,23 +281,22 @@ fun GenreBrowseScreen( Spacer(Modifier.height(16.dp)) LazyRow( + state = tabState, contentPadding = PaddingValues(horizontal = horizontalPadding), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - val tabs = listOf(ALL_MEDIA) + state.genres - rowItemsIndexed(tabs, key = { _, genre -> genre.ifEmpty { "all-media" } }) { index, genre -> - val selected = genre == state.selectedGenre - val label = genre.ifEmpty { allLabel } + rowItemsIndexed(state.categories, key = { _, category -> category.id }) { index, category -> + val selected = category.id == state.selectedCategoryId FocusScaleContainer( onFocused = {}, onClick = { if (!selected) { - browseViewModel.selectGenre(genre) + browseViewModel.selectCategory(category.id) } }, - contentDescription = "Browse $label", + contentDescription = "Browse ${category.label}", modifier = Modifier - .then(if (index == 0) Modifier.focusRequester(contentFocusRequester) else Modifier) + .then(if (selected) Modifier.focusRequester(contentFocusRequester) else Modifier) .focusProperties { if (index == 0) left = navigationFocusRequester } .background( when { @@ -229,7 +312,7 @@ fun GenreBrowseScreen( ), ) { focused -> Text( - label, + category.label, color = when { focused -> MembySurface selected -> Color.White @@ -250,18 +333,17 @@ fun GenreBrowseScreen( Spacer(Modifier.height(18.dp)) when { - !state.genresInitialised -> GenreMessage("Loading $mediaLabel…") + state.selectedCategoryId == null -> GenreMessage("Loading $mediaLabel…") state.isLoading && state.items.isEmpty() -> GenreMessage( - if (state.selectedGenre.isNullOrEmpty()) "Loading $allLabel…" - else "Loading ${state.selectedGenre} $mediaLabel…", + "Loading ${selectedCategory.label}…", ) state.errorMessage != null && state.items.isEmpty() -> GenreRetry( message = state.errorMessage.orEmpty(), onRetry = browseViewModel::retry, ) state.items.isEmpty() -> GenreMessage( - if (state.selectedGenre.isNullOrEmpty()) "No $mediaLabel were found." - else "No ${state.selectedGenre} $mediaLabel were found.", + if (selectedCategory.genres.isEmpty()) "No $mediaLabel were found." + else "No ${selectedCategory.label.lowercase()} $mediaLabel were found.", ) else -> { LaunchedEffect(gridState, state.items.size, state.canLoadMore) { @@ -290,15 +372,29 @@ fun GenreBrowseScreen( 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 = item, + item = displayedItem, width = cardWidth, - onFocused = { onItemFocused(item) }, - onClick = { onItemSelected(item) }, - onLongClick = { onItemSelected(item) }, - modifier = Modifier.focusProperties { - if (index % columns == 0) left = navigationFocusRequester - }, + onFocused = { onItemFocused(displayedItem) }, + onClick = { onItemSelected(displayedItem) }, + onLongClick = { onItemSelected(displayedItem) }, + modifier = Modifier + .then( + if (item.id == returnFocusItemId) { + Modifier.focusRequester(returnFocusRequester) + } else { + Modifier + }, + ) + .focusProperties { + if (index % columns == 0) left = navigationFocusRequester + }, ) } if (state.isLoadingMore) { 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 index 4321d33..331dcd4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt @@ -16,10 +16,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class GenreBrowseUiState( - val genres: List = emptyList(), - val genresInitialised: Boolean = false, - /** Empty means the whole media type; null means the screen has not been initialised. */ - val selectedGenre: String? = null, + val categories: List = emptyList(), + val selectedCategoryId: String? = null, val items: List = emptyList(), val isLoading: Boolean = false, val isLoadingMore: Boolean = false, @@ -32,47 +30,42 @@ class GenreBrowseViewModel( private val repository: EmbyRepository, private val itemType: String, ) : ViewModel() { - private val _state = MutableStateFlow(GenreBrowseUiState()) + private val categories = genreCategoryTabs(itemType) + private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories)) val state: StateFlow = _state.asStateFlow() private var pageJob: Job? = null + private val categoryPages = mutableMapOf() - fun setGenres(genres: List) { - val normalised = normaliseGenres(genres) - if (normalised == state.value.genres && state.value.genresInitialised) return - val selected = state.value.selectedGenre?.takeIf { current -> - current.isEmpty() || normalised.any { it.equals(current, ignoreCase = true) } - } ?: ALL_MEDIA - _state.update { it.copy(genres = normalised, genresInitialised = true) } - if (selected != state.value.selectedGenre) selectGenre(selected) - } - - fun selectGenre(genre: String) { - val selected = genre.trim() + 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] pageJob?.cancel() _state.update { it.copy( - selectedGenre = selected, - items = emptyList(), - isLoading = true, + selectedCategoryId = selected.id, + items = cached?.items.orEmpty(), + isLoading = cached == null, isLoadingMore = false, - canLoadMore = false, + canLoadMore = cached?.canLoadMore ?: false, errorMessage = null, ) } - pageJob = viewModelScope.launch { loadPage(selected, 0) } + if (cached == null) pageJob = viewModelScope.launch { loadPage(selected, 0) } } fun loadMore() { val current = state.value - val genre = current.selectedGenre ?: return + val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return _state.update { it.copy(isLoadingMore = true) } - pageJob = viewModelScope.launch { loadPage(genre, current.items.size) } + pageJob = viewModelScope.launch { loadPage(category, current.items.size) } } fun retry() { val current = state.value - val genre = current.selectedGenre ?: return + val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return val offset = current.items.size _state.update { it.copy( @@ -82,16 +75,38 @@ class GenreBrowseViewModel( ) } pageJob?.cancel() - pageJob = viewModelScope.launch { loadPage(genre, offset) } + pageJob = viewModelScope.launch { loadPage(category, offset) } } - private suspend fun loadPage(genre: String, offset: Int) { + /** Applies optimistic favourite changes made on a detail page to this screen's copies. */ + fun applyFavouriteStates(changes: Map) { + if (changes.isEmpty()) return + categoryPages.entries.forEach { entry -> + entry.setValue( + entry.value.copy( + items = entry.value.items.map { item -> + changes[item.id]?.let { item.withFavourite(it) } ?: item + }, + ), + ) + } + _state.update { current -> + current.copy( + items = current.items.map { item -> + val favourite = changes[item.id] ?: return@map item + item.withFavourite(favourite) + }, + ) + } + } + + private suspend fun loadPage(category: GenreCategory, offset: Int) { runCatching { - if (genre == ALL_MEDIA) { + if (category.genres.isEmpty()) { repository.browseLibrary(offset = offset, limit = GENRE_PAGE_SIZE, itemType = itemType) } else { repository.browseGenre( - genre = genre, + genre = category.filter, offset = offset, limit = GENRE_PAGE_SIZE, itemType = itemType, @@ -99,24 +114,28 @@ class GenreBrowseViewModel( } }.onSuccess { page -> _state.update { current -> - if (current.selectedGenre != genre || current.items.size != page.offset) return@update current + if (current.selectedCategoryId != category.id || current.items.size != page.offset) { + return@update current + } val items = (current.items + page.items).distinctBy(BaseItem::id) + val canLoadMore = hasMoreGenreItems( + loaded = items.size, + total = page.total, + lastPageSize = page.items.size, + pageSize = GENRE_PAGE_SIZE, + ) + categoryPages[category.id] = CachedCategoryPage(items, canLoadMore) current.copy( items = items, isLoading = false, isLoadingMore = false, - canLoadMore = hasMoreGenreItems( - loaded = items.size, - total = page.total, - lastPageSize = page.items.size, - pageSize = GENRE_PAGE_SIZE, - ), + canLoadMore = canLoadMore, errorMessage = null, ) } }.onFailure { error -> _state.update { current -> - if (current.selectedGenre != genre) current else current.copy( + if (current.selectedCategoryId != category.id) current else current.copy( isLoading = false, isLoadingMore = false, canLoadMore = false, @@ -127,18 +146,10 @@ class GenreBrowseViewModel( } } -const val ALL_MEDIA = "" - -fun allMediaLabel(itemType: String): String = - if (itemType.equals("Series", ignoreCase = true)) "All TV shows" else "All Movies" - -fun normaliseGenres(genres: List): List = genres - .asSequence() - .map(String::trim) - .filter(String::isNotEmpty) - .distinctBy { it.lowercase() } - .sortedWith(String.CASE_INSENSITIVE_ORDER) - .toList() +private data class CachedCategoryPage( + val items: List, + val canLoadMore: Boolean, +) class GenreBrowseViewModelFactory( private val repository: EmbyRepository, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt new file mode 100644 index 0000000..ddb3889 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt @@ -0,0 +1,104 @@ +package com.ponzischeme89.memby.ui.genre + +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.UserItemData + +/** + * A stable, human-sized genre catalogue. + * + * Emby libraries tend to describe the same shelf with several neighbouring labels + * (Action and Adventure, Science Fiction and Sci-Fi, Family and Animation). Presenting + * every raw label makes the picker long and unpredictable, so the television exposes a + * fixed set of useful categories and sends their pipe-delimited Emby genres as one OR + * filter. The order is product design, not server data, and therefore never jumps around + * while home rows are arriving. + */ +data class GenreCategory( + val id: String, + val label: String, + val genres: List, + val icon: GenreCategoryIcon, +) { + val filter: String get() = genres.joinToString("|") +} + +enum class GenreCategoryIcon { + ALL, + ACTION, + COMEDY, + CRIME, + DRAMA, + HORROR, + MYSTERY, + SCI_FI, + THRILLER, + WAR, + FAMILY, + DOCUMENTARY, + ROMANCE, + WESTERN, + MUSIC, + SPORT, + REALITY, +} + +const val ALL_MEDIA_CATEGORY_ID = "all" + +private val allMediaCategory = GenreCategory( + id = ALL_MEDIA_CATEGORY_ID, + label = "All", + genres = emptyList(), + icon = GenreCategoryIcon.ALL, +) + +private val coreGenreCategories = listOf( + GenreCategory("action-adventure", "Action & Adventure", listOf("Action", "Adventure"), GenreCategoryIcon.ACTION), + GenreCategory("comedy", "Comedy", listOf("Comedy"), GenreCategoryIcon.COMEDY), + GenreCategory("crime", "Crime", listOf("Crime", "Film-Noir"), GenreCategoryIcon.CRIME), + GenreCategory("drama", "Drama", listOf("Drama"), GenreCategoryIcon.DRAMA), + GenreCategory("horror", "Horror", listOf("Horror"), GenreCategoryIcon.HORROR), + GenreCategory("mystery", "Mystery", listOf("Mystery"), GenreCategoryIcon.MYSTERY), + GenreCategory( + "sci-fi-fantasy", + "Sci-Fi & Fantasy", + listOf("Science Fiction", "Sci-Fi", "Sci Fi", "Fantasy"), + GenreCategoryIcon.SCI_FI, + ), + GenreCategory("thriller", "Thriller", listOf("Thriller", "Suspense"), GenreCategoryIcon.THRILLER), + GenreCategory("war-history", "War & History", listOf("War", "History"), GenreCategoryIcon.WAR), + GenreCategory( + "family-animation", + "Family & Animation", + listOf("Family", "Animation", "Children", "Kids"), + GenreCategoryIcon.FAMILY, + ), + GenreCategory("documentary", "Documentary", listOf("Documentary"), GenreCategoryIcon.DOCUMENTARY), + GenreCategory("romance", "Romance", listOf("Romance"), GenreCategoryIcon.ROMANCE), + GenreCategory("western", "Western", listOf("Western"), GenreCategoryIcon.WESTERN), + GenreCategory("music-musicals", "Music & Musicals", listOf("Music", "Musical"), GenreCategoryIcon.MUSIC), + GenreCategory("sport", "Sport", listOf("Sport", "Sports"), GenreCategoryIcon.SPORT), +) + +private val realityCategory = GenreCategory( + "reality", + "Reality TV", + listOf("Reality", "Reality TV"), + GenreCategoryIcon.REALITY, +) + +fun genreCategories(itemType: String): List = + if (itemType.equals("Series", ignoreCase = true)) coreGenreCategories + realityCategory + else coreGenreCategories + +fun genreCategoryTabs(itemType: String): List = + listOf(allMediaCategory.copy(label = allMediaLabel(itemType))) + genreCategories(itemType) + +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 BaseItem.withFavourite(favourite: Boolean): BaseItem = copy( + userData = (userData ?: UserItemData()).copy(isFavorite = favourite), +) 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 index 9e61c63..b32c2ad 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt @@ -1,6 +1,9 @@ package com.ponzischeme89.memby.ui.genre +import com.ponzischeme89.memby.data.model.BaseItem import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class GenreBrowseTest { @@ -11,12 +14,48 @@ class GenreBrowseTest { } @Test - fun `genre picker trims deduplicates and sorts names`() { + fun `genre tabs have a stable product order`() { assertEquals( - listOf("Action", "comedy", "Science Fiction"), - normaliseGenres( - listOf(" Science Fiction ", "comedy", "", "Action", "Comedy", " "), + listOf( + "All Movies", + "Action & Adventure", + "Comedy", + "Crime", + "Drama", + "Horror", + "Mystery", + "Sci-Fi & Fantasy", + "Thriller", + "War & History", ), + genreCategoryTabs("Movie").take(10).map(GenreCategory::label), ) } + + @Test + fun `neighbouring raw genres merge into one Emby filter`() { + assertEquals("Action|Adventure", genreCategory("Movie", "action-adventure").filter) + assertEquals( + "Science Fiction|Sci-Fi|Sci Fi|Fantasy", + genreCategory("Series", "sci-fi-fantasy").filter, + ) + assertEquals("War|History", genreCategory("Movie", "war-history").filter) + } + + @Test + fun `series keeps the same core tabs and adds reality`() { + assertEquals( + genreCategories("Movie").map(GenreCategory::id), + genreCategories("Series").dropLast(1).map(GenreCategory::id), + ) + assertEquals("reality", genreCategories("Series").last().id) + } + + @Test + fun `favourite overrides update a paged card copy immediately`() { + val item = BaseItem(id = "film", name = "Film", type = "Movie") + assertFalse(item.isFavorite) + assertTrue(item.withFavourite(true).isFavorite) + assertFalse(item.withFavourite(true).withFavourite(false).isFavorite) + } }