0.2.74 - Genres brwoser

This commit is contained in:
ponzischeme89
2026-08-17 14:56:13 +12:00
parent 43ab18854c
commit d5a8d3ad88
9 changed files with 292 additions and 51 deletions
@@ -168,15 +168,24 @@ internal val TvRailCollapsedWidth = 54.dp
internal val TvRailExpandedWidth = 184.dp
internal val TvRailContentShift = 112.dp
/**
* The rail's order, which is this declaration order — see [navigationRailItems], the only
* thing that reads it. Nothing persists an ordinal, so an entry may be moved.
*
* The two ends are the fixed points: browsing the catalogue sits directly under Home
* because it is the whole library and therefore the widest question a viewer can ask,
* while For You is a *narrow* one — a handful of ranked titles — so it belongs down beside
* the calendar with the other answers rather than above the shelves it draws from.
*/
enum class BrowseDestination(val label: String, val icon: ImageVector) {
HOME("Home", Icons.Default.Home),
FOR_YOU("For You", Icons.Default.AutoAwesome),
SEARCH("Search", Icons.Default.Search),
MOVIES("Movies", Icons.Default.Movie),
SHOWS("TV Shows", Icons.Default.Tv),
// The catalogue, browsed by genre rather than by shelf. Hidden unless the gateway has
// the genre browser on — see [TvNavigationRail]'s genresEnabled.
GENRES("Genres", Icons.Default.GridView),
SEARCH("Search", Icons.Default.Search),
MOVIES("Movies", Icons.Default.Movie),
SHOWS("TV Shows", Icons.Default.Tv),
FOR_YOU("For You", Icons.Default.AutoAwesome),
// Sonarr's schedule, a month at a time. Hidden unless the gateway says the household
// has one — see [TvNavigationRail]'s calendarEnabled.
CALENDAR("TV Calendar", Icons.Default.CalendarMonth),
@@ -1519,6 +1528,25 @@ internal fun HomeRowHeaderIcon(icon: ImageVector, modifier: Modifier = Modifier)
}
}
/**
* Which of the two kinds of thing a card is, for a grid that holds both.
*
* Only the Genres destination genuinely mixes them — the Movies and TV Shows pages name a
* type, so every card on those wears the same mark and it says nothing. It is a *mark*
* rather than a word because it sits over artwork at three metres, where "TV SHOW" in a
* pill would be read before the poster it is covering.
*
* A season or an episode is marked as television: both are parts of a show, and a viewer
* separating films from shows is not asking about the difference between them. Anything
* else — a person, a collection, a schedule card — gets nothing rather than a guess.
*/
internal fun mediaTypeMark(item: BaseItem): Pair<ImageVector, String>? = when {
item.isSeries || item.isEpisode || item.type.equals("Season", ignoreCase = true) ->
Icons.Default.Tv to "TV show"
item.type.equals("Movie", ignoreCase = true) -> Icons.Default.Movie to "Film"
else -> null
}
@Composable
fun MediaBadge(label: String, modifier: Modifier = Modifier) {
Text(
@@ -1944,10 +1972,12 @@ fun PosterGridCard(
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
showWatchedEpisodeCount: Boolean = false,
showMediaTypeIcon: Boolean = false,
) {
MediaCard(
item, width, 2f / 3f, preferPrimary = true, showProgress = false,
showSecondaryMetadata = true, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
showMediaTypeIcon = showMediaTypeIcon,
)
}
@@ -2012,6 +2042,7 @@ private fun MediaCard(
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier,
showMediaTypeIcon: Boolean = false,
) {
val repo = ServiceLocator.repository
val density = LocalDensity.current
@@ -2148,11 +2179,25 @@ private fun MediaCard(
)
}
}
airingBadgeLabel(item)?.let { label ->
val airingLabel = airingBadgeLabel(item)
if (airingLabel != null) {
MediaBadge(
label,
airingLabel,
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
)
} else if (showMediaTypeIcon) {
// Which of the two things this is, for a grid that holds both. The
// top-left is the airing badge's corner first: a schedule card already
// says what it is by saying when it is on, and two marks in one corner
// would overlap. The watched tick and the heart own the other corner.
mediaTypeMark(item)?.let { (icon, description) ->
MediaStatusIcon(
icon = icon,
description = description,
tint = Color.White.copy(alpha = 0.92f),
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
)
}
}
if (focused) {
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
@@ -2193,9 +2238,10 @@ private fun MediaStatusIcon(
icon: ImageVector,
description: String,
tint: Color,
modifier: Modifier = Modifier,
) {
Box(
modifier = Modifier
modifier = modifier
.size(29.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.Black.copy(alpha = 0.78f))
@@ -91,10 +91,10 @@ import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.PosterGridCard
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentDeep
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
@@ -221,6 +221,15 @@ private const val GENRE_SELECT_DEBOUNCE_MS = 130L
/** How many rows of placeholder cards stand in for a genre that has not answered yet. */
private const val GENRE_PLACEHOLDER_ROWS = 3
/**
* Cards across the genre grid.
*
* It is also the width of a row for every rule that counts in them — the load-more trigger
* looks [GENRE_GRID_COLUMNS] * 2 cards ahead, and the first column is what Left returns to
* the rail from — so those must be read from here rather than from a second literal.
*/
internal const val GENRE_GRID_COLUMNS = 5
internal val GenreRailWidth = 214.dp
/**
@@ -358,14 +367,18 @@ fun GenreBrowseScreen(
) {
val horizontalPadding = 40.dp
val spacing = 18.dp
val columns = when {
maxWidth >= 1000.dp -> 6
maxWidth >= 820.dp -> 5
maxWidth >= 620.dp -> 4
else -> 3
}
val cardWidth = ((maxWidth - horizontalPadding * 2 - spacing * (columns - 1)) / columns)
.coerceAtLeast(104.dp)
// Fixed, not responsive. The pane's width is the television's, less a rail
// of a known width, so the "responsive" ladder this replaced only ever
// reached one rung on a real set — and the rung it reached was six, which
// is a poster small enough that the titles under them were the only way to
// tell two films apart. Five is the count the card size was chosen for.
val columns = GENRE_GRID_COLUMNS
// Exactly the cell GridCells.Fixed will hand out, and deliberately without
// the minimum width this carried while the column count was responsive. A
// floor and a fixed count cannot both be honoured: on a pane too narrow for
// five at that floor the cards no longer fit their own grid, and the last
// column is clipped by the edge of the screen instead of being made smaller.
val cardWidth = (maxWidth - horizontalPadding * 2 - spacing * (columns - 1)) / columns
Column(Modifier.fillMaxSize()) {
Spacer(Modifier.height(44.dp))
@@ -449,6 +462,15 @@ fun GenreBrowseScreen(
PosterGridCard(
item = displayedItem,
width = cardWidth,
// On every genre shelf, not only the mixed one.
// The Genres destination is the case that needs it
// — films and shows in one grid — but the Movies
// and TV Shows pages are the same screen, and a
// mark that came and went depending on which
// destination a viewer arrived from would read as
// meaning something about the title rather than
// about the shelf.
showMediaTypeIcon = true,
onFocused = {
onContentFocused()
shownCategoryId?.let { focusedCardIndexes[it] = index }
@@ -654,7 +676,10 @@ private fun GenreRailItem(
)
val plate = when {
focused -> MembyAccent
active -> MembyControlSurface
// Deep green rather than a neutral: the genre in force is what the grid beside
// the rail is showing, and a grey plate read as "this row is slightly raised"
// rather than as "this is the one you are looking at".
active -> MembyAccentDeep
else -> Color.Transparent
}
val bar = if (focused) MembyAccentInk.copy(alpha = 0.45f) else MembyAccent
@@ -60,38 +60,91 @@ private val allMediaCategory = GenreCategory(
icon = GenreCategoryIcon.ALL,
)
/**
* The aliases are three vocabularies, not one, and that is the whole reason this list is
* long. Emby stores whatever its metadata agent wrote, and the three that reach a real
* library disagree:
*
* - **TMDb films** use short single words (Action, Science Fiction, Documentary).
* - **TMDb television** uses *compound* names that exist in no film list at all —
* `Action & Adventure`, `Sci-Fi & Fantasy`, `War & Politics`. A filter naming only the
* parts matches none of them, which is why every TMDb-sourced science-fiction series in
* a library was missing from Sci-Fi & Fantasy while the films were all present.
* - **IMDb** hyphenates (`Film-Noir`, `Reality-TV`, `Talk-Show`) and has genres TMDb does
* not, `Sport` among them.
*
* Emby's `Genres` filter is an exact string match, so a spelling that is not listed here is
* a title that cannot be found. Add the variant rather than reaching for a looser match:
* the whole point of browsing a genre rather than searching for its name is that the answer
* is a rule instead of a relevance score.
*/
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(
"action-adventure",
"Action & Adventure",
listOf("Action", "Adventure", "Action & Adventure", "Action/Adventure", "Martial Arts"),
GenreCategoryIcon.ACTION,
),
GenreCategory("comedy", "Comedy", listOf("Comedy", "Stand-Up", "Stand-Up Comedy"), GenreCategoryIcon.COMEDY),
GenreCategory("crime", "Crime", listOf("Crime", "Film-Noir", "Film Noir"), GenreCategoryIcon.CRIME),
GenreCategory("drama", "Drama", listOf("Drama", "Soap"), 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"),
listOf(
"Science Fiction",
"Sci-Fi",
"Sci Fi",
"SciFi",
"Sci-Fi & Fantasy",
"Fantasy",
),
GenreCategoryIcon.SCI_FI,
),
GenreCategory("thriller", "Thriller", listOf("Thriller", "Suspense"), GenreCategoryIcon.THRILLER),
GenreCategory("war-history", "War & History", listOf("War", "History"), GenreCategoryIcon.WAR),
GenreCategory(
"war-history",
"War & History",
listOf("War", "History", "War & Politics", "Historical"),
GenreCategoryIcon.WAR,
),
GenreCategory(
"family-animation",
"Family & Animation",
listOf("Family", "Animation", "Children", "Kids"),
listOf("Family", "Animation", "Children", "Kids", "Anime"),
GenreCategoryIcon.FAMILY,
),
GenreCategory("documentary", "Documentary", listOf("Documentary"), GenreCategoryIcon.DOCUMENTARY),
GenreCategory(
"documentary",
"Documentary",
listOf("Documentary", "Documentaries", "Docuseries", "Biography"),
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),
GenreCategory(
"music-musicals",
"Music & Musicals",
listOf("Music", "Musical", "Musicals", "Concert"),
GenreCategoryIcon.MUSIC,
),
// TMDb has no Sport genre in *either* of its lists, so a sports film it catalogued
// carries only Documentary or Drama and no filter can find it — see the note above.
// These are the spellings the other agents write.
GenreCategory(
"sport",
"Sport",
listOf("Sport", "Sports", "Sporting Event", "Sports Film"),
GenreCategoryIcon.SPORT,
),
)
private val realityCategory = GenreCategory(
"reality",
"Reality TV",
listOf("Reality", "Reality TV"),
listOf("Reality", "Reality TV", "Reality-TV", "Game Show", "Game-Show"),
GenreCategoryIcon.REALITY,
)
@@ -149,6 +149,17 @@ val MembyAccentInk: Color get() = activePalette.accent.darken(0.90f)
/** A quiet accent wash for a plate that is *selected* rather than focused. */
val MembyAccentMuted: Color get() = activePalette.surfaceRaised.mix(activePalette.accent, 0.20f)
/**
* The accent taken down to a deep green: a plate that is *selected* where the neutral of
* [MembyAccentMuted] is too quiet to carry the claim at three metres.
*
* The genre rail is the case it exists for. Selection there means "the grid beside this is
* showing this genre", which is a stronger statement than a tinted grey makes — but it must
* still be plainly a step below the accent fill under the viewer's thumb, or focus and
* selection stop being distinguishable and the rail loses the one thing it has to say.
*/
val MembyAccentDeep: Color get() = activePalette.accent.darken(0.62f)
/**
* The three neutral steps between [MembySurfaceRaised] and the text on it: an unfocused
* control, the same control one step up, and the hairline border around it. Named by their
@@ -16,6 +16,44 @@ class NavigationRailTest {
assertFalse(items.contains(BrowseDestination.SETTINGS))
}
@Test
fun `the rail is ordered widest question first`() {
// Genres is the whole catalogue, so it sits directly under Home; For You is a
// handful of ranked titles, so it sits down beside the calendar rather than above
// the shelves it is drawn from. Nothing persists an ordinal, so this order is the
// only record of that decision.
assertEquals(
listOf(
BrowseDestination.PROFILES,
BrowseDestination.HOME,
BrowseDestination.GENRES,
BrowseDestination.SEARCH,
BrowseDestination.MOVIES,
BrowseDestination.SHOWS,
BrowseDestination.FOR_YOU,
BrowseDestination.CALENDAR,
BrowseDestination.FAVORITES,
),
navigationRailItems(calendarEnabled = true, genresEnabled = true),
)
}
@Test
fun `a withheld destination closes the gap rather than leaving one`() {
assertEquals(
listOf(
BrowseDestination.PROFILES,
BrowseDestination.HOME,
BrowseDestination.SEARCH,
BrowseDestination.MOVIES,
BrowseDestination.SHOWS,
BrowseDestination.FOR_YOU,
BrowseDestination.FAVORITES,
),
navigationRailItems(calendarEnabled = false, genresEnabled = false),
)
}
@Test
fun `calendar capability does not change the first two rail items`() {
val items = navigationRailItems(calendarEnabled = true)
@@ -34,12 +34,55 @@ class GenreBrowseTest {
@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",
"Action|Adventure|Action & Adventure|Action/Adventure|Martial Arts",
genreCategory("Movie", "action-adventure").filter,
)
assertEquals(
"Science Fiction|Sci-Fi|Sci Fi|SciFi|Sci-Fi & Fantasy|Fantasy",
genreCategory("Series", "sci-fi-fantasy").filter,
)
assertEquals("War|History", genreCategory("Movie", "war-history").filter)
assertEquals("War|History|War & Politics|Historical", genreCategory("Movie", "war-history").filter)
}
@Test
fun `compound television genre names are matched, not just their parts`() {
// TMDb's television list names genres that exist in no film list: a series it
// catalogued carries "Sci-Fi & Fantasy" as one string, so a filter asking only for
// "Science Fiction" and "Fantasy" matched none of them while every film matched.
// This is the regression that emptied those shelves of shows, and it is invisible
// from the client — Emby simply answers with fewer titles.
assertTrue(genreCategory("Series", "sci-fi-fantasy").genres.contains("Sci-Fi & Fantasy"))
assertTrue(genreCategory("Series", "action-adventure").genres.contains("Action & Adventure"))
assertTrue(genreCategory("Series", "war-history").genres.contains("War & Politics"))
}
@Test
fun `IMDb spellings are matched alongside TMDb's`() {
// IMDb hyphenates and has genres TMDb does not. Emby stores whichever agent wrote
// the item, so both spellings have to be asked for or half a library is unreachable.
assertTrue(genreCategory("Movie", "crime").genres.contains("Film-Noir"))
assertTrue(genreCategory("Movie", "sport").genres.contains("Sport"))
assertTrue(genreCategory("Movie", "sport").genres.contains("Sports"))
assertTrue(genreCategory("Series", "reality").genres.contains("Reality-TV"))
}
@Test
fun `no genre is claimed by two categories`() {
// A title in both would appear on two shelves, and the two would disagree about
// how many there are. Compared case-insensitively because Emby's match is exact
// but a duplicate differing only in case is still the same mistake.
val claimed = mutableMapOf<String, String>()
genreCategories(ALL_MEDIA_ITEM_TYPE).forEach { category ->
category.genres.forEach { genre ->
val previous = claimed.put(genre.lowercase(), category.id)
assertEquals(
"\"$genre\" is claimed by both $previous and ${category.id}",
null,
previous,
)
}
}
}
@Test
@@ -3,6 +3,7 @@ package com.ponzischeme89.memby.ui.genre
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -129,29 +130,43 @@ class GenreRailScreenshotTest {
compose.onRoot().captureRoboImage("build/screenshots/genre-rail/$name.png")
}
/** Enough of the pane to judge the heading against the rail beside it. */
/**
* Enough of the pane to judge the heading against the rail beside it.
*
* The card width is the real screen's arithmetic rather than a figure typed in here.
* A stand-in with a card size of its own drew a fifth column running off the edge of a
* grid that fits on a television, which is a picture of a fault that does not exist
* and would have hidden a real one.
*/
@Composable
private fun StandInGrid(heading: String) {
Column(Modifier.fillMaxSize().padding(start = 40.dp, top = 44.dp)) {
Text(heading, color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
Text(
genreMediaLabel(ALL_MEDIA_ITEM_TYPE),
color = MembyQuietText,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(18.dp))
repeat(2) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(18.dp)) {
repeat(5) {
Box(
Modifier.width(118.dp).aspectRatio(2f / 3f)
.clip(RoundedCornerShape(MembyCardCorner))
.background(if (it == 0) MembyControlSurface else MembySurfaceRaised),
)
val horizontalPadding = 40.dp
val spacing = 18.dp
BoxWithConstraints(Modifier.fillMaxSize()) {
val cardWidth =
(maxWidth - horizontalPadding * 2 - spacing * (GENRE_GRID_COLUMNS - 1)) /
GENRE_GRID_COLUMNS
Column(Modifier.fillMaxSize().padding(start = horizontalPadding, top = 44.dp)) {
Text(heading, color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
Text(
genreMediaLabel(ALL_MEDIA_ITEM_TYPE),
color = MembyQuietText,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(18.dp))
repeat(2) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(spacing)) {
repeat(GENRE_GRID_COLUMNS) {
Box(
Modifier.width(cardWidth).aspectRatio(2f / 3f)
.clip(RoundedCornerShape(MembyCardCorner))
.background(if (it == 0) MembyControlSurface else MembySurfaceRaised),
)
}
}
Spacer(Modifier.height(22.dp))
}
Spacer(Modifier.height(22.dp))
}
}
}