Release v0.2.37
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user