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
@@ -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)
@@ -145,6 +145,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
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())
val forYou: StateFlow<ForYouUiState> = _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(
@@ -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<String?>(null) }
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
var genreBrowseItemType by remember { mutableStateOf<String?>(null) }
var genreBrowseInitialCategoryId by remember { mutableStateOf<String?>(null) }
var navigationExpanded by rememberSaveable { mutableStateOf(false) }
var restoreRailAfterSettings by remember { mutableStateOf(false) }
var detailsItem by remember { mutableStateOf<BaseItem?>(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(
@@ -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<String>,
initialCategoryId: String,
favouriteStates: Map<String, Boolean>,
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) {
@@ -16,10 +16,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class GenreBrowseUiState(
val genres: List<String> = 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<GenreCategory> = emptyList(),
val selectedCategoryId: String? = null,
val items: List<BaseItem> = 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<GenreBrowseUiState> = _state.asStateFlow()
private var pageJob: Job? = null
private val categoryPages = mutableMapOf<String, CachedCategoryPage>()
fun setGenres(genres: List<String>) {
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<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 {
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<String>): List<String> = genres
.asSequence()
.map(String::trim)
.filter(String::isNotEmpty)
.distinctBy { it.lowercase() }
.sortedWith(String.CASE_INSENSITIVE_ORDER)
.toList()
private data class CachedCategoryPage(
val items: List<BaseItem>,
val canLoadMore: Boolean,
)
class GenreBrowseViewModelFactory(
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
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)
}
}