Add Live TV & DVR Support (#49)

Closes #1 

Adds support for Live TV & DVR functionality.

TODO:
- [x] EPG / TV Guide
- [x] View DVR schedule
- [x] Play live TV channel
- [x] Record program
- [x] Record Series
- [x] Cancel recordings
- [x] Access previously recorded content
This commit is contained in:
damontecres 2025-10-23 22:46:16 -04:00 committed by GitHub
parent a35ecb6570
commit 05f3811149
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1915 additions and 15 deletions

View file

@ -230,6 +230,7 @@ dependencies {
implementation(libs.aboutlibraries.compose.m3) implementation(libs.aboutlibraries.compose.m3)
implementation(libs.multiplatform.markdown.renderer) implementation(libs.multiplatform.markdown.renderer)
implementation(libs.multiplatform.markdown.renderer.m3) implementation(libs.multiplatform.markdown.renderer.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

@ -22,6 +22,11 @@ sealed class AppColors private constructor() {
val TransparentBlack25 = Color(0x40000000) val TransparentBlack25 = Color(0x40000000)
val TransparentBlack50 = Color(0x80000000) val TransparentBlack50 = Color(0x80000000)
val TransparentBlack75 = Color(0xBF000000) val TransparentBlack75 = Color(0xBF000000)
val DarkGreen = Color(0xFF114000)
val DarkRed = Color(0xFF400000)
val DarkCyan = Color(0xFF21556E)
val DarkPurple = Color(0xFF261370)
} }
} }

View file

@ -0,0 +1,222 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Tab
import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule
import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class LiveTvCollectionViewModel
@Inject
constructor(
val api: ApiClient,
val serverRepository: ServerRepository,
val navigationManager: NavigationManager,
val rememberTabManager: RememberTabManager,
) : ViewModel(),
RememberTabManager by rememberTabManager {
val recordingFolders = MutableLiveData<List<TabId>>()
init {
viewModelScope.launchIO {
val folders =
api.liveTvApi
.getRecordingFolders(userId = serverRepository.currentUser?.id)
.content.items
.map { TabId(it.name ?: "Recordings", it.id) }
this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders)
}
}
}
data class TabId(
val title: String,
val id: UUID,
)
@Composable
fun CollectionFolderLiveTv(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: LiveTvCollectionViewModel = hiltViewModel(),
) {
val rememberedTabIndex =
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
val folders by viewModel.recordingFolders.observeAsState(listOf())
val tabs =
listOf(
TabId("Guide", UUID.randomUUID()),
TabId("DVR Schedule", UUID.randomUUID()),
) + folders
var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() }
val firstTabFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) {
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
}
val onClickItem = { item: BaseItem ->
viewModel.navigationManager.navigateTo(item.destination())
}
var showHeader by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
modifier = modifier,
) {
AnimatedVisibility(
showHeader,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
TabRow(
selectedTabIndex = focusTabIndex,
modifier =
Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
.focusRestorer(firstTabFocusRequester)
.onFocusChanged {
if (!it.isFocused) {
focusTabIndex = selectedTabIndex
}
},
indicator =
@Composable { tabPositions, doesTabRowHaveFocus ->
tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition ->
// TabRowDefaults.PillIndicator(
// currentTabPosition = currentTabPosition,
// doesTabRowHaveFocus = doesTabRowHaveFocus,
// )
TabRowDefaults.UnderlinedIndicator(
currentTabPosition = currentTabPosition,
doesTabRowHaveFocus = doesTabRowHaveFocus,
activeColor = MaterialTheme.colorScheme.border,
)
}
},
tabs = {
tabs.forEachIndexed { index, tabId ->
Tab(
selected = focusTabIndex == index,
onClick = { selectedTabIndex = index },
onFocus = { focusTabIndex = index },
colors =
TabDefaults.pillIndicatorTabColors(
focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
Text(
text = tabId.title,
style = MaterialTheme.typography.titleMedium,
modifier =
Modifier
.padding(8.dp)
.ifElse(
index == selectedTabIndex,
Modifier.focusRequester(firstTabFocusRequester),
),
)
}
}
},
)
}
when (selectedTabIndex) {
0 -> {
TvGuideGrid(
true,
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
)
}
1 -> {
DvrSchedule(
true,
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
)
}
else -> {
val folderIndex = selectedTabIndex - 2
if (folderIndex in folders.indices) {
CollectionFolderGrid(
preferences = preferences,
onClickItem = onClickItem,
itemId = folders[folderIndex].id,
item = null,
initialFilter = GetItemsFilter(),
showTitle = showHeader,
recursive = false,
modifier =
Modifier
.padding(start = 16.dp),
positionCallback = { columns, position ->
showHeader = position < columns
},
)
} else {
ErrorMessage("Invalid tab index $selectedTabIndex", null)
}
}
}
}
}

View file

