0.2.74 - Genres brwoser
This commit is contained in:
@@ -1,3 +1,13 @@
|
|||||||
|
## 0.2.74 — 2026-08-17
|
||||||
|
- Fixed: Genre shelves were showing films only. A programme whose details came from TMDb carries its genre as one label — "Sci-Fi & Fantasy", "Action & Adventure", "War & Politics" — which the shelves did not recognise, so every show was missing from Sci-Fi & Fantasy, Action & Adventure and War & History. Shows now appear on them beside the films.
|
||||||
|
- Improved: Genre shelves recognise more ways of spelling the same genre, including the hyphenated names IMDb uses, so fewer titles are missed. Sport finds more than it did, though a film your server has not tagged with a sport genre at all still cannot appear there.
|
||||||
|
- Improved: The genre grid is now five titles across.
|
||||||
|
- New: While browsing genres, each poster carries a small mark in its top corner saying whether it is a film or a TV show, so you can tell them apart where a shelf holds both.
|
||||||
|
- Improved: The genre you are looking at is picked out in dark green in the genre list, so it stays clear which shelf is on screen once you move down into the posters.
|
||||||
|
|
||||||
|
## 0.2.73 — 2026-08-17
|
||||||
|
- Fixed: Reintroduced Genres.
|
||||||
|
|
||||||
## 0.2.72 — 2026-08-17
|
## 0.2.72 — 2026-08-17
|
||||||
- New: The player now has a Next episode button, so you can move on to the next episode at any point without going back to the programme's page.
|
- New: The player now has a Next episode button, so you can move on to the next episode at any point without going back to the programme's page.
|
||||||
- New: A "Play something else" button on the player picks another film from your library and starts it, based on what you have been watching.
|
- New: A "Play something else" button on the player picks another film from your library and starts it, based on what you have been watching.
|
||||||
|
|||||||
@@ -46,7 +46,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.73"
|
val defaultVersionName = "0.2.74"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -168,15 +168,24 @@ internal val TvRailCollapsedWidth = 54.dp
|
|||||||
internal val TvRailExpandedWidth = 184.dp
|
internal val TvRailExpandedWidth = 184.dp
|
||||||
internal val TvRailContentShift = 112.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) {
|
enum class BrowseDestination(val label: String, val icon: ImageVector) {
|
||||||
HOME("Home", Icons.Default.Home),
|
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 catalogue, browsed by genre rather than by shelf. Hidden unless the gateway has
|
||||||
// the genre browser on — see [TvNavigationRail]'s genresEnabled.
|
// the genre browser on — see [TvNavigationRail]'s genresEnabled.
|
||||||
GENRES("Genres", Icons.Default.GridView),
|
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
|
// Sonarr's schedule, a month at a time. Hidden unless the gateway says the household
|
||||||
// has one — see [TvNavigationRail]'s calendarEnabled.
|
// has one — see [TvNavigationRail]'s calendarEnabled.
|
||||||
CALENDAR("TV Calendar", Icons.Default.CalendarMonth),
|
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
|
@Composable
|
||||||
fun MediaBadge(label: String, modifier: Modifier = Modifier) {
|
fun MediaBadge(label: String, modifier: Modifier = Modifier) {
|
||||||
Text(
|
Text(
|
||||||
@@ -1944,10 +1972,12 @@ fun PosterGridCard(
|
|||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showWatchedEpisodeCount: Boolean = false,
|
showWatchedEpisodeCount: Boolean = false,
|
||||||
|
showMediaTypeIcon: Boolean = false,
|
||||||
) {
|
) {
|
||||||
MediaCard(
|
MediaCard(
|
||||||
item, width, 2f / 3f, preferPrimary = true, showProgress = false,
|
item, width, 2f / 3f, preferPrimary = true, showProgress = false,
|
||||||
showSecondaryMetadata = true, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
|
showSecondaryMetadata = true, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
|
||||||
|
showMediaTypeIcon = showMediaTypeIcon,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2012,6 +2042,7 @@ private fun MediaCard(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
|
showMediaTypeIcon: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val repo = ServiceLocator.repository
|
val repo = ServiceLocator.repository
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -2148,11 +2179,25 @@ private fun MediaCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
airingBadgeLabel(item)?.let { label ->
|
val airingLabel = airingBadgeLabel(item)
|
||||||
|
if (airingLabel != null) {
|
||||||
MediaBadge(
|
MediaBadge(
|
||||||
label,
|
airingLabel,
|
||||||
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
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) {
|
if (focused) {
|
||||||
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
|
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
|
||||||
@@ -2193,9 +2238,10 @@ private fun MediaStatusIcon(
|
|||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
description: String,
|
description: String,
|
||||||
tint: Color,
|
tint: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.size(29.dp)
|
.size(29.dp)
|
||||||
.clip(RoundedCornerShape(10.dp))
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.background(Color.Black.copy(alpha = 0.78f))
|
.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.FocusScaleContainer
|
||||||
import com.ponzischeme89.memby.ui.PosterGridCard
|
import com.ponzischeme89.memby.ui.PosterGridCard
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
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.MembyAccentInk
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
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.MembyMutedText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
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. */
|
/** How many rows of placeholder cards stand in for a genre that has not answered yet. */
|
||||||
private const val GENRE_PLACEHOLDER_ROWS = 3
|
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
|
internal val GenreRailWidth = 214.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -358,14 +367,18 @@ fun GenreBrowseScreen(
|
|||||||
) {
|
) {
|
||||||
val horizontalPadding = 40.dp
|
val horizontalPadding = 40.dp
|
||||||
val spacing = 18.dp
|
val spacing = 18.dp
|
||||||
val columns = when {
|
// Fixed, not responsive. The pane's width is the television's, less a rail
|
||||||
maxWidth >= 1000.dp -> 6
|
// of a known width, so the "responsive" ladder this replaced only ever
|
||||||
maxWidth >= 820.dp -> 5
|
// reached one rung on a real set — and the rung it reached was six, which
|
||||||
maxWidth >= 620.dp -> 4
|
// is a poster small enough that the titles under them were the only way to
|
||||||
else -> 3
|
// tell two films apart. Five is the count the card size was chosen for.
|
||||||
}
|
val columns = GENRE_GRID_COLUMNS
|
||||||
val cardWidth = ((maxWidth - horizontalPadding * 2 - spacing * (columns - 1)) / columns)
|
// Exactly the cell GridCells.Fixed will hand out, and deliberately without
|
||||||
.coerceAtLeast(104.dp)
|
// 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()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Spacer(Modifier.height(44.dp))
|
Spacer(Modifier.height(44.dp))
|
||||||
@@ -449,6 +462,15 @@ fun GenreBrowseScreen(
|
|||||||
PosterGridCard(
|
PosterGridCard(
|
||||||
item = displayedItem,
|
item = displayedItem,
|
||||||
width = cardWidth,
|
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 = {
|
onFocused = {
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
shownCategoryId?.let { focusedCardIndexes[it] = index }
|
shownCategoryId?.let { focusedCardIndexes[it] = index }
|
||||||
@@ -654,7 +676,10 @@ private fun GenreRailItem(
|
|||||||
)
|
)
|
||||||
val plate = when {
|
val plate = when {
|
||||||
focused -> MembyAccent
|
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
|
else -> Color.Transparent
|
||||||
}
|
}
|
||||||
val bar = if (focused) MembyAccentInk.copy(alpha = 0.45f) else MembyAccent
|
val bar = if (focused) MembyAccentInk.copy(alpha = 0.45f) else MembyAccent
|
||||||
|
|||||||
@@ -60,38 +60,91 @@ private val allMediaCategory = GenreCategory(
|
|||||||
icon = GenreCategoryIcon.ALL,
|
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(
|
private val coreGenreCategories = listOf(
|
||||||
GenreCategory("action-adventure", "Action & Adventure", listOf("Action", "Adventure"), GenreCategoryIcon.ACTION),
|
GenreCategory(
|
||||||
GenreCategory("comedy", "Comedy", listOf("Comedy"), GenreCategoryIcon.COMEDY),
|
"action-adventure",
|
||||||
GenreCategory("crime", "Crime", listOf("Crime", "Film-Noir"), GenreCategoryIcon.CRIME),
|
"Action & Adventure",
|
||||||
GenreCategory("drama", "Drama", listOf("Drama"), GenreCategoryIcon.DRAMA),
|
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("horror", "Horror", listOf("Horror"), GenreCategoryIcon.HORROR),
|
||||||
GenreCategory("mystery", "Mystery", listOf("Mystery"), GenreCategoryIcon.MYSTERY),
|
GenreCategory("mystery", "Mystery", listOf("Mystery"), GenreCategoryIcon.MYSTERY),
|
||||||
GenreCategory(
|
GenreCategory(
|
||||||
"sci-fi-fantasy",
|
"sci-fi-fantasy",
|
||||||
"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,
|
GenreCategoryIcon.SCI_FI,
|
||||||
),
|
),
|
||||||
GenreCategory("thriller", "Thriller", listOf("Thriller", "Suspense"), GenreCategoryIcon.THRILLER),
|
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(
|
GenreCategory(
|
||||||
"family-animation",
|
"family-animation",
|
||||||
"Family & Animation",
|
"Family & Animation",
|
||||||
listOf("Family", "Animation", "Children", "Kids"),
|
listOf("Family", "Animation", "Children", "Kids", "Anime"),
|
||||||
GenreCategoryIcon.FAMILY,
|
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("romance", "Romance", listOf("Romance"), GenreCategoryIcon.ROMANCE),
|
||||||
GenreCategory("western", "Western", listOf("Western"), GenreCategoryIcon.WESTERN),
|
GenreCategory("western", "Western", listOf("Western"), GenreCategoryIcon.WESTERN),
|
||||||
GenreCategory("music-musicals", "Music & Musicals", listOf("Music", "Musical"), GenreCategoryIcon.MUSIC),
|
GenreCategory(
|
||||||
GenreCategory("sport", "Sport", listOf("Sport", "Sports"), GenreCategoryIcon.SPORT),
|
"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(
|
private val realityCategory = GenreCategory(
|
||||||
"reality",
|
"reality",
|
||||||
"Reality TV",
|
"Reality TV",
|
||||||
listOf("Reality", "Reality TV"),
|
listOf("Reality", "Reality TV", "Reality-TV", "Game Show", "Game-Show"),
|
||||||
GenreCategoryIcon.REALITY,
|
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. */
|
/** A quiet accent wash for a plate that is *selected* rather than focused. */
|
||||||
val MembyAccentMuted: Color get() = activePalette.surfaceRaised.mix(activePalette.accent, 0.20f)
|
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
|
* 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
|
* 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))
|
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
|
@Test
|
||||||
fun `calendar capability does not change the first two rail items`() {
|
fun `calendar capability does not change the first two rail items`() {
|
||||||
val items = navigationRailItems(calendarEnabled = true)
|
val items = navigationRailItems(calendarEnabled = true)
|
||||||
|
|||||||
@@ -34,12 +34,55 @@ class GenreBrowseTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `neighbouring raw genres merge into one Emby filter`() {
|
fun `neighbouring raw genres merge into one Emby filter`() {
|
||||||
assertEquals("Action|Adventure", genreCategory("Movie", "action-adventure").filter)
|
|
||||||
assertEquals(
|
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,
|
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
|
@Test
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.ponzischeme89.memby.ui.genre
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -129,29 +130,43 @@ class GenreRailScreenshotTest {
|
|||||||
compose.onRoot().captureRoboImage("build/screenshots/genre-rail/$name.png")
|
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
|
@Composable
|
||||||
private fun StandInGrid(heading: String) {
|
private fun StandInGrid(heading: String) {
|
||||||
Column(Modifier.fillMaxSize().padding(start = 40.dp, top = 44.dp)) {
|
val horizontalPadding = 40.dp
|
||||||
Text(heading, color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
|
val spacing = 18.dp
|
||||||
Text(
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
genreMediaLabel(ALL_MEDIA_ITEM_TYPE),
|
val cardWidth =
|
||||||
color = MembyQuietText,
|
(maxWidth - horizontalPadding * 2 - spacing * (GENRE_GRID_COLUMNS - 1)) /
|
||||||
fontSize = 13.sp,
|
GENRE_GRID_COLUMNS
|
||||||
fontWeight = FontWeight.Medium,
|
Column(Modifier.fillMaxSize().padding(start = horizontalPadding, top = 44.dp)) {
|
||||||
)
|
Text(heading, color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
|
||||||
Spacer(Modifier.height(18.dp))
|
Text(
|
||||||
repeat(2) {
|
genreMediaLabel(ALL_MEDIA_ITEM_TYPE),
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
color = MembyQuietText,
|
||||||
repeat(5) {
|
fontSize = 13.sp,
|
||||||
Box(
|
fontWeight = FontWeight.Medium,
|
||||||
Modifier.width(118.dp).aspectRatio(2f / 3f)
|
)
|
||||||
.clip(RoundedCornerShape(MembyCardCorner))
|
Spacer(Modifier.height(18.dp))
|
||||||
.background(if (it == 0) MembyControlSurface else MembySurfaceRaised),
|
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user