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,57 +103,81 @@ 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 request = val minEndDate = guideStart.plusMinutes(1L)
GetLiveTvProgramsRequest( val channelsToFetch = channels.subList(range.first, range.last + 1)
maxStartDate = maxStartDate, Timber.v("Fetching programs for $range channels ${channelsToFetch.size}")
minEndDate = minEndDate, val request =
channelIds = channels.map { it.id }, GetProgramsDto(
sortBy = listOf(ItemSortBy.START_DATE), maxStartDate = maxStartDate,
) minEndDate = minEndDate,
val programs = channelIds = channelsToFetch.map { it.id },
api.liveTvApi sortBy = listOf(ItemSortBy.START_DATE),
.getLiveTvPrograms(request) )
.content.items val fetchedPrograms =
.map { dto -> api.liveTvApi
val category = .getPrograms(request)
if (dto.isKids ?: false) { .content.items
ProgramCategory.KIDS val programsByChannel = mutableMapOf<UUID, List<TvProgram>>()
} else if (dto.isMovie ?: false) { val fetchedGroupedBy = fetchedPrograms.groupBy { it.channelId }
ProgramCategory.MOVIE fetchedGroupedBy.forEach { (channelId, programs) ->
} else if (dto.isNews ?: false) { if (channelId != null) {
ProgramCategory.NEWS val listing = mutableListOf<TvProgram>()
} else if (dto.isSports ?: false) { programs as MutableList<BaseItemDto>
ProgramCategory.SPORTS programs.forEachIndexed { index, dto ->
} else { val category =
null 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( 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,24 +192,44 @@ class LiveTvViewModel
isRepeat = dto.isRepeat ?: false, isRepeat = dto.isRepeat ?: false,
category = category, category = category,
) )
} if (index == 0) {
if (p.startHours > 0) {
val programsByChannel = programs.groupBy { it.channelId } // Fill out before the first program
val emptyChannels = channels.filter { programsByChannel[it.id].orEmpty().isEmpty() } var start = 0f
val fake = mutableListOf<TvProgram>() var end = min(start + 1f, p.startHours)
val finalProgramsByChannel = while (start < p.startHours) {
programsByChannel.toMutableMap().apply { val fake =
emptyChannels.forEach { channel -> TvProgram.fake(
val fakePrograms = guideStart,
(0..<MAX_HOURS).map { 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( TvProgram(
id = UUID.randomUUID(), // TODO id = UUID.randomUUID(),
channelId = channel.id, channelId = channelId,
start = start.plusHours(it), start = previous.end,
end = start.plusHours(it + 1), end = previous.end.plusMinutes((duration * 60).toLong()),
startHours = it.toFloat(), startHours = start,
endHours = (it + 1).toFloat(), endHours = start + duration,
duration = 60.seconds, duration = (duration * 60).toInt().minutes,
name = context.getString(R.string.no_data), name = context.getString(R.string.no_data),
subtitle = null, subtitle = null,
seasonEpisode = null, seasonEpisode = null,
@ -181,24 +238,83 @@ class LiveTvViewModel
isRepeat = false, isRepeat = false,
category = ProgramCategory.FAKE, 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 = programsByChannel[channelId] = listing
(programs + fake).sortedWith( }
}
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( 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)
}
} }
}
fun getItem(programId: UUID) { fun getItem(programId: UUID) {
fetchingItem.value = LoadingState.Loading fetchingItem.value = LoadingState.Loading
@ -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,36 +387,58 @@ 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)
} }
} }
suspend fun refreshProgram( private var focusLoadingJob: Job? = null
programIndex: Int,
programId: UUID, fun onFocusChannel(position: RowColumn) {
) = mutex.withLock { channels.value?.let { channels ->
loading.setValueOnMain(LoadingState.Loading) val fetchedRange = programs.value!!.range
val program by api.liveTvApi.getProgram(programId.toServerString()) val quarter = range / 4
val newProgram = var rangeStart = fetchedRange.start + quarter
programs.value?.getOrNull(programIndex)?.copy( var rangeEnd = fetchedRange.last - quarter
isRecording = program.timerId.isNotNullOrBlank(),
isSeriesRecording = program.seriesTimerId.isNotNullOrBlank(), if (rangeEnd - rangeStart < range) {
) if (position.row < range / 2) {
Timber.v("new program %s", newProgram) // Close to beginning
if (newProgram != null) { rangeStart = 0
programs.value } else if (position.row > (channels.size - range / 2)) {
?.toMutableList() // Close to the end
?.apply { rangeEnd = channels.size
this[programIndex] = newProgram
}?.let {
this@LiveTvViewModel.programs.setValueOnMain(it)
} }
}
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] * 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 if (item.column == 0) {
channelColumnFocused = true
item
} 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[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
}
} }
} }
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
val pro = programs[channelId].orEmpty()
val pIndex =
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else { } else {
val pIndex = pro.indexOfFirst { it.startHours >= start } val currentChannel = channels[item.row].id
if (pIndex >= 0) { val currentProgram =
programsBeforeChannel.get(focusedChannelIndex) + pIndex 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 { } 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 -> { 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) {
// Find the first program where the start time (of the previously focused program) is in the middle of a program // This means there is no data for the new channel and it is loading in the background
val pIndex = return@onPreviewKeyEvent true
pro.indexOfFirst { start in (it.startHours..<it.endHours) } }
if (pIndex >= 0) { if (channelColumnFocused) {
// Found one, so focus on it // If focused on the channel column, move down a channel
// 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, 0)
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else { } else {
// Didn't find one, probably due to missing data // Get current program & its start time
// So now first the first one that starts after the previously focused program val currentChannel = channels[item.row].id
val pIndex = pro.indexOfFirst { it.startHours >= start } val currentProgram =
if (pIndex >= 0) { programs.programsByChannel[currentChannel]?.get(item.column)
// Found one, so focus on it if (currentProgram == null) {
programsBeforeChannel.get(focusedChannelIndex) + pIndex // Data is loading in the background
item
} else { } else {
// Did not find one, so focus on the final program in the list val start = currentProgram.startHours
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size // 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) 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(
return@onPreviewKeyEvent true 0,
} else { (programs.size - 1).coerceAtLeast(0),
Timber.w("newIndex is <0: $newIndex") ),
return@onPreviewKeyEvent true )
} focusedItem = toFocus
onFocus(toFocus)
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(