@ -0,0 +1,320 @@
package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.ListItem
import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.seasonEpisode
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneId
import javax.inject.Inject
@HiltViewModel
class DvrScheduleViewModel
@Inject
constructor(
private val api: ApiClient,
val navigationManager: NavigationManager,
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val active = MutableLiveData<List<BaseItem>>()
val scheduled = MutableLiveData<Map<LocalDate, List<BaseItem>>>()
fun init() {
// loading.value = LoadingState.Loading
viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching DVR Schedule")) {
val active =
api.liveTvApi
.getRecordings(
isInProgress = true,
fields = SlimItemFields,
limit = 100,
).content.items
.map { BaseItem.from(it, api, true) }
val scheduled =
api.liveTvApi
.getTimers(
isActive = false,
isScheduled = true,
).content.items
.map { BaseItem.from(it.programInfo!!, api, true) } // TODO this probably breaks for time based recordings
.groupBy {
it.data.startDate!!.toLocalDate()
}
withContext(Dispatchers.Main) {
this@DvrScheduleViewModel.active.value = active
this@DvrScheduleViewModel.scheduled.value = scheduled
loading.value = LoadingState.Success
}
}
}
fun cancelRecording(
timerId: String,
series: Boolean,
) {
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
if (series) {
api.liveTvApi.cancelSeriesTimer(timerId)
} else {
api.liveTvApi.cancelTimer(timerId)
}
init()
}
}
}
@Composable
fun DvrSchedule(
requestFocusAfterLoading: Boolean,
modifier: Modifier = Modifier,
viewModel: DvrScheduleViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init()
}
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val active by viewModel.active.observeAsState(listOf())
val recordings by viewModel.scheduled.observeAsState(mapOf())
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state, modifier)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage(modifier)
LoadingState.Success -> {
var showDialog by remember { mutableStateOf<BaseItem?>(null) }
val focusRequester = remember { FocusRequester() }
if (requestFocusAfterLoading) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
}
DvrScheduleContent(
activeRecordings = active,
scheduledRecordings = recordings,
onClickItem = {
showDialog = it
},
modifier =
modifier
.focusRequester(focusRequester),
)
showDialog?.let { item ->
ProgramDialog(
item = item,
canRecord = true,
loading = LoadingState.Success,
onDismissRequest = {
showDialog = null
},
onWatch = {
item.data.channelId?.let {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it,
positionMs = 0L,
),
)
}
},
onRecord = {
// no-op
},
onCancelRecord = { series ->
showDialog = null
val timerId = if (series) item.data.seriesTimerId else item.data.timerId
if (timerId != null) {
viewModel.cancelRecording(timerId, series)
}
},
)
}
}
}
}
@Composable
fun DvrScheduleContent(
activeRecordings: List<BaseItem>,
scheduledRecordings: Map<LocalDate, List<BaseItem>>,
onClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
Box(
contentAlignment = Alignment.TopCenter,
modifier = modifier,
) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth(.6f)
.background(MaterialTheme.colorScheme.surface),
) {
if (activeRecordings.isEmpty() && scheduledRecordings.isEmpty()) {
item {
Text(
text = "No scheduled recordings",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
if (activeRecordings.isNotEmpty()) {
item {
Text(
text = "Active",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
}
items(activeRecordings) { item ->
Recording(
item = item,
onClick = { onClickItem.invoke(item) },
modifier = Modifier,
)
}
}
scheduledRecordings.keys.sorted().forEach { date ->
val formattedDate =
DateUtils.formatDateTime(
context,
date
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.epochSecond * 1000,
DateUtils.FORMAT_SHOW_WEEKDAY or DateUtils.FORMAT_SHOW_DATE,
)
item {
Text(
text = formattedDate,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
}
val programs = scheduledRecordings[date].orEmpty()
items(programs) { item ->
Recording(
item = item,
onClick = { onClickItem.invoke(item) },
modifier = Modifier,
)
}
}
}
}
}
@Composable
fun Recording(
item: BaseItem,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
ListItem(
selected = false,
onClick = onClick,
modifier = modifier,
colors =
ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
),
leadingContent = {
// TODO
// AsyncImage(
// model = item.imageUrl,
// contentDescription = null,
// )
},
headlineContent = {
Text(
text = item.title ?: "",
)
},
supportingContent = {
if (item.data.isSeries ?: false) {
listOfNotNull(
item.data.seasonEpisode,
item.data.episodeTitle,
).joinToString(" - ")
.ifBlank { null }
?.let {
Text(
text = it,
modifier = Modifier,
)
}
}
},
trailingContent = {
val time =
DateUtils.formatDateRange(
context,
item.data.startDate!!
.toInstant(OffsetDateTime.now().offset)
.epochSecond * 1000,
item.data.endDate!!
.toInstant(OffsetDateTime.now().offset)
.epochSecond * 1000,
DateUtils.FORMAT_SHOW_TIME,
)
Text(
text = time,
)
},
)
}

View file

@ -0,0 +1,350 @@
package com.github.damontecres.wholphin.ui.detail.livetv
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ExceptionHandler
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.sync.Mutex
import kotlinx.coroutines.sync.withLock
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.ItemSortBy
import org.jellyfin.sdk.model.api.TimerInfoDto
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.api.request.GetLiveTvProgramsRequest
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
const val MAX_HOURS = 48L
@HiltViewModel
class LiveTvViewModel
@Inject
constructor(
val api: ApiClient,
val navigationManager: NavigationManager,
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
lateinit var start: LocalDateTime
private set
private lateinit var channelsIdToIndex: Map<UUID, Int>
private val mutex = Mutex()
val channels = MutableLiveData<List<TvChannel>>()
val programs = MutableLiveData<List<TvProgram>>()
val programsByChannel = MutableLiveData<Map<UUID, List<TvProgram>>>(mapOf())
val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending)
val fetchedItem = MutableLiveData<BaseItem?>(null)
fun init(firstLoad: Boolean) {
start = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
if (!firstLoad) {
loading.value = LoadingState.Loading
}
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Could not fetch channels")) {
val channelData by api.liveTvApi.getLiveTvChannels(
GetLiveTvChannelsRequest(
startIndex = 0,
),
)
val channels =
channelData.items
.map {
TvChannel(
it.id,
it.channelNumber,
it.channelName,
api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY),
)
}
Timber.d("Got ${channels.size} channels")
channelsIdToIndex =
channels.withIndex().associateBy({ it.value.id }, { it.index })
fetchPrograms(channels)
withContext(Dispatchers.Main) {
this@LiveTvViewModel.channels.value = channels
loading.value = LoadingState.Success
}
}
}
private suspend fun fetchProgramsWithLoading(channels: List<TvChannel>) {
loading.setValueOnMain(LoadingState.Loading)
fetchPrograms(channels)
loading.setValueOnMain(LoadingState.Success)
}
private suspend fun fetchPrograms(channels: List<TvChannel>) =
mutex.withLock {
val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1)
val minEndDate = start.plusMinutes(1L)
val request =
GetLiveTvProgramsRequest(
maxStartDate = maxStartDate,
minEndDate = minEndDate,
channelIds = channels.map { it.id },
sortBy = listOf(ItemSortBy.START_DATE),
)
val programs =
api.liveTvApi
.getLiveTvPrograms(request)
.content.items
.map { dto ->
val category =
if (dto.isKids ?: false) {
ProgramCategory.KIDS
} else if (dto.isMovie ?: false) {
ProgramCategory.MOVIE
} else if (dto.isNews ?: false) {
ProgramCategory.NEWS
} else if (dto.isSports ?: false) {
ProgramCategory.SPORTS
} else {
null
}
TvProgram(
id = dto.id,
channelId = dto.channelId!!,
start = dto.startDate!!,
end = dto.endDate!!,
startHours = hoursBetween(start, dto.startDate!!).coerceAtLeast(0f),
endHours = hoursBetween(start, dto.endDate!!),
duration = dto.runTimeTicks!!.ticks,
name = dto.seriesName ?: dto.name,
subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false },
seasonEpisode =
if (dto.indexNumber != null && dto.parentIndexNumber != null) {
SeasonEpisode(dto.parentIndexNumber!!, dto.indexNumber!!)
} else {
null
},
isRecording = dto.timerId.isNotNullOrBlank(),
isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank(),
isRepeat = dto.isRepeat ?: false,
category = category,
)
}
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 data",
subtitle = null,
seasonEpisode = null,
isRecording = false,
isSeriesRecording = false,
isRepeat = false,
category = ProgramCategory.FAKE,
)
}
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) {
this@LiveTvViewModel.programs.value = finalProgramList
this@LiveTvViewModel.programsByChannel.value = finalProgramsByChannel
}
}
fun getItem(programId: UUID) {
fetchingItem.value = LoadingState.Loading
viewModelScope.launchIO(LoadingExceptionHandler(fetchingItem, "Error")) {
val result =
api.liveTvApi
.getProgram(programId.toServerString())
.content
.let { BaseItem.from(it, api) }
withContext(Dispatchers.Main) {
fetchedItem.value = result
fetchingItem.value = LoadingState.Success
}
if (result.data.seriesTimerId != null) {
val items =
api.liveTvApi
.getPrograms(GetProgramsDto(seriesTimerId = result.data.seriesTimerId))
.content.items
Timber.v("items=$items")
}
}
}
fun cancelRecording(
programIndex: Int,
programId: UUID,
series: Boolean,
timerId: String?,
) {
if (timerId != null) {
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
if (series) {
api.liveTvApi.cancelSeriesTimer(timerId)
fetchProgramsWithLoading(channels.value.orEmpty())
} else {
api.liveTvApi.cancelTimer(timerId)
refreshProgram(programIndex, programId)
}
}
}
}
fun record(
programIndex: Int,
programId: UUID,
series: Boolean,
) {
viewModelScope.launchIO {
val d by api.liveTvApi.getDefaultTimer(programId.toServerString())
if (series) {
api.liveTvApi.createSeriesTimer(d)
fetchProgramsWithLoading(channels.value.orEmpty())
} else {
val payload =
TimerInfoDto(
id = d.id,
type = d.type,
serverId = d.serverId,
externalId = d.externalId,
channelId = d.channelId,
externalChannelId = d.externalChannelId,
channelName = d.channelName,
programId = d.programId,
externalProgramId = d.externalProgramId,
name = d.name,
overview = d.overview,
startDate = d.startDate,
endDate = d.endDate,
serviceName = d.serviceName,
priority = d.priority,
prePaddingSeconds = d.prePaddingSeconds,
postPaddingSeconds = d.postPaddingSeconds,
isPrePaddingRequired = d.isPrePaddingRequired,
isPostPaddingRequired = d.isPostPaddingRequired,
keepUntil = d.keepUntil,
)
api.liveTvApi.createTimer(payload)
refreshProgram(programIndex, programId)
}
}
}
suspend fun refreshProgram(
programIndex: Int,
programId: UUID,
) = mutex.withLock {
loading.setValueOnMain(LoadingState.Loading)
val program by api.liveTvApi.getProgram(programId.toServerString())
val newProgram =
programs.value?.getOrNull(programIndex)?.copy(
isRecording = program.timerId.isNotNullOrBlank(),
isSeriesRecording = program.seriesTimerId.isNotNullOrBlank(),
)
Timber.v("new program %s", newProgram)
if (newProgram != null) {
programs.value
?.toMutableList()
?.apply {
this[programIndex] = newProgram
}?.let {
this@LiveTvViewModel.programs.setValueOnMain(it)
}
}
loading.setValueOnMain(LoadingState.Success)
}
}
/**
* Returns the number of hours between two [LocalDateTime]
*/
fun hoursBetween(
start: LocalDateTime,
target: LocalDateTime,
): Float =
java.time.Duration
.between(start, target)
.seconds / (60f * 60f)
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 startHours: Float,
val endHours: Float,
val duration: Duration,
val name: String?,
val subtitle: String?,
val seasonEpisode: SeasonEpisode?,
val isRecording: Boolean,
val isSeriesRecording: Boolean,
val isRepeat: Boolean,
val category: ProgramCategory?,
) {
val isFake = category == ProgramCategory.FAKE
}
enum class ProgramCategory(
val color: Color?,
) {
KIDS(AppColors.DarkCyan),
NEWS(AppColors.DarkGreen),
MOVIE(AppColors.DarkPurple),
SPORTS(AppColors.DarkRed),
FAKE(null),
}

View file

@ -0,0 +1,228 @@
package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.tv.material3.Button
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.seasonEpisode
import java.time.LocalDateTime
import java.time.ZoneId
@Composable
fun ProgramDialog(
item: BaseItem?,
canRecord: Boolean,
loading: LoadingState,
onDismissRequest: () -> Unit,
onWatch: () -> Unit,
onRecord: (series: Boolean) -> Unit,
onCancelRecord: (series: Boolean) -> Unit,
) {
val context = LocalContext.current
Dialog(
onDismissRequest = onDismissRequest,
) {
val focusRequester = remember { FocusRequester() }
Box(
modifier =
Modifier
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp),
shape = RoundedCornerShape(16.dp),
).focusRequester(focusRequester),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(16.dp),
) {
when (val st = loading) {
is LoadingState.Error -> ErrorMessage(st)
LoadingState.Loading,
LoadingState.Pending,
->
CircularProgress(
Modifier
.padding(8.dp)
.size(48.dp),
)
LoadingState.Success ->
item?.let { item ->
val now = LocalDateTime.now()
val dto = item.data
val isRecording = dto.timerId.isNotNullOrBlank()
val isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank()
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Text(
text = item.name ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleLarge,
)
if (dto.isSeries ?: false) {
listOfNotNull(dto.seasonEpisode, dto.episodeTitle)
.joinToString(" - ")
.ifBlank { null }
?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium,
)
}
}
val time =
DateUtils.formatDateRange(
context,
dto.startDate!!
.atZone(ZoneId.systemDefault())
.toInstant()
.epochSecond * 1000,
dto.endDate!!
.atZone(ZoneId.systemDefault())
.toInstant()
.epochSecond * 1000,
DateUtils.FORMAT_SHOW_TIME,
)
Text(
text = time,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleSmall,
)
dto.overview?.let { overview ->
Text(
text = overview,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
maxLines = 3,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
) {
if (canRecord) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
if (dto.isSeries ?: false) {
Button(
onClick = {
if (isSeriesRecording) {
onCancelRecord.invoke(true)
} else {
onRecord.invoke(true)
}
},
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isSeriesRecording) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color.Red,
)
}
Text(
text = if (isSeriesRecording) "Cancel Series Recording" else "Record Series",
)
}
}
}
if (dto.endDate?.isAfter(LocalDateTime.now()) ?: true) {
// Only show program specific recording button if it hasn't finished yet
Button(
onClick = {
if (isRecording) {
onCancelRecord.invoke(false)
} else {
onRecord.invoke(false)
}
},
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isRecording) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color.Red,
)
}
Text(
text = if (isRecording) "Cancel Recording" else "Record Program",
)
}
}
}
}
}
if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) {
Button(
onClick = onWatch,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Delete",
tint = Color.Green.copy(alpha = .75f),
)
Text(
text = "Watch live",
)
}
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,64 @@
package com.github.damontecres.wholphin.ui.detail.livetv
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
// .align(Alignment.BottomEnd)
@Composable
fun RecordingMarker(
isRecording: Boolean,
isSeriesRecording: Boolean,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
if (isSeriesRecording) {
val color = if (isRecording) Color.Red else Color.Gray
Box(
modifier =
Modifier
.padding(4.dp)
.size(16.dp)
.background(color, shape = CircleShape),
)
Box(
modifier =
Modifier
.padding(start = 4.dp, top = 4.dp, bottom = 4.dp, end = 10.dp)
.offset(6.dp)
.size(16.dp)
.background(color.copy(alpha = .5f), shape = CircleShape),
)
} else if (isRecording) {
Box(
modifier =
Modifier
.padding(4.dp)
.size(16.dp)
.background(Color.Red, shape = CircleShape),
)
}
}
}
@PreviewTvSpec
@Composable
private fun RecordingMarkerPreview() {
WholphinTheme {
Column {
RecordingMarker(true, false)
RecordingMarker(true, true)
RecordingMarker(false, true)
}
}
}

View file

@ -0,0 +1,599 @@
package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
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.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.SurfaceDefaults
import androidx.tv.material3.Text
import androidx.tv.material3.contentColorFor
import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.nav.Destination
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 com.google.common.cache.CacheBuilder
import com.google.common.cache.CacheLoader
import eu.wewox.programguide.ProgramGuide
import eu.wewox.programguide.ProgramGuideDimensions
import eu.wewox.programguide.ProgramGuideItem
import eu.wewox.programguide.rememberSaveableProgramGuideState
import kotlinx.coroutines.launch
import timber.log.Timber
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.util.UUID
@Composable
fun TvGuideGrid(
requestFocusAfterLoading: Boolean,
modifier: Modifier = Modifier,
viewModel: LiveTvViewModel = hiltViewModel(),
) {
var firstLoad by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) {
viewModel.init(firstLoad)
firstLoad = false
}
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, modifier)
LoadingState.Pending,
-> LoadingPage(modifier)
LoadingState.Loading,
LoadingState.Success,
-> {
val context = LocalContext.current
val fetchedItem by viewModel.fetchedItem.observeAsState(null)
val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending)
var showItemDialog by remember { mutableStateOf<Int?>(null) }
val focusRequester = remember { FocusRequester() }
if (requestFocusAfterLoading) {
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
}
Column(modifier = modifier) {
TvGuideGrid(
loading = state is LoadingState.Loading,
channels = channels,
programList = programs,
programs = programsByChannel,
start = viewModel.start,
onClickChannel = { index, channel ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = channel.id,
positionMs = 0L,
),
)
},
onClickProgram = { index, program ->
if (program.isFake) {
val now = LocalDateTime.now()
if (now.isAfter(program.start) && now.isBefore(program.end)) {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = program.channelId,
positionMs = 0L,
),
)
} else {
Toast
.makeText(context, "No guide data found!", Toast.LENGTH_SHORT)
.show()
}
} else {
viewModel.getItem(program.id)
showItemDialog = index
}
},
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.focusRequester(focusRequester),
)
}
if (showItemDialog != null) {
val onDismissRequest = { showItemDialog = null }
ProgramDialog(
item = fetchedItem,
canRecord = true,
loading = loadingItem,
onDismissRequest = onDismissRequest,
onWatch = {
onDismissRequest.invoke()
fetchedItem?.data?.channelId?.let {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it,
positionMs = 0L,
),
)
}
},
onRecord = { series ->
fetchedItem?.let {
viewModel.record(
programIndex = showItemDialog!!,
programId = it.id,
series = series,
)
}
onDismissRequest.invoke()
},
onCancelRecord = { series ->
fetchedItem?.data?.let {
viewModel.cancelRecording(
programIndex = showItemDialog!!,
programId = it.id,
series = series,
timerId = if (series) it.seriesTimerId else it.timerId,
)
}
onDismissRequest.invoke()
},
)
}
}
}
}
const val CHANNEL_COLUMN = -1
@Composable
fun TvGuideGrid(
loading: Boolean,
channels: List<TvChannel>,
programList: List<TvProgram>,
programs: Map<UUID, List<TvProgram>>,
start: LocalDateTime,
onClickChannel: (Int, TvChannel) -> Unit,
onClickProgram: (Int, TvProgram) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val state = rememberSaveableProgramGuideState()
val scope = rememberCoroutineScope()
var focusedProgramIndex by rememberInt(0)
var focusedChannelIndex by rememberInt(0)
val dimensions =
ProgramGuideDimensions(
timelineHourWidth = 240.dp,
timelineHeight = 32.dp,
channelWidth = 120.dp,
channelHeight = 80.dp,
currentTimeWidth = 2.dp,
)
val programsBeforeChannel =
remember {
CacheBuilder
.newBuilder()
.maximumSize(200)
.build<Int, Int>(
object : CacheLoader<Int, Int>() {
override fun load(key: Int): Int =
channels
.subList(0, key)
.map { it.id }
.sumOf { programs[it]?.size ?: 0 }
},
)
}
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
Box(modifier = modifier) {
ProgramGuide(
state = state,
dimensions = dimensions,
modifier =
Modifier
.fillMaxSize()
.onFocusChanged {
gridHasFocus = it.hasFocus
}.focusable()
.onPreviewKeyEvent {
if (it.type == KeyEventType.KeyUp) {
return@onPreviewKeyEvent false
}
val newIndex =
when (it.key) {
Key.Back -> {
val pos = programsBeforeChannel.get(focusedChannelIndex)
if (focusedProgramIndex - pos > 0) {
// Not at beginning of row, so move to beginning
pos
} else {
// At beginning, so allow normal back button behavior
return@onPreviewKeyEvent false
}
}
Key.DirectionRight -> {
if (channelColumnFocused) {
channelColumnFocused = false
focusedProgramIndex
} else {
val nextProgramIndex = focusedProgramIndex + 1
val programsBefore =
programsBeforeChannel.get(focusedChannelIndex)
val relativePosition = nextProgramIndex - programsBefore
val channelPrograms =
programs[channels[focusedChannelIndex].id].orEmpty()
if (relativePosition >= channelPrograms.size) {
focusManager.moveFocus(FocusDirection.Right)
null
} else {
nextProgramIndex
}
}
}
Key.DirectionLeft -> {
if (channelColumnFocused) {
focusManager.moveFocus(FocusDirection.Left)
null
} else {
val nextProgramIndex = focusedProgramIndex - 1
val programsBefore =
programsBeforeChannel.get(focusedChannelIndex)
val relativePosition = nextProgramIndex - programsBefore
// val channelPrograms =
// programs[channels[focusedChannel].id].orEmpty()
if (relativePosition >= 0) {
nextProgramIndex
} else if (relativePosition == CHANNEL_COLUMN) {
channelColumnFocused = true
focusedProgramIndex
} else {
Timber.w("Move left to relativePosition=$relativePosition should not occur")
focusManager.moveFocus(FocusDirection.Left)
null
}
}
}
Key.DirectionUp -> {
val newFocusedChannelIndex = (focusedChannelIndex - 1)
// Timber.v("newFocusedChannelIndex=$newFocusedChannelIndex")
if (newFocusedChannelIndex < 0) {
focusManager.moveFocus(FocusDirection.Up)
null
} else if (channelColumnFocused) {
focusedChannelIndex = newFocusedChannelIndex
programsBeforeChannel.get(focusedChannelIndex)
} else {
val start = programList[focusedProgramIndex].startHours
focusedChannelIndex =
newFocusedChannelIndex.coerceAtLeast(0)
val channelId = channels[focusedChannelIndex].id
val pro = programs[channelId].orEmpty()
val pIndex =
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else {
val pIndex = pro.indexOfFirst { it.startHours >= start }
if (pIndex >= 0) {
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else {
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size
}
}
}
}
Key.DirectionDown -> {
// Move channel focus down
val newFocusedChannelIndex = (focusedChannelIndex + 1)
if (newFocusedChannelIndex >= channels.size) {
// If trying to move below the final channel, then move focus out of the grid
focusManager.moveFocus(FocusDirection.Down)
null
} else if (channelColumnFocused) {
// If focused on the channel column, move down a channel
focusedChannelIndex =
newFocusedChannelIndex.coerceAtMost(channels.size - 1)
programsBeforeChannel.get(focusedChannelIndex)
} else {
// Otherwise, moving to a new row
focusedChannelIndex =
newFocusedChannelIndex.coerceAtMost(channels.size - 1)
// Get the new row/channel's programs
val channelId = channels[focusedChannelIndex].id
val pro = programs[channelId].orEmpty()
// When the currently focused program starts
val start = programList[focusedProgramIndex].startHours
// Find the first program where the start time (of the previously focused program) is in the middle of a program
val pIndex =
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
// Found one, so focus on it
// Get the sum of all of the previous channels' program sizes, plus the index found to convert a relative program index into absolute
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else {
// Didn't find one, probably due to missing data
// So now first the first one that starts after the previously focused program
val pIndex = pro.indexOfFirst { it.startHours >= start }
if (pIndex >= 0) {
// Found one, so focus on it
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else {
// Did not find one, so focus on the final program in the list
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size
}
}
}
}
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
if (channelColumnFocused) {
val channel = channels[focusedChannelIndex]
Timber.v("Clicked on %s", channel)
onClickChannel.invoke(focusedChannelIndex, channel)
} else {
val program = programList[focusedProgramIndex]
Timber.v("Clicked on %s", program)
onClickProgram.invoke(focusedProgramIndex, program)
}
null
}
else -> {
null
}
}
if (newIndex != null) {
// Timber.v("newIndex=$newIndex")
if (newIndex >= 0) {
val before = programsBeforeChannel.get(focusedChannelIndex)
val max =
before + channels[focusedChannelIndex].let { programs[it.id]!!.size } - 1
val index = newIndex.coerceIn(before, max)
scope.launch(ExceptionHandler()) {
focusedProgramIndex = index
state.animateToProgram(index, Alignment.Center)
}
return@onPreviewKeyEvent true
} else {
Timber.w("newIndex is <0: $newIndex")
return@onPreviewKeyEvent true
}
}
return@onPreviewKeyEvent false
},
) {
guideStartHour = 0f
currentTime(
layoutInfo = {
ProgramGuideItem.CurrentTime(
hoursBetween(start, LocalDateTime.now()),
)
},
) {
Surface(
colors = SurfaceDefaults.colors(MaterialTheme.colorScheme.tertiary.copy(alpha = .25f)),
modifier = Modifier,
) {
// Empty
}
}
timeline(
count = MAX_HOURS.toInt(),
layoutInfo = { index ->
ProgramGuideItem.Timeline(
startHour = index.toFloat(),
endHour = index + 1f,
)
},
) { index ->
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
) {
val differentDay =
start.toLocalDate() !=
start
.plusHours(index.toLong())
.toLocalDate()
val time =
DateUtils.formatDateTime(
context,
start
.plusHours(index.toLong())
.toInstant(OffsetDateTime.now().offset)
.epochSecond * 1000,
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
)
Text(
text = time.toString(),
modifier = Modifier.background(MaterialTheme.colorScheme.background),
)
}
}
programs(
count = programList.size,
layoutInfo = { programIndex ->
val program = programList[programIndex]
val channelIndex = channels.indexOfFirst { it.id == program.channelId }
ProgramGuideItem.Program(channelIndex, program.startHours, program.endHours)
},
) { programIndex ->
val program = programList[programIndex]
val focused =
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
val background =
if (focused) {
MaterialTheme.colorScheme.inverseSurface
} else {
program.category?.color
?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
}
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
Box(
modifier =
Modifier
// .scale(if (focused) 1.1f else 1f)
.padding(2.dp)
.fillMaxSize()
.background(
background,
shape = RoundedCornerShape(4.dp),
),
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier =
Modifier
.fillMaxSize()
.padding(4.dp),
) {
Text(
text = program.name ?: program.id.toString(),
color = textColor,
maxLines = 1,
modifier = Modifier.enableMarquee(focused),
)
listOfNotNull(
program.seasonEpisode?.let { "S${it.season} E${it.episode}" },
program.subtitle,
).joinToString(" - ")
.ifBlank { null }
?.let {
Text(
text = it,
color = textColor,
modifier = Modifier,
)
}
}
RecordingMarker(
isRecording = program.isRecording,
isSeriesRecording = program.isSeriesRecording,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
}
channels(
count = channels.size,
layoutInfo = { channelIndex ->
ProgramGuideItem.Channel(channelIndex)
},
) { channelIndex ->
val channel = channels[channelIndex]
val focused =
gridHasFocus && channelColumnFocused && focusedChannelIndex == channelIndex
val background =
if (focused) {
MaterialTheme.colorScheme.inverseSurface
} else {
MaterialTheme.colorScheme.surface
}
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
Box(
modifier =
Modifier
.fillMaxSize()
.background(
background,
shape = RoundedCornerShape(4.dp),
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(4.dp)
.fillMaxSize(),
) {
Text(
text = channel.number ?: channel.name ?: channelIndex.toString(),
color = textColor,
modifier = Modifier,
)
AsyncImage(
model = channel.imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxHeight(.66f),
)
}
}
}
topCorner {
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
)
}
}
if (loading) {
CircularProgress(
Modifier
.background(
MaterialTheme.colorScheme.background.copy(alpha = .5f),
shape = CircleShape,
).size(64.dp)
.padding(16.dp)
.align(Alignment.BottomEnd),
)
}
}
}

