WIP translating season/episode to tab/row indexes

This commit is contained in:
Damontecres 2025-10-02 11:58:07 -04:00
parent a7046a942e
commit c5cfa876e4
No known key found for this signature in database
7 changed files with 285 additions and 55 deletions

View file

@ -22,6 +22,9 @@ data class BaseItem(
@Transient val name = data.name
@Transient
val indexNumber = data.indexNumber
fun destination(): Destination.MediaItem {
val result =
// Redirect episodes & seasons to their series if possible

View file

@ -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.preferences.UserPreferences
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.NavigationManager
import com.github.damontecres.dolphin.util.ApiRequestPager
import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler
import com.github.damontecres.dolphin.util.ItemPager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
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.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.util.UUID
@ -40,6 +46,7 @@ class SeriesViewModel
api: ApiClient,
) : ItemViewModel<Video>(api) {
val seasons = MutableLiveData<List<BaseItem?>>(listOf())
val episodes = MutableLiveData<List<BaseItem?>>(listOf())
override fun init(
itemId: UUID,
@ -67,6 +74,13 @@ class SeriesViewModel
pager.init()
seasons.value = pager
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,
potential: BaseItem?,
season: Int?,
episode: Int?,
) {
viewModelScope.launch {
init(itemId, potential).join()
season?.let {
(seasons.value!! as ItemPager)
.getBlocking(season)
?.let {
loadEpisodes(it.id)
}
season?.let { seasonNum ->
val targetSeasonPosition =
(seasons.value!! as ItemPager)
.toBlockingList()
.indexOfFirstOrNull { it.indexNumber == seasonNum }
loadEpisodes(seasonNum)
}
}
}
val episodes = MutableLiveData<List<BaseItem?>>(listOf())
fun loadEpisodes(seasonId: UUID): Job {
Timber.v("Loading episodes for season $seasonId")
episodes.value = listOf()
return viewModelScope.launch {
fun loadEpisodes(season: Int): Deferred<ApiRequestPager<*>> =
viewModelScope.async {
val request =
GetItemsRequest(
parentId = seasonId,
recursive = false,
includeItemTypes = listOf(BaseItemKind.EPISODE),
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
sortOrder = listOf(SortOrder.ASCENDING),
GetEpisodesRequest(
seriesId = item.value!!.id,
season = season,
sortBy = ItemSortBy.INDEX_NUMBER,
fields =
listOf(
ItemFields.MEDIA_SOURCES,
@ -109,12 +118,12 @@ class SeriesViewModel
ItemFields.TRICKPLAY,
),
)
val pager = ItemPager(api, request, viewModelScope)
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
pager.init()
Timber.v("Loaded ${pager.size} episodes for season $seasonId")
Timber.v("Loaded ${pager.size} episodes for season $season")
episodes.value = pager
pager
}
}
fun setWatched(
itemId: UUID,

View file

@ -32,6 +32,12 @@ data class SeasonEpisode(
val episode: Int,
)
@Serializable
data class SeriesOverviewPosition(
val seasonTabIndex: Int,
val episodeRowIndex: Int,
)
@Composable
fun SeriesOverview(
preferences: UserPreferences,
@ -45,19 +51,33 @@ fun SeriesOverview(
OneTimeLaunchedEffect {
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 seasons by viewModel.seasons.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,
stateSaver =
Saver(
save = { listOf(it.season, it.episode) },
restore = { SeasonEpisode(it[0], it[1]) },
save = { listOf(it.seasonTabIndex, it.episodeRowIndex) },
restore = { SeriesOverviewPosition(it[0], it[1]) },
),
) { mutableStateOf(initialSeasonEpisode) }
) {
mutableStateOf(
SeriesOverviewPosition(
initialSeasonEpisode.season,
initialSeasonEpisode.episode,
),
)
}
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
@ -65,8 +85,8 @@ fun SeriesOverview(
if (episodes.isNotEmpty()) {
// TODO focus on first episode when changing seasons
// firstItemFocusRequester.requestFocus()
episodes.getOrNull(seasonEpisode.episode)?.let {
viewModel.refreshEpisode(it.id, seasonEpisode.episode)
episodes.getOrNull(position.episodeRowIndex)?.let {
viewModel.refreshEpisode(it.id, position.episodeRowIndex)
}
}
}
@ -83,14 +103,14 @@ fun SeriesOverview(
series = series,
seasons = seasons,
episodes = episodes,
seasonEpisode = seasonEpisode,
position = position,
backdropImageUrl = remember { viewModel.imageUrl(series.id, ImageType.BACKDROP) },
firstItemFocusRequester = firstItemFocusRequester,
onFocus = {
if (it.season != seasonEpisode.season) {
viewModel.loadEpisodes(seasons[it.season]!!.id)
if (it.seasonTabIndex != position.seasonTabIndex) {
viewModel.loadEpisodes(seasons[it.seasonTabIndex]!!.indexNumber!!)
}
seasonEpisode = it
position = it
},
onClick = {
val resumePosition =
@ -103,7 +123,7 @@ fun SeriesOverview(
// TODO
},
playOnClick = { resume ->
episodes.getOrNull(seasonEpisode.episode)?.let {
episodes.getOrNull(position.episodeRowIndex)?.let {
navigationManager.navigateTo(
Destination.Playback(
it.id,
@ -114,18 +134,16 @@ fun SeriesOverview(
}
},
watchOnClick = {
// TODO toggle watched state
episodes.getOrNull(seasonEpisode.episode)?.let {
episodes.getOrNull(position.episodeRowIndex)?.let {
val played = it.data.userData?.played ?: false
// TODO map indexes
viewModel.setWatched(it.id, !played, seasonEpisode.episode)
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
}
},
moreOnClick = {
// TODO show more actions
},
overviewOnClick = {
episodes.getOrNull(seasonEpisode.episode)?.let {
episodes.getOrNull(position.episodeRowIndex)?.let {
overviewDialog =
ItemDetailsDialogInfo(
title = it.name ?: "Unknown",

View file

@ -50,10 +50,10 @@ fun SeriesOverviewContent(
series: BaseItem,
seasons: List<BaseItem?>,
episodes: List<BaseItem?>,
seasonEpisode: SeasonEpisode,
position: SeriesOverviewPosition,
backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester,
onFocus: (SeasonEpisode) -> Unit,
onFocus: (SeriesOverviewPosition) -> Unit,
onClick: (BaseItem) -> Unit,
onLongClick: (BaseItem) -> Unit,
playOnClick: (Duration) -> Unit,
@ -64,12 +64,12 @@ fun SeriesOverviewContent(
) {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
// 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() } }
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
val tabRowFocusRequester = remember { FocusRequester() }
val focusedEpisode = episodes.getOrNull(seasonEpisode.episode)
val focusedEpisode = episodes.getOrNull(position.episodeRowIndex)
Box(
modifier =
@ -132,7 +132,7 @@ fun SeriesOverviewContent(
onFocus = {},
onClick = {
selectedTabIndex = index
onFocus.invoke(SeasonEpisode(index, 0))
onFocus.invoke(SeriesOverviewPosition(index, 0))
},
colors =
TabDefaults.pillIndicatorTabColors(
@ -171,7 +171,7 @@ fun SeriesOverviewContent(
}
}
item {
key(seasonEpisode.season) {
key(position.seasonTabIndex) {
val state = rememberLazyListState()
LazyRow(
state = state,
@ -179,10 +179,15 @@ fun SeriesOverviewContent(
contentPadding = PaddingValues(start = 16.dp),
modifier = modifier.focusRestorer(firstItemFocusRequester),
) {
itemsIndexed(episodes) { index, episode ->
itemsIndexed(episodes) { episodeIndex, episode ->
val interactionSource = remember { MutableInteractionSource() }
if (interactionSource.collectIsFocusedAsState().value) {
onFocus.invoke(SeasonEpisode(selectedTabIndex, index))
onFocus.invoke(
SeriesOverviewPosition(
selectedTabIndex,
episodeIndex,
),
)
}
BannerCard(
imageUrl = episode?.imageUrl,
@ -193,7 +198,7 @@ fun SeriesOverviewContent(
onLongClick = { if (episode != null) onLongClick.invoke(episode) },
modifier =
Modifier.ifElse(
index == 0,
episodeIndex == 0,
Modifier.focusRequester(firstItemFocusRequester),
),
interactionSource = interactionSource,

View file

@ -15,12 +15,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.tv.material3.Text
import org.jellyfin.sdk.model.api.TrickplayInfo
import timber.log.Timber
@Composable
fun PlaybackOverlay(
@ -78,12 +75,7 @@ fun PlaybackOverlay(
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
val index =
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
val yOffsetDp = 180.dp + 160.dp
val heightPx = with(LocalDensity.current) { yOffsetDp.toPx().toInt() }
val imageUrl = trickplayUrlFor(index)
Timber.v("Trickplay imageUrl: $imageUrl")
if (imageUrl != null) {
SeekPreviewImage(

View file

@ -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
}
}
}
}

View file

@ -11,6 +11,7 @@ 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
@ -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 {
private const val DEBUG = false
}