diff --git a/CHANGELOG.md b/CHANGELOG.md index cd948c4..5cd68c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4251521..b91354f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 6183918..a8ce211 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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) { diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 617a6c6..a011aee 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index 7de49f3..18b96f2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -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) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index 51fe2a3..9b91ca7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -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) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt index f2fed4c..f8f770d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt @@ -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 } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt index fc3d3c7..4321d33 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt @@ -17,6 +17,8 @@ import kotlinx.coroutines.launch data class GenreBrowseUiState( val genres: List = 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 = emptyList(), val isLoading: Boolean = false, @@ -36,22 +38,16 @@ class GenreBrowseViewModel( fun setGenres(genres: List) { 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 { - repository.browseGenre( - genre = genre, - offset = offset, - limit = GENRE_PAGE_SIZE, - itemType = itemType, - ) + 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): List = genres .asSequence() .map(String::trim) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt index 4196f2f..9e61c63 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt @@ -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( diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 05ea767..0a04e43 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -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)) diff --git a/server/internal/api/genres.go b/server/internal/api/genres.go index 171ec0e..88d05c7 100644 --- a/server/internal/api/genres.go +++ b/server/internal/api/genres.go @@ -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{ diff --git a/server/internal/api/logcontext.go b/server/internal/api/logcontext.go index 5568fd5..88838a7 100644 --- a/server/internal/api/logcontext.go +++ b/server/internal/api/logcontext.go @@ -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" diff --git a/server/internal/api/logging_test.go b/server/internal/api/logging_test.go index 965253d..a9740f5 100644 --- a/server/internal/api/logging_test.go +++ b/server/internal/api/logging_test.go @@ -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",