Handle larger TV guide data (#132)

Fixes #110 

Switches loading the EPG/TV Guide data to lazy loading. The app will
load all of the channels, but only keep ~100 channels worth of data in
memory. As the user scrolls up and down, new channel program data will
be fetched as needed.

Eventually this technique can be applied to supporting more than 48
hours of data.

The logic for determine which program to focus on is also simplified and
more of it is delegated to composition.

Credit to @joshjryan for #131 hinting to use a different API endpoint to
avoid 414 errors.
This commit is contained in:
damontecres 2025-11-04 13:33:32 -05:00 committed by GitHub
parent 13c2bb32d2
commit 86977f846c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 417 additions and 233 deletions

View file

@ -6,8 +6,10 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
@ -20,6 +22,7 @@ import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -27,19 +30,21 @@ import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.GetProgramsDto
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
import java.time.temporal.ChronoUnit
import java.util.UUID
import javax.inject.Inject
import kotlin.math.min
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
const val MAX_HOURS = 48L
@ -54,24 +59,32 @@ class LiveTvViewModel
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
lateinit var start: LocalDateTime
lateinit var guideStart: LocalDateTime
private set
private lateinit var channelsIdToIndex: Map<UUID, Int>
private val mutex = Mutex()
val channels = MutableLiveData<List<TvChannel>>()
val programs = MutableLiveData<List<TvProgram>>()
val programsByChannel = MutableLiveData<Map<UUID, List<TvProgram>>>(mapOf())
val channelProgramCount = mutableMapOf<UUID, Int>()
val programs = MutableLiveData<FetchedPrograms>()
val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending)
val fetchedItem = MutableLiveData<BaseItem?>(null)
private val range = 100
fun init(firstLoad: Boolean) {
start = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
if (!firstLoad) {
loading.value = LoadingState.Loading
}
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Could not fetch channels")) {
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Could not fetch channels",
),
) {
val channelData by api.liveTvApi.getLiveTvChannels(
GetLiveTvChannelsRequest(
startIndex = 0,
@ -90,57 +103,81 @@ class LiveTvViewModel
Timber.d("Got ${channels.size} channels")
channelsIdToIndex =
channels.withIndex().associateBy({ it.value.id }, { it.index })
fetchPrograms(channels)
// Initially, quickly load the first 10 channels (only ~6 are visible immediately), then below will load more
// This makes the guide appear faster, and load more useable data in the background
val initial = 10
fetchPrograms(channels, 0..<initial.coerceAtMost(channels.size))
withContext(Dispatchers.Main) {
this@LiveTvViewModel.channels.value = channels
loading.value = LoadingState.Success
}
// Now load the full range
if (channels.size > initial) {
fetchPrograms(channels, 0..<range.coerceAtMost(channels.size))
}
}
}
private suspend fun fetchProgramsWithLoading(channels: List<TvChannel>) {
private suspend fun fetchProgramsWithLoading(
channels: List<TvChannel>,
range: IntRange,
) {
loading.setValueOnMain(LoadingState.Loading)
fetchPrograms(channels)
fetchPrograms(channels, range)
loading.setValueOnMain(LoadingState.Success)
}
private suspend fun fetchPrograms(channels: List<TvChannel>) =
mutex.withLock {
val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1)
val minEndDate = start.plusMinutes(1L)
val request =
GetLiveTvProgramsRequest(
maxStartDate = maxStartDate,
minEndDate = minEndDate,
channelIds = channels.map { it.id },
sortBy = listOf(ItemSortBy.START_DATE),
)
val programs =
api.liveTvApi
.getLiveTvPrograms(request)
.content.items
.map { dto ->
val category =
if (dto.isKids ?: false) {
ProgramCategory.KIDS
} else if (dto.isMovie ?: false) {
ProgramCategory.MOVIE
} else if (dto.isNews ?: false) {
ProgramCategory.NEWS
} else if (dto.isSports ?: false) {
ProgramCategory.SPORTS
} else {
null
}
private suspend fun fetchPrograms(
channels: List<TvChannel>,
range: IntRange,
) = mutex.withLock {
val maxStartDate = guideStart.plusHours(MAX_HOURS).minusMinutes(1)
val minEndDate = guideStart.plusMinutes(1L)
val channelsToFetch = channels.subList(range.first, range.last + 1)
Timber.v("Fetching programs for $range channels ${channelsToFetch.size}")
val request =
GetProgramsDto(
maxStartDate = maxStartDate,
minEndDate = minEndDate,
channelIds = channelsToFetch.map { it.id },
sortBy = listOf(ItemSortBy.START_DATE),
)
val fetchedPrograms =
api.liveTvApi
.getPrograms(request)
.content.items
val programsByChannel = mutableMapOf<UUID, List<TvProgram>>()
val fetchedGroupedBy = fetchedPrograms.groupBy { it.channelId }
fetchedGroupedBy.forEach { (channelId, programs) ->
if (channelId != null) {
val listing = mutableListOf<TvProgram>()
programs as MutableList<BaseItemDto>
programs.forEachIndexed { index, dto ->
val category =
if (dto.isKids ?: false) {
ProgramCategory.KIDS
} else if (dto.isMovie ?: false) {
ProgramCategory.MOVIE
} else if (dto.isNews ?: false) {
ProgramCategory.NEWS
} else if (dto.isSports ?: false) {
ProgramCategory.SPORTS
} else {
null
}
val p =
TvProgram(
id = dto.id,
channelId = dto.channelId!!,
start = dto.startDate!!,
end = dto.endDate!!,
startHours = hoursBetween(start, dto.startDate!!).coerceAtLeast(0f),
endHours = hoursBetween(start, dto.endDate!!),
startHours =
hoursBetween(
guideStart,
dto.startDate!!,
).coerceAtLeast(0f),
endHours = hoursBetween(guideStart, dto.endDate!!),
duration = dto.runTimeTicks!!.ticks,
name = dto.seriesName ?: dto.name,
subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false },
@ -155,24 +192,44 @@ class LiveTvViewModel
isRepeat = dto.isRepeat ?: false,
category = category,
)
}
val programsByChannel = programs.groupBy { it.channelId }
val emptyChannels = channels.filter { programsByChannel[it.id].orEmpty().isEmpty() }
val fake = mutableListOf<TvProgram>()
val finalProgramsByChannel =
programsByChannel.toMutableMap().apply {
emptyChannels.forEach { channel ->
val fakePrograms =
(0..<MAX_HOURS).map {
if (index == 0) {
if (p.startHours > 0) {
// Fill out before the first program
var start = 0f
var end = min(start + 1f, p.startHours)
while (start < p.startHours) {
val fake =
TvProgram.fake(
guideStart,
channelId,
start,
end,
)
start = end
end = min(start + 1f, p.startHours)
listing.add(fake)
}
}
listing.add(p)
} else if (index > 0) {
if (channelId == UUID.fromString("638232b7-7ef7-7e98-903d-2249ee3fd2cd")) {
Timber.v("Found ")
}
var previous = listing.last()
while (previous.endHours < p.startHours) {
// Fill gaps between programs
val start = previous.endHours
val duration = (p.startHours - start).coerceAtMost(1f)
// Timber.v("Adding fake for $channelId: $start=>${start + duration}")
previous =
TvProgram(
id = UUID.randomUUID(), // TODO
channelId = channel.id,
start = start.plusHours(it),
end = start.plusHours(it + 1),
startHours = it.toFloat(),
endHours = (it + 1).toFloat(),
duration = 60.seconds,
id = UUID.randomUUID(),
channelId = channelId,
start = previous.end,
end = previous.end.plusMinutes((duration * 60).toLong()),
startHours = start,
endHours = start + duration,
duration = (duration * 60).toInt().minutes,
name = context.getString(R.string.no_data),
subtitle = null,
seasonEpisode = null,
@ -181,24 +238,83 @@ class LiveTvViewModel
isRepeat = false,
category = ProgramCategory.FAKE,
)
listing.add(previous)
}
listing.add(p)
}
if (index == (programs.size - 1)) {
if (p.endHours < MAX_HOURS) {
// Fill after the last program
val end = (p.endHours + 1).toInt()
listing.add(
TvProgram.fake(
guideStart,
channelId,
p.endHours,
end.toFloat(),
),
)
(end..<MAX_HOURS).forEach {
listing.add(
TvProgram.fake(
guideStart,
channelId,
it.toFloat(),
it + 1f,
),
)
}
put(channel.id, fakePrograms)
fake.addAll(fakePrograms)
}
}
}
val finalProgramList =
(programs + fake).sortedWith(
programsByChannel[channelId] = listing
}
}
val emptyChannels =
channelsToFetch.filter { programsByChannel[it.id].orEmpty().isEmpty() }
val fake = mutableListOf<TvProgram>()
emptyChannels.forEach { channel ->
val fakePrograms =
(0..<MAX_HOURS).map {
TvProgram(
id = UUID.randomUUID(),
channelId = channel.id,
start = guideStart.plusHours(it),
end = guideStart.plusHours(it + 1),
startHours = it.toFloat(),
endHours = (it + 1).toFloat(),
duration = 60.seconds,
name = context.getString(R.string.no_data),
subtitle = null,
seasonEpisode = null,
isRecording = false,
isSeriesRecording = false,
isRepeat = false,
category = ProgramCategory.FAKE,
)
}
programsByChannel.put(channel.id, fakePrograms)
fake.addAll(fakePrograms)
}
programsByChannel.forEach { (channelId, programs) ->
channelProgramCount[channelId] = programs.size
}
val finalProgramList =
(programsByChannel.values.flatten())
.sortedWith(
compareBy(
{ channelsIdToIndex[it.channelId]!! },
{ it.start },
),
)
Timber.d("Got ${programs.size} programs & ${fake.size} fake programs")
withContext(Dispatchers.Main) {
this@LiveTvViewModel.programs.value = finalProgramList
this@LiveTvViewModel.programsByChannel.value = finalProgramsByChannel
}
Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs")
withContext(Dispatchers.Main) {
this@LiveTvViewModel.programs.value =
FetchedPrograms(range, finalProgramList, programsByChannel)
}
}
fun getItem(programId: UUID) {
fetchingItem.value = LoadingState.Loading
@ -223,8 +339,6 @@ class LiveTvViewModel
}
fun cancelRecording(
programIndex: Int,
programId: UUID,
series: Boolean,
timerId: String?,
) {
@ -232,17 +346,15 @@ class LiveTvViewModel
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
if (series) {
api.liveTvApi.cancelSeriesTimer(timerId)
fetchProgramsWithLoading(channels.value.orEmpty())
} else {
api.liveTvApi.cancelTimer(timerId)
refreshProgram(programIndex, programId)
}
fetchProgramsWithLoading(channels.value.orEmpty(), programs.value!!.range)
}
}
}
fun record(
programIndex: Int,
programId: UUID,
series: Boolean,
) {
@ -250,7 +362,6 @@ class LiveTvViewModel
val d by api.liveTvApi.getDefaultTimer(programId.toServerString())
if (series) {
api.liveTvApi.createSeriesTimer(d)
fetchProgramsWithLoading(channels.value.orEmpty())
} else {
val payload =
TimerInfoDto(
@ -276,36 +387,58 @@ class LiveTvViewModel
keepUntil = d.keepUntil,
)
api.liveTvApi.createTimer(payload)
refreshProgram(programIndex, programId)
}
fetchProgramsWithLoading(channels.value.orEmpty(), programs.value!!.range)
}
}
suspend fun refreshProgram(
programIndex: Int,
programId: UUID,
) = mutex.withLock {
loading.setValueOnMain(LoadingState.Loading)
val program by api.liveTvApi.getProgram(programId.toServerString())
val newProgram =
programs.value?.getOrNull(programIndex)?.copy(
isRecording = program.timerId.isNotNullOrBlank(),
isSeriesRecording = program.seriesTimerId.isNotNullOrBlank(),
)
Timber.v("new program %s", newProgram)
if (newProgram != null) {
programs.value
?.toMutableList()
?.apply {
this[programIndex] = newProgram
}?.let {
this@LiveTvViewModel.programs.setValueOnMain(it)
private var focusLoadingJob: Job? = null
fun onFocusChannel(position: RowColumn) {
channels.value?.let { channels ->
val fetchedRange = programs.value!!.range
val quarter = range / 4
var rangeStart = fetchedRange.start + quarter
var rangeEnd = fetchedRange.last - quarter
if (rangeEnd - rangeStart < range) {
if (position.row < range / 2) {
// Close to beginning
rangeStart = 0
} else if (position.row > (channels.size - range / 2)) {
// Close to the end
rangeEnd = channels.size
}
}
val testRange = rangeStart..<rangeEnd
Timber.v(
"onFocusChannel: position=%s, fetchedRange=%s, testRange=%s",
position,
fetchedRange,
testRange,
)
val fetchStart = (position.row - range).coerceAtLeast(0)
val fetchEnd = (position.row + range).coerceAtMost(channels.size)
val newFetchRange = fetchStart..<fetchEnd
// If current channel is not within +/- range
// And the potential new fetch range is not wholly within the current (eg not near the top or bottom)
// Fetch new data
if (position.row !in testRange && !newFetchRange.within(fetchedRange)) {
Timber.v("Loading more programs for channels $newFetchRange")
focusLoadingJob?.cancel()
focusLoadingJob =
viewModelScope.launchIO {
fetchProgramsWithLoading(channels, newFetchRange)
}
}
}
loading.setValueOnMain(LoadingState.Success)
}
}
fun IntRange.within(other: IntRange): Boolean = this.first >= other.first && this.last <= other.last
/**
* Returns the number of hours between two [LocalDateTime]
*/
@ -341,6 +474,32 @@ data class TvProgram(
val category: ProgramCategory?,
) {
val isFake = category == ProgramCategory.FAKE
companion object {
private val NO_DATA = WholphinApplication.instance.getString(R.string.no_data)
fun fake(
zeroHourStart: LocalDateTime,
channelId: UUID,
startHours: Float,
endHours: Float,
) = TvProgram(
id = UUID.randomUUID(),
channelId = channelId,
start = zeroHourStart.plusMinutes((startHours * 60).toLong()),
end = zeroHourStart.plusMinutes((endHours * 60).toLong()),
startHours = startHours,
endHours = endHours,
duration = ((endHours - startHours) * 60).toInt().minutes,
name = NO_DATA,
subtitle = null,
seasonEpisode = null,
isRecording = false,
isSeriesRecording = false,
isRepeat = false,
category = ProgramCategory.FAKE,
)
}
}
enum class ProgramCategory(
@ -352,3 +511,9 @@ enum class ProgramCategory(
SPORTS(AppColors.DarkRed),
FAKE(null),
}
data class FetchedPrograms(
val range: IntRange,
val programs: List<TvProgram>,
val programsByChannel: Map<UUID, List<TvProgram>>,
)

View file

@ -48,14 +48,13 @@ import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.google.common.cache.CacheBuilder
import com.google.common.cache.CacheLoader
import eu.wewox.programguide.ProgramGuide
import eu.wewox.programguide.ProgramGuideDimensions
import eu.wewox.programguide.ProgramGuideItem
@ -65,6 +64,7 @@ import timber.log.Timber
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.util.UUID
import kotlin.math.abs
@Composable
fun TvGuideGrid(
@ -79,8 +79,9 @@ fun TvGuideGrid(
}
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val channels by viewModel.channels.observeAsState(listOf())
val programs by viewModel.programs.observeAsState(listOf())
val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf()))
// val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
// val fetchedRange by viewModel.fetchedRange.observeAsState(0..0)
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state, modifier)
LoadingState.Pending,
@ -103,9 +104,9 @@ fun TvGuideGrid(
TvGuideGrid(
loading = state is LoadingState.Loading,
channels = channels,
programList = programs,
programs = programsByChannel,
start = viewModel.start,
programs = programs,
channelProgramCount = viewModel.channelProgramCount,
start = viewModel.guideStart,
onClickChannel = { index, channel ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
@ -114,6 +115,7 @@ fun TvGuideGrid(
),
)
},
onFocus = viewModel::onFocusChannel,
onClickProgram = { index, program ->
if (program.isFake) {
val now = LocalDateTime.now()
@ -162,7 +164,6 @@ fun TvGuideGrid(
onRecord = { series ->
fetchedItem?.let {
viewModel.record(
programIndex = showItemDialog!!,
programId = it.id,
series = series,
)
@ -172,8 +173,6 @@ fun TvGuideGrid(
onCancelRecord = { series ->
fetchedItem?.data?.let {
viewModel.cancelRecording(
programIndex = showItemDialog!!,
programId = it.id,
series = series,
timerId = if (series) it.seriesTimerId else it.timerId,
)
@ -186,26 +185,41 @@ fun TvGuideGrid(
}
}
const val CHANNEL_COLUMN = -1
@Composable
fun TvGuideGrid(
loading: Boolean,
channels: List<TvChannel>,
programList: List<TvProgram>,
programs: Map<UUID, List<TvProgram>>,
programs: FetchedPrograms,
channelProgramCount: Map<UUID, Int>,
start: LocalDateTime,
onClickChannel: (Int, TvChannel) -> Unit,
onClickProgram: (Int, TvProgram) -> Unit,
onFocus: (RowColumn) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val state = rememberSaveableProgramGuideState()
val scope = rememberCoroutineScope()
var focusedProgramIndex by rememberInt(0)
var focusedChannelIndex by rememberInt(0)
var focusedItem by rememberPosition(RowColumn(0, 0))
val focusedChannelIndex = focusedItem.row
val focusedProgramIndex =
remember(programs.range, focusedItem) {
focusedItem.let { focus ->
(programs.range.start..<focus.row).sumOf {
val channelId = channels[it].id
channelProgramCount[channelId] ?: 0
} + focus.column
}
}
LaunchedEffect(focusedProgramIndex) {
// Timber.v("Focusing on $focusedItem, programIndex=$focusedProgramIndex")
scope.launch(ExceptionHandler()) {
state.animateToProgram(focusedProgramIndex, Alignment.Center)
}
}
val dimensions =
ProgramGuideDimensions(
timelineHourWidth = 240.dp,
@ -214,23 +228,6 @@ fun TvGuideGrid(
channelHeight = 80.dp,
currentTimeWidth = 2.dp,
)
val programsBeforeChannel =
remember {
CacheBuilder
.newBuilder()
.maximumSize(200)
.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 }
},
)
}
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
Box(modifier = modifier) {
@ -247,13 +244,13 @@ fun TvGuideGrid(
if (it.type == KeyEventType.KeyUp) {
return@onPreviewKeyEvent false
}
val newIndex =
val item = focusedItem
val newFocusedItem =
when (it.key) {
Key.Back -> {
val pos = programsBeforeChannel.get(focusedChannelIndex)
if (focusedProgramIndex - pos > 0) {
if (item.column > 0) {
// Not at beginning of row, so move to beginning
pos
item.copy(column = 0)
} else {
// At beginning, so allow normal back button behavior
return@onPreviewKeyEvent false
@ -263,20 +260,9 @@ fun TvGuideGrid(
Key.DirectionRight -> {
if (channelColumnFocused) {
channelColumnFocused = false
focusedProgramIndex
item.copy(column = 0)
} else {
val nextProgramIndex = focusedProgramIndex + 1
val programsBefore =
programsBeforeChannel.get(focusedChannelIndex)
val relativePosition = nextProgramIndex - programsBefore
val channelPrograms =
programs[channels[focusedChannelIndex].id].orEmpty()
if (relativePosition >= channelPrograms.size) {
focusManager.moveFocus(FocusDirection.Right)
null
} else {
nextProgramIndex
}
item.copy(column = item.column + 1)
}
}
@ -284,51 +270,49 @@ fun TvGuideGrid(
if (channelColumnFocused) {
focusManager.moveFocus(FocusDirection.Left)
null
} else if (item.column == 0) {
channelColumnFocused = true
item
} else {
val nextProgramIndex = focusedProgramIndex - 1
val programsBefore =
programsBeforeChannel.get(focusedChannelIndex)
val relativePosition = nextProgramIndex - programsBefore
// val channelPrograms =
// programs[channels[focusedChannel].id].orEmpty()
if (relativePosition >= 0) {
nextProgramIndex
} else if (relativePosition == CHANNEL_COLUMN) {
channelColumnFocused = true
focusedProgramIndex
} else {
Timber.w("Move left to relativePosition=$relativePosition should not occur")
focusManager.moveFocus(FocusDirection.Left)
null
}
item.copy(column = item.column - 1)
}
}
Key.DirectionUp -> {
val newFocusedChannelIndex = (focusedChannelIndex - 1)
// Timber.v("newFocusedChannelIndex=$newFocusedChannelIndex")
if (newFocusedChannelIndex < 0) {
if (item.row <= 0) {
focusManager.moveFocus(FocusDirection.Up)
null
} else if (channelColumnFocused) {
focusedChannelIndex = newFocusedChannelIndex
programsBeforeChannel.get(focusedChannelIndex)
} else {
val start = programList[focusedProgramIndex].startHours
focusedChannelIndex =
newFocusedChannelIndex.coerceAtLeast(0)
val channelId = channels[focusedChannelIndex].id
val pro = programs[channelId].orEmpty()
val pIndex =
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
programsBeforeChannel.get(focusedChannelIndex) + pIndex
val newChannelIndex = item.row - 1
if (channelColumnFocused) {
RowColumn(newChannelIndex, 0)
} else {
val pIndex = pro.indexOfFirst { it.startHours >= start }
if (pIndex >= 0) {
programsBeforeChannel.get(focusedChannelIndex) + pIndex
val currentChannel = channels[item.row].id
val currentProgram =
programs.programsByChannel[currentChannel]?.get(item.column)
val newChannelId = channels[newChannelIndex].id
val newChannelPrograms =
programs.programsByChannel[newChannelId]
if (newChannelPrograms == null) {
return@onPreviewKeyEvent true
}
if (currentProgram == null) {
item
} else {
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size
val start = currentProgram.startHours
val pIndex =
newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
RowColumn(newChannelIndex, pIndex)
} else {
val pIndex =
newChannelPrograms.indexOfFirst { it.startHours >= start }
if (pIndex >= 0) {
RowColumn(newChannelIndex, pIndex)
} else {
RowColumn(newChannelIndex, 0)
}
}
}
}
}
@ -336,42 +320,56 @@ fun TvGuideGrid(
Key.DirectionDown -> {
// Move channel focus down
val newFocusedChannelIndex = (focusedChannelIndex + 1)
if (newFocusedChannelIndex >= channels.size) {
val newChannelIndex = item.row + 1
if (newChannelIndex >= channels.size) {
// If trying to move below the final channel, then move focus out of the grid
focusManager.moveFocus(FocusDirection.Down)
null
} else if (channelColumnFocused) {
// If focused on the channel column, move down a channel
focusedChannelIndex =
newFocusedChannelIndex.coerceAtMost(channels.size - 1)
programsBeforeChannel.get(focusedChannelIndex)
} else {
// Otherwise, moving to a new row
focusedChannelIndex =
newFocusedChannelIndex.coerceAtMost(channels.size - 1)
// Get the new row/channel's programs
val channelId = channels[focusedChannelIndex].id
val pro = programs[channelId].orEmpty()
// When the currently focused program starts
val start = programList[focusedProgramIndex].startHours
// Find the first program where the start time (of the previously focused program) is in the middle of a program
val pIndex =
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
// Found one, so focus on it
// Get the sum of all of the previous channels' program sizes, plus the index found to convert a relative program index into absolute
programsBeforeChannel.get(focusedChannelIndex) + pIndex
val newChannelId = channels[newChannelIndex].id
val newChannelPrograms =
programs.programsByChannel[newChannelId]
if (newChannelPrograms == null) {
// This means there is no data for the new channel and it is loading in the background
return@onPreviewKeyEvent true
}
if (channelColumnFocused) {
// If focused on the channel column, move down a channel
RowColumn(newChannelIndex, 0)
} else {
// Didn't find one, probably due to missing data
// So now first the first one that starts after the previously focused program
val pIndex = pro.indexOfFirst { it.startHours >= start }
if (pIndex >= 0) {
// Found one, so focus on it
programsBeforeChannel.get(focusedChannelIndex) + pIndex
// Get current program & its start time
val currentChannel = channels[item.row].id
val currentProgram =
programs.programsByChannel[currentChannel]?.get(item.column)
if (currentProgram == null) {
// Data is loading in the background
item
} else {
// Did not find one, so focus on the final program in the list
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size
val start = currentProgram.startHours
// Find the first program where the start time (of the previously focused program) is in the middle of a program
val pIndex =
newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
// Found one, so focus on it
RowColumn(newChannelIndex, pIndex)
} else {
// Didn't find one, probably due to missing data
// So now first the first one that starts after the previously focused program
val pIndex =
newChannelPrograms.indexOfFirst { it.startHours >= start }
if (pIndex >= 0) {
// Found one, so focus on it
RowColumn(newChannelIndex, pIndex)
} else {
// Did not find one, so focus on the final program in the list
RowColumn(
newChannelIndex,
newChannelPrograms.size - 1,
)
}
}
}
}
}
@ -383,9 +381,18 @@ fun TvGuideGrid(
Timber.v("Clicked on %s", channel)
onClickChannel.invoke(focusedChannelIndex, channel)
} else {
val program = programList[focusedProgramIndex]
Timber.v("Clicked on %s", program)
onClickProgram.invoke(focusedProgramIndex, program)
val currentChannel = channels[item.row].id
val currentProgram =
programs.programsByChannel[currentChannel]?.get(item.column)
if (currentProgram == null) {
// Data is loading in the background
return@onPreviewKeyEvent true
}
Timber.v("Clicked on %s", currentProgram)
onClickProgram.invoke(
focusedProgramIndex,
currentProgram,
)
}
null
}
@ -394,22 +401,24 @@ fun TvGuideGrid(
null
}
}
if (newIndex != null) {
// Timber.v("newIndex=$newIndex")
if (newIndex >= 0) {
val before = programsBeforeChannel.get(focusedChannelIndex)
val max =
before + channels[focusedChannelIndex].let { programs[it.id]!!.size } - 1
val index = newIndex.coerceIn(before, max)
scope.launch(ExceptionHandler()) {
focusedProgramIndex = index
state.animateToProgram(index, Alignment.Center)
}
return@onPreviewKeyEvent true
} else {
Timber.w("newIndex is <0: $newIndex")
return@onPreviewKeyEvent true
}
if (newFocusedItem != null) {
val channel = channels[newFocusedItem.row]
val programs = programs.programsByChannel[channel.id].orEmpty()
// Ensure it isn't going out of range
val toFocus =
newFocusedItem
.copy(
row = newFocusedItem.row.coerceIn(0, channels.size - 1),
column =
newFocusedItem.column.coerceIn(
0,
(programs.size - 1).coerceAtLeast(0),
),
)
focusedItem = toFocus
onFocus(toFocus)
return@onPreviewKeyEvent true
}
return@onPreviewKeyEvent false
},
@ -423,7 +432,12 @@ fun TvGuideGrid(
},
) {
Surface(
colors = SurfaceDefaults.colors(MaterialTheme.colorScheme.tertiary.copy(alpha = .25f)),
colors =
SurfaceDefaults.colors(
MaterialTheme.colorScheme.tertiary.copy(
alpha = .25f,
),
),
modifier = Modifier,
) {
// Empty
@ -464,16 +478,22 @@ fun TvGuideGrid(
)
}
}
programs(
count = programList.size,
count = programs.programs.size,
layoutInfo = { programIndex ->
val program = programList[programIndex]
val program = programs.programs[programIndex]
val channelIndex = channels.indexOfFirst { it.id == program.channelId }
ProgramGuideItem.Program(channelIndex, program.startHours, program.endHours)
// Using the duration for endHour accounts for daylight savings switch
// Eg a 1:30am-1:00am show
val duration = abs(program.endHours - program.startHours)
ProgramGuideItem.Program(
channelIndex,
program.startHours,
program.startHours + duration,
)
},
) { programIndex ->
val program = programList[programIndex]
val program = programs.programs[programIndex]
val focused =
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
val background =
@ -487,7 +507,6 @@ fun TvGuideGrid(
Box(
modifier =
Modifier
// .scale(if (focused) 1.1f else 1f)
.padding(2.dp)
.fillMaxSize()
.background(