View more discover results (#1198)

## Description
Adds a card at the end of the rows on the discover page/tab to view more
results. When clicking this card, it goes to a grid view of more results
starting focused on the next one.

### Dev notes

This PR modifies `ApiRequestPager` to move most of its functionality
into an abstract class, `RequestPager`. And then add a new concrete
class, `DiscoverRequestPager`, for Seerr API paging. `ApiRequestPager`
remains for Jellyfin paging.

### Related issues
Related to #1035

### Testing
Emulator

## Screenshots

![discover_view_more
Large](https://github.com/user-attachments/assets/180aa7a5-45c3-4544-b3f8-e3d394859178)
![discover_grid
Large](https://github.com/user-attachments/assets/ae5e8fbf-0fa4-4a2e-96ac-629ae688c05d)

## AI or LLM usage
None
This commit is contained in:
Ray 2026-04-06 13:18:55 -04:00 • committed by GitHub
parent 1366ee744d
commit 9a3af225a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 926 additions and 246 deletions

View file

@ -1,18 +1,10 @@
package com.github.damontecres.wholphin.util
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE
import com.google.common.cache.CacheBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.genresApi
@ -35,111 +27,36 @@ import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber
import java.util.UUID
import java.util.function.Predicate
/**
* Handles paging for an API request. You must call [init] prior to use.
*
* Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope].
* Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched.
*
* Finally, items are cached allow for backward and forward scrolling.
* A [RequestPager] for Jellyfin server queries
*/
class ApiRequestPager<T>(
val api: ApiClient,
val request: T,
val requestHandler: RequestHandler<T>,
private val scope: CoroutineScope,
private val pageSize: Int = DEFAULT_PAGE_SIZE,
scope: CoroutineScope,
pageSize: Int = DEFAULT_PAGE_SIZE,
cacheSize: Long = 8,
private val useSeriesForPrimary: Boolean = false,
) : AbstractList<BaseItem?>(),
BlockingList<BaseItem?> {
private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf()))
private var totalCount by mutableIntStateOf(-1)
private val mutex = Mutex()
private val cachedPages =
CacheBuilder
.newBuilder()
.maximumSize(cacheSize)
.build<Int, MutableList<BaseItem>>()
) : RequestPager<BaseItem>(scope, pageSize, cacheSize) {
override suspend fun init(initialPosition: Int): ApiRequestPager<T> = super.init(initialPosition) as ApiRequestPager<T>
suspend fun init(initialPosition: Int = 0): ApiRequestPager<T> {
if (totalCount < 0) {
fetchPageBlocking(initialPosition, true)
}
return this
}
override operator fun get(index: Int): BaseItem? {
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPage(index)
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
override suspend fun getBlocking(index: Int): BaseItem? {
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPageBlocking(index, false)
return items[index]
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
override suspend fun indexOfBlocking(predicate: Predicate<BaseItem?>): Int {
init()
for (i in 0 until totalCount) {
val currentItem = getBlocking(i)
if (currentItem != null && predicate.test(currentItem)) {
return i
}
}
return -1
}
override val size: Int
get() = totalCount
private fun fetchPage(position: Int): Job =
scope.launch(ExceptionHandler() + Dispatchers.IO) {
fetchPageBlocking(position, false)
}
private suspend fun fetchPageBlocking(
position: Int,
setTotalCount: Boolean,
) {
mutex.withLock {
val pageNumber = position / pageSize
if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) {
if (DEBUG) Timber.v("fetchPage: $pageNumber")
val newRequest =
requestHandler.prepare(
request,
pageNumber * pageSize,
pageSize,
setTotalCount,
)
val result = requestHandler.execute(api, newRequest).content
if (setTotalCount) {
totalCount = result.totalRecordCount.coerceAtLeast(0)
}
val data = mutableListOf<BaseItem>()
result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) }
cachedPages.put(pageNumber, data)
items = ItemList(totalCount, pageSize, cachedPages.asMap())
}
}
override suspend fun fetchPage(
pageNumber: Int,
includeTotalCount: Boolean,
): QueryResult<BaseItem> {
val newRequest =
requestHandler.prepare(
request,
pageNumber * pageSize,
pageSize,
includeTotalCount,
)
val result = requestHandler.execute(api, newRequest).content
val data = mutableListOf<BaseItem>()
result.items.forEach { data.add(BaseItem(it, useSeriesForPrimary)) }
return QueryResult(data, result.totalRecordCount)
}
suspend fun refreshItem(
@ -161,7 +78,7 @@ class ApiRequestPager<T>(
if (page != null && index in page.indices) {
page[index] = item
cachedPages.put(pageNumber, page)
items = ItemList(totalCount, pageSize, cachedPages.asMap())
items = ItemList(size, pageSize, cachedPages.asMap())
}
}
}
@ -181,39 +98,10 @@ class ApiRequestPager<T>(
}
fetchPageBlocking(position, true)
}
companion object {
private const val DEBUG = false
}
class ItemList<T>(
val size: Int,
val pageSize: Int,
val pages: Map<Int, List<T>>,
) {
operator fun get(position: Int): T? {
val page = position / pageSize
val data = pages[page]
if (data != null) {
val index = position % pageSize
if (index in data.indices) {
return data[index]
} else {
// This can happen when items are removed while scrolling
Timber.w(
"Index $index not in data: position=$position, data.size=${data.size}",
)
return null
}
} else {
return null
}
}
}
}
/**
* Specifies how the [ApiRequestPager] should prepare and execute API calls
* Specifies how a [RequestPager] should prepare and execute API calls
*/
interface RequestHandler<T> {
/**
@ -235,6 +123,7 @@ interface RequestHandler<T> {
): Response<BaseItemDtoQueryResult>
}
@Serializable
val GetItemsRequestHandler =
object : RequestHandler<GetItemsRequest> {
override fun prepare(
@ -255,6 +144,7 @@ val GetItemsRequestHandler =
): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request)
}
@Serializable
val GetEpisodesRequestHandler =
object : RequestHandler<GetEpisodesRequest> {
override fun prepare(
@ -274,6 +164,7 @@ val GetEpisodesRequestHandler =
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request)
}
@Serializable
val GetResumeItemsRequestHandler =
object : RequestHandler<GetResumeItemsRequest> {
override fun prepare(
@ -294,6 +185,7 @@ val GetResumeItemsRequestHandler =
): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request)
}
@Serializable
val GetNextUpRequestHandler =
object : RequestHandler<GetNextUpRequest> {
override fun prepare(
@ -314,6 +206,7 @@ val GetNextUpRequestHandler =
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request)
}
@Serializable
val GetSuggestionsRequestHandler =
object : RequestHandler<GetSuggestionsRequest> {
override fun prepare(
@ -334,6 +227,7 @@ val GetSuggestionsRequestHandler =
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request)
}
@Serializable
val GetPlaylistItemsRequestHandler =
object : RequestHandler<GetPlaylistItemsRequest> {
override fun prepare(
@ -353,6 +247,7 @@ val GetPlaylistItemsRequestHandler =
): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request)
}
@Serializable
val GetGenresRequestHandler =
object : RequestHandler<GetGenresRequest> {
override fun prepare(
@ -392,6 +287,7 @@ val GetProgramsDtoHandler =
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request)
}
@Serializable
val GetPersonsHandler =
object : RequestHandler<GetPersonsRequest> {
override fun prepare(

View file

@ -0,0 +1,111 @@
package com.github.damontecres.wholphin.util
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.MovieResult
import com.github.damontecres.wholphin.api.seerr.model.TvResult
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.services.SeerrSearchResult
import kotlinx.coroutines.CoroutineScope
/**
* A [RequestPager] for Seerr server queries
*/
class DiscoverRequestPager<T>(
private val api: SeerrApi,
val requestHandler: DiscoverRequestHandler<T>,
val transform: suspend (T) -> DiscoverItem,
scope: CoroutineScope,
pageSize: Int = SEERR_PAGE_SIZE,
cacheSize: Long = 16,
) : RequestPager<DiscoverItem>(scope, pageSize, cacheSize) {
override suspend fun init(initialPosition: Int): DiscoverRequestPager<T> = super.init(initialPosition) as DiscoverRequestPager<T>
override suspend fun fetchPage(
pageNumber: Int,
includeTotalCount: Boolean,
): QueryResult<DiscoverItem> {
val result = requestHandler.execute(api, pageNumber + 1) // Seerr pages are 1-indexed
val transformed = result.items.map { transform.invoke(it) }
return QueryResult(transformed, result.totalCount)
}
}
const val SEERR_PAGE_SIZE = 20
enum class DiscoverRequestType(
@param:StringRes val stringRes: Int,
) {
DISCOVER_TV(R.string.discover_tv),
DISCOVER_MOVIES(R.string.discover_movies),
TRENDING(R.string.trending),
UPCOMING_TV(R.string.upcoming_tv),
UPCOMING_MOVIES(R.string.upcoming_movies),
UNKNOWN(R.string.unknown),
}
/**
* Specifies how a [RequestPager] should prepare and execute API calls
*/
interface DiscoverRequestHandler<T> {
suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<T>
}
val DiscoverTvRequestHandler =
object : DiscoverRequestHandler<TvResult> {
override suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<TvResult> =
api.api.searchApi.discoverTvGet(page = pageNumber).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
}
}
val DiscoverMovieRequestHandler =
object : DiscoverRequestHandler<MovieResult> {
override suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<MovieResult> =
api.api.searchApi.discoverMoviesGet(page = pageNumber).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
}
}
val TrendingRequestHandler =
object : DiscoverRequestHandler<SeerrSearchResult> {
override suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<SeerrSearchResult> =
api.api.searchApi.discoverTrendingGet(page = pageNumber).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
}
}
val UpcomingTvRequestHandler =
object : DiscoverRequestHandler<TvResult> {
override suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<TvResult> =
api.api.searchApi.discoverTvUpcomingGet(page = pageNumber).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
}
}
val UpcomingMovieRequestHandler =
object : DiscoverRequestHandler<MovieResult> {
override suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<MovieResult> =
api.api.searchApi.discoverMoviesUpcomingGet(page = pageNumber).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
}
}

View file

@ -0,0 +1,148 @@
package com.github.damontecres.wholphin.util
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE
import com.google.common.cache.CacheBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.util.function.Predicate
/**
* Handles paging for an API request. You must call [init] prior to use.
*
* Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope].
* Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched.
*
* Finally, items are cached allow for backward and forward scrolling.
*/
abstract class RequestPager<T>(
protected val scope: CoroutineScope,
protected val pageSize: Int = DEFAULT_PAGE_SIZE,
cacheSize: Long = 8,
) : AbstractList<T?>(),
BlockingList<T?> {
protected var totalCount by mutableIntStateOf(-1)
protected var items by mutableStateOf(ItemList<T>(0, pageSize, mapOf()))
protected val mutex = Mutex()
protected val cachedPages =
CacheBuilder
.newBuilder()
.maximumSize(cacheSize)
.build<Int, MutableList<T>>()
open suspend fun init(initialPosition: Int = 0): RequestPager<T> {
if (totalCount < 0) {
fetchPageBlocking(initialPosition, true)
}
return this
}
override operator fun get(index: Int): T? {
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPage(index)
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
override suspend fun getBlocking(index: Int): T? {
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPageBlocking(index, false)
return items[index]
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
override suspend fun indexOfBlocking(predicate: Predicate<T?>): Int {
init(0)
for (i in 0 until totalCount) {
val currentItem = getBlocking(i)
if (currentItem != null && predicate.test(currentItem)) {
return i
}
}
return -1
}
override val size: Int
get() = totalCount
private fun fetchPage(position: Int): Job =
scope.launch(ExceptionHandler() + Dispatchers.IO) {
fetchPageBlocking(position, false)
}
protected suspend fun fetchPageBlocking(
position: Int,
setTotalCount: Boolean,
) {
mutex.withLock {
val pageNumber = position / pageSize
if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) {
if (DEBUG) Timber.v("fetchPage: $pageNumber")
val result = fetchPage(pageNumber, setTotalCount)
if (setTotalCount && result.totalCount != null) {
totalCount = result.totalCount.coerceAtLeast(0)
}
cachedPages.put(pageNumber, result.items.toMutableList())
items = ItemList(totalCount, pageSize, cachedPages.asMap())
}
}
}
protected abstract suspend fun fetchPage(
pageNumber: Int,
includeTotalCount: Boolean,
): QueryResult<T>
companion object {
internal const val DEBUG = false
}
class ItemList<T>(
val size: Int,
val pageSize: Int,
val pages: Map<Int, List<T>>,
) {
operator fun get(position: Int): T? {
val page = position / pageSize
val data = pages[page]
if (data != null) {
val index = position % pageSize
if (index in data.indices) {
return data[index]
} else {
// This can happen when items are removed while scrolling
Timber.w(
"Index $index not in data: position=$position, data.size=${data.size}",
)
return null
}
} else {
return null
}
}
}
}
data class QueryResult<T>(
val items: List<T>,
val totalCount: Int?,
)