Release v0.2.37
This commit is contained in:
@@ -728,7 +728,10 @@ 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).filter { candidate ->
|
||||
// An older gateway understands neither this route nor Emby's pipe
|
||||
// syntax. Searching the first member at least leaves a useful shelf;
|
||||
// sending "Action|Adventure" as prose would usually return nothing.
|
||||
val items = search(trimmed.substringBefore('|'), limit).filter { candidate ->
|
||||
embyItemType == "Movie,Series" || candidate.type.equals(embyItemType, ignoreCase = true)
|
||||
}
|
||||
return GenrePage(items, offset, items.size)
|
||||
|
||||
@@ -145,6 +145,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
|
||||
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
|
||||
// Genre pages own paged copies that do not live in HomeUiState. This small mutation
|
||||
// stream lets those copies reflect a heart press immediately, including a rollback
|
||||
// when the server rejects it, without refreshing or losing the active category.
|
||||
private val _favoriteChanges = MutableStateFlow<Map<String, Boolean>>(emptyMap())
|
||||
val favoriteChanges: StateFlow<Map<String, Boolean>> = _favoriteChanges.asStateFlow()
|
||||
private val _forYou = MutableStateFlow(ForYouUiState())
|
||||
val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow()
|
||||
private var metadataJob: Job? = null
|
||||
@@ -395,6 +400,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
}
|
||||
|
||||
private fun updateFavorite(item: BaseItem, favorite: Boolean) {
|
||||
_favoriteChanges.update { it + (item.id to favorite) }
|
||||
updateUserData(item.id) { it.copy(isFavorite = favorite) }
|
||||
_state.update { state ->
|
||||
val updatedItem = item.copy(
|
||||
|
||||
@@ -133,7 +133,7 @@ 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.GenreDiscoveryStrip
|
||||
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
|
||||
import com.ponzischeme89.memby.ui.player.PlayerActivity
|
||||
import com.ponzischeme89.memby.performance.PerformanceMonitor
|
||||
@@ -1354,6 +1354,7 @@ private fun HomeScreen(
|
||||
val homeContent by homeViewModel.content.collectAsStateWithLifecycle()
|
||||
val homeStatus by homeViewModel.status.collectAsStateWithLifecycle()
|
||||
val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle()
|
||||
val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle()
|
||||
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
|
||||
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
|
||||
// Only whether there is one, not its countdown — that is collected inside the banner
|
||||
@@ -1372,6 +1373,7 @@ private fun HomeScreen(
|
||||
var removingProfileId by remember { mutableStateOf<String?>(null) }
|
||||
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
|
||||
var genreBrowseItemType by remember { mutableStateOf<String?>(null) }
|
||||
var genreBrowseInitialCategoryId by remember { mutableStateOf<String?>(null) }
|
||||
var navigationExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var restoreRailAfterSettings by remember { mutableStateOf(false) }
|
||||
var detailsItem by remember { mutableStateOf<BaseItem?>(null) }
|
||||
@@ -1766,33 +1768,26 @@ private fun HomeScreen(
|
||||
}
|
||||
|
||||
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,
|
||||
initialCategoryId = genreBrowseInitialCategoryId
|
||||
?: com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID,
|
||||
favouriteStates = favoriteChanges,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
returnFocusItemId = returnItemId.takeIf { returnRowId == GENRE_BROWSER_ROW_ID },
|
||||
returnFocusRequester = cardReturnFocusRequester,
|
||||
onItemFocused = homeViewModel::focusItem,
|
||||
onItemSelected = { item ->
|
||||
returnRowId = GENRE_BROWSER_ROW_ID
|
||||
returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
detailsAiringNotice = null
|
||||
detailsItem = item
|
||||
},
|
||||
onClose = {
|
||||
genreBrowseItemType = null
|
||||
genreBrowseInitialCategoryId = null
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(16L)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
@@ -1940,12 +1935,15 @@ private fun HomeScreen(
|
||||
) {
|
||||
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",
|
||||
GenreDiscoveryStrip(
|
||||
itemType = itemType,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
entryFocusRequester = contentFocusRequester,
|
||||
onFocused = { navigationExpanded = false },
|
||||
onOpen = { genreBrowseItemType = itemType },
|
||||
modifier = Modifier.focusRequester(contentFocusRequester),
|
||||
onOpenCategory = { categoryId ->
|
||||
genreBrowseInitialCategoryId = categoryId
|
||||
genreBrowseItemType = itemType
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2650,6 +2648,7 @@ private fun PlaybackLaunchOverlay(item: BaseItem, modifier: Modifier = Modifier)
|
||||
|
||||
/** Row id under which the search grid records its return-focus target. */
|
||||
private const val SEARCH_ROW_ID = "search-results"
|
||||
private const val GENRE_BROWSER_ROW_ID = "genre-browser-results"
|
||||
|
||||
@Composable
|
||||
private fun RecentSearchesRow(
|
||||
|
||||
@@ -28,7 +28,22 @@ 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.automirrored.filled.HelpOutline
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Category
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Gavel
|
||||
import androidx.compose.material.icons.filled.Landscape
|
||||
import androidx.compose.material.icons.filled.LiveTv
|
||||
import androidx.compose.material.icons.filled.LocalFireDepartment
|
||||
import androidx.compose.material.icons.filled.MilitaryTech
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.SentimentVerySatisfied
|
||||
import androidx.compose.material.icons.filled.SportsSoccer
|
||||
import androidx.compose.material.icons.filled.TheaterComedy
|
||||
import androidx.compose.material.icons.filled.VideoLibrary
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -40,6 +55,7 @@ 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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -61,58 +77,118 @@ import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun GenreBrowseLauncher(
|
||||
mediaLabel: String,
|
||||
fun GenreDiscoveryStrip(
|
||||
itemType: String,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
entryFocusRequester: FocusRequester,
|
||||
onFocused: () -> Unit,
|
||||
onOpen: () -> Unit,
|
||||
onOpenCategory: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onOpen,
|
||||
contentDescription = "Browse ${if (mediaLabel == "movie") "movies" else mediaLabel}",
|
||||
modifier = modifier
|
||||
.padding(horizontal = 36.dp, vertical = 4.dp)
|
||||
.width(156.dp)
|
||||
.height(52.dp)
|
||||
.focusProperties { left = navigationFocusRequester }
|
||||
.background(MembySurfaceRaised, RoundedCornerShape(10.dp))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp)),
|
||||
) { focused ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
if (focused) Color.White else Color.Transparent,
|
||||
RoundedCornerShape(10.dp),
|
||||
)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
val categories = remember(itemType) { genreCategoryTabs(itemType) }
|
||||
Column(modifier.padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
"Browse genres",
|
||||
color = Color.White,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 36.dp, vertical = 6.dp),
|
||||
)
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Category,
|
||||
contentDescription = null,
|
||||
tint = if (focused) MembySurface else MembyAccent,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
"Browse",
|
||||
color = if (focused) MembySurface else Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
rowItemsIndexed(categories, key = { _, category -> category.id }) { index, category ->
|
||||
GenreDiscoveryCard(
|
||||
category = category,
|
||||
onFocused = onFocused,
|
||||
onClick = { onOpenCategory(category.id) },
|
||||
modifier = Modifier
|
||||
.then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier)
|
||||
.focusProperties { if (index == 0) left = navigationFocusRequester },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreDiscoveryCard(
|
||||
category: GenreCategory,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val visual = remember(category.icon) { genreVisual(category.icon) }
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = "Browse ${category.label}",
|
||||
modifier = modifier
|
||||
.width(184.dp)
|
||||
.height(92.dp)
|
||||
.background(visual.colour, RoundedCornerShape(12.dp))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.14f), RoundedCornerShape(12.dp)),
|
||||
) { focused ->
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
if (focused) Color.White.copy(alpha = 0.16f) else Color.Transparent,
|
||||
RoundedCornerShape(12.dp),
|
||||
)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Text(
|
||||
category.label,
|
||||
color = Color.White,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 19.sp,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.align(Alignment.BottomStart).width(120.dp),
|
||||
)
|
||||
Icon(
|
||||
visual.icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.92f),
|
||||
modifier = Modifier.align(Alignment.TopEnd).size(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class GenreVisual(val icon: ImageVector, val colour: Color)
|
||||
|
||||
private fun genreVisual(icon: GenreCategoryIcon): GenreVisual = when (icon) {
|
||||
GenreCategoryIcon.ALL -> GenreVisual(Icons.Default.Category, Color(0xFF4F46A5))
|
||||
GenreCategoryIcon.ACTION -> GenreVisual(Icons.Default.Bolt, Color(0xFFB45309))
|
||||
GenreCategoryIcon.COMEDY -> GenreVisual(Icons.Default.TheaterComedy, Color(0xFF15803D))
|
||||
GenreCategoryIcon.CRIME -> GenreVisual(Icons.Default.Gavel, Color(0xFF475569))
|
||||
GenreCategoryIcon.DRAMA -> GenreVisual(Icons.Default.TheaterComedy, Color(0xFF7E22CE))
|
||||
GenreCategoryIcon.HORROR -> GenreVisual(Icons.Default.LocalFireDepartment, Color(0xFF991B1B))
|
||||
GenreCategoryIcon.MYSTERY -> GenreVisual(Icons.AutoMirrored.Filled.HelpOutline, Color(0xFF4338CA))
|
||||
GenreCategoryIcon.SCI_FI -> GenreVisual(Icons.Default.AutoAwesome, Color(0xFF0369A1))
|
||||
GenreCategoryIcon.THRILLER -> GenreVisual(Icons.Default.VisibilityOff, Color(0xFF0F766E))
|
||||
GenreCategoryIcon.WAR -> GenreVisual(Icons.Default.MilitaryTech, Color(0xFF57534E))
|
||||
GenreCategoryIcon.FAMILY -> GenreVisual(Icons.Default.SentimentVerySatisfied, Color(0xFFDB2777))
|
||||
GenreCategoryIcon.DOCUMENTARY -> GenreVisual(Icons.Default.VideoLibrary, Color(0xFF0E7490))
|
||||
GenreCategoryIcon.ROMANCE -> GenreVisual(Icons.Default.Favorite, Color(0xFFBE185D))
|
||||
GenreCategoryIcon.WESTERN -> GenreVisual(Icons.Default.Landscape, Color(0xFF92400E))
|
||||
GenreCategoryIcon.MUSIC -> GenreVisual(Icons.Default.MusicNote, Color(0xFF6D28D9))
|
||||
GenreCategoryIcon.SPORT -> GenreVisual(Icons.Default.SportsSoccer, Color(0xFF047857))
|
||||
GenreCategoryIcon.REALITY -> GenreVisual(Icons.Default.LiveTv, Color(0xFFC2410C))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GenreBrowseScreen(
|
||||
itemType: String,
|
||||
genres: List<String>,
|
||||
initialCategoryId: String,
|
||||
favouriteStates: Map<String, Boolean>,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester,
|
||||
returnFocusItemId: String?,
|
||||
returnFocusRequester: FocusRequester,
|
||||
onItemFocused: (BaseItem) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
@@ -127,11 +203,19 @@ fun GenreBrowseScreen(
|
||||
)
|
||||
val state by browseViewModel.state.collectAsStateWithLifecycle()
|
||||
val gridState = rememberLazyGridState()
|
||||
val allLabel = remember(itemType) { allMediaLabel(itemType) }
|
||||
val tabState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val selectedCategory = remember(state.selectedCategoryId, itemType) {
|
||||
genreCategory(itemType, state.selectedCategoryId)
|
||||
}
|
||||
|
||||
LaunchedEffect(genres) { browseViewModel.setGenres(genres) }
|
||||
LaunchedEffect(state.selectedGenre) {
|
||||
if (state.selectedGenre != null) gridState.scrollToItem(0)
|
||||
LaunchedEffect(initialCategoryId) { browseViewModel.selectCategory(initialCategoryId) }
|
||||
LaunchedEffect(favouriteStates) { browseViewModel.applyFavouriteStates(favouriteStates) }
|
||||
LaunchedEffect(state.selectedCategoryId) {
|
||||
val index = state.categories.indexOfFirst { it.id == state.selectedCategoryId }.coerceAtLeast(0)
|
||||
if (state.selectedCategoryId != null) {
|
||||
gridState.scrollToItem(0)
|
||||
tabState.scrollToItem(index)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(32L)
|
||||
@@ -188,7 +272,7 @@ fun GenreBrowseScreen(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
state.selectedGenre?.takeIf(String::isNotEmpty) ?: allLabel,
|
||||
selectedCategory.label,
|
||||
color = MembyMutedText,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
@@ -197,23 +281,22 @@ fun GenreBrowseScreen(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
LazyRow(
|
||||
state = tabState,
|
||||
contentPadding = PaddingValues(horizontal = horizontalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val tabs = listOf(ALL_MEDIA) + state.genres
|
||||
rowItemsIndexed(tabs, key = { _, genre -> genre.ifEmpty { "all-media" } }) { index, genre ->
|
||||
val selected = genre == state.selectedGenre
|
||||
val label = genre.ifEmpty { allLabel }
|
||||
rowItemsIndexed(state.categories, key = { _, category -> category.id }) { index, category ->
|
||||
val selected = category.id == state.selectedCategoryId
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = {
|
||||
if (!selected) {
|
||||
browseViewModel.selectGenre(genre)
|
||||
browseViewModel.selectCategory(category.id)
|
||||
}
|
||||
},
|
||||
contentDescription = "Browse $label",
|
||||
contentDescription = "Browse ${category.label}",
|
||||
modifier = Modifier
|
||||
.then(if (index == 0) Modifier.focusRequester(contentFocusRequester) else Modifier)
|
||||
.then(if (selected) Modifier.focusRequester(contentFocusRequester) else Modifier)
|
||||
.focusProperties { if (index == 0) left = navigationFocusRequester }
|
||||
.background(
|
||||
when {
|
||||
@@ -229,7 +312,7 @@ fun GenreBrowseScreen(
|
||||
),
|
||||
) { focused ->
|
||||
Text(
|
||||
label,
|
||||
category.label,
|
||||
color = when {
|
||||
focused -> MembySurface
|
||||
selected -> Color.White
|
||||
@@ -250,18 +333,17 @@ fun GenreBrowseScreen(
|
||||
Spacer(Modifier.height(18.dp))
|
||||
|
||||
when {
|
||||
!state.genresInitialised -> GenreMessage("Loading $mediaLabel…")
|
||||
state.selectedCategoryId == null -> GenreMessage("Loading $mediaLabel…")
|
||||
state.isLoading && state.items.isEmpty() -> GenreMessage(
|
||||
if (state.selectedGenre.isNullOrEmpty()) "Loading $allLabel…"
|
||||
else "Loading ${state.selectedGenre} $mediaLabel…",
|
||||
"Loading ${selectedCategory.label}…",
|
||||
)
|
||||
state.errorMessage != null && state.items.isEmpty() -> GenreRetry(
|
||||
message = state.errorMessage.orEmpty(),
|
||||
onRetry = browseViewModel::retry,
|
||||
)
|
||||
state.items.isEmpty() -> GenreMessage(
|
||||
if (state.selectedGenre.isNullOrEmpty()) "No $mediaLabel were found."
|
||||
else "No ${state.selectedGenre} $mediaLabel were found.",
|
||||
if (selectedCategory.genres.isEmpty()) "No $mediaLabel were found."
|
||||
else "No ${selectedCategory.label.lowercase()} $mediaLabel were found.",
|
||||
)
|
||||
else -> {
|
||||
LaunchedEffect(gridState, state.items.size, state.canLoadMore) {
|
||||
@@ -290,15 +372,29 @@ fun GenreBrowseScreen(
|
||||
key = { _, item -> item.id },
|
||||
contentType = { _, _ -> "genre-poster" },
|
||||
) { index, item ->
|
||||
val favourite = favouriteStates[item.id]
|
||||
val displayedItem = if (favourite == null || favourite == item.isFavorite) {
|
||||
item
|
||||
} else {
|
||||
item.withFavourite(favourite)
|
||||
}
|
||||
PosterGridCard(
|
||||
item = item,
|
||||
item = displayedItem,
|
||||
width = cardWidth,
|
||||
onFocused = { onItemFocused(item) },
|
||||
onClick = { onItemSelected(item) },
|
||||
onLongClick = { onItemSelected(item) },
|
||||
modifier = Modifier.focusProperties {
|
||||
if (index % columns == 0) left = navigationFocusRequester
|
||||
},
|
||||
onFocused = { onItemFocused(displayedItem) },
|
||||
onClick = { onItemSelected(displayedItem) },
|
||||
onLongClick = { onItemSelected(displayedItem) },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (item.id == returnFocusItemId) {
|
||||
Modifier.focusRequester(returnFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.focusProperties {
|
||||
if (index % columns == 0) left = navigationFocusRequester
|
||||
},
|
||||
)
|
||||
}
|
||||
if (state.isLoadingMore) {
|
||||
|
||||
@@ -16,10 +16,8 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class GenreBrowseUiState(
|
||||
val genres: List<String> = emptyList(),
|
||||
val genresInitialised: Boolean = false,
|
||||
/** Empty means the whole media type; null means the screen has not been initialised. */
|
||||
val selectedGenre: String? = null,
|
||||
val categories: List<GenreCategory> = emptyList(),
|
||||
val selectedCategoryId: String? = null,
|
||||
val items: List<BaseItem> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingMore: Boolean = false,
|
||||
@@ -32,47 +30,42 @@ class GenreBrowseViewModel(
|
||||
private val repository: EmbyRepository,
|
||||
private val itemType: String,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(GenreBrowseUiState())
|
||||
private val categories = genreCategoryTabs(itemType)
|
||||
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
|
||||
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
|
||||
private var pageJob: Job? = null
|
||||
private val categoryPages = mutableMapOf<String, CachedCategoryPage>()
|
||||
|
||||
fun setGenres(genres: List<String>) {
|
||||
val normalised = normaliseGenres(genres)
|
||||
if (normalised == state.value.genres && state.value.genresInitialised) return
|
||||
val selected = state.value.selectedGenre?.takeIf { current ->
|
||||
current.isEmpty() || normalised.any { it.equals(current, ignoreCase = true) }
|
||||
} ?: ALL_MEDIA
|
||||
_state.update { it.copy(genres = normalised, genresInitialised = true) }
|
||||
if (selected != state.value.selectedGenre) selectGenre(selected)
|
||||
}
|
||||
|
||||
fun selectGenre(genre: String) {
|
||||
val selected = genre.trim()
|
||||
fun selectCategory(categoryId: String) {
|
||||
val selected = categories.firstOrNull { it.id == categoryId } ?: categories.first()
|
||||
val current = state.value
|
||||
if (current.selectedCategoryId == selected.id && (current.items.isNotEmpty() || current.isLoading)) return
|
||||
val cached = categoryPages[selected.id]
|
||||
pageJob?.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedGenre = selected,
|
||||
items = emptyList(),
|
||||
isLoading = true,
|
||||
selectedCategoryId = selected.id,
|
||||
items = cached?.items.orEmpty(),
|
||||
isLoading = cached == null,
|
||||
isLoadingMore = false,
|
||||
canLoadMore = false,
|
||||
canLoadMore = cached?.canLoadMore ?: false,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
pageJob = viewModelScope.launch { loadPage(selected, 0) }
|
||||
if (cached == null) pageJob = viewModelScope.launch { loadPage(selected, 0) }
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val current = state.value
|
||||
val genre = current.selectedGenre ?: return
|
||||
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return
|
||||
if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return
|
||||
_state.update { it.copy(isLoadingMore = true) }
|
||||
pageJob = viewModelScope.launch { loadPage(genre, current.items.size) }
|
||||
pageJob = viewModelScope.launch { loadPage(category, current.items.size) }
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
val current = state.value
|
||||
val genre = current.selectedGenre ?: return
|
||||
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return
|
||||
val offset = current.items.size
|
||||
_state.update {
|
||||
it.copy(
|
||||
@@ -82,16 +75,38 @@ class GenreBrowseViewModel(
|
||||
)
|
||||
}
|
||||
pageJob?.cancel()
|
||||
pageJob = viewModelScope.launch { loadPage(genre, offset) }
|
||||
pageJob = viewModelScope.launch { loadPage(category, offset) }
|
||||
}
|
||||
|
||||
private suspend fun loadPage(genre: String, offset: Int) {
|
||||
/** Applies optimistic favourite changes made on a detail page to this screen's copies. */
|
||||
fun applyFavouriteStates(changes: Map<String, Boolean>) {
|
||||
if (changes.isEmpty()) return
|
||||
categoryPages.entries.forEach { entry ->
|
||||
entry.setValue(
|
||||
entry.value.copy(
|
||||
items = entry.value.items.map { item ->
|
||||
changes[item.id]?.let { item.withFavourite(it) } ?: item
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
items = current.items.map { item ->
|
||||
val favourite = changes[item.id] ?: return@map item
|
||||
item.withFavourite(favourite)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadPage(category: GenreCategory, offset: Int) {
|
||||
runCatching {
|
||||
if (genre == ALL_MEDIA) {
|
||||
if (category.genres.isEmpty()) {
|
||||
repository.browseLibrary(offset = offset, limit = GENRE_PAGE_SIZE, itemType = itemType)
|
||||
} else {
|
||||
repository.browseGenre(
|
||||
genre = genre,
|
||||
genre = category.filter,
|
||||
offset = offset,
|
||||
limit = GENRE_PAGE_SIZE,
|
||||
itemType = itemType,
|
||||
@@ -99,24 +114,28 @@ class GenreBrowseViewModel(
|
||||
}
|
||||
}.onSuccess { page ->
|
||||
_state.update { current ->
|
||||
if (current.selectedGenre != genre || current.items.size != page.offset) return@update current
|
||||
if (current.selectedCategoryId != category.id || current.items.size != page.offset) {
|
||||
return@update current
|
||||
}
|
||||
val items = (current.items + page.items).distinctBy(BaseItem::id)
|
||||
val canLoadMore = hasMoreGenreItems(
|
||||
loaded = items.size,
|
||||
total = page.total,
|
||||
lastPageSize = page.items.size,
|
||||
pageSize = GENRE_PAGE_SIZE,
|
||||
)
|
||||
categoryPages[category.id] = CachedCategoryPage(items, canLoadMore)
|
||||
current.copy(
|
||||
items = items,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
canLoadMore = hasMoreGenreItems(
|
||||
loaded = items.size,
|
||||
total = page.total,
|
||||
lastPageSize = page.items.size,
|
||||
pageSize = GENRE_PAGE_SIZE,
|
||||
),
|
||||
canLoadMore = canLoadMore,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
_state.update { current ->
|
||||
if (current.selectedGenre != genre) current else current.copy(
|
||||
if (current.selectedCategoryId != category.id) current else current.copy(
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
canLoadMore = false,
|
||||
@@ -127,18 +146,10 @@ class GenreBrowseViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
const val ALL_MEDIA = ""
|
||||
|
||||
fun allMediaLabel(itemType: String): String =
|
||||
if (itemType.equals("Series", ignoreCase = true)) "All TV shows" else "All Movies"
|
||||
|
||||
fun normaliseGenres(genres: List<String>): List<String> = genres
|
||||
.asSequence()
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
.distinctBy { it.lowercase() }
|
||||
.sortedWith(String.CASE_INSENSITIVE_ORDER)
|
||||
.toList()
|
||||
private data class CachedCategoryPage(
|
||||
val items: List<BaseItem>,
|
||||
val canLoadMore: Boolean,
|
||||
)
|
||||
|
||||
class GenreBrowseViewModelFactory(
|
||||
private val repository: EmbyRepository,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.ponzischeme89.memby.ui.genre
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
|
||||
/**
|
||||
* A stable, human-sized genre catalogue.
|
||||
*
|
||||
* Emby libraries tend to describe the same shelf with several neighbouring labels
|
||||
* (Action and Adventure, Science Fiction and Sci-Fi, Family and Animation). Presenting
|
||||
* every raw label makes the picker long and unpredictable, so the television exposes a
|
||||
* fixed set of useful categories and sends their pipe-delimited Emby genres as one OR
|
||||
* filter. The order is product design, not server data, and therefore never jumps around
|
||||
* while home rows are arriving.
|
||||
*/
|
||||
data class GenreCategory(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val genres: List<String>,
|
||||
val icon: GenreCategoryIcon,
|
||||
) {
|
||||
val filter: String get() = genres.joinToString("|")
|
||||
}
|
||||
|
||||
enum class GenreCategoryIcon {
|
||||
ALL,
|
||||
ACTION,
|
||||
COMEDY,
|
||||
CRIME,
|
||||
DRAMA,
|
||||
HORROR,
|
||||
MYSTERY,
|
||||
SCI_FI,
|
||||
THRILLER,
|
||||
WAR,
|
||||
FAMILY,
|
||||
DOCUMENTARY,
|
||||
ROMANCE,
|
||||
WESTERN,
|
||||
MUSIC,
|
||||
SPORT,
|
||||
REALITY,
|
||||
}
|
||||
|
||||
const val ALL_MEDIA_CATEGORY_ID = "all"
|
||||
|
||||
private val allMediaCategory = GenreCategory(
|
||||
id = ALL_MEDIA_CATEGORY_ID,
|
||||
label = "All",
|
||||
genres = emptyList(),
|
||||
icon = GenreCategoryIcon.ALL,
|
||||
)
|
||||
|
||||
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("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"),
|
||||
GenreCategoryIcon.SCI_FI,
|
||||
),
|
||||
GenreCategory("thriller", "Thriller", listOf("Thriller", "Suspense"), GenreCategoryIcon.THRILLER),
|
||||
GenreCategory("war-history", "War & History", listOf("War", "History"), GenreCategoryIcon.WAR),
|
||||
GenreCategory(
|
||||
"family-animation",
|
||||
"Family & Animation",
|
||||
listOf("Family", "Animation", "Children", "Kids"),
|
||||
GenreCategoryIcon.FAMILY,
|
||||
),
|
||||
GenreCategory("documentary", "Documentary", listOf("Documentary"), 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),
|
||||
)
|
||||
|
||||
private val realityCategory = GenreCategory(
|
||||
"reality",
|
||||
"Reality TV",
|
||||
listOf("Reality", "Reality TV"),
|
||||
GenreCategoryIcon.REALITY,
|
||||
)
|
||||
|
||||
fun genreCategories(itemType: String): List<GenreCategory> =
|
||||
if (itemType.equals("Series", ignoreCase = true)) coreGenreCategories + realityCategory
|
||||
else coreGenreCategories
|
||||
|
||||
fun genreCategoryTabs(itemType: String): List<GenreCategory> =
|
||||
listOf(allMediaCategory.copy(label = allMediaLabel(itemType))) + genreCategories(itemType)
|
||||
|
||||
fun genreCategory(itemType: String, id: String?): GenreCategory =
|
||||
genreCategoryTabs(itemType).firstOrNull { it.id == id } ?: genreCategoryTabs(itemType).first()
|
||||
|
||||
fun allMediaLabel(itemType: String): String =
|
||||
if (itemType.equals("Series", ignoreCase = true)) "All TV shows" else "All Movies"
|
||||
|
||||
fun BaseItem.withFavourite(favourite: Boolean): BaseItem = copy(
|
||||
userData = (userData ?: UserItemData()).copy(isFavorite = favourite),
|
||||
)
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.ponzischeme89.memby.ui.genre
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GenreBrowseTest {
|
||||
@@ -11,12 +14,48 @@ class GenreBrowseTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `genre picker trims deduplicates and sorts names`() {
|
||||
fun `genre tabs have a stable product order`() {
|
||||
assertEquals(
|
||||
listOf("Action", "comedy", "Science Fiction"),
|
||||
normaliseGenres(
|
||||
listOf(" Science Fiction ", "comedy", "", "Action", "Comedy", " "),
|
||||
listOf(
|
||||
"All Movies",
|
||||
"Action & Adventure",
|
||||
"Comedy",
|
||||
"Crime",
|
||||
"Drama",
|
||||
"Horror",
|
||||
"Mystery",
|
||||
"Sci-Fi & Fantasy",
|
||||
"Thriller",
|
||||
"War & History",
|
||||
),
|
||||
genreCategoryTabs("Movie").take(10).map(GenreCategory::label),
|
||||
)
|
||||
}
|
||||
|
||||
@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",
|
||||
genreCategory("Series", "sci-fi-fantasy").filter,
|
||||
)
|
||||
assertEquals("War|History", genreCategory("Movie", "war-history").filter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `series keeps the same core tabs and adds reality`() {
|
||||
assertEquals(
|
||||
genreCategories("Movie").map(GenreCategory::id),
|
||||
genreCategories("Series").dropLast(1).map(GenreCategory::id),
|
||||
)
|
||||
assertEquals("reality", genreCategories("Series").last().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `favourite overrides update a paged card copy immediately`() {
|
||||
val item = BaseItem(id = "film", name = "Film", type = "Movie")
|
||||
assertFalse(item.isFavorite)
|
||||
assertTrue(item.withFavourite(true).isFavorite)
|
||||
assertFalse(item.withFavourite(true).withFavourite(false).isFavorite)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user