Release v0.2.36

This commit is contained in:
ponzischeme89
2026-08-09 12:53:25 +12:00
parent add8aa831c
commit 574826dc03
13 changed files with 193 additions and 63 deletions
+5 -6
View File
@@ -1,12 +1,11 @@
## 0.2.36 — 2026-08-09
- Improved: Browse all movies or TV shows alongside individual genres, with clearer navigation and paging.
## 0.2.35 — 2026-08-09
- Improved: Genre browsing now opens from a simpler button on the Movies and TV Series pages.
- General bug fixes and improvements.
## 0.2.34 — 2026-08-09
- Added: Closing credits can move aside and speed up while the next episode is ready.
- Added: More colour themes, including seasonal themes for the whole household.
- Added: Missing subtitles can now be found through OpenSubtitles as well as Bazarr.
- Improved: Genre browsing now shows the right titles and loads more as you scroll.
- Improved: The home screen, search, subtitles and settings are clearer and easier to use.
- General bug fixes and improvements.
## 0.2.33 — 2026-08-08
- General bug fixes and improvements.
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.35"
val defaultVersionName = "0.2.36"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -757,6 +757,54 @@ class EmbyRepository(private val settings: SettingsStore) {
return GenrePage(items, offset, total)
}
/**
* One stable, paged slice of all films or all series.
*
* This deliberately mirrors [browseGenre] so switching between All and a genre never
* changes the grid's ordering, user-data fields, or infinite-scroll behaviour.
*/
suspend fun browseLibrary(
offset: Int = 0,
limit: Int = GENRE_PAGE_SIZE,
itemType: String,
): GenrePage {
val embyItemType = if (itemType.trim().equals("Series", ignoreCase = true)) "Series" else "Movie"
if (ServerConfig.isGateway) {
runCatching { requireGateway().libraryItems(offset, limit, embyItemType) }
.onSuccess { page -> return GenrePage(page.items, offset, page.total) }
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
if (offset > 0) throw error
// An older gateway has no whole-library route. Its home response is a
// useful first shelf and, crucially, is not claimed to be pageable.
val home = getHome(limit)
val items = (home.rows.asSequence().flatMap { it.items.asSequence() } +
home.continueWatching.asSequence() + home.nextUp.asSequence() +
home.favorites.asSequence() + home.latestMovies.asSequence())
.filter { it.type.equals(embyItemType, ignoreCase = true) }
.distinctBy(BaseItem::id)
.take(limit)
.toList()
return GenrePage(items, offset, items.size)
}
}
val items = getHomeItems(
params = mapOf(
"IncludeItemTypes" to embyItemType,
"Recursive" to "true",
"StartIndex" to offset.toString(),
"Limit" to limit.toString(),
"SortBy" to "PremiereDate,SortName",
"SortOrder" to "Descending",
),
fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
val total = offset + items.size + if (items.size >= limit) 1 else 0
return GenrePage(items, offset, total)
}
/** Records a successful gateway search without affecting the direct Emby path. */
suspend fun recordSearch(term: String) {
if (ServerConfig.isGateway && term.trim().length >= 2) {
@@ -388,8 +388,7 @@ data class GatewayItems(
)
/**
* One page of `GET /v1/genres/{genre}/items` — browsing a genre rather than searching for
* its name.
* One page of `GET /v1/genres/{genre}/items` or `GET /v1/library/items`.
*
* [total] is what ends the scroll. A page shorter than [limit] ends it too, but a genre
* whose last page happens to divide evenly would otherwise cost one more empty request to
@@ -85,6 +85,14 @@ interface GatewayApi {
@Query("type") itemType: String? = null,
): com.ponzischeme89.memby.data.model.GatewayGenrePage
/** One paged media-type shelf without a genre filter. */
@GET("v1/library/items")
suspend fun libraryItems(
@Query("offset") offset: Int,
@Query("limit") limit: Int,
@Query("type") itemType: String,
): com.ponzischeme89.memby.data.model.GatewayGenrePage
@POST("v1/search/history")
suspend fun recordSearch(@Body body: Map<String, String>)
@@ -2021,22 +2021,20 @@ private fun MediaCard(
if (item.userData?.played == true || item.isFavorite) {
Column(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (item.userData?.played == true) {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Watched",
MediaStatusIcon(
icon = Icons.Default.CheckCircle,
description = "Watched",
tint = EmbyGreen,
modifier = Modifier.size(19.dp),
)
}
if (item.isFavorite) {
Icon(
Icons.Default.Favorite,
contentDescription = "Favourite",
tint = EmbyGreen,
modifier = Modifier.size(19.dp),
MediaStatusIcon(
icon = Icons.Default.Favorite,
description = "Favourite",
tint = Color(0xFFFF6B81),
)
}
}
@@ -2097,6 +2095,29 @@ private fun MediaCard(
}
}
@Composable
private fun MediaStatusIcon(
icon: ImageVector,
description: String,
tint: Color,
) {
Box(
modifier = Modifier
.size(29.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.Black.copy(alpha = 0.78f))
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = description,
tint = tint,
modifier = Modifier.size(18.dp),
)
}
}
@Composable
private fun ScheduleStatusBadge(status: String, label: String, modifier: Modifier = Modifier) {
val color = when (status) {
@@ -71,7 +71,7 @@ fun GenreBrowseLauncher(
FocusScaleContainer(
onFocused = onFocused,
onClick = onOpen,
contentDescription = "Browse ${if (mediaLabel == "movie") "movies" else mediaLabel} by genre",
contentDescription = "Browse ${if (mediaLabel == "movie") "movies" else mediaLabel}",
modifier = modifier
.padding(horizontal = 36.dp, vertical = 4.dp)
.width(156.dp)
@@ -98,7 +98,7 @@ fun GenreBrowseLauncher(
)
Spacer(Modifier.width(10.dp))
Text(
"Genres",
"Browse",
color = if (focused) MembySurface else Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
@@ -118,7 +118,7 @@ fun GenreBrowseScreen(
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val mediaLabel = if (itemType.equals("Series", ignoreCase = true)) "TV series" else "movies"
val mediaLabel = if (itemType.equals("Series", ignoreCase = true)) "TV shows" else "movies"
val browseViewModel: GenreBrowseViewModel = viewModel(
key = "genre-browse-${itemType.lowercase()}",
factory = remember(itemType) {
@@ -127,8 +127,12 @@ fun GenreBrowseScreen(
)
val state by browseViewModel.state.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState()
val allLabel = remember(itemType) { allMediaLabel(itemType) }
LaunchedEffect(genres) { browseViewModel.setGenres(genres) }
LaunchedEffect(state.selectedGenre) {
if (state.selectedGenre != null) gridState.scrollToItem(0)
}
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(32L)
runCatching { contentFocusRequester.requestFocus() }
@@ -178,13 +182,13 @@ fun GenreBrowseScreen(
Spacer(Modifier.width(14.dp))
Column {
Text(
"Browse $mediaLabel by genre",
"Browse $mediaLabel",
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
state.selectedGenre ?: "Choose a genre",
state.selectedGenre?.takeIf(String::isNotEmpty) ?: allLabel,
color = MembyMutedText,
fontSize = 14.sp,
)
@@ -194,10 +198,12 @@ fun GenreBrowseScreen(
Spacer(Modifier.height(16.dp))
LazyRow(
contentPadding = PaddingValues(horizontal = horizontalPadding),
horizontalArrangement = Arrangement.spacedBy(9.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
rowItemsIndexed(state.genres, key = { _, genre -> genre }) { index, genre ->
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 }
FocusScaleContainer(
onFocused = {},
onClick = {
@@ -205,31 +211,38 @@ fun GenreBrowseScreen(
browseViewModel.selectGenre(genre)
}
},
contentDescription = "Browse $genre $mediaLabel",
contentDescription = "Browse $label",
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),
when {
selected -> MembyAccent
else -> MembySurfaceRaised.copy(alpha = 0.72f)
},
RoundedCornerShape(18.dp),
)
.border(
1.dp,
if (selected) MembyAccent else Color.White.copy(alpha = 0.08f),
RoundedCornerShape(10.dp),
if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.06f),
RoundedCornerShape(18.dp),
),
) { focused ->
Text(
genre,
color = if (focused || selected) Color.White else MembyMutedText,
label,
color = when {
focused -> MembySurface
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),
if (focused) Color.White else Color.Transparent,
RoundedCornerShape(18.dp),
)
.padding(horizontal = 16.dp, vertical = 10.dp),
.padding(horizontal = 17.dp, vertical = 9.dp),
)
}
}
@@ -237,13 +250,19 @@ fun GenreBrowseScreen(
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.genresInitialised -> GenreMessage("Loading $mediaLabel")
state.isLoading && state.items.isEmpty() -> GenreMessage(
if (state.selectedGenre.isNullOrEmpty()) "Loading $allLabel"
else "Loading ${state.selectedGenre} $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.")
state.items.isEmpty() -> GenreMessage(
if (state.selectedGenre.isNullOrEmpty()) "No $mediaLabel were found."
else "No ${state.selectedGenre} $mediaLabel were found.",
)
else -> {
LaunchedEffect(gridState, state.items.size, state.canLoadMore) {
snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }
@@ -17,6 +17,8 @@ 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 items: List<BaseItem> = emptyList(),
val isLoading: Boolean = false,
@@ -36,22 +38,16 @@ class GenreBrowseViewModel(
fun setGenres(genres: List<String>) {
val normalised = normaliseGenres(genres)
if (normalised == state.value.genres) return
if (normalised.isEmpty()) {
pageJob?.cancel()
_state.value = GenreBrowseUiState()
return
}
if (normalised == state.value.genres && state.value.genresInitialised) 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)
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()
if (selected.isEmpty()) return
pageJob?.cancel()
_state.update {
it.copy(
@@ -91,12 +87,16 @@ class GenreBrowseViewModel(
private suspend fun loadPage(genre: String, offset: Int) {
runCatching {
if (genre == ALL_MEDIA) {
repository.browseLibrary(offset = offset, limit = GENRE_PAGE_SIZE, itemType = itemType)
} else {
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
@@ -127,6 +127,11 @@ 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)
@@ -4,6 +4,12 @@ import org.junit.Assert.assertEquals
import org.junit.Test
class GenreBrowseTest {
@Test
fun `whole library tab names match each destination`() {
assertEquals("All Movies", allMediaLabel("Movie"))
assertEquals("All TV shows", allMediaLabel("Series"))
}
@Test
fun `genre picker trims deduplicates and sorts names`() {
assertEquals(
+1
View File
@@ -139,6 +139,7 @@ func (s *Server) Routes() http.Handler {
// A genre is browsed, not searched: the chip is a filter and this is the route that
// treats it as one. Paged, because a household's Drama shelf is not a screenful.
v1.Handle("GET /v1/genres/{genre}/items", s.authed(s.handleGenreItems))
v1.Handle("GET /v1/library/items", s.authed(s.handleLibraryItems))
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
+29 -6
View File
@@ -45,21 +45,38 @@ type genrePage struct {
}
func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
genre := strings.TrimSpace(r.PathValue("genre"))
if genre == "" {
writeError(w, http.StatusBadRequest, "a genre is required")
return
}
s.handleBrowseItems(w, r, sess, genre)
}
func (s *Server) handleLibraryItems(w http.ResponseWriter, r *http.Request, sess store.Session) {
s.handleBrowseItems(w, r, sess, "")
}
func (s *Server) handleBrowseItems(
w http.ResponseWriter,
r *http.Request,
sess store.Session,
genre string,
) {
ctx := r.Context()
limit := queryInt(r, "limit", genrePageSize, genrePageMax)
offset := queryOffset(r, "offset")
itemType, ok := genreItemType(r.URL.Query().Get("type"))
if !ok {
if !ok || (genre == "" && itemType == "Movie,Series") {
writeError(w, http.StatusBadRequest, "type must be Movie or Series")
return
}
key := cache.UserKey(sess.EmbyUserID, "genre:"+itemType+":"+genre+":"+itoa(offset)+":"+itoa(limit))
filterKey := "all"
if genre != "" {
filterKey = "genre:" + genre
}
key := cache.UserKey(sess.EmbyUserID, "browse:"+itemType+":"+filterKey+":"+itoa(offset)+":"+itoa(limit))
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
@@ -67,7 +84,6 @@ func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess s
}
params := rowParams(url.Values{
"Genres": {genre},
"IncludeItemTypes": {itemType},
"Recursive": {"true"},
"StartIndex": {itoa(offset)},
@@ -79,6 +95,9 @@ func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess s
"SortBy": {"PremiereDate,SortName"},
"SortOrder": {"Descending"},
}, fieldsRow)
if genre != "" {
params.Set("Genres", genre)
}
// rowParams turns this off for the home rows, which never page. Here it is the number
// the scroll stops on.
params.Set("EnableTotalRecordCount", "true")
@@ -98,10 +117,14 @@ func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess s
// The first page is somebody opening a genre, which is a navigation event worth the
// log; the pages after it are one viewer scrolling and would bury it.
if offset == 0 {
if offset == 0 && genre != "" {
s.loggerFor(ctx).Info("genre browsed", "genre", genre, "results", len(items), "total", total)
} else {
} else if offset == 0 {
s.loggerFor(ctx).Info("library browsed", "type", itemType, "results", len(items), "total", total)
} else if genre != "" {
s.loggerFor(ctx).Debug("genre page", "genre", genre, "offset", offset, "results", len(items))
} else {
s.loggerFor(ctx).Debug("library page", "type", itemType, "offset", offset, "results", len(items))
}
body, err := json.Marshal(genrePage{
+1 -1
View File
@@ -137,7 +137,7 @@ func componentFor(path string) string {
return "settings"
case path == "/v1/screensaver", path == "/v1/preroll":
return "screensaver"
case strings.HasPrefix(path, "/v1/search"), strings.HasPrefix(path, "/v1/genres/"):
case strings.HasPrefix(path, "/v1/search"), strings.HasPrefix(path, "/v1/genres/"), path == "/v1/library/items":
return "search"
case strings.HasPrefix(path, "/v1/requests"):
return "requests"
+1
View File
@@ -43,6 +43,7 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
"/v1/auth/devices/tv-1": "devices",
"/v1/search": "search",
"/v1/genres/Comedy/items": "search",
"/v1/library/items": "search",
"/v1/items/42": "details",
"/v1/items/42/related": "details",
"/v1/items/42/playback": "playback",