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(