This commit is contained in:
Damontecres 2025-10-16 23:02:53 -04:00
parent 77aaa87126
commit 548f942609
No known key found for this signature in database
10 changed files with 723 additions and 0 deletions

View file

@ -228,6 +228,7 @@ dependencies {
implementation(libs.timber) implementation(libs.timber)
implementation(libs.aboutlibraries.core) implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose.m3) implementation(libs.aboutlibraries.compose.m3)
implementation(libs.programguide)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -0,0 +1,7 @@
package com.github.damontecres.wholphin.ui.detail.livetv
import androidx.compose.runtime.Composable
@Composable
fun ChannelRow() {
}

View file

@ -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>(LoadingState.Pending)
val start = LocalDateTime.now()
val channels = MutableLiveData<List<TvChannel>>()
val programs = MutableLiveData<List<TvProgram?>>()
private val programMap = mutableMapOf<UUID, MutableLiveData<List<TvProgram?>>>()
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..<pager.size).forEach { pager.getBlocking(it) }
val programs =
LazyList(pager) { item ->
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<List<TvProgram?>> =
programMap.getOrPut(channelId) {
val data = MutableLiveData<List<TvProgram?>>(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<TvProgram?>() {
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?,
)

View file

@ -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<Program> {
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<TvProgram?>,
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() {
}

View file

@ -85,6 +85,9 @@ sealed class Destination(
val recursive: Boolean, val recursive: Boolean,
) : Destination(false) ) : Destination(false)
@Serializable
data object LiveTvGuide : Destination(false)
@Serializable @Serializable
data object UpdateApp : Destination(true) data object UpdateApp : Destination(true)

View file

@ -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.DebugPage
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
import com.github.damontecres.wholphin.ui.detail.SeriesDetails 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.movie.MovieDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.HomePage
@ -166,6 +167,11 @@ fun DestinationContent(
modifier = modifier, modifier = modifier,
) )
Destination.LiveTvGuide ->
TvGrid(
modifier = modifier,
)
Destination.UpdateApp -> InstallUpdatePage(preferences, modifier) Destination.UpdateApp -> InstallUpdatePage(preferences, modifier)
Destination.License -> LicenseInfo(modifier) Destination.License -> LicenseInfo(modifier)

View file

@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle 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.Home
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings 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 -> itemsIndexed(libraries) { index, it ->
LibraryNavItem( LibraryNavItem(
library = it, library = it,

View file

@ -17,10 +17,12 @@ import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.itemsApi 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.playlistsApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult 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.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
@ -317,3 +319,22 @@ val GetGenresRequestHandler =
request: GetGenresRequest, request: GetGenresRequest,
): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request) ): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request)
} }
val GetProgramsDtoHandler =
object : RequestHandler<GetProgramsDto> {
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<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request)
}

View file

@ -0,0 +1,11 @@
package com.github.damontecres.wholphin.util
class LazyList<Source, Transform>(
private val source: List<Source>,
private val transform: (Source) -> Transform,
) : AbstractList<Transform>() {
override fun get(index: Int): Transform = transform.invoke(source[index])
override val size: Int
get() = source.size
}

View file

@ -9,6 +9,7 @@ coreKtx = "1.17.0"
appcompat = "1.7.1" appcompat = "1.7.1"
composeBom = "2025.10.00" composeBom = "2025.10.00"
compose-runtime = "1.9.3" compose-runtime = "1.9.3"
programguide = "1.6.0"
timber = "5.0.1" timber = "5.0.1"
tvFoundation = "1.0.0-alpha12" tvFoundation = "1.0.0-alpha12"
tvMaterial = "1.0.1" 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" } 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 = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", 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" } 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-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" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }