mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
WIP translating season/episode to tab/row indexes
This commit is contained in:
parent
a7046a942e
commit
c5cfa876e4
7 changed files with 285 additions and 55 deletions
|
|
@ -22,6 +22,9 @@ data class BaseItem(
|
||||||
|
|
||||||
@Transient val name = data.name
|
@Transient val name = data.name
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
val indexNumber = data.indexNumber
|
||||||
|
|
||||||
fun destination(): Destination.MediaItem {
|
fun destination(): Destination.MediaItem {
|
||||||
val result =
|
val result =
|
||||||
// Redirect episodes & seasons to their series if possible
|
// Redirect episodes & seasons to their series if possible
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,16 @@ import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.data.model.Video
|
import com.github.damontecres.dolphin.data.model.Video
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.dolphin.ui.cards.ItemRow
|
import com.github.damontecres.dolphin.ui.cards.ItemRow
|
||||||
|
import com.github.damontecres.dolphin.ui.indexOfFirstOrNull
|
||||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||||
|
import com.github.damontecres.dolphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler
|
||||||
import com.github.damontecres.dolphin.util.ItemPager
|
import com.github.damontecres.dolphin.util.ItemPager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||||
|
|
@ -28,6 +33,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ItemFields
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -40,6 +46,7 @@ class SeriesViewModel
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
) : ItemViewModel<Video>(api) {
|
) : ItemViewModel<Video>(api) {
|
||||||
val seasons = MutableLiveData<List<BaseItem?>>(listOf())
|
val seasons = MutableLiveData<List<BaseItem?>>(listOf())
|
||||||
|
val episodes = MutableLiveData<List<BaseItem?>>(listOf())
|
||||||
|
|
||||||
override fun init(
|
override fun init(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
|
|
@ -67,6 +74,13 @@ class SeriesViewModel
|
||||||
pager.init()
|
pager.init()
|
||||||
seasons.value = pager
|
seasons.value = pager
|
||||||
Timber.v("Loaded ${pager.size} seasons for series ${item.id}")
|
Timber.v("Loaded ${pager.size} seasons for series ${item.id}")
|
||||||
|
val pairs =
|
||||||
|
pager.mapIndexed { index, _ ->
|
||||||
|
val season = pager.getBlocking(index)
|
||||||
|
Pair(season?.indexNumber!!, index)
|
||||||
|
}
|
||||||
|
mapOf(*pairs.toTypedArray())
|
||||||
|
mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,32 +88,27 @@ class SeriesViewModel
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
potential: BaseItem?,
|
potential: BaseItem?,
|
||||||
season: Int?,
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
init(itemId, potential).join()
|
init(itemId, potential).join()
|
||||||
season?.let {
|
season?.let { seasonNum ->
|
||||||
|
val targetSeasonPosition =
|
||||||
(seasons.value!! as ItemPager)
|
(seasons.value!! as ItemPager)
|
||||||
.getBlocking(season)
|
.toBlockingList()
|
||||||
?.let {
|
.indexOfFirstOrNull { it.indexNumber == seasonNum }
|
||||||
loadEpisodes(it.id)
|
loadEpisodes(seasonNum)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val episodes = MutableLiveData<List<BaseItem?>>(listOf())
|
fun loadEpisodes(season: Int): Deferred<ApiRequestPager<*>> =
|
||||||
|
viewModelScope.async {
|
||||||
fun loadEpisodes(seasonId: UUID): Job {
|
|
||||||
Timber.v("Loading episodes for season $seasonId")
|
|
||||||
episodes.value = listOf()
|
|
||||||
return viewModelScope.launch {
|
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetEpisodesRequest(
|
||||||
parentId = seasonId,
|
seriesId = item.value!!.id,
|
||||||
recursive = false,
|
season = season,
|
||||||
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
sortBy = ItemSortBy.INDEX_NUMBER,
|
||||||
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
|
|
||||||
sortOrder = listOf(SortOrder.ASCENDING),
|
|
||||||
fields =
|
fields =
|
||||||
listOf(
|
listOf(
|
||||||
ItemFields.MEDIA_SOURCES,
|
ItemFields.MEDIA_SOURCES,
|
||||||
|
|
@ -109,11 +118,11 @@ class SeriesViewModel
|
||||||
ItemFields.TRICKPLAY,
|
ItemFields.TRICKPLAY,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val pager = ItemPager(api, request, viewModelScope)
|
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
|
||||||
pager.init()
|
pager.init()
|
||||||
Timber.v("Loaded ${pager.size} episodes for season $seasonId")
|
Timber.v("Loaded ${pager.size} episodes for season $season")
|
||||||
episodes.value = pager
|
episodes.value = pager
|
||||||
}
|
pager
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setWatched(
|
fun setWatched(
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,12 @@ data class SeasonEpisode(
|
||||||
val episode: Int,
|
val episode: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SeriesOverviewPosition(
|
||||||
|
val seasonTabIndex: Int,
|
||||||
|
val episodeRowIndex: Int,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SeriesOverview(
|
fun SeriesOverview(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
|
|
@ -45,19 +51,33 @@ fun SeriesOverview(
|
||||||
|
|
||||||
OneTimeLaunchedEffect {
|
OneTimeLaunchedEffect {
|
||||||
Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode")
|
Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode")
|
||||||
viewModel.init(destination.itemId, destination.item, initialSeasonEpisode.season)
|
viewModel.init(
|
||||||
|
destination.itemId,
|
||||||
|
destination.item,
|
||||||
|
initialSeasonEpisode.season,
|
||||||
|
initialSeasonEpisode.episode,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val series by viewModel.item.observeAsState(null)
|
val series by viewModel.item.observeAsState(null)
|
||||||
val seasons by viewModel.seasons.observeAsState(listOf())
|
val seasons by viewModel.seasons.observeAsState(listOf())
|
||||||
val episodes by viewModel.episodes.observeAsState(listOf())
|
val episodes by viewModel.episodes.observeAsState(listOf())
|
||||||
var seasonEpisode by rememberSaveable(
|
|
||||||
|
// TODO need to translate season/episode into tab/row index in case of missing seasons/episodes
|
||||||
|
var position by rememberSaveable(
|
||||||
destination,
|
destination,
|
||||||
stateSaver =
|
stateSaver =
|
||||||
Saver(
|
Saver(
|
||||||
save = { listOf(it.season, it.episode) },
|
save = { listOf(it.seasonTabIndex, it.episodeRowIndex) },
|
||||||
restore = { SeasonEpisode(it[0], it[1]) },
|
restore = { SeriesOverviewPosition(it[0], it[1]) },
|
||||||
),
|
),
|
||||||
) { mutableStateOf(initialSeasonEpisode) }
|
) {
|
||||||
|
mutableStateOf(
|
||||||
|
SeriesOverviewPosition(
|
||||||
|
initialSeasonEpisode.season,
|
||||||
|
initialSeasonEpisode.episode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
|
|
||||||
|
|
@ -65,8 +85,8 @@ fun SeriesOverview(
|
||||||
if (episodes.isNotEmpty()) {
|
if (episodes.isNotEmpty()) {
|
||||||
// TODO focus on first episode when changing seasons
|
// TODO focus on first episode when changing seasons
|
||||||
// firstItemFocusRequester.requestFocus()
|
// firstItemFocusRequester.requestFocus()
|
||||||
episodes.getOrNull(seasonEpisode.episode)?.let {
|
episodes.getOrNull(position.episodeRowIndex)?.let {
|
||||||
viewModel.refreshEpisode(it.id, seasonEpisode.episode)
|
viewModel.refreshEpisode(it.id, position.episodeRowIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -83,14 +103,14 @@ fun SeriesOverview(
|
||||||
series = series,
|
series = series,
|
||||||
seasons = seasons,
|
seasons = seasons,
|
||||||
episodes = episodes,
|
episodes = episodes,
|
||||||
seasonEpisode = seasonEpisode,
|
position = position,
|
||||||
backdropImageUrl = remember { viewModel.imageUrl(series.id, ImageType.BACKDROP) },
|
backdropImageUrl = remember { viewModel.imageUrl(series.id, ImageType.BACKDROP) },
|
||||||
firstItemFocusRequester = firstItemFocusRequester,
|
firstItemFocusRequester = firstItemFocusRequester,
|
||||||
onFocus = {
|
onFocus = {
|
||||||
if (it.season != seasonEpisode.season) {
|
if (it.seasonTabIndex != position.seasonTabIndex) {
|
||||||
viewModel.loadEpisodes(seasons[it.season]!!.id)
|
viewModel.loadEpisodes(seasons[it.seasonTabIndex]!!.indexNumber!!)
|
||||||
}
|
}
|
||||||
seasonEpisode = it
|
position = it
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
val resumePosition =
|
val resumePosition =
|
||||||
|
|
@ -103,7 +123,7 @@ fun SeriesOverview(
|
||||||
// TODO
|
// TODO
|
||||||
},
|
},
|
||||||
playOnClick = { resume ->
|
playOnClick = { resume ->
|
||||||
episodes.getOrNull(seasonEpisode.episode)?.let {
|
episodes.getOrNull(position.episodeRowIndex)?.let {
|
||||||
navigationManager.navigateTo(
|
navigationManager.navigateTo(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
it.id,
|
it.id,
|
||||||
|
|
@ -114,18 +134,16 @@ fun SeriesOverview(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watchOnClick = {
|
watchOnClick = {
|
||||||
// TODO toggle watched state
|
episodes.getOrNull(position.episodeRowIndex)?.let {
|
||||||
episodes.getOrNull(seasonEpisode.episode)?.let {
|
|
||||||
val played = it.data.userData?.played ?: false
|
val played = it.data.userData?.played ?: false
|
||||||
// TODO map indexes
|
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
|
||||||
viewModel.setWatched(it.id, !played, seasonEpisode.episode)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
moreOnClick = {
|
moreOnClick = {
|
||||||
// TODO show more actions
|
// TODO show more actions
|
||||||
},
|
},
|
||||||
overviewOnClick = {
|
overviewOnClick = {
|
||||||
episodes.getOrNull(seasonEpisode.episode)?.let {
|
episodes.getOrNull(position.episodeRowIndex)?.let {
|
||||||
overviewDialog =
|
overviewDialog =
|
||||||
ItemDetailsDialogInfo(
|
ItemDetailsDialogInfo(
|
||||||
title = it.name ?: "Unknown",
|
title = it.name ?: "Unknown",
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,10 @@ fun SeriesOverviewContent(
|
||||||
series: BaseItem,
|
series: BaseItem,
|
||||||
seasons: List<BaseItem?>,
|
seasons: List<BaseItem?>,
|
||||||
episodes: List<BaseItem?>,
|
episodes: List<BaseItem?>,
|
||||||
seasonEpisode: SeasonEpisode,
|
position: SeriesOverviewPosition,
|
||||||
backdropImageUrl: String?,
|
backdropImageUrl: String?,
|
||||||
firstItemFocusRequester: FocusRequester,
|
firstItemFocusRequester: FocusRequester,
|
||||||
onFocus: (SeasonEpisode) -> Unit,
|
onFocus: (SeriesOverviewPosition) -> Unit,
|
||||||
onClick: (BaseItem) -> Unit,
|
onClick: (BaseItem) -> Unit,
|
||||||
onLongClick: (BaseItem) -> Unit,
|
onLongClick: (BaseItem) -> Unit,
|
||||||
playOnClick: (Duration) -> Unit,
|
playOnClick: (Duration) -> Unit,
|
||||||
|
|
@ -64,12 +64,12 @@ fun SeriesOverviewContent(
|
||||||
) {
|
) {
|
||||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
// TODO need to map between season index and tab index in case of missing seasons
|
// TODO need to map between season index and tab index in case of missing seasons
|
||||||
var selectedTabIndex by rememberSaveable(seasonEpisode) { mutableIntStateOf(seasonEpisode.season) }
|
var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) }
|
||||||
val focusRequesters = remember(seasons.size) { List(seasons.size) { FocusRequester() } }
|
val focusRequesters = remember(seasons.size) { List(seasons.size) { FocusRequester() } }
|
||||||
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
|
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
|
||||||
val tabRowFocusRequester = remember { FocusRequester() }
|
val tabRowFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
val focusedEpisode = episodes.getOrNull(seasonEpisode.episode)
|
val focusedEpisode = episodes.getOrNull(position.episodeRowIndex)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -132,7 +132,7 @@ fun SeriesOverviewContent(
|
||||||
onFocus = {},
|
onFocus = {},
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedTabIndex = index
|
selectedTabIndex = index
|
||||||
onFocus.invoke(SeasonEpisode(index, 0))
|
onFocus.invoke(SeriesOverviewPosition(index, 0))
|
||||||
},
|
},
|
||||||
colors =
|
colors =
|
||||||
TabDefaults.pillIndicatorTabColors(
|
TabDefaults.pillIndicatorTabColors(
|
||||||
|
|
@ -171,7 +171,7 @@ fun SeriesOverviewContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
key(seasonEpisode.season) {
|
key(position.seasonTabIndex) {
|
||||||
val state = rememberLazyListState()
|
val state = rememberLazyListState()
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = state,
|
state = state,
|
||||||
|
|
@ -179,10 +179,15 @@ fun SeriesOverviewContent(
|
||||||
contentPadding = PaddingValues(start = 16.dp),
|
contentPadding = PaddingValues(start = 16.dp),
|
||||||
modifier = modifier.focusRestorer(firstItemFocusRequester),
|
modifier = modifier.focusRestorer(firstItemFocusRequester),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(episodes) { index, episode ->
|
itemsIndexed(episodes) { episodeIndex, episode ->
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
if (interactionSource.collectIsFocusedAsState().value) {
|
if (interactionSource.collectIsFocusedAsState().value) {
|
||||||
onFocus.invoke(SeasonEpisode(selectedTabIndex, index))
|
onFocus.invoke(
|
||||||
|
SeriesOverviewPosition(
|
||||||
|
selectedTabIndex,
|
||||||
|
episodeIndex,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
BannerCard(
|
BannerCard(
|
||||||
imageUrl = episode?.imageUrl,
|
imageUrl = episode?.imageUrl,
|
||||||
|
|
@ -193,7 +198,7 @@ fun SeriesOverviewContent(
|
||||||
onLongClick = { if (episode != null) onLongClick.invoke(episode) },
|
onLongClick = { if (episode != null) onLongClick.invoke(episode) },
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.ifElse(
|
Modifier.ifElse(
|
||||||
index == 0,
|
episodeIndex == 0,
|
||||||
Modifier.focusRequester(firstItemFocusRequester),
|
Modifier.focusRequester(firstItemFocusRequester),
|
||||||
),
|
),
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,9 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaybackOverlay(
|
fun PlaybackOverlay(
|
||||||
|
|
@ -78,12 +75,7 @@ fun PlaybackOverlay(
|
||||||
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
|
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
|
||||||
val index =
|
val index =
|
||||||
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
|
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
|
||||||
|
|
||||||
val yOffsetDp = 180.dp + 160.dp
|
|
||||||
val heightPx = with(LocalDensity.current) { yOffsetDp.toPx().toInt() }
|
|
||||||
|
|
||||||
val imageUrl = trickplayUrlFor(index)
|
val imageUrl = trickplayUrlFor(index)
|
||||||
Timber.v("Trickplay imageUrl: $imageUrl")
|
|
||||||
|
|
||||||
if (imageUrl != null) {
|
if (imageUrl != null) {
|
||||||
SeekPreviewImage(
|
SeekPreviewImage(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
package com.github.damontecres.dolphin.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.dolphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.dolphin.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.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.Response
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
interface RequestHandler<T> {
|
||||||
|
fun prepare(
|
||||||
|
request: T,
|
||||||
|
startIndex: Int,
|
||||||
|
limit: Int,
|
||||||
|
enableTotalRecordCount: Boolean,
|
||||||
|
): T
|
||||||
|
|
||||||
|
suspend fun execute(
|
||||||
|
api: ApiClient,
|
||||||
|
request: T,
|
||||||
|
): Response<BaseItemDtoQueryResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
val GetItemsRequestHandler =
|
||||||
|
object : RequestHandler<GetItemsRequest> {
|
||||||
|
override fun prepare(
|
||||||
|
request: GetItemsRequest,
|
||||||
|
startIndex: Int,
|
||||||
|
limit: Int,
|
||||||
|
enableTotalRecordCount: Boolean,
|
||||||
|
): GetItemsRequest =
|
||||||
|
request.copy(
|
||||||
|
startIndex = startIndex,
|
||||||
|
limit = limit,
|
||||||
|
enableTotalRecordCount = enableTotalRecordCount,
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun execute(
|
||||||
|
api: ApiClient,
|
||||||
|
request: GetItemsRequest,
|
||||||
|
): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
val GetEpisodesRequestHandler =
|
||||||
|
object : RequestHandler<GetEpisodesRequest> {
|
||||||
|
override fun prepare(
|
||||||
|
request: GetEpisodesRequest,
|
||||||
|
startIndex: Int,
|
||||||
|
limit: Int,
|
||||||
|
enableTotalRecordCount: Boolean,
|
||||||
|
): GetEpisodesRequest =
|
||||||
|
request.copy(
|
||||||
|
startIndex = startIndex,
|
||||||
|
limit = limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun execute(
|
||||||
|
api: ApiClient,
|
||||||
|
request: GetEpisodesRequest,
|
||||||
|
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiRequestPager<T>(
|
||||||
|
val api: ApiClient,
|
||||||
|
val request: T,
|
||||||
|
val requestHandler: RequestHandler<T>,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val pageSize: Int = DEFAULT_PAGE_SIZE,
|
||||||
|
itemCount: Int? = null,
|
||||||
|
cacheSize: Long = 8,
|
||||||
|
) : AbstractList<BaseItem?>() {
|
||||||
|
private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf()))
|
||||||
|
private var totalCount by mutableIntStateOf(itemCount ?: -1)
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val cachedPages =
|
||||||
|
CacheBuilder
|
||||||
|
.newBuilder()
|
||||||
|
.maximumSize(cacheSize)
|
||||||
|
.build<Int, List<BaseItem>>()
|
||||||
|
|
||||||
|
suspend fun init() {
|
||||||
|
if (totalCount < 0) {
|
||||||
|
val newRequest = requestHandler.prepare(request, 0, 1, true)
|
||||||
|
val result = requestHandler.execute(api, newRequest).content
|
||||||
|
totalCount = result.totalRecordCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getBlocking(position: Int): BaseItem? {
|
||||||
|
if (position in 0..<totalCount) {
|
||||||
|
val item = items[position]
|
||||||
|
if (item == null) {
|
||||||
|
fetchPage(position).join()
|
||||||
|
return items[position]
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
} else {
|
||||||
|
throw IndexOutOfBoundsException("$position of $totalCount")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val size: Int
|
||||||
|
get() = totalCount
|
||||||
|
|
||||||
|
private fun fetchPage(position: Int): Job =
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val pageNumber = position / pageSize
|
||||||
|
if (cachedPages.getIfPresent(pageNumber) == null) {
|
||||||
|
if (DEBUG) Timber.v("fetchPage: $pageNumber")
|
||||||
|
val newRequest =
|
||||||
|
requestHandler.prepare(
|
||||||
|
request,
|
||||||
|
pageNumber * pageSize,
|
||||||
|
pageSize,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val result = requestHandler.execute(api, newRequest).content
|
||||||
|
val data = result.items.map { BaseItem.from(it, api) }
|
||||||
|
cachedPages.put(pageNumber, data)
|
||||||
|
items = ItemList(totalCount, pageSize, cachedPages.asMap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun toBlockingList(): List<BaseItem> {
|
||||||
|
init()
|
||||||
|
return object : AbstractList<BaseItem>() {
|
||||||
|
override val size: Int
|
||||||
|
get() = totalCount
|
||||||
|
|
||||||
|
override fun get(index: Int): BaseItem = runBlocking { getBlocking(index)!! }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -100,6 +101,16 @@ class ItemPager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun toBlockingList(): List<BaseItem> {
|
||||||
|
init()
|
||||||
|
return object : AbstractList<BaseItem>() {
|
||||||
|
override val size: Int
|
||||||
|
get() = totalCount
|
||||||
|
|
||||||
|
override fun get(index: Int): BaseItem = runBlocking { getBlocking(index)!! }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val DEBUG = false
|
private const val DEBUG = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue