diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 68dbd17b..2948e841 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -228,6 +228,7 @@ dependencies { implementation(libs.timber) implementation(libs.aboutlibraries.core) implementation(libs.aboutlibraries.compose.m3) + implementation(libs.programguide) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ChannelRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ChannelRow.kt new file mode 100644 index 00000000..3f105e65 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ChannelRow.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.ui.detail.livetv + +import androidx.compose.runtime.Composable + +@Composable +fun ChannelRow() { +} 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 new file mode 100644 index 00000000..280900ca --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -0,0 +1,163 @@ +package com.github.damontecres.wholphin.ui.detail.livetv + +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 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +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.GetProgramsDto +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest +import org.jellyfin.sdk.model.extensions.ticks +import java.time.LocalDateTime +import java.util.AbstractList +import java.util.UUID +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +@HiltViewModel +class LiveTvViewModel + @Inject + constructor( + val api: ApiClient, + ) : ViewModel() { + val loading = MutableLiveData(LoadingState.Pending) + + val start = LocalDateTime.now() + val channels = MutableLiveData>() + val programs = MutableLiveData>() + + private val programMap = mutableMapOf>>() + + fun init() { + loading.value = LoadingState.Loading + viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Could not fetch channels")) { + val channelData by api.liveTvApi.getLiveTvChannels( + GetLiveTvChannelsRequest( + startIndex = 8, + ), + ) + val channels = + LazyList(channelData.items) { + TvChannel( + it.id, + it.channelNumber, + it.channelName, + api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY), + ) + } + val request = + GetProgramsDto( + minStartDate = start, + channelIds = channels.map { it.id }, + ) + val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init() + (0.. + item?.data?.let { dto -> + TvProgram( + id = dto.id, + channelId = dto.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) { + this@LiveTvViewModel.channels.value = channels + this@LiveTvViewModel.programs.value = programs + loading.value = LoadingState.Success + } + } + } + + 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( + val id: UUID, + val number: String?, + val name: String?, + val imageUrl: String?, +) + +data class TvProgram( + val id: UUID, + val channelId: UUID, + val start: LocalDateTime, + val end: LocalDateTime, + val duration: Duration, + val name: String?, + val subtitle: String?, + val seasonEpisode: SeasonEpisode?, +) 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 new file mode 100644 index 00000000..49458cc2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGrid.kt @@ -0,0 +1,490 @@ +package com.github.damontecres.wholphin.ui.detail.livetv + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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 +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +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.remember +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.util.LoadingState +import eu.wewox.programguide.ProgramGuide +import eu.wewox.programguide.ProgramGuideItem +import timber.log.Timber +import java.time.LocalDateTime +import java.util.UUID +import kotlin.time.Duration.Companion.minutes + +private val hourWidth = 200.dp + +data class Program( + val channel: Int, + val start: Float, + val end: Float, + val title: String, +) + +/** + * Formats time. + */ +fun formatTime( + from: Float, + to: Float? = null, +): String { + fun Float.hour(): String = toInt().formatWithPrefix(2) + + fun Float.minutes(): String = ((this % 1) * 60).toInt().formatWithPrefix(2) + + val fromFormatted = "${from.hour()}:${from.minutes()}" + return if (to != null) { + val toFormatted = "${to.hour()}:${to.minutes()}" + "$fromFormatted - $toFormatted" + } else { + fromFormatted + } +} + +/** + * Creates a list of programs to view in program guide. + */ +fun createPrograms( + channels: Int = CHANNELS_COUNT, + timeline: IntRange = 0..HOURS_COUNT, +): List { + var channel = 0 + var hour = timeline.first + HOURS.random() + return buildList { + while (channel < channels) { + while (true) { + val end = hour + HOURS.random() + if (end > timeline.last) { + break + } + + add(Program(channel, hour, end, "Program #$size")) + hour = end + } + hour = timeline.first + HOURS.random() + channel += 1 + } + } +} + +private fun Int.formatWithPrefix( + length: Int, + prefix: Char = '0', +): String { + val number = toString() + val prefixLength = (length - number.length).coerceAtLeast(0) + val prefixFull = List(prefixLength) { prefix }.joinToString(separator = "") + return "$prefixFull$number" +} + +private val HOURS = listOf(0.5f, 1f, 1.25f, 1.5f, 2f, 2.25f, 2.5f) + +private const val CHANNELS_COUNT = 30 +private const val HOURS_COUNT = 24 + +@Composable +fun TvGrid(modifier: Modifier = Modifier) { + val channels = 20 + val timeline = 8..22 + val programs = remember { createPrograms(channels, timeline) } + ProgramGuide( + modifier = + modifier, + ) { + guideStartHour = timeline.first.toFloat() + + programs( + items = programs, + layoutInfo = { + ProgramGuideItem.Program( + channelIndex = it.channel, + startHour = it.start, + endHour = it.end, + ) + }, + itemContent = { + ListItem( + selected = false, + onClick = {}, + headlineContent = { + Text( + text = it.title, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier, + ) + }, + modifier = Modifier, + ) + }, + ) + + channels( + count = channels, + layoutInfo = { + ProgramGuideItem.Channel( + index = it, + ) + }, + itemContent = { + Text( + text = it.toString(), + color = MaterialTheme.colorScheme.onSurface, + ) + }, + ) + + timeline( + count = timeline.count(), + layoutInfo = { + val start = timeline.toList()[it].toFloat() + ProgramGuideItem.Timeline( + startHour = start, + endHour = start + 1f, + ) + }, + itemContent = { + Text( + text = "${timeline.toList()[it].toFloat()} o'clock", + color = MaterialTheme.colorScheme.onSurface, + ) + }, + ) + } +} + +@Composable +fun TvGrid3( + modifier: Modifier = Modifier, + viewModel: LiveTvViewModel = hiltViewModel(), +) { + OneTimeLaunchedEffect { + viewModel.init() + } + val loading by viewModel.loading.observeAsState(LoadingState.Pending) + + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state) + + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() + + LoadingState.Success -> { + val channels by viewModel.channels.observeAsState(listOf()) + val programs by viewModel.programs.observeAsState(listOf()) + ProgramGuide( + modifier = modifier, + ) { + guideStartHour = 22f + programs( + items = programs, + layoutInfo = { p -> + if (p != null) { + Timber.v("${p.id}: ${p.start.hour + p.start.minute / 60f}") + ProgramGuideItem.Program( + channelIndex = channels.indexOfFirst { it.id == p.channelId }, + startHour = p.start.hour + p.start.minute / 60f, + endHour = p.start.hour + p.start.minute / 60f, + ) + } else { + ProgramGuideItem.Program(0, 0f, 0f) + } + }, + itemContent = { program -> +// Timber.v("Render $program") + Text( + text = program?.name ?: "", + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.focusable(), + ) +// ListItem( +// selected = false, +// onClick = {}, +// headlineContent = { +// Text( +// text = program?.name ?: "", +// color = MaterialTheme.colorScheme.onSurface, +// modifier = Modifier, +// ) +// }, +// supportingContent = { +// program?.subtitle?.let { +// Text( +// text = program.subtitle, +// color = MaterialTheme.colorScheme.onSurface, +// ) +// } +// }, +// modifier = Modifier, +// ) + }, + ) + channels( + count = channels.size, + layoutInfo = { + ProgramGuideItem.Channel( + index = it, + ) + }, + itemContent = { + val channel = channels[it] + Text( + text = channel.number ?: "", + ) + }, + ) + timeline( + count = 12, + layoutInfo = { + val start = 22f + it + ProgramGuideItem.Timeline( + startHour = start, + endHour = start + 1f, + ) + }, + itemContent = { + Text( + text = "${22f + it} o'clock", + ) + }, + ) + } + } + } +} + +@Composable +fun TvGrid2( + modifier: Modifier = Modifier, + viewModel: LiveTvViewModel = hiltViewModel(), +) { + OneTimeLaunchedEffect { + viewModel.init() + } + val loading by viewModel.loading.observeAsState(LoadingState.Pending) + + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state) + + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() + + LoadingState.Success -> { + val channels by viewModel.channels.observeAsState(listOf()) + var focusedRowIndex by rememberInt() + + val columnState = rememberLazyListState() + val rowStates = + rememberSaveable( + saver = + listSaver( + save = { it.map { listOf(it.firstVisibleItemIndex, it.firstVisibleItemScrollOffset) } }, + restore = { + it.map { + LazyListState( + firstVisibleItemIndex = it[0], + firstVisibleItemScrollOffset = it[1], + ) + } + }, + ), + ) { List(channels.size) { LazyListState(0, 0) } } + + val currentRowState = rowStates[focusedRowIndex] + LaunchedEffect(currentRowState.firstVisibleItemScrollOffset) { + rowStates.forEachIndexed { index, state -> + if (index != focusedRowIndex) { + currentRowState.layoutInfo.visibleItemsInfo + state.scrollToItem(currentRowState.firstVisibleItemIndex, currentRowState.firstVisibleItemScrollOffset) + } + } + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + state = columnState, + modifier = modifier, + ) { + itemsIndexed(channels) { channelIndex, channel -> + val programs by viewModel.getPrograms(channel.id).observeAsState(listOf()) + ChannelRow( + lazyListState = rowStates[channelIndex], + channel = channel, + programs = programs, + modifier = + Modifier + .height(56.dp) + .fillMaxWidth() + .onFocusChanged { + if (it.hasFocus) { + focusedRowIndex = channelIndex + } + }, + ) + } + } + } + } +} + +@Composable +fun ChannelRow( + lazyListState: LazyListState, + channel: TvChannel, + programs: List, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + ) { + ListItem( + selected = false, + onClick = { + Timber.v("Clicked channel $channel") + }, + leadingContent = { + Text( + text = channel.number ?: "", + modifier = Modifier.width(40.dp), + ) + }, + headlineContent = { + Text( + text = channel.name ?: "", + modifier = Modifier.width(80.dp), + ) + }, + trailingContent = { + AsyncImage( + model = channel.imageUrl, + contentDescription = null, + modifier = Modifier.width(64.dp).fillMaxHeight(), + ) + }, + modifier = Modifier.width(220.dp).fillMaxHeight(), + ) + LazyRow( + state = lazyListState, + modifier = + Modifier + .weight(1f) + .fillMaxSize(), + ) { + itemsIndexed(programs) { programIndex, program -> + Timber.v("program=$program") + val duration = (program?.duration ?: 30.minutes).coerceAtLeast(2.minutes) + val width = duration.inWholeMinutes / 60f * hourWidth + ListItem( + selected = false, + onClick = {}, + headlineContent = { + Text( + text = program?.name ?: "", + modifier = Modifier, + ) + }, + supportingContent = { + program?.subtitle?.let { + Text( + text = program.subtitle, + ) + } + }, + modifier = Modifier.width(width).fillMaxHeight(), + ) + } + } + } +} + +@PreviewTvSpec +@Composable +private fun ChannelRowPreview() { + val channelId = UUID.randomUUID() + val channel = + TvChannel( + id = channelId, + number = "2.1", + name = "WJTV", + imageUrl = "", + ) + val programs = + listOf( + TvProgram( + id = UUID.randomUUID(), + channelId = channelId, + start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), + end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), + duration = 60.minutes, + name = "Program #1", + subtitle = null, + seasonEpisode = null, + ), + TvProgram( + id = UUID.randomUUID(), + channelId = channelId, + start = LocalDateTime.of(2025, 10, 16, 19, 0, 0), + end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), + duration = 30.minutes, + name = "Program #1", + subtitle = null, + seasonEpisode = null, + ), + TvProgram( + id = UUID.randomUUID(), + channelId = channelId, + start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), + end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), + duration = 30.minutes, + name = "Program #1", + subtitle = null, + seasonEpisode = null, + ), + ) + WholphinTheme { + ChannelRow( + lazyListState = rememberLazyListState(), + channel = channel, + programs = programs, + modifier = Modifier.height(140.dp), + ) + } +} + +@PreviewTvSpec +@Composable +private fun TvGridPreview() { +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 89f2e203..18d85518 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -85,6 +85,9 @@ sealed class Destination( val recursive: Boolean, ) : Destination(false) + @Serializable + data object LiveTvGuide : Destination(false) + @Serializable data object UpdateApp : Destination(true) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index d924e2b5..3c1144fa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv import com.github.damontecres.wholphin.ui.detail.DebugPage import com.github.damontecres.wholphin.ui.detail.PlaylistDetails import com.github.damontecres.wholphin.ui.detail.SeriesDetails +import com.github.damontecres.wholphin.ui.detail.livetv.TvGrid import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.main.HomePage @@ -166,6 +167,11 @@ fun DestinationContent( modifier = modifier, ) + Destination.LiveTvGuide -> + TvGrid( + modifier = modifier, + ) + Destination.UpdateApp -> InstallUpdatePage(preferences, modifier) Destination.License -> LicenseInfo(modifier) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 14559c34..5695518a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings @@ -212,6 +213,24 @@ fun NavDrawer( ), ) } + + // TODO placeholder + item { + IconNavItem( + text = "Live TV", + icon = Icons.Default.Build, + selected = selectedIndex == -3, + onClick = { + viewModel.setIndex(-3) + viewModel.navigationManager.navigateToFromDrawer(Destination.LiveTvGuide) + }, + modifier = + Modifier.ifElse( + selectedIndex == -2, + Modifier.focusRequester(focusRequester), + ), + ) + } itemsIndexed(libraries) { index, it -> LibraryNavItem( library = it, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index 3cf7668c..bde82618 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -17,10 +17,12 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.GetProgramsDto import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest @@ -317,3 +319,22 @@ val GetGenresRequestHandler = request: GetGenresRequest, ): Response = api.genresApi.getGenres(request) } + +val GetProgramsDtoHandler = + object : RequestHandler { + override fun prepare( + request: GetProgramsDto, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetProgramsDto = + request.copy( + startIndex = startIndex, + limit = limit, + ) + + override suspend fun execute( + api: ApiClient, + request: GetProgramsDto, + ): Response = api.liveTvApi.getPrograms(request) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LazyList.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LazyList.kt new file mode 100644 index 00000000..e6eb2a98 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LazyList.kt @@ -0,0 +1,11 @@ +package com.github.damontecres.wholphin.util + +class LazyList( + private val source: List, + private val transform: (Source) -> Transform, +) : AbstractList() { + override fun get(index: Int): Transform = transform.invoke(source[index]) + + override val size: Int + get() = source.size +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8e585bdb..cbe514f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2025.10.00" compose-runtime = "1.9.3" +programguide = "1.6.0" timber = "5.0.1" tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" @@ -56,6 +57,7 @@ androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "d desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }