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   ## AI or LLM usage None
This commit is contained in:
parent
1366ee744d
commit
9a3af225a4
16 changed files with 926 additions and 246 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue