Complete genre browsing for v0.2.34
This commit is contained in:
@@ -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
|
||||
* *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()
|
||||
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) {
|
||||
runCatching { requireGateway().genreItems(trimmed, offset, limit) }
|
||||
runCatching {
|
||||
requireGateway().genreItems(
|
||||
trimmed,
|
||||
offset,
|
||||
limit,
|
||||
embyItemType.takeUnless { it == "Movie,Series" },
|
||||
)
|
||||
}
|
||||
.onSuccess { page ->
|
||||
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
|
||||
// search's results pasted onto the end of a genre would be nonsense.
|
||||
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)
|
||||
}
|
||||
}
|
||||
val items = getHomeItems(
|
||||
params = mapOf(
|
||||
"Genres" to trimmed,
|
||||
"IncludeItemTypes" to "Movie,Series",
|
||||
"IncludeItemTypes" to embyItemType,
|
||||
"Recursive" to "true",
|
||||
"StartIndex" to offset.toString(),
|
||||
"Limit" to limit.toString(),
|
||||
|
||||
@@ -82,6 +82,7 @@ interface GatewayApi {
|
||||
@Path("genre") genre: String,
|
||||
@Query("offset") offset: Int,
|
||||
@Query("limit") limit: Int,
|
||||
@Query("type") itemType: String? = null,
|
||||
): com.ponzischeme89.memby.data.model.GatewayGenrePage
|
||||
|
||||
@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.airingNoticeFor
|
||||
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.performance.PerformanceMonitor
|
||||
import com.ponzischeme89.memby.ui.search.SearchScreen
|
||||
@@ -1369,6 +1371,7 @@ private fun HomeScreen(
|
||||
var switchingProfileId by remember { mutableStateOf<String?>(null) }
|
||||
var removingProfileId by remember { mutableStateOf<String?>(null) }
|
||||
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
|
||||
var genreBrowseItemType by remember { mutableStateOf<String?>(null) }
|
||||
var navigationExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var restoreRailAfterSettings by remember { mutableStateOf(false) }
|
||||
var detailsItem by remember { mutableStateOf<BaseItem?>(null) }
|
||||
@@ -1641,6 +1644,7 @@ private fun HomeScreen(
|
||||
navigationExpanded = it
|
||||
},
|
||||
onDestinationSelected = { destination ->
|
||||
genreBrowseItemType = null
|
||||
when (destination) {
|
||||
BrowseDestination.SETTINGS -> {
|
||||
restoreRailAfterSettings = true
|
||||
@@ -1761,6 +1765,43 @@ private fun HomeScreen(
|
||||
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)
|
||||
val verticalState = verticalStates.getOrPut(selectedDestination.name) {
|
||||
LazyListState()
|
||||
@@ -1799,7 +1840,9 @@ private fun HomeScreen(
|
||||
val firstPopulatedRowId = remember(rows) {
|
||||
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 (
|
||||
selectedDestination == BrowseDestination.FAVORITES &&
|
||||
recentSearches.isNotEmpty()
|
||||
@@ -1891,6 +1934,21 @@ private fun HomeScreen(
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.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) {
|
||||
item(key = "my-shows", contentType = "my-shows") {
|
||||
MyShowsStrip(
|
||||
@@ -1899,7 +1957,9 @@ private fun HomeScreen(
|
||||
availableWidth = contentWidth,
|
||||
density = settings.homeCardDensity,
|
||||
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 },
|
||||
onContentFocused = { navigationExpanded = false },
|
||||
)
|
||||
@@ -1957,7 +2017,8 @@ private fun HomeScreen(
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentEntryFocusRequester = contentFocusRequester.takeIf {
|
||||
!hasHomeHero &&
|
||||
(selectedDestination != BrowseDestination.SHOWS || myShows.isEmpty()) &&
|
||||
selectedDestination != BrowseDestination.MOVIES &&
|
||||
selectedDestination != BrowseDestination.SHOWS &&
|
||||
row.id == firstPopulatedRowId
|
||||
},
|
||||
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", " "),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user