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

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.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(

View file

@ -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",

View file

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

View file

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

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