mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
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:
parent
13c2bb32d2
commit
86977f846c
2 changed files with 417 additions and 233 deletions
|
|
@ -6,8 +6,10 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
|
|
@ -20,6 +22,7 @@ import com.github.damontecres.wholphin.util.LoadingState
|
|||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
|
@ -27,19 +30,21 @@ import kotlinx.coroutines.withContext
|
|||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.GetProgramsDto
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.TimerInfoDto
|
||||
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetLiveTvProgramsRequest
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
const val MAX_HOURS = 48L
|
||||
|
|
@ -54,24 +59,32 @@ class LiveTvViewModel
|
|||
) : ViewModel() {
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
|
||||
lateinit var start: LocalDateTime
|
||||
lateinit var guideStart: LocalDateTime
|
||||
private set
|
||||
private lateinit var channelsIdToIndex: Map<UUID, Int>
|
||||
private val mutex = Mutex()
|
||||
|
||||
val channels = MutableLiveData<List<TvChannel>>()
|
||||
val programs = MutableLiveData<List<TvProgram>>()
|
||||
val programsByChannel = MutableLiveData<Map<UUID, List<TvProgram>>>(mapOf())
|
||||
val channelProgramCount = mutableMapOf<UUID, Int>()
|
||||
val programs = MutableLiveData<FetchedPrograms>()
|
||||
|
||||
val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val fetchedItem = MutableLiveData<BaseItem?>(null)
|
||||
|
||||
private val range = 100
|
||||
|
||||
fun init(firstLoad: Boolean) {
|
||||
start = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
|
||||
guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
|
||||
if (!firstLoad) {
|
||||
loading.value = LoadingState.Loading
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Could not fetch channels")) {
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Could not fetch channels",
|
||||
),
|
||||
) {
|
||||
val channelData by api.liveTvApi.getLiveTvChannels(
|
||||
GetLiveTvChannelsRequest(
|
||||
startIndex = 0,
|
||||
|
|
@ -90,38 +103,57 @@ class LiveTvViewModel
|
|||
Timber.d("Got ${channels.size} channels")
|
||||
channelsIdToIndex =
|
||||
channels.withIndex().associateBy({ it.value.id }, { it.index })
|
||||
fetchPrograms(channels)
|
||||
// Initially, quickly load the first 10 channels (only ~6 are visible immediately), then below will load more
|
||||
// This makes the guide appear faster, and load more useable data in the background
|
||||
val initial = 10
|
||||
fetchPrograms(channels, 0..<initial.coerceAtMost(channels.size))
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
this@LiveTvViewModel.channels.value = channels
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
// Now load the full range
|
||||
if (channels.size > initial) {
|
||||
fetchPrograms(channels, 0..<range.coerceAtMost(channels.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchProgramsWithLoading(channels: List<TvChannel>) {
|
||||
private suspend fun fetchProgramsWithLoading(
|
||||
channels: List<TvChannel>,
|
||||
range: IntRange,
|
||||
) {
|
||||
loading.setValueOnMain(LoadingState.Loading)
|
||||
fetchPrograms(channels)
|
||||
fetchPrograms(channels, range)
|
||||
loading.setValueOnMain(LoadingState.Success)
|
||||
}
|
||||
|
||||
private suspend fun fetchPrograms(channels: List<TvChannel>) =
|
||||
mutex.withLock {
|
||||
val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1)
|
||||
val minEndDate = start.plusMinutes(1L)
|
||||
|
||||
private suspend fun fetchPrograms(
|
||||
channels: List<TvChannel>,
|
||||
range: IntRange,
|
||||
) = mutex.withLock {
|
||||
val maxStartDate = guideStart.plusHours(MAX_HOURS).minusMinutes(1)
|
||||
val minEndDate = guideStart.plusMinutes(1L)
|
||||
val channelsToFetch = channels.subList(range.first, range.last + 1)
|
||||
Timber.v("Fetching programs for $range channels ${channelsToFetch.size}")
|
||||
val request =
|
||||
GetLiveTvProgramsRequest(
|
||||
GetProgramsDto(
|
||||
maxStartDate = maxStartDate,
|
||||
minEndDate = minEndDate,
|
||||
channelIds = channels.map { it.id },
|
||||
channelIds = channelsToFetch.map { it.id },
|
||||
sortBy = listOf(ItemSortBy.START_DATE),
|
||||
)
|
||||
val programs =
|
||||
val fetchedPrograms =
|
||||
api.liveTvApi
|
||||
.getLiveTvPrograms(request)
|
||||
.getPrograms(request)
|
||||
.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 =
|
||||
if (dto.isKids ?: false) {
|
||||
ProgramCategory.KIDS
|
||||
|
|
@ -134,13 +166,18 @@ class LiveTvViewModel
|
|||
} 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,21 +192,96 @@ class LiveTvViewModel
|
|||
isRepeat = dto.isRepeat ?: false,
|
||||
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 }
|
||||
val emptyChannels = channels.filter { programsByChannel[it.id].orEmpty().isEmpty() }
|
||||
}
|
||||
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(),
|
||||
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 finalProgramsByChannel =
|
||||
programsByChannel.toMutableMap().apply {
|
||||
|
||||
emptyChannels.forEach { channel ->
|
||||
val fakePrograms =
|
||||
(0..<MAX_HOURS).map {
|
||||
TvProgram(
|
||||
id = UUID.randomUUID(), // TODO
|
||||
id = UUID.randomUUID(),
|
||||
channelId = channel.id,
|
||||
start = start.plusHours(it),
|
||||
end = start.plusHours(it + 1),
|
||||
start = guideStart.plusHours(it),
|
||||
end = guideStart.plusHours(it + 1),
|
||||
startHours = it.toFloat(),
|
||||
endHours = (it + 1).toFloat(),
|
||||
duration = 60.seconds,
|
||||
|
|
@ -182,21 +294,25 @@ class LiveTvViewModel
|
|||
category = ProgramCategory.FAKE,
|
||||
)
|
||||
}
|
||||
put(channel.id, fakePrograms)
|
||||
programsByChannel.put(channel.id, fakePrograms)
|
||||
fake.addAll(fakePrograms)
|
||||
}
|
||||
|
||||
programsByChannel.forEach { (channelId, programs) ->
|
||||
channelProgramCount[channelId] = programs.size
|
||||
}
|
||||
val finalProgramList =
|
||||
(programs + fake).sortedWith(
|
||||
(programsByChannel.values.flatten())
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ channelsIdToIndex[it.channelId]!! },
|
||||
{ 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) {
|
||||
this@LiveTvViewModel.programs.value = finalProgramList
|
||||
this@LiveTvViewModel.programsByChannel.value = finalProgramsByChannel
|
||||
this@LiveTvViewModel.programs.value =
|
||||
FetchedPrograms(range, finalProgramList, programsByChannel)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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,35 +387,57 @@ class LiveTvViewModel
|
|||
keepUntil = d.keepUntil,
|
||||
)
|
||||
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(
|
||||
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)
|
||||
}
|
||||
}
|
||||
fun IntRange.within(other: IntRange): Boolean = this.first >= other.first && this.last <= other.last
|
||||
|
||||
/**
|
||||
* Returns the number of hours between two [LocalDateTime]
|
||||
|
|
@ -341,6 +474,32 @@ data class TvProgram(
|
|||
val category: ProgramCategory?,
|
||||
) {
|
||||
val isFake = category == ProgramCategory.FAKE
|
||||
|
||||
companion object {
|
||||
private val NO_DATA = WholphinApplication.instance.getString(R.string.no_data)
|
||||
|
||||
fun fake(
|
||||
zeroHourStart: LocalDateTime,
|
||||
channelId: UUID,
|
||||
startHours: Float,
|
||||
endHours: Float,
|
||||
) = TvProgram(
|
||||
id = UUID.randomUUID(),
|
||||
channelId = channelId,
|
||||
start = zeroHourStart.plusMinutes((startHours * 60).toLong()),
|
||||
end = zeroHourStart.plusMinutes((endHours * 60).toLong()),
|
||||
startHours = startHours,
|
||||
endHours = endHours,
|
||||
duration = ((endHours - startHours) * 60).toInt().minutes,
|
||||
name = NO_DATA,
|
||||
subtitle = null,
|
||||
seasonEpisode = null,
|
||||
isRecording = false,
|
||||
isSeriesRecording = false,
|
||||
isRepeat = false,
|
||||
category = ProgramCategory.FAKE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class ProgramCategory(
|
||||
|
|
@ -352,3 +511,9 @@ enum class ProgramCategory(
|
|||
SPORTS(AppColors.DarkRed),
|
||||
FAKE(null),
|
||||
}
|
||||
|
||||
data class FetchedPrograms(
|
||||
val range: IntRange,
|
||||
val programs: List<TvProgram>,
|
||||
val programsByChannel: Map<UUID, List<TvProgram>>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -48,14 +48,13 @@ import coil3.compose.AsyncImage
|
|||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import com.google.common.cache.CacheLoader
|
||||
import eu.wewox.programguide.ProgramGuide
|
||||
import eu.wewox.programguide.ProgramGuideDimensions
|
||||
import eu.wewox.programguide.ProgramGuideItem
|
||||
|
|
@ -65,6 +64,7 @@ import timber.log.Timber
|
|||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import kotlin.math.abs
|
||||
|
||||
@Composable
|
||||
fun TvGuideGrid(
|
||||
|
|
@ -79,8 +79,9 @@ fun TvGuideGrid(
|
|||
}
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||
val channels by viewModel.channels.observeAsState(listOf())
|
||||
val programs by viewModel.programs.observeAsState(listOf())
|
||||
val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
|
||||
val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf()))
|
||||
// val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
|
||||
// val fetchedRange by viewModel.fetchedRange.observeAsState(0..0)
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> ErrorMessage(state, modifier)
|
||||
LoadingState.Pending,
|
||||
|
|
@ -103,9 +104,9 @@ fun TvGuideGrid(
|
|||
TvGuideGrid(
|
||||
loading = state is LoadingState.Loading,
|
||||
channels = channels,
|
||||
programList = programs,
|
||||
programs = programsByChannel,
|
||||
start = viewModel.start,
|
||||
programs = programs,
|
||||
channelProgramCount = viewModel.channelProgramCount,
|
||||
start = viewModel.guideStart,
|
||||
onClickChannel = { index, channel ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.Playback(
|
||||
|
|
@ -114,6 +115,7 @@ fun TvGuideGrid(
|
|||
),
|
||||
)
|
||||
},
|
||||
onFocus = viewModel::onFocusChannel,
|
||||
onClickProgram = { index, program ->
|
||||
if (program.isFake) {
|
||||
val now = LocalDateTime.now()
|
||||
|
|
@ -162,7 +164,6 @@ fun TvGuideGrid(
|
|||
onRecord = { series ->
|
||||
fetchedItem?.let {
|
||||
viewModel.record(
|
||||
programIndex = showItemDialog!!,
|
||||
programId = it.id,
|
||||
series = series,
|
||||
)
|
||||
|
|
@ -172,8 +173,6 @@ fun TvGuideGrid(
|
|||
onCancelRecord = { series ->
|
||||
fetchedItem?.data?.let {
|
||||
viewModel.cancelRecording(
|
||||
programIndex = showItemDialog!!,
|
||||
programId = it.id,
|
||||
series = series,
|
||||
timerId = if (series) it.seriesTimerId else it.timerId,
|
||||
)
|
||||
|
|
@ -186,26 +185,41 @@ fun TvGuideGrid(
|
|||
}
|
||||
}
|
||||
|
||||
const val CHANNEL_COLUMN = -1
|
||||
|
||||
@Composable
|
||||
fun TvGuideGrid(
|
||||
loading: Boolean,
|
||||
channels: List<TvChannel>,
|
||||
programList: List<TvProgram>,
|
||||
programs: Map<UUID, List<TvProgram>>,
|
||||
programs: FetchedPrograms,
|
||||
channelProgramCount: Map<UUID, Int>,
|
||||
start: LocalDateTime,
|
||||
onClickChannel: (Int, TvChannel) -> Unit,
|
||||
onClickProgram: (Int, TvProgram) -> Unit,
|
||||
onFocus: (RowColumn) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val state = rememberSaveableProgramGuideState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var focusedProgramIndex by rememberInt(0)
|
||||
var focusedChannelIndex by rememberInt(0)
|
||||
|
||||
var focusedItem by rememberPosition(RowColumn(0, 0))
|
||||
val focusedChannelIndex = focusedItem.row
|
||||
val focusedProgramIndex =
|
||||
remember(programs.range, focusedItem) {
|
||||
focusedItem.let { focus ->
|
||||
(programs.range.start..<focus.row).sumOf {
|
||||
val channelId = channels[it].id
|
||||
channelProgramCount[channelId] ?: 0
|
||||
} + focus.column
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(focusedProgramIndex) {
|
||||
// Timber.v("Focusing on $focusedItem, programIndex=$focusedProgramIndex")
|
||||
scope.launch(ExceptionHandler()) {
|
||||
state.animateToProgram(focusedProgramIndex, Alignment.Center)
|
||||
}
|
||||
}
|
||||
val dimensions =
|
||||
ProgramGuideDimensions(
|
||||
timelineHourWidth = 240.dp,
|
||||
|
|
@ -214,23 +228,6 @@ fun TvGuideGrid(
|
|||
channelHeight = 80.dp,
|
||||
currentTimeWidth = 2.dp,
|
||||
)
|
||||
|
||||
val programsBeforeChannel =
|
||||
remember {
|
||||
CacheBuilder
|
||||
.newBuilder()
|
||||
.maximumSize(200)
|
||||
.build<Int, Int>(
|
||||
object : CacheLoader<Int, Int>() {
|
||||
override fun load(key: Int): Int =
|
||||
channels
|
||||
.subList(0, key)
|
||||
.map { it.id }
|
||||
.sumOf { programs[it]?.size ?: 0 }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
|
||||
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
|
||||
Box(modifier = modifier) {
|
||||
|
|
@ -247,13 +244,13 @@ fun TvGuideGrid(
|
|||
if (it.type == KeyEventType.KeyUp) {
|
||||
return@onPreviewKeyEvent false
|
||||
}
|
||||
val newIndex =
|
||||
val item = focusedItem
|
||||
val newFocusedItem =
|
||||
when (it.key) {
|
||||
Key.Back -> {
|
||||
val pos = programsBeforeChannel.get(focusedChannelIndex)
|
||||
if (focusedProgramIndex - pos > 0) {
|
||||
if (item.column > 0) {
|
||||
// Not at beginning of row, so move to beginning
|
||||
pos
|
||||
item.copy(column = 0)
|
||||
} else {
|
||||
// At beginning, so allow normal back button behavior
|
||||
return@onPreviewKeyEvent false
|
||||
|
|
@ -263,20 +260,9 @@ fun TvGuideGrid(
|
|||
Key.DirectionRight -> {
|
||||
if (channelColumnFocused) {
|
||||
channelColumnFocused = false
|
||||
focusedProgramIndex
|
||||
item.copy(column = 0)
|
||||
} else {
|
||||
val nextProgramIndex = focusedProgramIndex + 1
|
||||
val programsBefore =
|
||||
programsBeforeChannel.get(focusedChannelIndex)
|
||||
val relativePosition = nextProgramIndex - programsBefore
|
||||
val channelPrograms =
|
||||
programs[channels[focusedChannelIndex].id].orEmpty()
|
||||
if (relativePosition >= channelPrograms.size) {
|
||||
focusManager.moveFocus(FocusDirection.Right)
|
||||
null
|
||||
} else {
|
||||
nextProgramIndex
|
||||
}
|
||||
item.copy(column = item.column + 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -284,51 +270,49 @@ fun TvGuideGrid(
|
|||
if (channelColumnFocused) {
|
||||
focusManager.moveFocus(FocusDirection.Left)
|
||||
null
|
||||
} else {
|
||||
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) {
|
||||
} else if (item.column == 0) {
|
||||
channelColumnFocused = true
|
||||
focusedProgramIndex
|
||||
item
|
||||
} 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 newChannelIndex = item.row - 1
|
||||
if (channelColumnFocused) {
|
||||
RowColumn(newChannelIndex, 0)
|
||||
} else {
|
||||
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 =
|
||||
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
|
||||
newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) }
|
||||
if (pIndex >= 0) {
|
||||
programsBeforeChannel.get(focusedChannelIndex) + pIndex
|
||||
RowColumn(newChannelIndex, pIndex)
|
||||
} else {
|
||||
val pIndex = pro.indexOfFirst { it.startHours >= start }
|
||||
val pIndex =
|
||||
newChannelPrograms.indexOfFirst { it.startHours >= start }
|
||||
if (pIndex >= 0) {
|
||||
programsBeforeChannel.get(focusedChannelIndex) + pIndex
|
||||
RowColumn(newChannelIndex, pIndex)
|
||||
} else {
|
||||
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size
|
||||
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
|
||||
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 {
|
||||
// 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
|
||||
val pIndex =
|
||||
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
|
||||
newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) }
|
||||
if (pIndex >= 0) {
|
||||
// Found one, so focus on it
|
||||
// Get the sum of all of the previous channels' program sizes, plus the index found to convert a relative program index into absolute
|
||||
programsBeforeChannel.get(focusedChannelIndex) + pIndex
|
||||
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 = pro.indexOfFirst { it.startHours >= start }
|
||||
val pIndex =
|
||||
newChannelPrograms.indexOfFirst { it.startHours >= start }
|
||||
if (pIndex >= 0) {
|
||||
// Found one, so focus on it
|
||||
programsBeforeChannel.get(focusedChannelIndex) + pIndex
|
||||
RowColumn(newChannelIndex, pIndex)
|
||||
} else {
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
} else {
|
||||
Timber.w("newIndex is <0: $newIndex")
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue