This commit is contained in:
Damontecres 2025-11-01 14:14:09 -04:00
parent b288f0f100
commit 841b4a0907
No known key found for this signature in database
2 changed files with 85 additions and 13 deletions

View file

@ -17,6 +17,7 @@ import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
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
@ -29,7 +30,6 @@ 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
@ -39,7 +39,7 @@ import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
const val MAX_HOURS = 48L const val MAX_HOURS = 3L
@HiltViewModel @HiltViewModel
class LiveTvViewModel class LiveTvViewModel
@ -56,12 +56,18 @@ class LiveTvViewModel
private val mutex = Mutex() private val mutex = Mutex()
val channels = MutableLiveData<List<TvChannel>>() val channels = MutableLiveData<List<TvChannel>>()
val channelProgramCount = mutableMapOf<UUID, Int>()
val programs = MutableLiveData<List<TvProgram>>() val programs = MutableLiveData<List<TvProgram>>()
val programsByChannel = MutableLiveData<Map<UUID, List<TvProgram>>>(mapOf()) val programsByChannel = MutableLiveData<Map<UUID, List<TvProgram>>>(mapOf())
val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending) val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending)
val fetchedItem = MutableLiveData<BaseItem?>(null) val fetchedItem = MutableLiveData<BaseItem?>(null)
val offset = MutableLiveData(0)
val programOffset = MutableLiveData(0)
private val range = 10
private var currentIndex = 0
fun init(firstLoad: Boolean) { fun init(firstLoad: Boolean) {
start = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) start = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
if (!firstLoad) { if (!firstLoad) {
@ -86,7 +92,7 @@ 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) fetchPrograms(channels.subList(0, range.coerceAtMost(channels.size)))
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@LiveTvViewModel.channels.value = channels this@LiveTvViewModel.channels.value = channels
@ -105,9 +111,9 @@ class LiveTvViewModel
mutex.withLock { mutex.withLock {
val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1) val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1)
val minEndDate = start.plusMinutes(1L) val minEndDate = start.plusMinutes(1L)
Timber.v("Fetching programs for ${channels.size} channels")
val request = val request =
GetLiveTvProgramsRequest( GetProgramsDto(
maxStartDate = maxStartDate, maxStartDate = maxStartDate,
minEndDate = minEndDate, minEndDate = minEndDate,
channelIds = channels.map { it.id }, channelIds = channels.map { it.id },
@ -115,7 +121,7 @@ class LiveTvViewModel
) )
val programs = val programs =
api.liveTvApi api.liveTvApi
.getLiveTvPrograms(request) .getPrograms(request)
.content.items .content.items
.map { dto -> .map { dto ->
val category = val category =
@ -182,6 +188,9 @@ class LiveTvViewModel
fake.addAll(fakePrograms) fake.addAll(fakePrograms)
} }
} }
finalProgramsByChannel.forEach { (channelId, programs) ->
channelProgramCount[channelId] = programs.size
}
val finalProgramList = val finalProgramList =
(programs + fake).sortedWith( (programs + fake).sortedWith(
compareBy( compareBy(
@ -300,6 +309,34 @@ class LiveTvViewModel
} }
loading.setValueOnMain(LoadingState.Success) loading.setValueOnMain(LoadingState.Success)
} }
fun onFocusChannel(index: Int): Job? {
return channels.value?.let { channels ->
val offset = offset.value!!
val absoluteIndex = offset + index
val rangeStart = (currentIndex - range / 2).coerceAtLeast(0)
val rangeEnd = (currentIndex + range / 2).coerceAtMost(channels.size)
Timber.v(
"onFocusChannel: index=$index, currentIndex=$currentIndex, offset=$offset, rangeStart=$rangeStart, rangeEnd=$rangeEnd",
)
if (absoluteIndex !in (rangeStart..<rangeEnd)) {
val fetchStart = (absoluteIndex - range).coerceAtLeast(0)
val fetchEnd = (absoluteIndex + range).coerceAtMost(channels.size)
Timber.v("Loading more programs for channels $fetchStart=>$fetchEnd")
return viewModelScope.launchIO {
fetchPrograms(channels.subList(fetchStart, fetchEnd))
val programOffset =
(offset..<fetchStart).sumOf { channelProgramCount[channels[it].id]!! }
withContext(Dispatchers.Main) {
this@LiveTvViewModel.offset.value = fetchStart
currentIndex = index
this@LiveTvViewModel.programOffset.value = programOffset
}
}
}
return null
}
}
} }
/** /**

View file

@ -81,6 +81,7 @@ fun TvGuideGrid(
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(listOf())
val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf()) val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
val programOffset by viewModel.programOffset.observeAsState(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,
@ -105,7 +106,9 @@ fun TvGuideGrid(
channels = channels, channels = channels,
programList = programs, programList = programs,
programs = programsByChannel, programs = programsByChannel,
channelProgramCount = viewModel.channelProgramCount,
start = viewModel.start, start = viewModel.start,
programOffset = programOffset,
onClickChannel = { index, channel -> onClickChannel = { index, channel ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.Playback( Destination.Playback(
@ -114,6 +117,9 @@ fun TvGuideGrid(
), ),
) )
}, },
onFocusChannel = { index ->
viewModel.onFocusChannel(index)
},
onClickProgram = { index, program -> onClickProgram = { index, program ->
if (program.isFake) { if (program.isFake) {
val now = LocalDateTime.now() val now = LocalDateTime.now()
@ -194,9 +200,12 @@ fun TvGuideGrid(
channels: List<TvChannel>, channels: List<TvChannel>,
programList: List<TvProgram>, programList: List<TvProgram>,
programs: Map<UUID, List<TvProgram>>, 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,
onFocusChannel: (Int) -> Unit,
programOffset: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -223,13 +232,25 @@ fun TvGuideGrid(
.build<Int, Int>( .build<Int, Int>(
object : CacheLoader<Int, Int>() { object : CacheLoader<Int, Int>() {
override fun load(key: Int): Int = override fun load(key: Int): Int =
(
channels channels
.subList(0, key) .subList(0, key)
.map { it.id } .map { it.id }
.sumOf { programs[it]?.size ?: 0 } .sumOf {
channelProgramCount[it] ?: 0
} - programOffset
).coerceAtLeast(0)
}, },
) )
} }
var movementEnabled by remember { mutableStateOf(true) }
LaunchedEffect(programOffset) {
movementEnabled = false
programsBeforeChannel.invalidateAll()
Timber.v("Adjusting focusedProgramIndex, $focusedProgramIndex - $programOffset")
focusedProgramIndex = (focusedProgramIndex - programOffset).coerceAtLeast(0)
movementEnabled = true
}
var gridHasFocus by rememberSaveable { mutableStateOf(false) } var gridHasFocus by rememberSaveable { mutableStateOf(false) }
var channelColumnFocused by rememberSaveable { mutableStateOf(false) } var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
@ -247,6 +268,9 @@ fun TvGuideGrid(
if (it.type == KeyEventType.KeyUp) { if (it.type == KeyEventType.KeyUp) {
return@onPreviewKeyEvent false return@onPreviewKeyEvent false
} }
if (!movementEnabled) {
return@onPreviewKeyEvent true
}
val newIndex = val newIndex =
when (it.key) { when (it.key) {
Key.Back -> { Key.Back -> {
@ -328,7 +352,10 @@ fun TvGuideGrid(
if (pIndex >= 0) { if (pIndex >= 0) {
programsBeforeChannel.get(focusedChannelIndex) + pIndex programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else { } else {
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size programsBeforeChannel.get(focusedChannelIndex) + (
programs[channelId]?.size
?: 0
)
} }
} }
} }
@ -371,7 +398,10 @@ fun TvGuideGrid(
programsBeforeChannel.get(focusedChannelIndex) + pIndex programsBeforeChannel.get(focusedChannelIndex) + 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 programsBeforeChannel.get(focusedChannelIndex) + (
programs[channelId]?.size
?: 0
)
} }
} }
} }
@ -399,11 +429,16 @@ fun TvGuideGrid(
if (newIndex >= 0) { if (newIndex >= 0) {
val before = programsBeforeChannel.get(focusedChannelIndex) val before = programsBeforeChannel.get(focusedChannelIndex)
val max = val max =
before + channels[focusedChannelIndex].let { programs[it.id]!!.size } - 1 before +
channels[focusedChannelIndex].let {
programs[it.id]?.size ?: 0
} - 1
val index = newIndex.coerceIn(before, max) val index = newIndex.coerceIn(before, max)
Timber.v("move: programIndex=$index, channelIndex=$focusedChannelIndex")
scope.launch(ExceptionHandler()) { scope.launch(ExceptionHandler()) {
focusedProgramIndex = index focusedProgramIndex = index
state.animateToProgram(index, Alignment.Center) state.animateToProgram(index, Alignment.Center)
onFocusChannel.invoke(focusedChannelIndex)
} }
return@onPreviewKeyEvent true return@onPreviewKeyEvent true
} else { } else {