From 86977f846c9c72d296fa524e37782b9014ab0f3c Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Tue, 4 Nov 2025 13:33:32 -0500 Subject: [PATCH] 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. --- .../ui/detail/livetv/LiveTvViewModel.kt | 351 +++++++++++++----- .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 299 ++++++++------- 2 files changed, 417 insertions(+), 233 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index d69e6eab..e8040056 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -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.Pending) - lateinit var start: LocalDateTime + lateinit var guideStart: LocalDateTime private set private lateinit var channelsIdToIndex: Map private val mutex = Mutex() val channels = MutableLiveData>() - val programs = MutableLiveData>() - val programsByChannel = MutableLiveData>>(mapOf()) + val channelProgramCount = mutableMapOf() + val programs = MutableLiveData() val fetchingItem = MutableLiveData(LoadingState.Pending) val fetchedItem = MutableLiveData(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) { + fetchPrograms(channels, 0..) { + private suspend fun fetchProgramsWithLoading( + channels: List, + range: IntRange, + ) { loading.setValueOnMain(LoadingState.Loading) - fetchPrograms(channels) + fetchPrograms(channels, range) loading.setValueOnMain(LoadingState.Success) } - private suspend fun fetchPrograms(channels: List) = - 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, + 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>() + val fetchedGroupedBy = fetchedPrograms.groupBy { it.channelId } + fetchedGroupedBy.forEach { (channelId, programs) -> + if (channelId != null) { + val listing = mutableListOf() + programs as MutableList + 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() - val finalProgramsByChannel = - programsByChannel.toMutableMap().apply { - emptyChannels.forEach { channel -> - val fakePrograms = - (0.. 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..() + + emptyChannels.forEach { channel -> + val fakePrograms = + (0.. + 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..= 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, + val programsByChannel: Map>, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index 40e26b17..7db259d3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -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, - programList: List, - programs: Map>, + programs: FetchedPrograms, + channelProgramCount: Map, 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..( - object : CacheLoader() { - 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..= 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..= 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..= 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..= 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(