Release v0.2.37

This commit is contained in:
ponzischeme89
2026-08-09 13:10:55 +12:00
parent 574826dc03
commit 0dffd59440
10 changed files with 411 additions and 145 deletions
+4
View File
@@ -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 ## 0.2.36 — 2026-08-09
- Improved: Browse all movies or TV shows alongside individual genres, with clearer navigation and paging. - Improved: Browse all movies or TV shows alongside individual genres, with clearer navigation and paging.
+11 -7
View File
@@ -510,13 +510,17 @@ at a time. Things to preserve:
focused. focused.
- **Episodes are excluded on both paths.** An episode inherits its series' genres, so - **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. 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 - **Movies and TV Series have their own full-width genre browser.** A fixed row of colourful
the first focus target on both destinations and `ui/genre/` reuses the same paged mini cards is the first focus target on both destinations; it is deliberately a product
repository call in a poster grid. Its `type=Movie` / `type=Series` query is enforced by catalogue rather than whatever genre names happened to arrive in the home rows, so its
the gateway and on the direct Emby path, so opening Drama from Movies can never mix a order and availability never jump around during refresh. Neighbouring Emby labels are
television series into the grid (and vice versa). The picker derives its catalogue from merged where they answer the same browsing intent (`Action|Adventure`,
the unfiltered server rows as well as the visible destination rows: hiding a home shelf `Science Fiction|Sci-Fi|Sci Fi|Fantasy`, `War|History`) using Emby's pipe-delimited genre
must not make that genre disappear from Browse. 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 - **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 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 does not: a gateway that answered page one and failed on page two is having trouble, not
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the // 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. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.36" val defaultVersionName = "0.2.37"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -728,7 +728,10 @@ class EmbyRepository(private val settings: SettingsStore) {
// failed on page two is having trouble, not missing the route, and a // 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. // search's results pasted onto the end of a genre would be nonsense.
if (offset > 0) throw error 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) embyItemType == "Movie,Series" || candidate.type.equals(embyItemType, ignoreCase = true)
} }
return GenrePage(items, offset, items.size) return GenrePage(items, offset, items.size)
@@ -145,6 +145,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value)) private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow() val focusedItem: StateFlow<BaseItem?> = _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<Map<String, Boolean>>(emptyMap())
val favoriteChanges: StateFlow<Map<String, Boolean>> = _favoriteChanges.asStateFlow()
private val _forYou = MutableStateFlow(ForYouUiState()) private val _forYou = MutableStateFlow(ForYouUiState())
val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow() val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow()
private var metadataJob: Job? = null private var metadataJob: Job? = null
@@ -395,6 +400,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
private fun updateFavorite(item: BaseItem, favorite: Boolean) { private fun updateFavorite(item: BaseItem, favorite: Boolean) {
_favoriteChanges.update { it + (item.id to favorite) }
updateUserData(item.id) { it.copy(isFavorite = favorite) } updateUserData(item.id) { it.copy(isFavorite = favorite) }
_state.update { state -> _state.update { state ->
val updatedItem = item.copy( val updatedItem = item.copy(
@@ -133,7 +133,7 @@ import com.ponzischeme89.memby.ui.alerts.MyAlertsPage
import com.ponzischeme89.memby.ui.detail.AiringNotice import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.airingNoticeFor import com.ponzischeme89.memby.ui.detail.airingNoticeFor
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub 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.genre.GenreBrowseScreen
import com.ponzischeme89.memby.ui.player.PlayerActivity import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.performance.PerformanceMonitor import com.ponzischeme89.memby.performance.PerformanceMonitor
@@ -1354,6 +1354,7 @@ private fun HomeScreen(
val homeContent by homeViewModel.content.collectAsStateWithLifecycle() val homeContent by homeViewModel.content.collectAsStateWithLifecycle()
val homeStatus by homeViewModel.status.collectAsStateWithLifecycle() val homeStatus by homeViewModel.status.collectAsStateWithLifecycle()
val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle() val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle()
val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle()
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle() val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle() val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
// Only whether there is one, not its countdown — that is collected inside the banner // 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<String?>(null) } var removingProfileId by remember { mutableStateOf<String?>(null) }
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
var genreBrowseItemType by remember { mutableStateOf<String?>(null) } var genreBrowseItemType by remember { mutableStateOf<String?>(null) }
var genreBrowseInitialCategoryId by remember { mutableStateOf<String?>(null) }
var navigationExpanded by rememberSaveable { mutableStateOf(false) } var navigationExpanded by rememberSaveable { mutableStateOf(false) }
var restoreRailAfterSettings by remember { mutableStateOf(false) } var restoreRailAfterSettings by remember { mutableStateOf(false) }
var detailsItem by remember { mutableStateOf<BaseItem?>(null) } var detailsItem by remember { mutableStateOf<BaseItem?>(null) }
@@ -1766,33 +1768,26 @@ private fun HomeScreen(
} }
genreBrowseItemType?.let { itemType -> 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( GenreBrowseScreen(
itemType = itemType, itemType = itemType,
genres = genres, initialCategoryId = genreBrowseInitialCategoryId
?: com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID,
favouriteStates = favoriteChanges,
navigationFocusRequester = navigationFocusRequester, navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester, contentFocusRequester = contentFocusRequester,
returnFocusItemId = returnItemId.takeIf { returnRowId == GENRE_BROWSER_ROW_ID },
returnFocusRequester = cardReturnFocusRequester,
onItemFocused = homeViewModel::focusItem, onItemFocused = homeViewModel::focusItem,
onItemSelected = { item -> onItemSelected = { item ->
returnRowId = GENRE_BROWSER_ROW_ID
returnItemId = item.id
homeViewModel.focusItem(item) homeViewModel.focusItem(item)
detailsAiringNotice = null detailsAiringNotice = null
detailsItem = item detailsItem = item
}, },
onClose = { onClose = {
genreBrowseItemType = null genreBrowseItemType = null
genreBrowseInitialCategoryId = null
scope.launch { scope.launch {
kotlinx.coroutines.delay(16L) kotlinx.coroutines.delay(16L)
runCatching { contentFocusRequester.requestFocus() } runCatching { contentFocusRequester.requestFocus() }
@@ -1940,12 +1935,15 @@ private fun HomeScreen(
) { ) {
item(key = "genre-browser", contentType = "genre-browser") { item(key = "genre-browser", contentType = "genre-browser") {
val itemType = if (selectedDestination == BrowseDestination.SHOWS) "Series" else "Movie" val itemType = if (selectedDestination == BrowseDestination.SHOWS) "Series" else "Movie"
GenreBrowseLauncher( GenreDiscoveryStrip(
mediaLabel = if (itemType == "Series") "TV series" else "movie", itemType = itemType,
navigationFocusRequester = navigationFocusRequester, navigationFocusRequester = navigationFocusRequester,
entryFocusRequester = contentFocusRequester,
onFocused = { navigationExpanded = false }, onFocused = { navigationExpanded = false },
onOpen = { genreBrowseItemType = itemType }, onOpenCategory = { categoryId ->
modifier = Modifier.focusRequester(contentFocusRequester), 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. */ /** Row id under which the search grid records its return-focus target. */
private const val SEARCH_ROW_ID = "search-results" private const val SEARCH_ROW_ID = "search-results"
private const val GENRE_BROWSER_ROW_ID = "genre-browser-results"
@Composable @Composable
private fun RecentSearchesRow( private fun RecentSearchesRow(
@@ -28,7 +28,22 @@ import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -61,58 +77,118 @@ import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@Composable @Composable
fun GenreBrowseLauncher( fun GenreDiscoveryStrip(
mediaLabel: String, itemType: String,
navigationFocusRequester: FocusRequester, navigationFocusRequester: FocusRequester,
entryFocusRequester: FocusRequester,
onFocused: () -> Unit, onFocused: () -> Unit,
onOpen: () -> Unit, onOpenCategory: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
FocusScaleContainer( val categories = remember(itemType) { genreCategoryTabs(itemType) }
onFocused = onFocused, Column(modifier.padding(vertical = 4.dp)) {
onClick = onOpen, Text(
contentDescription = "Browse ${if (mediaLabel == "movie") "movies" else mediaLabel}", "Browse genres",
modifier = modifier color = Color.White,
.padding(horizontal = 36.dp, vertical = 4.dp) fontSize = 20.sp,
.width(156.dp) fontWeight = FontWeight.SemiBold,
.height(52.dp) modifier = Modifier.padding(horizontal = 36.dp, vertical = 6.dp),
.focusProperties { left = navigationFocusRequester } )
.background(MembySurfaceRaised, RoundedCornerShape(10.dp)) LazyRow(
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp)), contentPadding = PaddingValues(horizontal = 36.dp, vertical = 6.dp),
) { focused -> horizontalArrangement = Arrangement.spacedBy(12.dp),
Row(
modifier = Modifier
.fillMaxSize()
.background(
if (focused) Color.White else Color.Transparent,
RoundedCornerShape(10.dp),
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( rowItemsIndexed(categories, key = { _, category -> category.id }) { index, category ->
Icons.Default.Category, GenreDiscoveryCard(
contentDescription = null, category = category,
tint = if (focused) MembySurface else MembyAccent, onFocused = onFocused,
modifier = Modifier.size(22.dp), onClick = { onOpenCategory(category.id) },
) modifier = Modifier
Spacer(Modifier.width(10.dp)) .then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier)
Text( .focusProperties { if (index == 0) left = navigationFocusRequester },
"Browse", )
color = if (focused) MembySurface else Color.White, }
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
} }
} }
} }
@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 @Composable
fun GenreBrowseScreen( fun GenreBrowseScreen(
itemType: String, itemType: String,
genres: List<String>, initialCategoryId: String,
favouriteStates: Map<String, Boolean>,
navigationFocusRequester: FocusRequester, navigationFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester, contentFocusRequester: FocusRequester,
returnFocusItemId: String?,
returnFocusRequester: FocusRequester,
onItemFocused: (BaseItem) -> Unit, onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit, onItemSelected: (BaseItem) -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
@@ -127,11 +203,19 @@ fun GenreBrowseScreen(
) )
val state by browseViewModel.state.collectAsStateWithLifecycle() val state by browseViewModel.state.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() 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(initialCategoryId) { browseViewModel.selectCategory(initialCategoryId) }
LaunchedEffect(state.selectedGenre) { LaunchedEffect(favouriteStates) { browseViewModel.applyFavouriteStates(favouriteStates) }
if (state.selectedGenre != null) gridState.scrollToItem(0) 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) { LaunchedEffect(Unit) {
kotlinx.coroutines.delay(32L) kotlinx.coroutines.delay(32L)
@@ -188,7 +272,7 @@ fun GenreBrowseScreen(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Text( Text(
state.selectedGenre?.takeIf(String::isNotEmpty) ?: allLabel, selectedCategory.label,
color = MembyMutedText, color = MembyMutedText,
fontSize = 14.sp, fontSize = 14.sp,
) )
@@ -197,23 +281,22 @@ fun GenreBrowseScreen(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
LazyRow( LazyRow(
state = tabState,
contentPadding = PaddingValues(horizontal = horizontalPadding), contentPadding = PaddingValues(horizontal = horizontalPadding),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
val tabs = listOf(ALL_MEDIA) + state.genres rowItemsIndexed(state.categories, key = { _, category -> category.id }) { index, category ->
rowItemsIndexed(tabs, key = { _, genre -> genre.ifEmpty { "all-media" } }) { index, genre -> val selected = category.id == state.selectedCategoryId
val selected = genre == state.selectedGenre
val label = genre.ifEmpty { allLabel }
FocusScaleContainer( FocusScaleContainer(
onFocused = {}, onFocused = {},
onClick = { onClick = {
if (!selected) { if (!selected) {
browseViewModel.selectGenre(genre) browseViewModel.selectCategory(category.id)
} }
}, },
contentDescription = "Browse $label", contentDescription = "Browse ${category.label}",
modifier = Modifier 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 } .focusProperties { if (index == 0) left = navigationFocusRequester }
.background( .background(
when { when {
@@ -229,7 +312,7 @@ fun GenreBrowseScreen(
), ),
) { focused -> ) { focused ->
Text( Text(
label, category.label,
color = when { color = when {
focused -> MembySurface focused -> MembySurface
selected -> Color.White selected -> Color.White
@@ -250,18 +333,17 @@ fun GenreBrowseScreen(
Spacer(Modifier.height(18.dp)) Spacer(Modifier.height(18.dp))
when { when {
!state.genresInitialised -> GenreMessage("Loading $mediaLabel") state.selectedCategoryId == null -> GenreMessage("Loading $mediaLabel")
state.isLoading && state.items.isEmpty() -> GenreMessage( state.isLoading && state.items.isEmpty() -> GenreMessage(
if (state.selectedGenre.isNullOrEmpty()) "Loading $allLabel" "Loading ${selectedCategory.label}",
else "Loading ${state.selectedGenre} $mediaLabel",
) )
state.errorMessage != null && state.items.isEmpty() -> GenreRetry( state.errorMessage != null && state.items.isEmpty() -> GenreRetry(
message = state.errorMessage.orEmpty(), message = state.errorMessage.orEmpty(),
onRetry = browseViewModel::retry, onRetry = browseViewModel::retry,
) )
state.items.isEmpty() -> GenreMessage( state.items.isEmpty() -> GenreMessage(
if (state.selectedGenre.isNullOrEmpty()) "No $mediaLabel were found." if (selectedCategory.genres.isEmpty()) "No $mediaLabel were found."
else "No ${state.selectedGenre} $mediaLabel were found.", else "No ${selectedCategory.label.lowercase()} $mediaLabel were found.",
) )
else -> { else -> {
LaunchedEffect(gridState, state.items.size, state.canLoadMore) { LaunchedEffect(gridState, state.items.size, state.canLoadMore) {
@@ -290,15 +372,29 @@ fun GenreBrowseScreen(
key = { _, item -> item.id }, key = { _, item -> item.id },
contentType = { _, _ -> "genre-poster" }, contentType = { _, _ -> "genre-poster" },
) { index, item -> ) { index, item ->
val favourite = favouriteStates[item.id]
val displayedItem = if (favourite == null || favourite == item.isFavorite) {
item
} else {
item.withFavourite(favourite)
}
PosterGridCard( PosterGridCard(
item = item, item = displayedItem,
width = cardWidth, width = cardWidth,
onFocused = { onItemFocused(item) }, onFocused = { onItemFocused(displayedItem) },
onClick = { onItemSelected(item) }, onClick = { onItemSelected(displayedItem) },
onLongClick = { onItemSelected(item) }, onLongClick = { onItemSelected(displayedItem) },
modifier = Modifier.focusProperties { modifier = Modifier
if (index % columns == 0) left = navigationFocusRequester .then(
}, if (item.id == returnFocusItemId) {
Modifier.focusRequester(returnFocusRequester)
} else {
Modifier
},
)
.focusProperties {
if (index % columns == 0) left = navigationFocusRequester
},
) )
} }
if (state.isLoadingMore) { if (state.isLoadingMore) {
@@ -16,10 +16,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class GenreBrowseUiState( data class GenreBrowseUiState(
val genres: List<String> = emptyList(), val categories: List<GenreCategory> = emptyList(),
val genresInitialised: Boolean = false, val selectedCategoryId: String? = null,
/** Empty means the whole media type; null means the screen has not been initialised. */
val selectedGenre: String? = null,
val items: List<BaseItem> = emptyList(), val items: List<BaseItem> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
@@ -32,47 +30,42 @@ class GenreBrowseViewModel(
private val repository: EmbyRepository, private val repository: EmbyRepository,
private val itemType: String, private val itemType: String,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(GenreBrowseUiState()) private val categories = genreCategoryTabs(itemType)
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow() val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
private var pageJob: Job? = null private var pageJob: Job? = null
private val categoryPages = mutableMapOf<String, CachedCategoryPage>()
fun setGenres(genres: List<String>) { fun selectCategory(categoryId: String) {
val normalised = normaliseGenres(genres) val selected = categories.firstOrNull { it.id == categoryId } ?: categories.first()
if (normalised == state.value.genres && state.value.genresInitialised) return val current = state.value
val selected = state.value.selectedGenre?.takeIf { current -> if (current.selectedCategoryId == selected.id && (current.items.isNotEmpty() || current.isLoading)) return
current.isEmpty() || normalised.any { it.equals(current, ignoreCase = true) } val cached = categoryPages[selected.id]
} ?: 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()
pageJob?.cancel() pageJob?.cancel()
_state.update { _state.update {
it.copy( it.copy(
selectedGenre = selected, selectedCategoryId = selected.id,
items = emptyList(), items = cached?.items.orEmpty(),
isLoading = true, isLoading = cached == null,
isLoadingMore = false, isLoadingMore = false,
canLoadMore = false, canLoadMore = cached?.canLoadMore ?: false,
errorMessage = null, errorMessage = null,
) )
} }
pageJob = viewModelScope.launch { loadPage(selected, 0) } if (cached == null) pageJob = viewModelScope.launch { loadPage(selected, 0) }
} }
fun loadMore() { fun loadMore() {
val current = state.value 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 if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return
_state.update { it.copy(isLoadingMore = true) } _state.update { it.copy(isLoadingMore = true) }
pageJob = viewModelScope.launch { loadPage(genre, current.items.size) } pageJob = viewModelScope.launch { loadPage(category, current.items.size) }
} }
fun retry() { fun retry() {
val current = state.value val current = state.value
val genre = current.selectedGenre ?: return val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return
val offset = current.items.size val offset = current.items.size
_state.update { _state.update {
it.copy( it.copy(
@@ -82,16 +75,38 @@ class GenreBrowseViewModel(
) )
} }
pageJob?.cancel() 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<String, Boolean>) {
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 { runCatching {
if (genre == ALL_MEDIA) { if (category.genres.isEmpty()) {
repository.browseLibrary(offset = offset, limit = GENRE_PAGE_SIZE, itemType = itemType) repository.browseLibrary(offset = offset, limit = GENRE_PAGE_SIZE, itemType = itemType)
} else { } else {
repository.browseGenre( repository.browseGenre(
genre = genre, genre = category.filter,
offset = offset, offset = offset,
limit = GENRE_PAGE_SIZE, limit = GENRE_PAGE_SIZE,
itemType = itemType, itemType = itemType,
@@ -99,24 +114,28 @@ class GenreBrowseViewModel(
} }
}.onSuccess { page -> }.onSuccess { page ->
_state.update { current -> _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 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( current.copy(
items = items, items = items,
isLoading = false, isLoading = false,
isLoadingMore = false, isLoadingMore = false,
canLoadMore = hasMoreGenreItems( canLoadMore = canLoadMore,
loaded = items.size,
total = page.total,
lastPageSize = page.items.size,
pageSize = GENRE_PAGE_SIZE,
),
errorMessage = null, errorMessage = null,
) )
} }
}.onFailure { error -> }.onFailure { error ->
_state.update { current -> _state.update { current ->
if (current.selectedGenre != genre) current else current.copy( if (current.selectedCategoryId != category.id) current else current.copy(
isLoading = false, isLoading = false,
isLoadingMore = false, isLoadingMore = false,
canLoadMore = false, canLoadMore = false,
@@ -127,18 +146,10 @@ class GenreBrowseViewModel(
} }
} }
const val ALL_MEDIA = "" private data class CachedCategoryPage(
val items: List<BaseItem>,
fun allMediaLabel(itemType: String): String = val canLoadMore: Boolean,
if (itemType.equals("Series", ignoreCase = true)) "All TV shows" else "All Movies" )
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( class GenreBrowseViewModelFactory(
private val repository: EmbyRepository, private val repository: EmbyRepository,
@@ -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<String>,
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<GenreCategory> =
if (itemType.equals("Series", ignoreCase = true)) coreGenreCategories + realityCategory
else coreGenreCategories
fun genreCategoryTabs(itemType: String): List<GenreCategory> =
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),
)
@@ -1,6 +1,9 @@
package com.ponzischeme89.memby.ui.genre package com.ponzischeme89.memby.ui.genre
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class GenreBrowseTest { class GenreBrowseTest {
@@ -11,12 +14,48 @@ class GenreBrowseTest {
} }
@Test @Test
fun `genre picker trims deduplicates and sorts names`() { fun `genre tabs have a stable product order`() {
assertEquals( assertEquals(
listOf("Action", "comedy", "Science Fiction"), listOf(
normaliseGenres( "All Movies",
listOf(" Science Fiction ", "comedy", "", "Action", "Comedy", " "), "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)
}
} }