Minimal UI implementation

This commit is contained in:
Damontecres 2025-10-20 14:09:34 -04:00
parent 24763d6d8c
commit 8af376cf63
No known key found for this signature in database
2 changed files with 301 additions and 107 deletions

View file

@ -4,10 +4,7 @@ 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.ui.detail.series.SeasonEpisode 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.GetProgramsDtoHandler
import com.github.damontecres.wholphin.util.LazyList
import com.github.damontecres.wholphin.util.LoadingExceptionHandler 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
@ -19,14 +16,19 @@ 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.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.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.AbstractList import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds
const val MAX_HOURS = 24L
@HiltViewModel @HiltViewModel
class LiveTvViewModel class LiveTvViewModel
@ -36,9 +38,12 @@ class LiveTvViewModel
) : ViewModel() { ) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val start = LocalDateTime.now() val start =
LocalDateTime.now().minusMinutes(30).truncatedTo(ChronoUnit.HOURS)
val channels = MutableLiveData<List<TvChannel>>() val channels = MutableLiveData<List<TvChannel>>()
val programs = MutableLiveData<List<TvProgram?>>() val programs = MutableLiveData<List<TvProgram>>()
val programsByChannel = MutableLiveData<Map<UUID, List<TvProgram>>>(mapOf())
private val programMap = mutableMapOf<UUID, MutableLiveData<List<TvProgram?>>>() private val programMap = mutableMapOf<UUID, MutableLiveData<List<TvProgram?>>>()
@ -47,11 +52,12 @@ class LiveTvViewModel
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 = 8, startIndex = 0,
), ),
) )
val channels = val channels =
LazyList(channelData.items) { channelData.items
.map {
TvChannel( TvChannel(
it.id, it.id,
it.channelNumber, it.channelNumber,
@ -59,89 +65,167 @@ class LiveTvViewModel
api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY), 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 = val request =
GetProgramsDto( GetProgramsDto(
minStartDate = start, maxStartDate = maxStartDate,
minEndDate = minEndDate,
// maxEndDate = start.plusHours(25),
channelIds = channels.map { it.id }, channelIds = channels.map { it.id },
sortBy = listOf(ItemSortBy.START_DATE),
) )
val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init() // val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init()
(0..<pager.size).forEach { pager.getBlocking(it) } // (0..<pager.size).forEach { pager.getBlocking(it) }
val programs = val programs =
LazyList(pager) { item -> GetProgramsDtoHandler
item?.data?.let { dto -> .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( 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 =
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, duration = dto.runTimeTicks!!.ticks,
name = dto.seriesName ?: dto.name, name = dto.seriesName ?: dto.name,
subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() }, subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() },
seasonEpisode = null, // TODO 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<TvProgram>()
val finalProgramsByChannel =
programsByChannel.toMutableMap().apply {
emptyChannels.forEach { channel ->
val fakePrograms =
(0..<MAX_HOURS).map {
TvProgram(
id = UUID.randomUUID(), // TODO
channelId = channel.id,
start = start.plusHours(it),
end = start.plusHours(it + 1),
startHours = it.toFloat(),
endHours = (it + 1).toFloat(),
duration = 60.seconds,
name = "No date",
subtitle = null,
seasonEpisode = null,
)
}
put(channel.id, fakePrograms)
fake.addAll(fakePrograms)
} }
} }
val finalProgramList =
(programs + fake).sortedWith(
compareBy(
{ channelsIdToIndex[it.channelId]!! },
{ it.start },
),
)
Timber.d("Got ${programs.size} programs & ${fake.size} fake programs")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@LiveTvViewModel.channels.value = channels this@LiveTvViewModel.channels.value = channels
this@LiveTvViewModel.programs.value = programs this@LiveTvViewModel.programs.value = finalProgramList
this@LiveTvViewModel.programsByChannel.value = finalProgramsByChannel
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
} }
} }
fun getPrograms(channelId: UUID): MutableLiveData<List<TvProgram?>> = // fun getPrograms(channelId: UUID): MutableLiveData<List<TvProgram?>> =
programMap.getOrPut(channelId) { // programMap.getOrPut(channelId) {
val data = MutableLiveData<List<TvProgram?>>(listOf()) // val data = MutableLiveData<List<TvProgram?>>(listOf())
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { // viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val request = // val request =
GetProgramsDto( // GetProgramsDto(
minStartDate = start, // minStartDate = start,
channelIds = listOf(channelId), // channelIds = listOf(channelId),
) // )
val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init() // val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init()
val programList = // val programList =
if (pager.isEmpty()) { // if (pager.isEmpty()) {
object : AbstractList<TvProgram?>() { // object : AbstractList<TvProgram?>() {
override fun get(index: Int): TvProgram? { // override fun get(index: Int): TvProgram? {
val start = start.plusHours(index.toLong()) // val start = start.plusHours(index.toLong())
val end = start.plusHours(1L) // val end = start.plusHours(1L)
return TvProgram( // return TvProgram(
id = UUID.randomUUID(), // id = UUID.randomUUID(),
channelId = channelId, // channelId = channelId,
start = start, // start = start,
end = start, // end = start,
duration = 60.minutes, // duration = 60.minutes,
name = "Unknown", // name = "Unknown",
subtitle = null, // subtitle = null,
seasonEpisode = null, // TODO // seasonEpisode = null, // TODO
) // )
} // }
//
override val size: Int // override val size: Int
get() = Int.MAX_VALUE // get() = Int.MAX_VALUE
} // }
} else { // } else {
LazyList(pager) { item -> // LazyList(pager) { item ->
item?.data?.let { dto -> // item?.data?.let { dto ->
TvProgram( // TvProgram(
id = dto.id, // id = dto.id,
channelId = channelId, // channelId = channelId,
start = dto.startDate!!, // start = dto.startDate!!,
end = dto.endDate!!, // end = dto.endDate!!,
duration = dto.runTimeTicks!!.ticks, // duration = dto.runTimeTicks!!.ticks,
name = dto.seriesName ?: dto.name, // name = dto.seriesName ?: dto.name,
subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() }, // subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() },
seasonEpisode = null, // TODO // seasonEpisode = null, // TODO
) // )
} // }
} // }
} // }
withContext(Dispatchers.Main) { // withContext(Dispatchers.Main) {
data.value = programList // data.value = programList
} // }
} // }
data // data
} // }
} }
data class TvChannel( data class TvChannel(
@ -156,6 +240,8 @@ data class TvProgram(
val channelId: UUID, val channelId: UUID,
val start: LocalDateTime, val start: LocalDateTime,
val end: LocalDateTime, val end: LocalDateTime,
val startHours: Float,
val endHours: Float,
val duration: Duration, val duration: Duration,
val name: String?, val name: String?,
val subtitle: String?, val subtitle: String?,

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -23,6 +25,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester 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.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times 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.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage 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.ifElse
import com.github.damontecres.wholphin.ui.playback.isDown import com.github.damontecres.wholphin.ui.playback.isDown
import com.github.damontecres.wholphin.ui.playback.isUp import com.github.damontecres.wholphin.ui.playback.isUp
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
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 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
@ -66,14 +75,57 @@ fun translate(
programIndex - before 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 @Composable
fun TvGrid(modifier: Modifier = Modifier) { fun TvGrid(
channels: List<TvChannel>,
programList: List<TvProgram>,
programs: Map<UUID, List<TvProgram>>,
start: LocalDateTime,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
val state = rememberSaveableProgramGuideState() val state = rememberSaveableProgramGuideState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val startHours = 6 // val timeline = 0..<24
val timeline = 16..24
var focusedProgram by rememberInt(0) var focusedProgram by rememberInt(0)
var focusedChannel by rememberInt(0) var focusedChannel by rememberInt(0)
@ -86,6 +138,8 @@ fun TvGrid(modifier: Modifier = Modifier) {
currentTimeWidth = 2.dp, currentTimeWidth = 2.dp,
) )
fun programsBeforeChannel(channelIndex: Int): Int = channels.subList(0, channelIndex).map { it.id }.sumOf { programs[it]?.size ?: 0 }
ProgramGuide( ProgramGuide(
state = state, state = state,
dimensions = dimensions, dimensions = dimensions,
@ -101,11 +155,15 @@ fun TvGrid(modifier: Modifier = Modifier) {
Key.DirectionRight -> focusedProgram + 1 Key.DirectionRight -> focusedProgram + 1
Key.DirectionLeft -> focusedProgram - 1 Key.DirectionLeft -> focusedProgram - 1
Key.DirectionUp -> { Key.DirectionUp -> {
val start = programList[focusedProgram].start.hours val start = programList[focusedProgram].startHours
focusedChannel = (focusedChannel - 1).coerceAtLeast(0) focusedChannel = (focusedChannel - 1)
if (focusedChannel < 0) {
focusManager.moveFocus(FocusDirection.Up)
null
} else {
val channelId = channels[focusedChannel].id val channelId = channels[focusedChannel].id
val pIndex = val pIndex =
programs[channelId]?.indexOfFirst { start in (it.start.hours..<it.end.hours) } programs[channelId]?.indexOfFirst { start in (it.startHours..<it.endHours) }
?: -1 ?: -1
if (pIndex >= 0) { if (pIndex >= 0) {
programsBeforeChannel(focusedChannel) + pIndex programsBeforeChannel(focusedChannel) + pIndex
@ -113,21 +171,26 @@ fun TvGrid(modifier: Modifier = Modifier) {
programsBeforeChannel(focusedChannel) + programs[channelId]!!.size 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 =
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
programsBeforeChannel(focusedChannel) + pIndex
} else {
programsBeforeChannel(focusedChannel) + programs[channelId]!!.size
}
}
Key.DirectionDown -> { Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
val start = programList[focusedProgram].start.hours Timber.v("Clicked on ${programList[focusedProgram]}")
focusedChannel = null
(focusedChannel + 1).coerceAtMost(channels.size - 1)
val channelId = channels[focusedChannel].id
val pro = programs[channelId]!!
val pIndex =
pro.indexOfFirst { start in (it.start.hours..<it.end.hours) }
?: -1
if (pIndex >= 0) {
programsBeforeChannel(focusedChannel) + pIndex
} else {
programsBeforeChannel(focusedChannel) + programs[channelId]!!.size
}
} }
else -> { else -> {
@ -148,20 +211,19 @@ fun TvGrid(modifier: Modifier = Modifier) {
return@onPreviewKeyEvent false return@onPreviewKeyEvent false
}, },
) { ) {
guideStartHour = timeline.first.toFloat() guideStartHour = 0f
timeline( timeline(
count = timeline.count(), count = MAX_HOURS.toInt(),
layoutInfo = { layoutInfo = { index ->
val start = timeline.toList()[it].toFloat()
ProgramGuideItem.Timeline( ProgramGuideItem.Timeline(
startHour = start, startHour = index.toFloat(),
endHour = start + 1f, endHour = index + 1f,
) )
}, },
) { index -> ) { index ->
val start = timeline.toList()[index].toFloat() val time = start.plusHours(index.toLong()).toLocalTime()
Text( Text(
text = "$start o'clock", text = time.toString(),
) )
} }
@ -170,7 +232,7 @@ fun TvGrid(modifier: Modifier = Modifier) {
layoutInfo = { programIndex -> layoutInfo = { programIndex ->
val program = programList[programIndex] val program = programList[programIndex]
val channelIndex = channels.indexOfFirst { it.id == program.channelId } 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 -> ) { programIndex ->
val program = programList[programIndex] val program = programList[programIndex]
@ -191,7 +253,7 @@ fun TvGrid(modifier: Modifier = Modifier) {
}, },
) { channelIndex -> ) { channelIndex ->
Text( Text(
text = channels[channelIndex].name ?: channelIndex.toString(), text = channels[channelIndex].number ?: channelIndex.toString(),
modifier = Modifier.background(MaterialTheme.colorScheme.background), modifier = Modifier.background(MaterialTheme.colorScheme.background),
) )
} }
@ -442,6 +504,8 @@ val programs =
channelId = channel1Id, channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), start = LocalDateTime.of(2025, 10, 16, 18, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), end = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
startHours = 18f,
endHours = 19f,
duration = 60.minutes, duration = 60.minutes,
name = "C1 Program #1", name = "C1 Program #1",
subtitle = null, subtitle = null,
@ -452,6 +516,8 @@ val programs =
channelId = channel1Id, channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 19, 0, 0), start = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), end = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
startHours = 19f,
endHours = 19.5f,
duration = 30.minutes, duration = 30.minutes,
name = "C1 Program #2", name = "C1 Program #2",
subtitle = null, subtitle = null,
@ -462,6 +528,8 @@ val programs =
channelId = channel1Id, channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), start = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), end = LocalDateTime.of(2025, 10, 16, 20, 0, 0),
startHours = 19.5f,
endHours = 20f,
duration = 30.minutes, duration = 30.minutes,
name = "C1 Program #3", name = "C1 Program #3",
subtitle = null, subtitle = null,
@ -472,6 +540,8 @@ val programs =
channelId = channel1Id, channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 20, 0, 0, 0), start = LocalDateTime.of(2025, 10, 16, 20, 0, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 21, 0, 0), end = LocalDateTime.of(2025, 10, 16, 21, 0, 0),
startHours = 20f,
endHours = 21f,
duration = 60.minutes, duration = 60.minutes,
name = "C1 Program #3", name = "C1 Program #3",
subtitle = null, subtitle = null,
@ -482,6 +552,8 @@ val programs =
channelId = channel1Id, channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 21, 0, 0, 0), start = LocalDateTime.of(2025, 10, 16, 21, 0, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 22, 0, 0), end = LocalDateTime.of(2025, 10, 16, 22, 0, 0),
startHours = 21f,
endHours = 22f,
duration = 60.minutes, duration = 60.minutes,
name = "C1 Program #3", name = "C1 Program #3",
subtitle = null, subtitle = null,
@ -492,11 +564,25 @@ val programs =
channelId = channel1Id, channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 22, 0, 0, 0), start = LocalDateTime.of(2025, 10, 16, 22, 0, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 23, 0, 0), end = LocalDateTime.of(2025, 10, 16, 23, 0, 0),
startHours = 22f,
endHours = 23f,
duration = 60.minutes, duration = 60.minutes,
name = "C1 Program #3", name = "C1 Program #3",
subtitle = null, subtitle = null,
seasonEpisode = 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 channel2Id to
listOf( listOf(
@ -505,6 +591,8 @@ val programs =
channelId = channel2Id, channelId = channel2Id,
start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), start = LocalDateTime.of(2025, 10, 16, 18, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 18, 30, 0), end = LocalDateTime.of(2025, 10, 16, 18, 30, 0),
startHours = 18f,
endHours = 18.5f,
duration = 30.minutes, duration = 30.minutes,
name = "C2 Program #1", name = "C2 Program #1",
subtitle = null, subtitle = null,
@ -515,6 +603,8 @@ val programs =
channelId = channel2Id, channelId = channel2Id,
start = LocalDateTime.of(2025, 10, 16, 18, 30, 0), start = LocalDateTime.of(2025, 10, 16, 18, 30, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), end = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
startHours = 18.5f,
endHours = 19f,
duration = 60.minutes, duration = 60.minutes,
name = "C2 Program #2", name = "C2 Program #2",
subtitle = null, subtitle = null,
@ -525,6 +615,8 @@ val programs =
channelId = channel2Id, channelId = channel2Id,
start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), start = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), end = LocalDateTime.of(2025, 10, 16, 20, 0, 0),
startHours = 19.5f,
endHours = 20f,
duration = 30.minutes, duration = 30.minutes,
name = "C2 Program #3", name = "C2 Program #3",
subtitle = null, subtitle = null,
@ -535,6 +627,8 @@ val programs =
channelId = channel2Id, channelId = channel2Id,
start = LocalDateTime.of(2025, 10, 16, 21, 0, 0, 0), start = LocalDateTime.of(2025, 10, 16, 21, 0, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 22, 0, 0), end = LocalDateTime.of(2025, 10, 16, 22, 0, 0),
startHours = 21f,
endHours = 22f,
duration = 60.minutes, duration = 60.minutes,
name = "C2 Program #4", name = "C2 Program #4",
subtitle = null, subtitle = null,
@ -549,6 +643,8 @@ val programs =
start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), start = LocalDateTime.of(2025, 10, 16, 18, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 18, 15, 0), end = LocalDateTime.of(2025, 10, 16, 18, 15, 0),
duration = 15.minutes, duration = 15.minutes,
startHours = 18f,
endHours = 18.25f,
name = "C3 Program #1", name = "C3 Program #1",
subtitle = null, subtitle = null,
seasonEpisode = null, seasonEpisode = null,
@ -558,6 +654,8 @@ val programs =
channelId = channel3Id, channelId = channel3Id,
start = LocalDateTime.of(2025, 10, 16, 18, 15, 0), start = LocalDateTime.of(2025, 10, 16, 18, 15, 0),
end = LocalDateTime.of(2025, 10, 16, 18, 45, 0), end = LocalDateTime.of(2025, 10, 16, 18, 45, 0),
startHours = 18.25f,
endHours = 18.75f,
duration = 30.minutes, duration = 30.minutes,
name = "C3 Program #2", name = "C3 Program #2",
subtitle = null, subtitle = null,
@ -568,6 +666,8 @@ val programs =
channelId = channel3Id, channelId = channel3Id,
start = LocalDateTime.of(2025, 10, 16, 18, 45, 0), start = LocalDateTime.of(2025, 10, 16, 18, 45, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), end = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
startHours = 18.75f,
endHours = 19f,
duration = 15.minutes, duration = 15.minutes,
name = "C3 Program #3", name = "C3 Program #3",
subtitle = null, subtitle = null,
@ -578,6 +678,8 @@ val programs =
channelId = channel3Id, channelId = channel3Id,
start = LocalDateTime.of(2025, 10, 16, 19, 0, 0), start = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), end = LocalDateTime.of(2025, 10, 16, 20, 0, 0),
startHours = 19f,
endHours = 20f,
duration = 60.minutes, duration = 60.minutes,
name = "C3 Program #3", name = "C3 Program #3",
subtitle = null, subtitle = null,
@ -591,6 +693,8 @@ val programs =
channelId = channel4Id, channelId = channel4Id,
start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), start = LocalDateTime.of(2025, 10, 16, 18, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), end = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
startHours = 18f,
endHours = 19f,
duration = 60.minutes, duration = 60.minutes,
name = "C4 Program #1", name = "C4 Program #1",
subtitle = null, subtitle = null,
@ -601,6 +705,8 @@ val programs =
channelId = channel4Id, channelId = channel4Id,
start = LocalDateTime.of(2025, 10, 16, 19, 0, 0), start = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), end = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
startHours = 19f,
endHours = 19.5f,
duration = 30.minutes, duration = 30.minutes,
name = "C4 Program #2", name = "C4 Program #2",
subtitle = null, subtitle = null,
@ -611,6 +717,8 @@ val programs =
channelId = channel4Id, channelId = channel4Id,
start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), start = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), end = LocalDateTime.of(2025, 10, 16, 20, 0, 0),
startHours = 19.5f,
endHours = 20f,
duration = 30.minutes, duration = 30.minutes,
name = "C4 Program #3", name = "C4 Program #3",
subtitle = null, subtitle = null,