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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R 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.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors 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.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock 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.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.liveTvApi 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.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.TimerInfoDto import org.jellyfin.sdk.model.api.TimerInfoDto
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.api.request.GetLiveTvProgramsRequest
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.min
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
const val MAX_HOURS = 48L const val MAX_HOURS = 48L
@ -54,24 +59,32 @@ class LiveTvViewModel
) : ViewModel() { ) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
lateinit var start: LocalDateTime lateinit var guideStart: LocalDateTime
private set private set
private lateinit var channelsIdToIndex: Map<UUID, Int> private lateinit var channelsIdToIndex: Map<UUID, Int>
private val mutex = Mutex() private val mutex = Mutex()
val channels = MutableLiveData<List<TvChannel>>() val channels = MutableLiveData<List<TvChannel>>()
val programs = MutableLiveData<List<TvProgram>>() val channelProgramCount = mutableMapOf<UUID, Int>()
val programsByChannel = MutableLiveData<Map<UUID, List<TvProgram>>>(mapOf()) val programs = MutableLiveData<FetchedPrograms>()
val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending) val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending)
val fetchedItem = MutableLiveData<BaseItem?>(null) val fetchedItem = MutableLiveData<BaseItem?>(null)
private val range = 100
fun init(firstLoad: Boolean) { fun init(firstLoad: Boolean) {
start = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
if (!firstLoad) { if (!firstLoad) {
loading.value = LoadingState.Loading 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( val channelData by api.liveTvApi.getLiveTvChannels(
GetLiveTvChannelsRequest( GetLiveTvChannelsRequest(
startIndex = 0, startIndex = 0,
@ -90,38 +103,57 @@ class LiveTvViewModel
Timber.d("Got ${channels.size} channels") Timber.d("Got ${channels.size} channels")
channelsIdToIndex = channelsIdToIndex =
channels.withIndex().associateBy({ it.value.id }, { it.index }) 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) { withContext(Dispatchers.Main) {
this@LiveTvViewModel.channels.value = channels this@LiveTvViewModel.channels.value = channels
loading.value = LoadingState.Success 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) loading.setValueOnMain(LoadingState.Loading)
fetchPrograms(channels) fetchPrograms(channels, range)
loading.setValueOnMain(LoadingState.Success) loading.setValueOnMain(LoadingState.Success)
} }
private suspend fun fetchPrograms(channels: List<TvChannel>) = private suspend fun fetchPrograms(
mutex.withLock { channels: List<TvChannel>,
val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1) range: IntRange,
val minEndDate = start.plusMinutes(1L) ) = 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 = val request =
GetLiveTvProgramsRequest( GetProgramsDto(
maxStartDate = maxStartDate, maxStartDate = maxStartDate,
minEndDate = minEndDate, minEndDate = minEndDate,
channelIds = channels.map { it.id }, channelIds = channelsToFetch.map { it.id },
sortBy = listOf(ItemSortBy.START_DATE), sortBy = listOf(ItemSortBy.START_DATE),
) )
val programs = val fetchedPrograms =
api.liveTvApi api.liveTvApi
.getLiveTvPrograms(request) .getPrograms(request)
.content.items .content.items
.map { dto -> 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 = val category =
if (dto.isKids ?: false) { if (dto.isKids ?: false) {
ProgramCategory.KIDS ProgramCategory.KIDS
@ -134,13 +166,18 @@ class LiveTvViewModel
} else { } else {
null null
} }
val p =
TvProgram( TvProgram(
id = dto.id, id = dto.id,
channelId = dto.channelId!!, channelId = dto.channelId!!,
start = dto.startDate!!, start = dto.startDate!!,
end = dto.endDate!!, end = dto.endDate!!,
startHours = hoursBetween(start, dto.startDate!!).coerceAtLeast(0f), startHours =
endHours = hoursBetween(start, dto.endDate!!), hoursBetween(
guideStart,
dto.startDate!!,
).coerceAtLeast(0f),
endHours = hoursBetween(guideStart, dto.endDate!!),
duration = dto.runTimeTicks!!.ticks, duration = dto.runTimeTicks!!.ticks,
name = dto.seriesName ?: dto.name, name = dto.seriesName ?: dto.name,
subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false }, subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false },
@ -155,21 +192,96 @@ class LiveTvViewModel
isRepeat = dto.isRepeat ?: false, isRepeat = dto.isRepeat ?: false,
category = category, category = category,
) )
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)
} }
}
val programsByChannel = programs.groupBy { it.channelId } listing.add(p)
val emptyChannels = channels.filter { programsByChannel[it.id].orEmpty().isEmpty() } } 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(),
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,
isRecording = false,
isSeriesRecording = false,
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,
),
)
}
}
}
}
programsByChannel[channelId] = listing
}
}
val emptyChannels =
channelsToFetch.filter { programsByChannel[it.id].orEmpty().isEmpty() }
val fake = mutableListOf<TvProgram>() val fake = mutableListOf<TvProgram>()
val finalProgramsByChannel =
programsByChannel.toMutableMap().apply {
emptyChannels.forEach { channel -> emptyChannels.forEach { channel ->
val fakePrograms = val fakePrograms =
(0..<MAX_HOURS).map { (0..<MAX_HOURS).map {
TvProgram( TvProgram(
id = UUID.randomUUID(), // TODO id = UUID.randomUUID(),
channelId = channel.id, channelId = channel.id,
start = start.plusHours(it), start = guideStart.plusHours(it),
end = start.plusHours(it + 1), end = guideStart.plusHours(it + 1),
startHours = it.toFloat(), startHours = it.toFloat(),
endHours = (it + 1).toFloat(), endHours = (it + 1).toFloat(),
duration = 60.seconds, duration = 60.seconds,
@ -182,21 +294,25 @@ class LiveTvViewModel
category = ProgramCategory.FAKE, category = ProgramCategory.FAKE,
) )
} }
put(channel.id, fakePrograms) programsByChannel.put(channel.id, fakePrograms)
fake.addAll(fakePrograms) fake.addAll(fakePrograms)
} }
programsByChannel.forEach { (channelId, programs) ->
channelProgramCount[channelId] = programs.size
} }
val finalProgramList = val finalProgramList =
(programs + fake).sortedWith( (programsByChannel.values.flatten())
.sortedWith(
compareBy( compareBy(
{ channelsIdToIndex[it.channelId]!! }, { channelsIdToIndex[it.channelId]!! },
{ it.start }, { it.start },
), ),
) )
Timber.d("Got ${programs.size} programs & ${fake.size} fake programs") Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@LiveTvViewModel.programs.value = finalProgramList this@LiveTvViewModel.programs.value =
this@LiveTvViewModel.programsByChannel.value = finalProgramsByChannel FetchedPrograms(range, finalProgramList, programsByChannel)
} }
} }
@ -223,8 +339,6 @@ class LiveTvViewModel
} }
fun cancelRecording( fun cancelRecording(
programIndex: Int,
programId: UUID,
series: Boolean, series: Boolean,
timerId: String?, timerId: String?,
) { ) {
@ -232,17 +346,15 @@ class LiveTvViewModel
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
if (series) { if (series) {
api.liveTvApi.cancelSeriesTimer(timerId) api.liveTvApi.cancelSeriesTimer(timerId)
fetchProgramsWithLoading(channels.value.orEmpty())
} else { } else {
api.liveTvApi.cancelTimer(timerId) api.liveTvApi.cancelTimer(timerId)
refreshProgram(programIndex, programId)
} }
fetchProgramsWithLoading(channels.value.orEmpty(), programs.value!!.range)
} }
} }
} }
fun record( fun record(
programIndex: Int,
programId: UUID, programId: UUID,
series: Boolean, series: Boolean,
) { ) {
@ -250,7 +362,6 @@ class LiveTvViewModel
val d by api.liveTvApi.getDefaultTimer(programId.toServerString()) val d by api.liveTvApi.getDefaultTimer(programId.toServerString())
if (series) { if (series) {
api.liveTvApi.createSeriesTimer(d) api.liveTvApi.createSeriesTimer(d)
fetchProgramsWithLoading(channels.value.orEmpty())
} else { } else {
val payload = val payload =
TimerInfoDto( TimerInfoDto(
@ -276,35 +387,57 @@ class LiveTvViewModel
keepUntil = d.keepUntil, keepUntil = d.keepUntil,
) )
api.liveTvApi.createTimer(payload) api.liveTvApi.createTimer(payload)
refreshProgram(programIndex, programId) }
fetchProgramsWithLoading(channels.value.orEmpty(), programs.value!!.range)
}
}
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)
}
}
} }
} }
} }
suspend fun refreshProgram( fun IntRange.within(other: IntRange): Boolean = this.first >= other.first && this.last <= other.last
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)
}
}
loading.setValueOnMain(LoadingState.Success)
}
}
/** /**
* Returns the number of hours between two [LocalDateTime] * Returns the number of hours between two [LocalDateTime]
@ -341,6 +474,32 @@ data class TvProgram(
val category: ProgramCategory?, val category: ProgramCategory?,
) { ) {
val isFake = category == ProgramCategory.FAKE 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( enum class ProgramCategory(
@ -352,3 +511,9 @@ enum class ProgramCategory(
SPORTS(AppColors.DarkRed), SPORTS(AppColors.DarkRed),
FAKE(null), 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.CircularProgress
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage 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.enableMarquee
import com.github.damontecres.wholphin.ui.nav.Destination 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.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState 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.ProgramGuide
import eu.wewox.programguide.ProgramGuideDimensions import eu.wewox.programguide.ProgramGuideDimensions
import eu.wewox.programguide.ProgramGuideItem import eu.wewox.programguide.ProgramGuideItem
@ -65,6 +64,7 @@ import timber.log.Timber
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
import kotlin.math.abs
@Composable @Composable
fun TvGuideGrid( fun TvGuideGrid(
@ -79,8 +79,9 @@ fun TvGuideGrid(
} }
val loading by viewModel.loading.observeAsState(LoadingState.Pending) val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val channels by viewModel.channels.observeAsState(listOf()) val channels by viewModel.channels.observeAsState(listOf())
val programs by viewModel.programs.observeAsState(listOf()) val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf()))
val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf()) // val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
// val fetchedRange by viewModel.fetchedRange.observeAsState(0..0)
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state, modifier) is LoadingState.Error -> ErrorMessage(state, modifier)
LoadingState.Pending, LoadingState.Pending,
@ -103,9 +104,9 @@ fun TvGuideGrid(
TvGuideGrid( TvGuideGrid(
loading = state is LoadingState.Loading, loading = state is LoadingState.Loading,
channels = channels, channels = channels,
programList = programs, programs = programs,
programs = programsByChannel, channelProgramCount = viewModel.channelProgramCount,
start = viewModel.start, start = viewModel.guideStart,
onClickChannel = { index, channel -> onClickChannel = { index, channel ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.Playback( Destination.Playback(
@ -114,6 +115,7 @@ fun TvGuideGrid(
), ),
) )
}, },
onFocus = viewModel::onFocusChannel,
onClickProgram = { index, program -> onClickProgram = { index, program ->
if (program.isFake) { if (program.isFake) {
val now = LocalDateTime.now() val now = LocalDateTime.now()
@ -162,7 +164,6 @@ fun TvGuideGrid(
onRecord = { series -> onRecord = { series ->
fetchedItem?.let { fetchedItem?.let {
viewModel.record( viewModel.record(
programIndex = showItemDialog!!,
programId = it.id, programId = it.id,
series = series, series = series,
) )
@ -172,8 +173,6 @@ fun TvGuideGrid(
onCancelRecord = { series -> onCancelRecord = { series ->
fetchedItem?.data?.let { fetchedItem?.data?.let {
viewModel.cancelRecording( viewModel.cancelRecording(
programIndex = showItemDialog!!,
programId = it.id,
series = series, series = series,
timerId = if (series) it.seriesTimerId else it.timerId, timerId = if (series) it.seriesTimerId else it.timerId,
) )
@ -186,26 +185,41 @@ fun TvGuideGrid(
} }
} }
const val CHANNEL_COLUMN = -1
@Composable @Composable
fun TvGuideGrid( fun TvGuideGrid(
loading: Boolean, loading: Boolean,
channels: List<TvChannel>, channels: List<TvChannel>,
programList: List<TvProgram>, programs: FetchedPrograms,
programs: Map<UUID, List<TvProgram>>, channelProgramCount: Map<UUID, Int>,
start: LocalDateTime, start: LocalDateTime,
onClickChannel: (Int, TvChannel) -> Unit, onClickChannel: (Int, TvChannel) -> Unit,
onClickProgram: (Int, TvProgram) -> Unit, onClickProgram: (Int, TvProgram) -> Unit,
onFocus: (RowColumn) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val state = rememberSaveableProgramGuideState() val state = rememberSaveableProgramGuideState()
val scope = rememberCoroutineScope() 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 = val dimensions =
ProgramGuideDimensions( ProgramGuideDimensions(
timelineHourWidth = 240.dp, timelineHourWidth = 240.dp,
@ -214,23 +228,6 @@ fun TvGuideGrid(
channelHeight = 80.dp, channelHeight = 80.dp,
currentTimeWidth = 2.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 gridHasFocus by rememberSaveable { mutableStateOf(false) }
var channelColumnFocused by rememberSaveable { mutableStateOf(false) } var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
Box(modifier = modifier) { Box(modifier = modifier) {
@ -247,13 +244,13 @@ fun TvGuideGrid(
if (it.type == KeyEventType.KeyUp) { if (it.type == KeyEventType.KeyUp) {
return@onPreviewKeyEvent false return@onPreviewKeyEvent false
} }
val newIndex = val item = focusedItem
val newFocusedItem =
when (it.key) { when (it.key) {
Key.Back -> { Key.Back -> {
val pos = programsBeforeChannel.get(focusedChannelIndex) if (item.column > 0) {
if (focusedProgramIndex - pos > 0) {
// Not at beginning of row, so move to beginning // Not at beginning of row, so move to beginning
pos item.copy(column = 0)
} else { } else {
// At beginning, so allow normal back button behavior // At beginning, so allow normal back button behavior
return@onPreviewKeyEvent false return@onPreviewKeyEvent false
@ -263,20 +260,9 @@ fun TvGuideGrid(
Key.DirectionRight -> { Key.DirectionRight -> {
if (channelColumnFocused) { if (channelColumnFocused) {
channelColumnFocused = false channelColumnFocused = false
focusedProgramIndex item.copy(column = 0)
} else { } else {
val nextProgramIndex = focusedProgramIndex + 1 item.copy(column = item.column + 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
}
} }
} }
@ -284,51 +270,49 @@ fun TvGuideGrid(
if (channelColumnFocused) { if (channelColumnFocused) {
focusManager.moveFocus(FocusDirection.Left) focusManager.moveFocus(FocusDirection.Left)
null null
} else { } else if (item.column == 0) {
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 channelColumnFocused = true
focusedProgramIndex item
} else { } else {
Timber.w("Move left to relativePosition=$relativePosition should not occur") item.copy(column = item.column - 1)
focusManager.moveFocus(FocusDirection.Left)
null
}
} }
} }
Key.DirectionUp -> { Key.DirectionUp -> {
val newFocusedChannelIndex = (focusedChannelIndex - 1) if (item.row <= 0) {
// Timber.v("newFocusedChannelIndex=$newFocusedChannelIndex")
if (newFocusedChannelIndex < 0) {
focusManager.moveFocus(FocusDirection.Up) focusManager.moveFocus(FocusDirection.Up)
null null
} else if (channelColumnFocused) {
focusedChannelIndex = newFocusedChannelIndex
programsBeforeChannel.get(focusedChannelIndex)
} else { } else {
val start = programList[focusedProgramIndex].startHours val newChannelIndex = item.row - 1
focusedChannelIndex = if (channelColumnFocused) {
newFocusedChannelIndex.coerceAtLeast(0) RowColumn(newChannelIndex, 0)
val channelId = channels[focusedChannelIndex].id } else {
val pro = programs[channelId].orEmpty() 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 {
val start = currentProgram.startHours
val pIndex = val pIndex =
pro.indexOfFirst { start in (it.startHours..<it.endHours) } newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) { if (pIndex >= 0) {
programsBeforeChannel.get(focusedChannelIndex) + pIndex RowColumn(newChannelIndex, pIndex)
} else { } else {
val pIndex = pro.indexOfFirst { it.startHours >= start } val pIndex =
newChannelPrograms.indexOfFirst { it.startHours >= start }
if (pIndex >= 0) { if (pIndex >= 0) {
programsBeforeChannel.get(focusedChannelIndex) + pIndex RowColumn(newChannelIndex, pIndex)
} else { } else {
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size RowColumn(newChannelIndex, 0)
}
}
} }
} }
} }
@ -336,42 +320,56 @@ fun TvGuideGrid(
Key.DirectionDown -> { Key.DirectionDown -> {
// Move channel focus down // Move channel focus down
val newFocusedChannelIndex = (focusedChannelIndex + 1) val newChannelIndex = item.row + 1
if (newFocusedChannelIndex >= channels.size) { if (newChannelIndex >= channels.size) {
// If trying to move below the final channel, then move focus out of the grid // If trying to move below the final channel, then move focus out of the grid
focusManager.moveFocus(FocusDirection.Down) focusManager.moveFocus(FocusDirection.Down)
null null
} else if (channelColumnFocused) {
// If focused on the channel column, move down a channel
focusedChannelIndex =
newFocusedChannelIndex.coerceAtMost(channels.size - 1)
programsBeforeChannel.get(focusedChannelIndex)
} else { } else {
// Otherwise, moving to a new row // Otherwise, moving to a new row
focusedChannelIndex =
newFocusedChannelIndex.coerceAtMost(channels.size - 1)
// Get the new row/channel's programs // Get the new row/channel's programs
val channelId = channels[focusedChannelIndex].id val newChannelId = channels[newChannelIndex].id
val pro = programs[channelId].orEmpty() val newChannelPrograms =
// When the currently focused program starts programs.programsByChannel[newChannelId]
val start = programList[focusedProgramIndex].startHours 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 {
// 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 {
val start = currentProgram.startHours
// Find the first program where the start time (of the previously focused program) is in the middle of a program // Find the first program where the start time (of the previously focused program) is in the middle of a program
val pIndex = val pIndex =
pro.indexOfFirst { start in (it.startHours..<it.endHours) } newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) { if (pIndex >= 0) {
// Found one, so focus on it // 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 RowColumn(newChannelIndex, pIndex)
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else { } else {
// Didn't find one, probably due to missing data // Didn't find one, probably due to missing data
// So now first the first one that starts after the previously focused program // So now first the first one that starts after the previously focused program
val pIndex = pro.indexOfFirst { it.startHours >= start } val pIndex =
newChannelPrograms.indexOfFirst { it.startHours >= start }
if (pIndex >= 0) { if (pIndex >= 0) {
// Found one, so focus on it // Found one, so focus on it
programsBeforeChannel.get(focusedChannelIndex) + pIndex RowColumn(newChannelIndex, pIndex)
} else { } else {
// Did not find one, so focus on the final program in the list // Did not find one, so focus on the final program in the list
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size RowColumn(
newChannelIndex,
newChannelPrograms.size - 1,
)
}
}
} }
} }
} }
@ -383,9 +381,18 @@ fun TvGuideGrid(
Timber.v("Clicked on %s", channel) Timber.v("Clicked on %s", channel)
onClickChannel.invoke(focusedChannelIndex, channel) onClickChannel.invoke(focusedChannelIndex, channel)
} else { } else {
val program = programList[focusedProgramIndex] val currentChannel = channels[item.row].id
Timber.v("Clicked on %s", program) val currentProgram =
onClickProgram.invoke(focusedProgramIndex, program) 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 null
} }
@ -394,22 +401,24 @@ fun TvGuideGrid(
null null
} }
} }
if (newIndex != null) {
// Timber.v("newIndex=$newIndex") if (newFocusedItem != null) {
if (newIndex >= 0) { val channel = channels[newFocusedItem.row]
val before = programsBeforeChannel.get(focusedChannelIndex) val programs = programs.programsByChannel[channel.id].orEmpty()
val max = // Ensure it isn't going out of range
before + channels[focusedChannelIndex].let { programs[it.id]!!.size } - 1 val toFocus =
val index = newIndex.coerceIn(before, max) newFocusedItem
scope.launch(ExceptionHandler()) { .copy(
focusedProgramIndex = index row = newFocusedItem.row.coerceIn(0, channels.size - 1),
state.animateToProgram(index, Alignment.Center) column =
} newFocusedItem.column.coerceIn(
0,
(programs.size - 1).coerceAtLeast(0),
),
)
focusedItem = toFocus
onFocus(toFocus)
return@onPreviewKeyEvent true return@onPreviewKeyEvent true
} else {
Timber.w("newIndex is <0: $newIndex")
return@onPreviewKeyEvent true
}
} }
return@onPreviewKeyEvent false return@onPreviewKeyEvent false
}, },
@ -423,7 +432,12 @@ fun TvGuideGrid(
}, },
) { ) {
Surface( Surface(
colors = SurfaceDefaults.colors(MaterialTheme.colorScheme.tertiary.copy(alpha = .25f)), colors =
SurfaceDefaults.colors(
MaterialTheme.colorScheme.tertiary.copy(
alpha = .25f,
),
),
modifier = Modifier, modifier = Modifier,
) { ) {
// Empty // Empty
@ -464,16 +478,22 @@ fun TvGuideGrid(
) )
} }
} }
programs( programs(
count = programList.size, count = programs.programs.size,
layoutInfo = { programIndex -> layoutInfo = { programIndex ->
val program = programList[programIndex] val program = programs.programs[programIndex]
val channelIndex = channels.indexOfFirst { it.id == program.channelId } 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 -> ) { programIndex ->
val program = programList[programIndex] val program = programs.programs[programIndex]
val focused = val focused =
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
val background = val background =
@ -487,7 +507,6 @@ fun TvGuideGrid(
Box( Box(
modifier = modifier =
Modifier Modifier
// .scale(if (focused) 1.1f else 1f)
.padding(2.dp) .padding(2.dp)
.fillMaxSize() .fillMaxSize()
.background( .background(