This commit is contained in:
Damontecres 2025-11-01 14:14:09 -04:00
parent b288f0f100
commit 841b4a0907
No known key found for this signature in database
2 changed files with 85 additions and 13 deletions

View file

@ -17,6 +17,7 @@ import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -29,7 +30,6 @@ import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.TimerInfoDto
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.api.request.GetLiveTvProgramsRequest
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.time.LocalDateTime
@ -39,7 +39,7 @@ import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
const val MAX_HOURS = 48L
const val MAX_HOURS = 3L
@HiltViewModel
class LiveTvViewModel
@ -56,12 +56,18 @@ class LiveTvViewModel
private val mutex = Mutex()
val channels = MutableLiveData<List<TvChannel>>()
val channelProgramCount = mutableMapOf<UUID, Int>()
val programs = MutableLiveData<List<TvProgram>>()
val programsByChannel = MutableLiveData<Map<UUID, List<TvProgram>>>(mapOf())
val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending)
val fetchedItem = MutableLiveData<BaseItem?>(null)
val offset = MutableLiveData(0)
val programOffset = MutableLiveData(0)
private val range = 10
private var currentIndex = 0
fun init(firstLoad: Boolean) {
start = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
if (!firstLoad) {
@ -86,7 +92,7 @@ class LiveTvViewModel
Timber.d("Got ${channels.size} channels")
channelsIdToIndex =
channels.withIndex().associateBy({ it.value.id }, { it.index })
fetchPrograms(channels)
fetchPrograms(channels.subList(0, range.coerceAtMost(channels.size)))
withContext(Dispatchers.Main) {
this@LiveTvViewModel.channels.value = channels
@ -105,9 +111,9 @@ class LiveTvViewModel
mutex.withLock {
val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1)
val minEndDate = start.plusMinutes(1L)
Timber.v("Fetching programs for ${channels.size} channels")
val request =
GetLiveTvProgramsRequest(
GetProgramsDto(
maxStartDate = maxStartDate,
minEndDate = minEndDate,
channelIds = channels.map { it.id },
@ -115,7 +121,7 @@ class LiveTvViewModel
)
val programs =
api.liveTvApi
.getLiveTvPrograms(request)
.getPrograms(request)
.content.items
.map { dto ->
val category =
@ -182,6 +188,9 @@ class LiveTvViewModel
fake.addAll(fakePrograms)
}
}
finalProgramsByChannel.forEach { (channelId, programs) ->
channelProgramCount[channelId] = programs.size
}
val finalProgramList =
(programs + fake).sortedWith(
compareBy(
@ -300,6 +309,34 @@ class LiveTvViewModel
}
loading.setValueOnMain(LoadingState.Success)
}
fun onFocusChannel(index: Int): Job? {
return channels.value?.let { channels ->
val offset = offset.value!!
val absoluteIndex = offset + index
val rangeStart = (currentIndex - range / 2).coerceAtLeast(0)
val rangeEnd = (currentIndex + range / 2).coerceAtMost(channels.size)
Timber.v(
"onFocusChannel: index=$index, currentIndex=$currentIndex, offset=$offset, rangeStart=$rangeStart, rangeEnd=$rangeEnd",
)
if (absoluteIndex !in (rangeStart..<rangeEnd)) {
val fetchStart = (absoluteIndex - range).coerceAtLeast(0)
val fetchEnd = (absoluteIndex + range).coerceAtMost(channels.size)
Timber.v("Loading more programs for channels $fetchStart=>$fetchEnd")
return viewModelScope.launchIO {
fetchPrograms(channels.subList(fetchStart, fetchEnd))
val programOffset =
(offset..<fetchStart).sumOf { channelProgramCount[channels[it].id]!! }
withContext(Dispatchers.Main) {
this@LiveTvViewModel.offset.value = fetchStart
currentIndex = index
this@LiveTvViewModel.programOffset.value = programOffset
}
}
}
return null
}
}
}
/**

View file

@ -81,6 +81,7 @@ fun TvGuideGrid(
val channels by viewModel.channels.observeAsState(listOf())
val programs by viewModel.programs.observeAsState(listOf())
val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
val programOffset by viewModel.programOffset.observeAsState(0)
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state, modifier)
LoadingState.Pending,
@ -105,7 +106,9 @@ fun TvGuideGrid(
channels = channels,
programList = programs,
programs = programsByChannel,
channelProgramCount = viewModel.channelProgramCount,
start = viewModel.start,
programOffset = programOffset,
onClickChannel = { index, channel ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
@ -114,6 +117,9 @@ fun TvGuideGrid(
),
)
},
onFocusChannel = { index ->
viewModel.onFocusChannel(index)
},
onClickProgram = { index, program ->
if (program.isFake) {
val now = LocalDateTime.now()
@ -194,9 +200,12 @@ fun TvGuideGrid(
channels: List<TvChannel>,
programList: List<TvProgram>,
programs: Map<UUID, List<TvProgram>>,
channelProgramCount: Map<UUID, Int>,
start: LocalDateTime,
onClickChannel: (Int, TvChannel) -> Unit,
onClickProgram: (Int, TvProgram) -> Unit,
onFocusChannel: (Int) -> Unit,
programOffset: Int,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -223,13 +232,25 @@ fun TvGuideGrid(
.build<Int, Int>(
object : CacheLoader<Int, Int>() {
override fun load(key: Int): Int =
(
channels
.subList(0, key)
.map { it.id }
.sumOf { programs[it]?.size ?: 0 }
.sumOf {
channelProgramCount[it] ?: 0
} - programOffset
).coerceAtLeast(0)
},
)
}
var movementEnabled by remember { mutableStateOf(true) }
LaunchedEffect(programOffset) {
movementEnabled = false
programsBeforeChannel.invalidateAll()
Timber.v("Adjusting focusedProgramIndex, $focusedProgramIndex - $programOffset")
focusedProgramIndex = (focusedProgramIndex - programOffset).coerceAtLeast(0)
movementEnabled = true
}
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
@ -247,6 +268,9 @@ fun TvGuideGrid(
if (it.type == KeyEventType.KeyUp) {
return@onPreviewKeyEvent false
}
if (!movementEnabled) {
return@onPreviewKeyEvent true
}
val newIndex =
when (it.key) {
Key.Back -> {
@ -328,7 +352,10 @@ fun TvGuideGrid(
if (pIndex >= 0) {
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else {
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size
programsBeforeChannel.get(focusedChannelIndex) + (
programs[channelId]?.size
?: 0
)
}
}
}
@ -371,7 +398,10 @@ fun TvGuideGrid(
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else {
// Did not find one, so focus on the final program in the list
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size
programsBeforeChannel.get(focusedChannelIndex) + (
programs[channelId]?.size
?: 0
)
}
}
}
@ -399,11 +429,16 @@ fun TvGuideGrid(
if (newIndex >= 0) {
val before = programsBeforeChannel.get(focusedChannelIndex)
val max =
before + channels[focusedChannelIndex].let { programs[it.id]!!.size } - 1
before +
channels[focusedChannelIndex].let {
programs[it.id]?.size ?: 0
} - 1
val index = newIndex.coerceIn(before, max)
Timber.v("move: programIndex=$index, channelIndex=$focusedChannelIndex")
scope.launch(ExceptionHandler()) {
focusedProgramIndex = index
state.animateToProgram(index, Alignment.Center)
onFocusChannel.invoke(focusedChannelIndex)
}
return@onPreviewKeyEvent true
} else {