From 8af376cf63af3e78a4b265b2ea732ce17cb826c4 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 20 Oct 2025 14:09:34 -0400 Subject: [PATCH] Minimal UI implementation --- .../ui/detail/livetv/LiveTvViewModel.kt | 236 ++++++++++++------ .../wholphin/ui/detail/livetv/TvGrid.kt | 172 ++++++++++--- 2 files changed, 301 insertions(+), 107 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 280900ca..ff5f84ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -4,10 +4,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode -import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetProgramsDtoHandler -import com.github.damontecres.wholphin.util.LazyList import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel @@ -19,14 +16,19 @@ import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.liveTvApi 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.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber import java.time.LocalDateTime -import java.util.AbstractList +import java.time.ZoneOffset +import java.time.temporal.ChronoUnit import java.util.UUID import javax.inject.Inject import kotlin.time.Duration -import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +const val MAX_HOURS = 24L @HiltViewModel class LiveTvViewModel @@ -36,9 +38,12 @@ class LiveTvViewModel ) : ViewModel() { val loading = MutableLiveData(LoadingState.Pending) - val start = LocalDateTime.now() + val start = + LocalDateTime.now().minusMinutes(30).truncatedTo(ChronoUnit.HOURS) + val channels = MutableLiveData>() - val programs = MutableLiveData>() + val programs = MutableLiveData>() + val programsByChannel = MutableLiveData>>(mapOf()) private val programMap = mutableMapOf>>() @@ -47,101 +52,180 @@ class LiveTvViewModel viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Could not fetch channels")) { val channelData by api.liveTvApi.getLiveTvChannels( GetLiveTvChannelsRequest( - startIndex = 8, + startIndex = 0, ), ) val channels = - LazyList(channelData.items) { - TvChannel( - it.id, - it.channelNumber, - it.channelName, - api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY), - ) - } + channelData.items + .map { + TvChannel( + it.id, + it.channelNumber, + it.channelName, + api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY), + ) + } + Timber.d("Got ${channels.size} channels") + val channelsIdToIndex = + channels.withIndex().associateBy({ it.value.id }, { it.index }) + val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1) + val minEndDate = start.plusMinutes(1L) val request = GetProgramsDto( - minStartDate = start, + maxStartDate = maxStartDate, + minEndDate = minEndDate, +// maxEndDate = start.plusHours(25), channelIds = channels.map { it.id }, + sortBy = listOf(ItemSortBy.START_DATE), ) - val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init() - (0.. - item?.data?.let { dto -> + GetProgramsDtoHandler + .execute(api, request) + .content.items + .map { dto -> + val utcStart = dto.startDate!!.toEpochSecond(ZoneOffset.UTC) + utcStart + val ttt = + java.time.Duration + .between( + start, + dto.startDate!!, + ) + ttt TvProgram( id = dto.id, channelId = dto.channelId!!, start = dto.startDate!!, end = dto.endDate!!, + startHours = + java.time.Duration + .between(start, dto.startDate!!) + .seconds / (60f * 60f), +// dto.startDate!!.hours + +// ( +// java.time.Duration +// .between( +// start, +// dto.startDate!!, +// ).toDaysPart() / 24 +// ).coerceAtLeast(0), + endHours = + java.time.Duration + .between(start, dto.endDate!!) + .seconds / (60f * 60f), +// dto.endDate!!.hours + +// ( +// java.time.Duration +// .between( +// start, +// dto.endDate!!, +// ).toHours() / 24 +// ).coerceAtLeast(0), duration = dto.runTimeTicks!!.ticks, name = dto.seriesName ?: dto.name, subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() }, seasonEpisode = null, // TODO ) + }.filter { it.startHours >= 0 && it.endHours >= 0 } // TODO shouldn't need to filter client side + + val programsByChannel = programs.groupBy { it.channelId } + val emptyChannels = channels.filter { programsByChannel[it.id].orEmpty().isEmpty() } + val fake = mutableListOf() + val finalProgramsByChannel = + programsByChannel.toMutableMap().apply { + emptyChannels.forEach { channel -> + val fakePrograms = + (0..> = - programMap.getOrPut(channelId) { - val data = MutableLiveData>(listOf()) - viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { - val request = - GetProgramsDto( - minStartDate = start, - channelIds = listOf(channelId), - ) - val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init() - val programList = - if (pager.isEmpty()) { - object : AbstractList() { - override fun get(index: Int): TvProgram? { - val start = start.plusHours(index.toLong()) - val end = start.plusHours(1L) - return TvProgram( - id = UUID.randomUUID(), - channelId = channelId, - start = start, - end = start, - duration = 60.minutes, - name = "Unknown", - subtitle = null, - seasonEpisode = null, // TODO - ) - } - - override val size: Int - get() = Int.MAX_VALUE - } - } else { - LazyList(pager) { item -> - item?.data?.let { dto -> - TvProgram( - id = dto.id, - channelId = channelId, - start = dto.startDate!!, - end = dto.endDate!!, - duration = dto.runTimeTicks!!.ticks, - name = dto.seriesName ?: dto.name, - subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() }, - seasonEpisode = null, // TODO - ) - } - } - } - withContext(Dispatchers.Main) { - data.value = programList - } - } - data - } +// fun getPrograms(channelId: UUID): MutableLiveData> = +// programMap.getOrPut(channelId) { +// val data = MutableLiveData>(listOf()) +// viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { +// val request = +// GetProgramsDto( +// minStartDate = start, +// channelIds = listOf(channelId), +// ) +// val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init() +// val programList = +// if (pager.isEmpty()) { +// object : AbstractList() { +// override fun get(index: Int): TvProgram? { +// val start = start.plusHours(index.toLong()) +// val end = start.plusHours(1L) +// return TvProgram( +// id = UUID.randomUUID(), +// channelId = channelId, +// start = start, +// end = start, +// duration = 60.minutes, +// name = "Unknown", +// subtitle = null, +// seasonEpisode = null, // TODO +// ) +// } +// +// override val size: Int +// get() = Int.MAX_VALUE +// } +// } else { +// LazyList(pager) { item -> +// item?.data?.let { dto -> +// TvProgram( +// id = dto.id, +// channelId = channelId, +// start = dto.startDate!!, +// end = dto.endDate!!, +// duration = dto.runTimeTicks!!.ticks, +// name = dto.seriesName ?: dto.name, +// subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() }, +// seasonEpisode = null, // TODO +// ) +// } +// } +// } +// withContext(Dispatchers.Main) { +// data.value = programList +// } +// } +// data +// } } data class TvChannel( @@ -156,6 +240,8 @@ data class TvProgram( val channelId: UUID, val start: LocalDateTime, val end: LocalDateTime, + val startHours: Float, + val endHours: Float, val duration: Duration, val name: String?, val subtitle: String?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGrid.kt index 3bc9d2f6..f0d35aeb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGrid.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width @@ -16,6 +17,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -23,6 +25,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester @@ -34,19 +37,25 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.Button import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.playback.isDown import com.github.damontecres.wholphin.ui.playback.isUp import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState import eu.wewox.programguide.ProgramGuide import eu.wewox.programguide.ProgramGuideDimensions import eu.wewox.programguide.ProgramGuideItem @@ -66,14 +75,57 @@ fun translate( programIndex - before } -fun programsBeforeChannel(channelIndex: Int): Int = channels.subList(0, channelIndex).map { it.id }.sumOf { programs[it]?.size ?: 0 } +@Composable +fun TvGrid( + modifier: Modifier = Modifier, + viewModel: LiveTvViewModel = hiltViewModel(), +) { + LaunchedEffect(Unit) { + viewModel.init() + } + 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()) + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state) + + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() + + LoadingState.Success -> + Column(modifier = modifier) { + Button( + onClick = {}, + ) { + Text( + text = "Button", + ) + } + TvGrid( + channels = channels, + programList = programs, + programs = programsByChannel, + start = viewModel.start, + modifier = Modifier.fillMaxSize(), + ) + } + } +} @Composable -fun TvGrid(modifier: Modifier = Modifier) { +fun TvGrid( + channels: List, + programList: List, + programs: Map>, + start: LocalDateTime, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current val state = rememberSaveableProgramGuideState() val scope = rememberCoroutineScope() - val startHours = 6 - val timeline = 16..24 +// val timeline = 0..<24 var focusedProgram by rememberInt(0) var focusedChannel by rememberInt(0) @@ -86,6 +138,8 @@ fun TvGrid(modifier: Modifier = Modifier) { currentTimeWidth = 2.dp, ) + fun programsBeforeChannel(channelIndex: Int): Int = channels.subList(0, channelIndex).map { it.id }.sumOf { programs[it]?.size ?: 0 } + ProgramGuide( state = state, dimensions = dimensions, @@ -101,12 +155,32 @@ fun TvGrid(modifier: Modifier = Modifier) { Key.DirectionRight -> focusedProgram + 1 Key.DirectionLeft -> focusedProgram - 1 Key.DirectionUp -> { - val start = programList[focusedProgram].start.hours - focusedChannel = (focusedChannel - 1).coerceAtLeast(0) + val start = programList[focusedProgram].startHours + focusedChannel = (focusedChannel - 1) + if (focusedChannel < 0) { + focusManager.moveFocus(FocusDirection.Up) + null + } else { + val channelId = channels[focusedChannel].id + val pIndex = + programs[channelId]?.indexOfFirst { start in (it.startHours..= 0) { + programsBeforeChannel(focusedChannel) + pIndex + } else { + programsBeforeChannel(focusedChannel) + programs[channelId]!!.size + } + } + } + + Key.DirectionDown -> { + val start = programList[focusedProgram].startHours + focusedChannel = + (focusedChannel + 1).coerceAtMost(channels.size - 1) val channelId = channels[focusedChannel].id + val pro = programs[channelId].orEmpty() val pIndex = - programs[channelId]?.indexOfFirst { start in (it.start.hours..= 0) { programsBeforeChannel(focusedChannel) + pIndex } else { @@ -114,20 +188,9 @@ fun TvGrid(modifier: Modifier = Modifier) { } } - Key.DirectionDown -> { - val start = programList[focusedProgram].start.hours - focusedChannel = - (focusedChannel + 1).coerceAtMost(channels.size - 1) - val channelId = channels[focusedChannel].id - val pro = programs[channelId]!! - val pIndex = - pro.indexOfFirst { start in (it.start.hours..= 0) { - programsBeforeChannel(focusedChannel) + pIndex - } else { - programsBeforeChannel(focusedChannel) + programs[channelId]!!.size - } + Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { + Timber.v("Clicked on ${programList[focusedProgram]}") + null } else -> { @@ -148,20 +211,19 @@ fun TvGrid(modifier: Modifier = Modifier) { return@onPreviewKeyEvent false }, ) { - guideStartHour = timeline.first.toFloat() + guideStartHour = 0f timeline( - count = timeline.count(), - layoutInfo = { - val start = timeline.toList()[it].toFloat() + count = MAX_HOURS.toInt(), + layoutInfo = { index -> ProgramGuideItem.Timeline( - startHour = start, - endHour = start + 1f, + startHour = index.toFloat(), + endHour = index + 1f, ) }, ) { index -> - val start = timeline.toList()[index].toFloat() + val time = start.plusHours(index.toLong()).toLocalTime() Text( - text = "$start o'clock", + text = time.toString(), ) } @@ -170,7 +232,7 @@ fun TvGrid(modifier: Modifier = Modifier) { layoutInfo = { programIndex -> val program = programList[programIndex] val channelIndex = channels.indexOfFirst { it.id == program.channelId } - ProgramGuideItem.Program(channelIndex, program.start.hours, program.end.hours) + ProgramGuideItem.Program(channelIndex, program.startHours, program.endHours) }, ) { programIndex -> val program = programList[programIndex] @@ -191,7 +253,7 @@ fun TvGrid(modifier: Modifier = Modifier) { }, ) { channelIndex -> Text( - text = channels[channelIndex].name ?: channelIndex.toString(), + text = channels[channelIndex].number ?: channelIndex.toString(), modifier = Modifier.background(MaterialTheme.colorScheme.background), ) } @@ -442,6 +504,8 @@ val programs = channelId = channel1Id, start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), + startHours = 18f, + endHours = 19f, duration = 60.minutes, name = "C1 Program #1", subtitle = null, @@ -452,6 +516,8 @@ val programs = channelId = channel1Id, start = LocalDateTime.of(2025, 10, 16, 19, 0, 0), end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), + startHours = 19f, + endHours = 19.5f, duration = 30.minutes, name = "C1 Program #2", subtitle = null, @@ -462,6 +528,8 @@ val programs = channelId = channel1Id, start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), + startHours = 19.5f, + endHours = 20f, duration = 30.minutes, name = "C1 Program #3", subtitle = null, @@ -472,6 +540,8 @@ val programs = channelId = channel1Id, start = LocalDateTime.of(2025, 10, 16, 20, 0, 0, 0), end = LocalDateTime.of(2025, 10, 16, 21, 0, 0), + startHours = 20f, + endHours = 21f, duration = 60.minutes, name = "C1 Program #3", subtitle = null, @@ -482,6 +552,8 @@ val programs = channelId = channel1Id, start = LocalDateTime.of(2025, 10, 16, 21, 0, 0, 0), end = LocalDateTime.of(2025, 10, 16, 22, 0, 0), + startHours = 21f, + endHours = 22f, duration = 60.minutes, name = "C1 Program #3", subtitle = null, @@ -492,11 +564,25 @@ val programs = channelId = channel1Id, start = LocalDateTime.of(2025, 10, 16, 22, 0, 0, 0), end = LocalDateTime.of(2025, 10, 16, 23, 0, 0), + startHours = 22f, + endHours = 23f, duration = 60.minutes, name = "C1 Program #3", subtitle = null, seasonEpisode = null, ), + TvProgram( + id = UUID.randomUUID(), + channelId = channel1Id, + start = LocalDateTime.of(2025, 10, 17, 1, 0, 0, 0), + end = LocalDateTime.of(2025, 10, 17, 2, 0, 0), + startHours = 25f, + endHours = 26f, + duration = 60.minutes, + name = "C1 Program #4", + subtitle = null, + seasonEpisode = null, + ), ), channel2Id to listOf( @@ -505,6 +591,8 @@ val programs = channelId = channel2Id, start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), end = LocalDateTime.of(2025, 10, 16, 18, 30, 0), + startHours = 18f, + endHours = 18.5f, duration = 30.minutes, name = "C2 Program #1", subtitle = null, @@ -515,6 +603,8 @@ val programs = channelId = channel2Id, start = LocalDateTime.of(2025, 10, 16, 18, 30, 0), end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), + startHours = 18.5f, + endHours = 19f, duration = 60.minutes, name = "C2 Program #2", subtitle = null, @@ -525,6 +615,8 @@ val programs = channelId = channel2Id, start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), + startHours = 19.5f, + endHours = 20f, duration = 30.minutes, name = "C2 Program #3", subtitle = null, @@ -535,6 +627,8 @@ val programs = channelId = channel2Id, start = LocalDateTime.of(2025, 10, 16, 21, 0, 0, 0), end = LocalDateTime.of(2025, 10, 16, 22, 0, 0), + startHours = 21f, + endHours = 22f, duration = 60.minutes, name = "C2 Program #4", subtitle = null, @@ -549,6 +643,8 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), end = LocalDateTime.of(2025, 10, 16, 18, 15, 0), duration = 15.minutes, + startHours = 18f, + endHours = 18.25f, name = "C3 Program #1", subtitle = null, seasonEpisode = null, @@ -558,6 +654,8 @@ val programs = channelId = channel3Id, start = LocalDateTime.of(2025, 10, 16, 18, 15, 0), end = LocalDateTime.of(2025, 10, 16, 18, 45, 0), + startHours = 18.25f, + endHours = 18.75f, duration = 30.minutes, name = "C3 Program #2", subtitle = null, @@ -568,6 +666,8 @@ val programs = channelId = channel3Id, start = LocalDateTime.of(2025, 10, 16, 18, 45, 0), end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), + startHours = 18.75f, + endHours = 19f, duration = 15.minutes, name = "C3 Program #3", subtitle = null, @@ -578,6 +678,8 @@ val programs = channelId = channel3Id, start = LocalDateTime.of(2025, 10, 16, 19, 0, 0), end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), + startHours = 19f, + endHours = 20f, duration = 60.minutes, name = "C3 Program #3", subtitle = null, @@ -591,6 +693,8 @@ val programs = channelId = channel4Id, start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), + startHours = 18f, + endHours = 19f, duration = 60.minutes, name = "C4 Program #1", subtitle = null, @@ -601,6 +705,8 @@ val programs = channelId = channel4Id, start = LocalDateTime.of(2025, 10, 16, 19, 0, 0), end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), + startHours = 19f, + endHours = 19.5f, duration = 30.minutes, name = "C4 Program #2", subtitle = null, @@ -611,6 +717,8 @@ val programs = channelId = channel4Id, start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), + startHours = 19.5f, + endHours = 20f, duration = 30.minutes, name = "C4 Program #3", subtitle = null,