Complete genre browsing for v0.2.34
This commit is contained in:
@@ -510,6 +510,13 @@ 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
|
||||||
|
the first focus target on both destinations and `ui/genre/` reuses the same paged
|
||||||
|
repository call in a poster grid. Its `type=Movie` / `type=Series` query is enforced by
|
||||||
|
the gateway and on the direct Emby path, so opening Drama from Movies can never mix a
|
||||||
|
television series into the grid (and vice versa). The picker derives its catalogue from
|
||||||
|
the unfiltered server rows as well as the visible destination rows: hiding a home shelf
|
||||||
|
must not make that genre disappear from Browse.
|
||||||
- **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
|
||||||
|
|||||||
@@ -696,11 +696,29 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
* on this build talking to a gateway that predates the route still shows a viewer
|
* on this build talking to a gateway that predates the route still shows a viewer
|
||||||
* *something* when they press a genre — which is exactly what it did before.
|
* *something* when they press a genre — which is exactly what it did before.
|
||||||
*/
|
*/
|
||||||
suspend fun browseGenre(genre: String, offset: Int = 0, limit: Int = GENRE_PAGE_SIZE): GenrePage {
|
suspend fun browseGenre(
|
||||||
|
genre: String,
|
||||||
|
offset: Int = 0,
|
||||||
|
limit: Int = GENRE_PAGE_SIZE,
|
||||||
|
itemType: String? = null,
|
||||||
|
): GenrePage {
|
||||||
val trimmed = genre.trim()
|
val trimmed = genre.trim()
|
||||||
if (trimmed.isEmpty()) return GenrePage(emptyList(), offset, 0)
|
if (trimmed.isEmpty()) return GenrePage(emptyList(), offset, 0)
|
||||||
|
val embyItemType = when (itemType?.trim()?.lowercase()) {
|
||||||
|
null, "" -> "Movie,Series"
|
||||||
|
"movie" -> "Movie"
|
||||||
|
"series" -> "Series"
|
||||||
|
else -> "Movie,Series"
|
||||||
|
}
|
||||||
if (ServerConfig.isGateway) {
|
if (ServerConfig.isGateway) {
|
||||||
runCatching { requireGateway().genreItems(trimmed, offset, limit) }
|
runCatching {
|
||||||
|
requireGateway().genreItems(
|
||||||
|
trimmed,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
embyItemType.takeUnless { it == "Movie,Series" },
|
||||||
|
)
|
||||||
|
}
|
||||||
.onSuccess { page ->
|
.onSuccess { page ->
|
||||||
return GenrePage(page.items, offset, page.total)
|
return GenrePage(page.items, offset, page.total)
|
||||||
}
|
}
|
||||||
@@ -710,14 +728,16 @@ 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)
|
val items = search(trimmed, limit).filter { candidate ->
|
||||||
|
embyItemType == "Movie,Series" || candidate.type.equals(embyItemType, ignoreCase = true)
|
||||||
|
}
|
||||||
return GenrePage(items, offset, items.size)
|
return GenrePage(items, offset, items.size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val items = getHomeItems(
|
val items = getHomeItems(
|
||||||
params = mapOf(
|
params = mapOf(
|
||||||
"Genres" to trimmed,
|
"Genres" to trimmed,
|
||||||
"IncludeItemTypes" to "Movie,Series",
|
"IncludeItemTypes" to embyItemType,
|
||||||
"Recursive" to "true",
|
"Recursive" to "true",
|
||||||
"StartIndex" to offset.toString(),
|
"StartIndex" to offset.toString(),
|
||||||
"Limit" to limit.toString(),
|
"Limit" to limit.toString(),
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ interface GatewayApi {
|
|||||||
@Path("genre") genre: String,
|
@Path("genre") genre: String,
|
||||||
@Query("offset") offset: Int,
|
@Query("offset") offset: Int,
|
||||||
@Query("limit") limit: Int,
|
@Query("limit") limit: Int,
|
||||||
|
@Query("type") itemType: String? = null,
|
||||||
): com.ponzischeme89.memby.data.model.GatewayGenrePage
|
): com.ponzischeme89.memby.data.model.GatewayGenrePage
|
||||||
|
|
||||||
@POST("v1/search/history")
|
@POST("v1/search/history")
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ 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.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
|
||||||
import com.ponzischeme89.memby.ui.search.SearchScreen
|
import com.ponzischeme89.memby.ui.search.SearchScreen
|
||||||
@@ -1369,6 +1371,7 @@ private fun HomeScreen(
|
|||||||
var switchingProfileId by remember { mutableStateOf<String?>(null) }
|
var switchingProfileId by remember { mutableStateOf<String?>(null) }
|
||||||
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 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) }
|
||||||
@@ -1641,6 +1644,7 @@ private fun HomeScreen(
|
|||||||
navigationExpanded = it
|
navigationExpanded = it
|
||||||
},
|
},
|
||||||
onDestinationSelected = { destination ->
|
onDestinationSelected = { destination ->
|
||||||
|
genreBrowseItemType = null
|
||||||
when (destination) {
|
when (destination) {
|
||||||
BrowseDestination.SETTINGS -> {
|
BrowseDestination.SETTINGS -> {
|
||||||
restoreRailAfterSettings = true
|
restoreRailAfterSettings = true
|
||||||
@@ -1761,6 +1765,43 @@ private fun HomeScreen(
|
|||||||
return@BoxWithConstraints
|
return@BoxWithConstraints
|
||||||
}
|
}
|
||||||
|
|
||||||
|
genreBrowseItemType?.let { itemType ->
|
||||||
|
val genres = remember(homeContent.rows, rows, itemType) {
|
||||||
|
// Use the unfiltered server rows as well as what this destination
|
||||||
|
// currently draws. Hiding a shelf must not remove its genre from
|
||||||
|
// the catalogue-wide browser.
|
||||||
|
(homeContent.rows.asSequence().flatMap { it.items.asSequence() } +
|
||||||
|
rows.asSequence().flatMap { it.items.asSequence() })
|
||||||
|
.filter { item -> item.type.equals(itemType, ignoreCase = true) }
|
||||||
|
.flatMap { it.genres.asSequence() }
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(String::isNotEmpty)
|
||||||
|
.distinctBy { it.lowercase() }
|
||||||
|
.sortedWith(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
GenreBrowseScreen(
|
||||||
|
itemType = itemType,
|
||||||
|
genres = genres,
|
||||||
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
|
contentFocusRequester = contentFocusRequester,
|
||||||
|
onItemFocused = homeViewModel::focusItem,
|
||||||
|
onItemSelected = { item ->
|
||||||
|
homeViewModel.focusItem(item)
|
||||||
|
detailsAiringNotice = null
|
||||||
|
detailsItem = item
|
||||||
|
},
|
||||||
|
onClose = {
|
||||||
|
genreBrowseItemType = null
|
||||||
|
scope.launch {
|
||||||
|
kotlinx.coroutines.delay(16L)
|
||||||
|
runCatching { contentFocusRequester.requestFocus() }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return@BoxWithConstraints
|
||||||
|
}
|
||||||
|
|
||||||
FocusedHomeBackdrop(homeViewModel)
|
FocusedHomeBackdrop(homeViewModel)
|
||||||
val verticalState = verticalStates.getOrPut(selectedDestination.name) {
|
val verticalState = verticalStates.getOrPut(selectedDestination.name) {
|
||||||
LazyListState()
|
LazyListState()
|
||||||
@@ -1799,7 +1840,9 @@ private fun HomeScreen(
|
|||||||
val firstPopulatedRowId = remember(rows) {
|
val firstPopulatedRowId = remember(rows) {
|
||||||
rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id
|
rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id
|
||||||
}
|
}
|
||||||
val leadingItemCount = (if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
|
val leadingItemCount =
|
||||||
|
(if (selectedDestination == BrowseDestination.MOVIES || selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
|
||||||
|
(if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
|
||||||
if (
|
if (
|
||||||
selectedDestination == BrowseDestination.FAVORITES &&
|
selectedDestination == BrowseDestination.FAVORITES &&
|
||||||
recentSearches.isNotEmpty()
|
recentSearches.isNotEmpty()
|
||||||
@@ -1891,6 +1934,21 @@ private fun HomeScreen(
|
|||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp),
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
selectedDestination == BrowseDestination.MOVIES ||
|
||||||
|
selectedDestination == BrowseDestination.SHOWS
|
||||||
|
) {
|
||||||
|
item(key = "genre-browser", contentType = "genre-browser") {
|
||||||
|
val itemType = if (selectedDestination == BrowseDestination.SHOWS) "Series" else "Movie"
|
||||||
|
GenreBrowseLauncher(
|
||||||
|
mediaLabel = if (itemType == "Series") "TV series" else "movie",
|
||||||
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
|
onFocused = { navigationExpanded = false },
|
||||||
|
onOpen = { genreBrowseItemType = itemType },
|
||||||
|
modifier = Modifier.focusRequester(contentFocusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (selectedDestination == BrowseDestination.SHOWS) {
|
if (selectedDestination == BrowseDestination.SHOWS) {
|
||||||
item(key = "my-shows", contentType = "my-shows") {
|
item(key = "my-shows", contentType = "my-shows") {
|
||||||
MyShowsStrip(
|
MyShowsStrip(
|
||||||
@@ -1899,7 +1957,9 @@ private fun HomeScreen(
|
|||||||
availableWidth = contentWidth,
|
availableWidth = contentWidth,
|
||||||
density = settings.homeCardDensity,
|
density = settings.homeCardDensity,
|
||||||
navigationFocusRequester = navigationFocusRequester,
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
contentFocusRequester = contentFocusRequester.takeIf { myShows.isNotEmpty() },
|
// The Genres launcher above is the destination's entry
|
||||||
|
// target. One requester cannot be attached to both.
|
||||||
|
contentFocusRequester = null,
|
||||||
onShowSelected = { selectedMyShow = it },
|
onShowSelected = { selectedMyShow = it },
|
||||||
onContentFocused = { navigationExpanded = false },
|
onContentFocused = { navigationExpanded = false },
|
||||||
)
|
)
|
||||||
@@ -1957,7 +2017,8 @@ private fun HomeScreen(
|
|||||||
navigationFocusRequester = navigationFocusRequester,
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
contentEntryFocusRequester = contentFocusRequester.takeIf {
|
contentEntryFocusRequester = contentFocusRequester.takeIf {
|
||||||
!hasHomeHero &&
|
!hasHomeHero &&
|
||||||
(selectedDestination != BrowseDestination.SHOWS || myShows.isEmpty()) &&
|
selectedDestination != BrowseDestination.MOVIES &&
|
||||||
|
selectedDestination != BrowseDestination.SHOWS &&
|
||||||
row.id == firstPopulatedRowId
|
row.id == firstPopulatedRowId
|
||||||
},
|
},
|
||||||
heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf {
|
heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf {
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||||
|
|
||||||
|
package com.ponzischeme89.memby.ui.genre
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
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.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Category
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
|
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.MembyMutedText
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GenreBrowseLauncher(
|
||||||
|
mediaLabel: String,
|
||||||
|
navigationFocusRequester: FocusRequester,
|
||||||
|
onFocused: () -> Unit,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
FocusScaleContainer(
|
||||||
|
onFocused = onFocused,
|
||||||
|
onClick = onOpen,
|
||||||
|
contentDescription = "Browse ${if (mediaLabel == "movie") "movies" else mediaLabel} by genre",
|
||||||
|
modifier = modifier
|
||||||
|
.padding(horizontal = 36.dp, vertical = 4.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(70.dp)
|
||||||
|
.focusProperties { left = navigationFocusRequester }
|
||||||
|
.background(MembySurfaceRaised, RoundedCornerShape(12.dp))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(12.dp)),
|
||||||
|
) { focused ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
if (focused) MembyAccent.copy(alpha = 0.22f) else Color.Transparent,
|
||||||
|
RoundedCornerShape(12.dp),
|
||||||
|
)
|
||||||
|
.padding(horizontal = 20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Category,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (focused) Color.White else MembyAccent,
|
||||||
|
modifier = Modifier.size(27.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column {
|
||||||
|
Text("Genres", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
"Browse every $mediaLabel title in a full poster grid",
|
||||||
|
color = MembyMutedText,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GenreBrowseScreen(
|
||||||
|
itemType: String,
|
||||||
|
genres: List<String>,
|
||||||
|
navigationFocusRequester: FocusRequester,
|
||||||
|
contentFocusRequester: FocusRequester,
|
||||||
|
onItemFocused: (BaseItem) -> Unit,
|
||||||
|
onItemSelected: (BaseItem) -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val mediaLabel = if (itemType.equals("Series", ignoreCase = true)) "TV series" else "movies"
|
||||||
|
val browseViewModel: GenreBrowseViewModel = viewModel(
|
||||||
|
key = "genre-browse-${itemType.lowercase()}",
|
||||||
|
factory = remember(itemType) {
|
||||||
|
GenreBrowseViewModelFactory(ServiceLocator.repository, itemType)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val state by browseViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val gridState = rememberLazyGridState()
|
||||||
|
|
||||||
|
LaunchedEffect(genres) { browseViewModel.setGenres(genres) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(32L)
|
||||||
|
runCatching { contentFocusRequester.requestFocus() }
|
||||||
|
}
|
||||||
|
BackHandler(onBack = onClose)
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier.fillMaxSize().background(MembySurface)) {
|
||||||
|
val columns = when {
|
||||||
|
maxWidth >= 1080.dp -> 7
|
||||||
|
maxWidth >= 860.dp -> 6
|
||||||
|
maxWidth >= 680.dp -> 5
|
||||||
|
else -> 4
|
||||||
|
}
|
||||||
|
val spacing = 16.dp
|
||||||
|
val horizontalPadding = 36.dp
|
||||||
|
val cardWidth = ((maxWidth - horizontalPadding * 2 - spacing * (columns - 1)) / columns)
|
||||||
|
.coerceAtLeast(112.dp)
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(top = 24.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = horizontalPadding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
FocusScaleContainer(
|
||||||
|
onFocused = {},
|
||||||
|
onClick = onClose,
|
||||||
|
contentDescription = "Back to $mediaLabel",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(42.dp)
|
||||||
|
.focusProperties { left = navigationFocusRequester }
|
||||||
|
.background(MembySurfaceRaised, RoundedCornerShape(10.dp)),
|
||||||
|
) { focused ->
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(if (focused) Color.White else Color.Transparent, RoundedCornerShape(10.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (focused) MembySurface else Color.White,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"Browse $mediaLabel by genre",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 26.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
state.selectedGenre ?: "Choose a genre",
|
||||||
|
color = MembyMutedText,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
LazyRow(
|
||||||
|
contentPadding = PaddingValues(horizontal = horizontalPadding),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||||
|
) {
|
||||||
|
rowItemsIndexed(state.genres, key = { _, genre -> genre }) { index, genre ->
|
||||||
|
val selected = genre == state.selectedGenre
|
||||||
|
FocusScaleContainer(
|
||||||
|
onFocused = {},
|
||||||
|
onClick = {
|
||||||
|
if (!selected) {
|
||||||
|
browseViewModel.selectGenre(genre)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentDescription = "Browse $genre $mediaLabel",
|
||||||
|
modifier = Modifier
|
||||||
|
.then(if (index == 0) Modifier.focusRequester(contentFocusRequester) else Modifier)
|
||||||
|
.focusProperties { if (index == 0) left = navigationFocusRequester }
|
||||||
|
.background(
|
||||||
|
if (selected) MembyAccent.copy(alpha = 0.24f) else MembySurfaceRaised,
|
||||||
|
RoundedCornerShape(10.dp),
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
1.dp,
|
||||||
|
if (selected) MembyAccent else Color.White.copy(alpha = 0.08f),
|
||||||
|
RoundedCornerShape(10.dp),
|
||||||
|
),
|
||||||
|
) { focused ->
|
||||||
|
Text(
|
||||||
|
genre,
|
||||||
|
color = if (focused || selected) Color.White else MembyMutedText,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
if (focused) Color.White.copy(alpha = 0.12f) else Color.Transparent,
|
||||||
|
RoundedCornerShape(10.dp),
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.genres.isEmpty() -> GenreMessage("No genres are available for $mediaLabel yet.")
|
||||||
|
state.isLoading && state.items.isEmpty() -> GenreMessage("Loading ${state.selectedGenre.orEmpty()} $mediaLabel…")
|
||||||
|
state.errorMessage != null && state.items.isEmpty() -> GenreRetry(
|
||||||
|
message = state.errorMessage.orEmpty(),
|
||||||
|
onRetry = browseViewModel::retry,
|
||||||
|
)
|
||||||
|
state.items.isEmpty() -> GenreMessage("No ${state.selectedGenre.orEmpty()} $mediaLabel were found.")
|
||||||
|
else -> {
|
||||||
|
LaunchedEffect(gridState, state.items.size, state.canLoadMore) {
|
||||||
|
snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { last ->
|
||||||
|
if (state.canLoadMore && last >= state.items.size - columns * 2) {
|
||||||
|
browseViewModel.loadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(columns),
|
||||||
|
state = gridState,
|
||||||
|
contentPadding = PaddingValues(
|
||||||
|
start = horizontalPadding,
|
||||||
|
end = horizontalPadding,
|
||||||
|
bottom = 72.dp,
|
||||||
|
),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
state.items,
|
||||||
|
key = { _, item -> item.id },
|
||||||
|
contentType = { _, _ -> "genre-poster" },
|
||||||
|
) { index, item ->
|
||||||
|
PosterGridCard(
|
||||||
|
item = item,
|
||||||
|
width = cardWidth,
|
||||||
|
onFocused = { onItemFocused(item) },
|
||||||
|
onClick = { onItemSelected(item) },
|
||||||
|
onLongClick = { onItemSelected(item) },
|
||||||
|
modifier = Modifier.focusProperties {
|
||||||
|
if (index % columns == 0) left = navigationFocusRequester
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.isLoadingMore) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
GenreMessage("Loading more…")
|
||||||
|
}
|
||||||
|
} else if (state.errorMessage != null) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
GenreRetry(
|
||||||
|
message = state.errorMessage.orEmpty(),
|
||||||
|
onRetry = browseViewModel::retry,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GenreMessage(message: String) {
|
||||||
|
Box(Modifier.fillMaxWidth().padding(36.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text(message, color = MembyMutedText, fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GenreRetry(message: String, onRetry: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().padding(36.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(message, color = MembyMutedText, fontSize = 15.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
FocusScaleContainer(
|
||||||
|
onFocused = {},
|
||||||
|
onClick = onRetry,
|
||||||
|
contentDescription = "Try loading the genre again",
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MembyAccent, RoundedCornerShape(9.dp))
|
||||||
|
.semantics { contentDescription = "Try again" },
|
||||||
|
) { focused ->
|
||||||
|
Text(
|
||||||
|
"Try again",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.ponzischeme89.memby.ui.genre
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.ponzischeme89.memby.data.EmbyRepository
|
||||||
|
import com.ponzischeme89.memby.data.GENRE_PAGE_SIZE
|
||||||
|
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||||
|
import com.ponzischeme89.memby.data.hasMoreGenreItems
|
||||||
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class GenreBrowseUiState(
|
||||||
|
val genres: List<String> = emptyList(),
|
||||||
|
val selectedGenre: String? = null,
|
||||||
|
val items: List<BaseItem> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isLoadingMore: Boolean = false,
|
||||||
|
val canLoadMore: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A paged, media-type-specific genre shelf used by the Movies and TV Series pages. */
|
||||||
|
class GenreBrowseViewModel(
|
||||||
|
private val repository: EmbyRepository,
|
||||||
|
private val itemType: String,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(GenreBrowseUiState())
|
||||||
|
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
|
||||||
|
private var pageJob: Job? = null
|
||||||
|
|
||||||
|
fun setGenres(genres: List<String>) {
|
||||||
|
val normalised = normaliseGenres(genres)
|
||||||
|
if (normalised == state.value.genres) return
|
||||||
|
if (normalised.isEmpty()) {
|
||||||
|
pageJob?.cancel()
|
||||||
|
_state.value = GenreBrowseUiState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val selected = state.value.selectedGenre?.takeIf { current ->
|
||||||
|
normalised.any { it.equals(current, ignoreCase = true) }
|
||||||
|
} ?: normalised.firstOrNull()
|
||||||
|
_state.update { it.copy(genres = normalised) }
|
||||||
|
if (selected != null && selected != state.value.selectedGenre) selectGenre(selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectGenre(genre: String) {
|
||||||
|
val selected = genre.trim()
|
||||||
|
if (selected.isEmpty()) return
|
||||||
|
pageJob?.cancel()
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedGenre = selected,
|
||||||
|
items = emptyList(),
|
||||||
|
isLoading = true,
|
||||||
|
isLoadingMore = false,
|
||||||
|
canLoadMore = false,
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pageJob = viewModelScope.launch { loadPage(selected, 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMore() {
|
||||||
|
val current = state.value
|
||||||
|
val genre = current.selectedGenre ?: return
|
||||||
|
if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return
|
||||||
|
_state.update { it.copy(isLoadingMore = true) }
|
||||||
|
pageJob = viewModelScope.launch { loadPage(genre, current.items.size) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retry() {
|
||||||
|
val current = state.value
|
||||||
|
val genre = current.selectedGenre ?: return
|
||||||
|
val offset = current.items.size
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = offset == 0,
|
||||||
|
isLoadingMore = offset > 0,
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pageJob?.cancel()
|
||||||
|
pageJob = viewModelScope.launch { loadPage(genre, offset) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadPage(genre: String, offset: Int) {
|
||||||
|
runCatching {
|
||||||
|
repository.browseGenre(
|
||||||
|
genre = genre,
|
||||||
|
offset = offset,
|
||||||
|
limit = GENRE_PAGE_SIZE,
|
||||||
|
itemType = itemType,
|
||||||
|
)
|
||||||
|
}.onSuccess { page ->
|
||||||
|
_state.update { current ->
|
||||||
|
if (current.selectedGenre != genre || current.items.size != page.offset) return@update current
|
||||||
|
val items = (current.items + page.items).distinctBy(BaseItem::id)
|
||||||
|
current.copy(
|
||||||
|
items = items,
|
||||||
|
isLoading = false,
|
||||||
|
isLoadingMore = false,
|
||||||
|
canLoadMore = hasMoreGenreItems(
|
||||||
|
loaded = items.size,
|
||||||
|
total = page.total,
|
||||||
|
lastPageSize = page.items.size,
|
||||||
|
pageSize = GENRE_PAGE_SIZE,
|
||||||
|
),
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
_state.update { current ->
|
||||||
|
if (current.selectedGenre != genre) current else current.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isLoadingMore = false,
|
||||||
|
canLoadMore = false,
|
||||||
|
errorMessage = friendlyEmbyError(error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
private val repository: EmbyRepository,
|
||||||
|
private val itemType: String,
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
GenreBrowseViewModel(repository, itemType) as T
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.ponzischeme89.memby.ui.genre
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class GenreBrowseTest {
|
||||||
|
@Test
|
||||||
|
fun `genre picker trims deduplicates and sorts names`() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("Action", "comedy", "Science Fiction"),
|
||||||
|
normaliseGenres(
|
||||||
|
listOf(" Science Fiction ", "comedy", "", "Action", "Comedy", " "),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,8 +53,13 @@ func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess s
|
|||||||
}
|
}
|
||||||
limit := queryInt(r, "limit", genrePageSize, genrePageMax)
|
limit := queryInt(r, "limit", genrePageSize, genrePageMax)
|
||||||
offset := queryOffset(r, "offset")
|
offset := queryOffset(r, "offset")
|
||||||
|
itemType, ok := genreItemType(r.URL.Query().Get("type"))
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "type must be Movie or Series")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
key := cache.UserKey(sess.EmbyUserID, "genre:"+genre+":"+itoa(offset)+":"+itoa(limit))
|
key := cache.UserKey(sess.EmbyUserID, "genre:"+itemType+":"+genre+":"+itoa(offset)+":"+itoa(limit))
|
||||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||||
w.Header().Set("X-Memby-Cache", "hit")
|
w.Header().Set("X-Memby-Cache", "hit")
|
||||||
writeRaw(w, http.StatusOK, raw)
|
writeRaw(w, http.StatusOK, raw)
|
||||||
@@ -63,7 +68,7 @@ func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess s
|
|||||||
|
|
||||||
params := rowParams(url.Values{
|
params := rowParams(url.Values{
|
||||||
"Genres": {genre},
|
"Genres": {genre},
|
||||||
"IncludeItemTypes": {"Movie,Series"},
|
"IncludeItemTypes": {itemType},
|
||||||
"Recursive": {"true"},
|
"Recursive": {"true"},
|
||||||
"StartIndex": {itoa(offset)},
|
"StartIndex": {itoa(offset)},
|
||||||
"Limit": {itoa(limit)},
|
"Limit": {itoa(limit)},
|
||||||
@@ -117,6 +122,21 @@ func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess s
|
|||||||
writeRaw(w, http.StatusOK, body)
|
writeRaw(w, http.StatusOK, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// genreItemType keeps the old mixed search as the default for Search, while the Movies
|
||||||
|
// and TV Series destinations can ask for a shelf that never crosses media types.
|
||||||
|
func genreItemType(value string) (string, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "":
|
||||||
|
return "Movie,Series", true
|
||||||
|
case "movie":
|
||||||
|
return "Movie", true
|
||||||
|
case "series":
|
||||||
|
return "Series", true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// genreTotal is what the television's scroll stops on, and it has to be right in the case
|
// genreTotal is what the television's scroll stops on, and it has to be right in the case
|
||||||
// where nobody counted.
|
// where nobody counted.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -45,3 +45,22 @@ func TestQueryOffsetTreatsZeroAsAValueRatherThanAMissingOne(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenreItemTypeSeparatesMoviesAndSeries(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "Movie,Series",
|
||||||
|
"movie": "Movie",
|
||||||
|
"Movie": "Movie",
|
||||||
|
"series": "Series",
|
||||||
|
" Series ": "Series",
|
||||||
|
}
|
||||||
|
for input, want := range cases {
|
||||||
|
got, ok := genreItemType(input)
|
||||||
|
if !ok || got != want {
|
||||||
|
t.Errorf("genreItemType(%q) = %q, %v; want %q, true", input, got, ok, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got, ok := genreItemType("Episode"); ok || got != "" {
|
||||||
|
t.Fatalf("genreItemType(Episode) = %q, %v; want empty, false", got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user