View file

@ -13,7 +13,6 @@ import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
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 com.github.damontecres.wholphin.util.supportItemKinds import com.github.damontecres.wholphin.util.supportItemKinds
import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -21,10 +20,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
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.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest
@ -169,15 +170,30 @@ class HomeViewModel
val rows = val rows =
latestMediaIncludes latestMediaIncludes
.mapNotNull { viewId -> views.items.firstOrNull { it.id == viewId } } .mapNotNull { viewId -> views.items.firstOrNull { it.id == viewId } }
.filter { it.collectionType in supportedCollectionTypes } .filter { it.collectionType in supportedLatestCollectionTypes }
.map { view -> .map { view ->
val title = val title =
if (view.collectionType == CollectionType.LIVETV) {
"Recently Recorded"
} else {
view.name?.let { "Recently Added in $it" } view.name?.let { "Recently Added in $it" }
}
val viewId =
if (view.collectionType == CollectionType.LIVETV) {
api.liveTvApi
.getRecordingFolders(
userId = user.id,
).content.items
.firstOrNull()
?.id
} else {
view.id
}
val request = val request =
GetLatestMediaRequest( GetLatestMediaRequest(
fields = SlimItemFields, fields = SlimItemFields,
imageTypeLimit = 1, imageTypeLimit = 1,
parentId = view.id, parentId = viewId,
groupItems = true, groupItems = true,
limit = limit, limit = limit,
isPlayed = null, // Server will handle user's preference isPlayed = null, // Server will handle user's preference
@ -196,3 +212,10 @@ class HomeViewModel
return rows return rows
} }
} }
val supportedLatestCollectionTypes =
setOf(
CollectionType.MOVIES,
CollectionType.TVSHOWS,
CollectionType.LIVETV,
)

View file

@ -6,6 +6,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.LicenseInfo import com.github.damontecres.wholphin.ui.components.LicenseInfo
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv
import com.github.damontecres.wholphin.ui.detail.DebugPage import com.github.damontecres.wholphin.ui.detail.DebugPage
@ -24,6 +25,7 @@ import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import timber.log.Timber
/** /**
* Chose the page for the [Destination] * Chose the page for the [Destination]
@ -144,6 +146,15 @@ fun DestinationContent(
) )
BaseItemKind.USER_VIEW -> BaseItemKind.USER_VIEW ->
when (destination.item?.data?.collectionType) {
CollectionType.LIVETV ->
CollectionFolderLiveTv(
preferences,
destination,
modifier,
)
else ->
CollectionFolderGeneric( CollectionFolderGeneric(
preferences, preferences,
destination.itemId, destination.itemId,
@ -151,8 +162,19 @@ fun DestinationContent(
true, true,
modifier, modifier,
) )
}
BaseItemKind.FOLDER ->
CollectionFolderGeneric(
preferences,
destination.itemId,
destination.item,
false,
modifier,
)
else -> { else -> {
Timber.w("Unsupported item type: ${destination.type}")
Text("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}")
} }
} }

View file

@ -36,6 +36,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -403,6 +404,7 @@ fun NavigationDrawerScope.NavItem(
containerColor: Color = Color.Unspecified, containerColor: Color = Color.Unspecified,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val useFont = library !is ServerNavDrawerItem || library.type != CollectionType.LIVETV
val icon = val icon =
when (library) { when (library) {
NavDrawerItem.Favorites -> R.string.fa_heart NavDrawerItem.Favorites -> R.string.fa_heart
@ -413,7 +415,7 @@ fun NavigationDrawerScope.NavItem(
CollectionType.MOVIES -> R.string.fa_film CollectionType.MOVIES -> R.string.fa_film
CollectionType.TVSHOWS -> R.string.fa_tv CollectionType.TVSHOWS -> R.string.fa_tv
CollectionType.HOMEVIDEOS -> R.string.fa_video CollectionType.HOMEVIDEOS -> R.string.fa_video
CollectionType.LIVETV -> R.string.fa_tv CollectionType.LIVETV -> R.drawable.gf_dvr
CollectionType.MUSIC -> R.string.fa_music CollectionType.MUSIC -> R.string.fa_music
CollectionType.BOXSETS -> R.string.fa_open_folder CollectionType.BOXSETS -> R.string.fa_open_folder
CollectionType.PLAYLISTS -> R.string.fa_list_ul CollectionType.PLAYLISTS -> R.string.fa_list_ul
@ -437,6 +439,7 @@ fun NavigationDrawerScope.NavItem(
), ),
leadingContent = { leadingContent = {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (useFont) {
Text( Text(
text = stringResource(icon), text = stringResource(icon),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@ -444,6 +447,12 @@ fun NavigationDrawerScope.NavItem(
fontFamily = FontAwesome, fontFamily = FontAwesome,
color = color, color = color,
) )
} else {
Icon(
painter = painterResource(icon),
contentDescription = null,
)
}
} }
}, },
trailingContent = { trailingContent = {

View file

@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.EqualityMutableLiveData
@ -224,6 +225,7 @@ class PlaybackViewModel
) )
return@withContext false return@withContext false
} }
val isLiveTv = base.type == BaseItemKind.TV_CHANNEL
dto = base dto = base
val title = val title =
@ -324,7 +326,7 @@ class PlaybackViewModel
rowId = -1, rowId = -1,
userId = -1, userId = -1,
itemId = base.id, itemId = base.id,
sourceId = mediaSource.id?.toUUIDOrNull(), sourceId = if (!isLiveTv) mediaSource.id?.toUUIDOrNull() else null,
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED, subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED,
) )
@ -394,12 +396,16 @@ class PlaybackViewModel
subtitleStreamIndex = subtitleIndex, subtitleStreamIndex = subtitleIndex,
allowVideoStreamCopy = true, allowVideoStreamCopy = true,
allowAudioStreamCopy = true, allowAudioStreamCopy = true,
autoOpenLiveStream = false, autoOpenLiveStream = true,
mediaSourceId = currentItemPlayback.sourceId?.toServerString(), mediaSourceId = currentItemPlayback.sourceId?.toServerString(),
alwaysBurnInSubtitleWhenTranscoding = null, alwaysBurnInSubtitleWhenTranscoding = null,
maxStreamingBitrate = maxBitrate.toInt(), maxStreamingBitrate = maxBitrate.toInt(),
), ),
) )
if (response.errorCode != null) {
loading.setValueOnMain(LoadingState.Error(response.errorCode?.serialName))
return@withContext
}
val source = response.mediaSources.firstOrNull() val source = response.mediaSources.firstOrNull()
source?.let { source -> source?.let { source ->
val mediaUrl = val mediaUrl =
@ -462,6 +468,7 @@ class PlaybackViewModel
listOf(), listOf(),
playMethod = transcodeType, playMethod = transcodeType,
playSessionId = response.playSessionId, playSessionId = response.playSessionId,
liveStreamId = source.liveStreamId,
) )
val itemPlayback = val itemPlayback =
currentItemPlayback.copy( currentItemPlayback.copy(
@ -780,6 +787,7 @@ data class CurrentPlayback(
val tracks: List<TrackSupport>, val tracks: List<TrackSupport>,
val playMethod: PlayMethod, val playMethod: PlayMethod,
val playSessionId: String?, val playSessionId: String?,
val liveStreamId: String?,
) )
val Format.idAsInt: Int? val Format.idAsInt: Int?

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

@ -19,8 +19,14 @@ val supportItemKinds =
BaseItemKind.VIDEO, BaseItemKind.VIDEO,
BaseItemKind.SEASON, BaseItemKind.SEASON,
BaseItemKind.COLLECTION_FOLDER, BaseItemKind.COLLECTION_FOLDER,
BaseItemKind.FOLDER,
BaseItemKind.USER_VIEW, BaseItemKind.USER_VIEW,
BaseItemKind.TRAILER, BaseItemKind.TRAILER,
BaseItemKind.TV_CHANNEL,
BaseItemKind.TV_PROGRAM,
BaseItemKind.LIVE_TV_CHANNEL,
BaseItemKind.LIVE_TV_PROGRAM,
BaseItemKind.RECORDING,
) )
val supportedCollectionTypes = val supportedCollectionTypes =
@ -30,4 +36,5 @@ val supportedCollectionTypes =
CollectionType.HOMEVIDEOS, CollectionType.HOMEVIDEOS,
CollectionType.PLAYLISTS, CollectionType.PLAYLISTS,
CollectionType.BOXSETS, CollectionType.BOXSETS,
CollectionType.LIVETV,
) )

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

@ -49,6 +49,7 @@ class TrackActivityPlaybackListener(
isMuted = false, isMuted = false,
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
liveStreamId = playback.liveStreamId,
), ),
) )
} }
@ -78,6 +79,7 @@ class TrackActivityPlaybackListener(
positionTicks = position, positionTicks = position,
failed = false, failed = false,
playSessionId = playback.playSessionId, playSessionId = playback.playSessionId,
liveStreamId = playback.liveStreamId,
), ),
) )
} }
@ -114,6 +116,7 @@ class TrackActivityPlaybackListener(
playSessionId = playback.playSessionId, playSessionId = playback.playSessionId,
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
liveStreamId = playback.liveStreamId,
), ),
) )
} }

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="64dp" android:viewportHeight="960" android:viewportWidth="960" android:width="64dp">
<path android:fillColor="#5f6368" android:pathData="M279.98,553q14.02,0 23.52,-9.48t9.5,-23.5q0,-14.02 -9.48,-23.52t-23.5,-9.5q-14.02,0 -23.52,9.48t-9.5,23.5q0,14.02 9.48,23.52t23.5,9.5ZM279.98,393q14.02,0 23.52,-9.48t9.5,-23.5q0,-14.02 -9.48,-23.52t-23.5,-9.5q-14.02,0 -23.52,9.48t-9.5,23.5q0,14.02 9.48,23.52t23.5,9.5ZM360,550h360v-60L360,490v60ZM360,390h360v-60L360,330v60ZM330,840v-80L140,760q-24,0 -42,-18t-18,-42v-520q0,-24 18,-42t42,-18h680q24,0 42,18t18,42v520q0,24 -18,42t-42,18L630,760v80L330,840ZM140,700h680v-520L140,180v520ZM140,700v-520,520Z"/>
</vector>

View file

@ -10,6 +10,7 @@ appcompat = "1.7.1"
composeBom = "2025.10.00" composeBom = "2025.10.00"
compose-runtime = "1.9.3" compose-runtime = "1.9.3"
multiplatformMarkdownRenderer = "0.37.0" multiplatformMarkdownRenderer = "0.37.0"
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"
@ -59,6 +60,7 @@ 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" }
multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
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" }