0.2.74 - Genres brwoser
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user