Enhancements to live TV guide/EPG (#1393)

## Description
This PR has some UI/UX updates for live tv epg/guide.

- Show program images, if provided by the EPG source
- Show channel name under logo
- Show & set favorite channels
- Adjusts the header to match the rest of the app (font, padding, etc)

Additionally, navigation around the guide has been rewritten. It is more
efficient and feels a bit more smooth to scroll around.

### Related issues
Closes #529

### Testing
Emulator mostly

## Screenshots
<img width="1280" height="720" alt="epg_new Large"
src="https://github.com/user-attachments/assets/c53c01be-9209-4c59-82cb-47f268811d3e"
/>


## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-05-19 14:11:55 -04:00 committed by GitHub
parent 87a109c6b6
commit da7f7048e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 748 additions and 715 deletions

View file

@ -142,7 +142,6 @@ fun CollectionFolderLiveTv(
when (selectedTabIndex) {
0 -> {
TvGuideGrid(
true,
onRowPosition = {
showHeader = it <= 0
},

View file

@ -5,21 +5,26 @@ 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.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import androidx.tv.material3.contentColorFor
import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
@ -30,20 +35,11 @@ import java.time.LocalDateTime
fun Program(
guideStart: LocalDateTime,
program: TvProgram,
focused: Boolean,
colorCode: Boolean,
onClick: () -> Unit,
onLongClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val background =
if (focused) {
MaterialTheme.colorScheme.inverseSurface
} else if (colorCode) {
program.category?.color
?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
}
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
val startedBeforeGuide = program.start.isBefore(guideStart)
val shape =
remember(startedBeforeGuide) {
@ -60,15 +56,28 @@ fun Program(
}
}
val title = program.name ?: program.id.toString()
Box(
Surface(
onClick = onClick,
onLongClick = onLongClick,
shape = ClickableSurfaceDefaults.shape(shape),
scale = ClickableSurfaceDefaults.scale(1f, 1f, .95f),
colors =
ClickableSurfaceDefaults.colors(
containerColor =
if (colorCode) {
program.category?.color
?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
},
contentColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = MaterialTheme.colorScheme.inverseSurface,
focusedContentColor = MaterialTheme.colorScheme.inverseOnSurface,
),
modifier =
modifier
.padding(2.dp)
.fillMaxSize()
.background(
color = background,
shape = shape,
),
.fillMaxSize(),
) {
Row(
modifier = Modifier.fillMaxSize(),
@ -77,7 +86,7 @@ fun Program(
Text(
text = stringResource(R.string.fa_caret_left),
fontFamily = FontAwesome,
color = textColor,
color = LocalContentColor.current,
fontSize = 16.sp,
modifier =
Modifier
@ -94,21 +103,23 @@ fun Program(
) {
Text(
text = title,
color = textColor,
color = LocalContentColor.current,
fontSize = 16.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier,
)
val subtitle =
remember(program) {
listOfNotNull(
program.seasonEpisode?.let { "S${it.season} E${it.episode}" },
program.subtitle,
).joinToString(" - ")
.ifBlank { null }
?.let {
).joinToString(" - ").ifBlank { null }
}
subtitle?.let {
Text(
text = it,
color = textColor,
color = LocalContentColor.current,
fontSize = 14.sp,
overflow = TextOverflow.Ellipsis,
modifier = Modifier,
@ -128,43 +139,84 @@ fun Program(
fun Channel(
channel: TvChannel,
channelIndex: Int,
focused: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val background =
if (focused) {
MaterialTheme.colorScheme.inverseSurface
} else {
MaterialTheme.colorScheme.surface
}
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
Box(
Surface(
onClick = onClick,
onLongClick = onLongClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)),
scale = ClickableSurfaceDefaults.scale(1f, 1f, .95f),
colors =
ClickableSurfaceDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
focusedContainerColor = MaterialTheme.colorScheme.inverseSurface,
),
modifier =
modifier
.fillMaxSize()
.background(
background,
shape = RoundedCornerShape(4.dp),
),
.background(MaterialTheme.colorScheme.background)
.padding(2.dp)
.fillMaxSize(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
Box(
modifier =
Modifier
.padding(4.dp)
.fillMaxSize(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(),
) {
Text(
text = channel.number ?: channel.name ?: channelIndex.toString(),
color = textColor,
text = channel.number ?: "",
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier,
)
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier,
) {
var showImage by remember { mutableStateOf(true) }
if (showImage) {
AsyncImage(
model = channel.imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxHeight(.66f),
contentDescription = channel.name,
modifier = Modifier.weight(1f),
onError = {
showImage = false
},
)
}
channel.name?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
fontSize = if (showImage) 10.sp else 16.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier,
)
}
}
}
if (channel.favorite) {
Text(
color = colorResource(android.R.color.holo_red_light),
text = stringResource(R.string.fa_heart),
fontSize = 16.sp,
fontFamily = FontAwesome,
modifier =
Modifier
.padding(2.dp)
.align(Alignment.TopEnd),
)
}
}
}
}

View file

@ -42,6 +42,7 @@ import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
@ -159,14 +160,13 @@ fun DvrSchedule(
)
showDialog?.let { item ->
ProgramDialog(
item = item,
state = DataLoadingState.Success(item),
canRecord = true,
loading = LoadingState.Success,
onDismissRequest = {
showDialog = null
},
onWatch = {
item.data.channelId?.let {
onWatch = { program ->
program.data.channelId?.let {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it,
@ -175,12 +175,13 @@ fun DvrSchedule(
)
}
},
onRecord = {
onRecord = { _, _ ->
// no-op
},
onCancelRecord = { series ->
onCancelRecord = { program, series ->
showDialog = null
val timerId = if (series) item.data.seriesTimerId else item.data.timerId
val timerId =
if (series) program.data.seriesTimerId else program.data.timerId
if (timerId != null) {
viewModel.cancelRecording(timerId, series)
}

View file

@ -6,7 +6,6 @@ import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.buildAnnotatedString
import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
@ -14,35 +13,36 @@ import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.DataLoadingState
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 dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.update
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.BaseItemDto
import org.jellyfin.sdk.model.api.GetProgramsDto
@ -78,24 +78,22 @@ class LiveTvViewModel
val navigationManager: NavigationManager,
val preferences: DataStore<AppPreferences>,
private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService,
private val favoriteWatchManager: FavoriteWatchManager,
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
private lateinit var channelsIdToIndex: Map<UUID, Int>
private val mutex = Mutex()
val guideTimes = MutableLiveData<List<LocalDateTime>>(buildGuideTimes())
val channels = MutableLiveData<List<TvChannel>>()
val channelProgramCount = mutableMapOf<UUID, Int>()
val programs = MutableLiveData<FetchedPrograms>()
private val _state = MutableStateFlow(LiveTvState())
val state: StateFlow<LiveTvState> = _state
val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending)
val fetchedItem = MutableLiveData<BaseItem?>(null)
private val _programDialogState = MutableStateFlow(ProgramDialogState())
val programDialogState: StateFlow<ProgramDialogState> = _programDialogState
private val range = 100
init {
viewModelScope.launchIO {
viewModelScope.launchDefault {
preferences.data
.map {
it.interfacePreferences.liveTvPreferences.let {
@ -105,37 +103,34 @@ class LiveTvViewModel
.debounce { 500.milliseconds }
.collectLatest {
Timber.v("Init due to pref change")
loading.setValueOnMain(LoadingState.Pending)
init()
_state.update { LiveTvState() }
init(it.first, it.second)
}
}
}
fun init() {
val guideStart = guideTimes.value!!.first()
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Could not fetch channels",
),
private fun init(
sortByRecentlyWatched: Boolean,
favoriteChannelsAtBeginning: Boolean,
) {
val prefs =
(preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance())
.interfacePreferences.liveTvPreferences
viewModelScope.launchDefault {
try {
val guideTimes = buildGuideTimes()
_state.update { it.copy(guideTimes = guideTimes) }
val guideStart = guideTimes.first()
val channelData by api.liveTvApi.getLiveTvChannels(
GetLiveTvChannelsRequest(
startIndex = 0,
userId = serverRepository.currentUser.value?.id,
enableFavoriteSorting = prefs.favoriteChannelsAtBeginning,
enableFavoriteSorting = favoriteChannelsAtBeginning,
sortBy =
if (prefs.sortByRecentlyWatched) {
if (sortByRecentlyWatched) {
listOf(ItemSortBy.DATE_PLAYED)
} else {
null
},
sortOrder =
if (prefs.sortByRecentlyWatched) {
if (sortByRecentlyWatched) {
SortOrder.DESCENDING
} else {
null
@ -147,10 +142,12 @@ class LiveTvViewModel
channelData.items
.map {
TvChannel(
it.id,
it.channelNumber,
it.channelName,
api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY),
id = it.id,
number = it.channelNumber,
name = it.channelName ?: it.name,
imageUrl =
imageUrlService.getItemImageUrl(it.id, ImageType.PRIMARY),
favorite = it.userData?.isFavorite == true,
)
}
Timber.d("Got ${channels.size} channels")
@ -161,14 +158,20 @@ class LiveTvViewModel
val initial = 10
fetchPrograms(guideStart, channels, 0..<initial.coerceAtMost(channels.size))
withContext(Dispatchers.Main) {
this@LiveTvViewModel.channels.value = channels
loading.value = LoadingState.Success
_state.update {
it.copy(
channels = channels,
loading = LoadingState.Success,
)
}
// Now load the full range
if (channels.size > initial) {
fetchPrograms(guideStart, channels, 0..<range.coerceAtMost(channels.size))
}
} catch (ex: Exception) {
Timber.e(ex, "Erroring during init")
_state.update { it.copy(loading = LoadingState.Error(ex)) }
}
}
}
@ -187,14 +190,21 @@ class LiveTvViewModel
}
}
private suspend fun fetchProgramsWithLoading(
private suspend fun refreshPrograms(
channels: List<TvChannel>,
range: IntRange,
) {
loading.setValueOnMain(LoadingState.Loading)
val guideStart = guideTimes.value!!.first()
_state.update { it.copy(refreshing = true) }
val guideStart = _state.value.guideTimes.first()
try {
fetchPrograms(guideStart, channels, range)
loading.setValueOnMain(LoadingState.Success)
_state.update { it.copy(refreshing = false) }
} catch (_: CancellationException) {
// no-op
} catch (ex: Exception) {
Timber.e(ex, "Error fetching programs")
_state.update { it.copy(loading = LoadingState.Error(ex)) }
}
}
/**
@ -282,6 +292,11 @@ class LiveTvViewModel
isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank(),
isRepeat = dto.isRepeat ?: false,
category = category,
imageUrl =
imageUrlService.getItemImageUrl(
dto.id,
ImageType.PRIMARY,
),
)
if (index == 0) {
if (p.startHours > 0) {
@ -382,13 +397,12 @@ class LiveTvViewModel
category = ProgramCategory.FAKE,
)
}
programsByChannel.put(channel.id, fakePrograms)
programsByChannel[channel.id] = fakePrograms
fake.addAll(fakePrograms)
}
programsByChannel.forEach { (channelId, programs) ->
channelProgramCount[channelId] = programs.size
}
val channelProgramCount = programsByChannel.map { it.key to it.value.size }.toMap()
val finalProgramList =
(programsByChannel.values.flatten())
.sortedWith(
@ -398,30 +412,32 @@ class LiveTvViewModel
),
)
Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs")
withContext(Dispatchers.Main) {
this@LiveTvViewModel.programs.value =
FetchedPrograms(channelIndices, finalProgramList, programsByChannel)
_state.update {
it.copy(
channelProgramCount = channelProgramCount,
programs =
FetchedPrograms(
channelIndices,
finalProgramList,
programsByChannel,
),
)
}
}
fun getItem(programId: UUID) {
fetchingItem.value = LoadingState.Loading
viewModelScope.launchIO(LoadingExceptionHandler(fetchingItem, "Error")) {
fun fetchProgramForDialog(programId: UUID) {
_programDialogState.update { it.copy(loading = DataLoadingState.Loading) }
viewModelScope.launchDefault {
try {
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")
.let { BaseItem(it) }
_programDialogState.update { it.copy(loading = DataLoadingState.Success(result)) }
} catch (ex: Exception) {
Timber.e(ex, "Error fetching program $programId")
_programDialogState.update { it.copy(loading = DataLoadingState.Error(ex)) }
}
}
}
@ -437,7 +453,9 @@ class LiveTvViewModel
} else {
api.liveTvApi.cancelTimer(timerId)
}
fetchProgramsWithLoading(channels.value.orEmpty(), programs.value!!.range)
state.value.let {
refreshPrograms(it.channels, it.programs.range)
}
}
}
}
@ -476,7 +494,9 @@ class LiveTvViewModel
)
api.liveTvApi.createTimer(payload)
}
fetchProgramsWithLoading(channels.value.orEmpty(), programs.value!!.range)
state.value.let {
refreshPrograms(it.channels, it.programs.range)
}
}
}
@ -488,8 +508,9 @@ class LiveTvViewModel
* This determines if more programs/channels should be fetched based on the current position
*/
fun onFocusChannel(position: RowColumn) {
channels.value?.let { channels ->
val fetchedRange = programs.value!!.range
val state = state.value
val channels = state.channels
val fetchedRange = state.programs.range
val quarter = range / 4
var rangeStart = fetchedRange.start + quarter
var rangeEnd = fetchedRange.last - quarter
@ -523,10 +544,26 @@ class LiveTvViewModel
focusLoadingJob?.cancel()
focusLoadingJob =
viewModelScope.launchIO {
fetchProgramsWithLoading(channels, newFetchRange)
refreshPrograms(channels, newFetchRange)
}
}
}
fun toggleFavorite(
index: Int,
channel: TvChannel,
) {
viewModelScope.launchIO {
favoriteWatchManager.setFavorite(channel.id, !channel.favorite)
_state.update {
it.copy(
channels =
it.channels.toMutableList().apply {
set(index, channel.copy(favorite = !channel.favorite))
},
)
}
}
}
}
@ -543,11 +580,25 @@ fun hoursBetween(
.between(start, target)
.seconds / (60f * 60f)
data class LiveTvState(
val loading: LoadingState = LoadingState.Pending,
val refreshing: Boolean = false,
val guideTimes: List<LocalDateTime> = emptyList(),
val channels: List<TvChannel> = emptyList(),
val channelProgramCount: Map<UUID, Int> = emptyMap(),
val programs: FetchedPrograms = FetchedPrograms(),
)
data class ProgramDialogState(
val loading: DataLoadingState<BaseItem> = DataLoadingState.Pending,
)
data class TvChannel(
val id: UUID,
val number: String?,
val name: String?,
val imageUrl: String?,
val favorite: Boolean,
)
@Stable
@ -568,6 +619,7 @@ data class TvProgram(
val isRepeat: Boolean,
val category: ProgramCategory?,
val officialRating: String? = null,
val imageUrl: String? = null,
) {
val isFake = category == ProgramCategory.FAKE
@ -589,27 +641,29 @@ data class TvProgram(
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
)
append(time)
dot()
if (!isFake) {
dot()
duration
.roundMinutes
.toString()
.let(::append)
dot()
if (now.isAfter(start) && now.isBefore(end)) {
dot()
java.time.Duration
.between(now, end)
.toKotlinDuration()
.roundMinutes
.let { append("$it left") }
dot()
}
seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let {
append(it)
dot()
append(it)
}
officialRating?.let {
dot()
append(it)
}
officialRating?.let(::append)
}
}
}
@ -653,9 +707,9 @@ enum class ProgramCategory(
}
data class FetchedPrograms(
val range: IntRange,
val programs: List<TvProgram>,
val programsByChannel: Map<UUID, List<TvProgram>>,
val range: IntRange = 0..0,
val programs: List<TvProgram> = emptyList(),
val programsByChannel: Map<UUID, List<TvProgram>> = emptyMap(),
)
fun LocalDateTime.roundDownToHalfHour(): LocalDateTime {

View file

@ -34,7 +34,6 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.TextButton
@ -42,19 +41,18 @@ import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.DataLoadingState
import java.time.LocalDateTime
import java.time.ZoneId
@Composable
fun ProgramDialog(
item: BaseItem?,
state: DataLoadingState<BaseItem>,
canRecord: Boolean,
loading: LoadingState,
onDismissRequest: () -> Unit,
onWatch: () -> Unit,
onRecord: (series: Boolean) -> Unit,
onCancelRecord: (series: Boolean) -> Unit,
onWatch: (BaseItem) -> Unit,
onRecord: (BaseItem, series: Boolean) -> Unit,
onCancelRecord: (BaseItem, series: Boolean) -> Unit,
) {
val context = LocalContext.current
Dialog(
@ -65,7 +63,7 @@ fun ProgramDialog(
modifier =
Modifier
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp),
MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp),
shape = RoundedCornerShape(16.dp),
).focusRequester(focusRequester),
) {
@ -75,13 +73,13 @@ fun ProgramDialog(
Modifier
.padding(16.dp),
) {
when (val st = loading) {
is LoadingState.Error -> {
when (val st = state) {
is DataLoadingState.Error -> {
ErrorMessage(st)
}
LoadingState.Loading,
LoadingState.Pending,
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
CircularProgress(
Modifier
@ -90,8 +88,8 @@ fun ProgramDialog(
)
}
LoadingState.Success -> {
item?.let { item ->
is DataLoadingState.Success<BaseItem> -> {
val item = st.data
val now = LocalDateTime.now()
val dto = item.data
val isRecording = dto.timerId.isNotNullOrBlank()
@ -151,7 +149,7 @@ fun ProgramDialog(
) {
if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) {
TextButton(
onClick = onWatch,
onClick = { onWatch.invoke(item) },
modifier = Modifier,
) {
Row(
@ -187,9 +185,9 @@ fun ProgramDialog(
TextButton(
onClick = {
if (isSeriesRecording) {
onCancelRecord.invoke(true)
onCancelRecord.invoke(item, true)
} else {
onRecord.invoke(true)
onRecord.invoke(item, true)
}
},
modifier =
@ -229,9 +227,9 @@ fun ProgramDialog(
TextButton(
onClick = {
if (isRecording) {
onCancelRecord.invoke(false)
onCancelRecord.invoke(item, false)
} else {
onRecord.invoke(false)
onRecord.invoke(item, false)
}
},
modifier =
@ -281,4 +279,3 @@ fun ProgramDialog(
}
}
}
}

View file

@ -2,11 +2,11 @@ package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
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
@ -16,31 +16,26 @@ 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.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.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.focusProperties
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
@ -51,10 +46,15 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.LiveTvPreferences
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.RowColumn
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.rememberPosition
@ -64,7 +64,9 @@ import eu.wewox.programguide.ProgramGuide
import eu.wewox.programguide.ProgramGuideDimensions
import eu.wewox.programguide.ProgramGuideItem
import eu.wewox.programguide.rememberSaveableProgramGuideState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.time.LocalDateTime
import java.time.OffsetDateTime
@ -73,29 +75,21 @@ import kotlin.math.abs
@Composable
fun TvGuideGrid(
requestFocusAfterLoading: Boolean,
onRowPosition: (Int) -> Unit,
modifier: Modifier = Modifier,
viewModel: LiveTvViewModel = hiltViewModel(),
) {
val scope = rememberCoroutineScope()
// 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(FetchedPrograms(0..0, listOf(), mapOf()))
// val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
// val fetchedRange by viewModel.fetchedRange.observeAsState(0..0)
val state by viewModel.state.collectAsState()
val preferences by viewModel.preferences.data
.collectAsState(AppPreferences.getDefaultInstance())
val tvPrefs = preferences.interfacePreferences.liveTvPreferences
var showViewOptions by remember { mutableStateOf(false) }
when (val state = loading) {
var showChannelDialog by remember { mutableStateOf<Pair<Int, TvChannel>?>(null) }
when (val st = state.loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
ErrorMessage(st, modifier)
}
LoadingState.Pending,
@ -107,39 +101,46 @@ fun TvGuideGrid(
LoadingState.Success,
-> {
val context = LocalContext.current
val guideTimes by viewModel.guideTimes.observeAsState(listOf())
val fetchedItem by viewModel.fetchedItem.observeAsState(null)
val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending)
val programDialogState =
viewModel.programDialogState
.collectAsState()
.value.loading
var showItemDialog by remember { mutableStateOf<Int?>(null) }
val focusRequester = remember { FocusRequester() }
val buttonFocusRequester = remember { FocusRequester() }
if (requestFocusAfterLoading) {
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
}
var focusedPosition by rememberPosition(0, 0)
val focusedProgram =
remember(focusedPosition) {
focusedPosition.let {
val channelId = channels.getOrNull(it.row)?.id
programs.programsByChannel[channelId]?.getOrNull(it.column)
val channelId = state.channels.getOrNull(it.row)?.id
state.programs.programsByChannel[channelId]?.getOrNull(it.column)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
if (channels.isEmpty()) {
if (state.channels.isEmpty()) {
ErrorMessage("Live TV is enabled, but no channels were found.", null)
} else {
AnimatedVisibility(tvPrefs.showHeader) {
AnimatedVisibility(
visible = tvPrefs.showHeader,
enter = expandVertically(),
exit = shrinkVertically(),
) {
TvGuideHeader(
program = focusedProgram,
modifier =
Modifier
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
.fillMaxHeight(.30f),
.padding(
top = HeaderUtils.topPadding,
bottom = 0.dp,
start = HeaderUtils.startPadding,
).fillMaxHeight(.30f),
)
}
AnimatedVisibility(
@ -159,18 +160,13 @@ fun TvGuideGrid(
}
TvGuideGridContent(
preferences = tvPrefs,
loading = state is LoadingState.Loading,
channels = channels,
programs = programs,
channelProgramCount = viewModel.channelProgramCount,
guideTimes = guideTimes,
refreshing = state.refreshing,
channels = state.channels,
programs = state.programs,
channelProgramCount = state.channelProgramCount,
guideTimes = state.guideTimes,
onClickChannel = { index, channel ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = channel.id,
positionMs = 0L,
),
)
showChannelDialog = index to channel
},
onFocus = {
focusedPosition = it
@ -196,15 +192,14 @@ fun TvGuideGrid(
).show()
}
} else {
viewModel.getItem(program.id)
viewModel.fetchProgramForDialog(program.id)
showItemDialog = index
}
},
buttonFocusRequester = buttonFocusRequester,
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.background(MaterialTheme.colorScheme.background)
.focusRequester(focusRequester),
)
}
@ -212,13 +207,12 @@ fun TvGuideGrid(
if (showItemDialog != null) {
val onDismissRequest = { showItemDialog = null }
ProgramDialog(
item = fetchedItem,
state = programDialogState,
canRecord = true,
loading = loadingItem,
onDismissRequest = onDismissRequest,
onWatch = {
onDismissRequest.invoke()
fetchedItem?.data?.channelId?.let {
it.data.channelId?.let {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it,
@ -227,22 +221,18 @@ fun TvGuideGrid(
)
}
},
onRecord = { series ->
fetchedItem?.let {
onRecord = { program, series ->
viewModel.record(
programId = it.id,
programId = program.id,
series = series,
)
}
onDismissRequest.invoke()
},
onCancelRecord = { series ->
fetchedItem?.data?.let {
onCancelRecord = { program, series ->
viewModel.cancelRecording(
series = series,
timerId = if (series) it.seriesTimerId else it.timerId,
timerId = if (series) program.data.seriesTimerId else program.data.timerId,
)
}
onDismissRequest.invoke()
},
)
@ -262,6 +252,40 @@ fun TvGuideGrid(
},
)
}
showChannelDialog?.let { (index, channel) ->
val watchLiveStr = stringResource(R.string.watch_live)
val dialogItems =
remember {
listOf(
DialogItem(
watchLiveStr,
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = channel.id,
positionMs = 0L,
),
)
},
DialogItem(
text = if (channel.favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
iconColor = if (channel.favorite) Color.Red else Color.Unspecified,
dismissOnClick = true,
) {
viewModel.toggleFavorite(index, channel)
},
)
}
DialogPopup(
onDismissRequest = { showChannelDialog = null },
params = DialogParams(false, channel.name ?: "", dialogItems),
elevation = 3.dp,
)
}
}
val tvGuideDimensions =
@ -276,7 +300,7 @@ val tvGuideDimensions =
@Composable
fun TvGuideGridContent(
preferences: LiveTvPreferences,
loading: Boolean,
refreshing: Boolean,
channels: List<TvChannel>,
programs: FetchedPrograms,
channelProgramCount: Map<UUID, Int>,
@ -284,21 +308,56 @@ fun TvGuideGridContent(
onClickChannel: (Int, TvChannel) -> Unit,
onClickProgram: (Int, TvProgram) -> Unit,
onFocus: (RowColumn) -> Unit,
buttonFocusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val state = rememberSaveableProgramGuideState()
val scope = rememberCoroutineScope()
val guideStart = guideTimes.first()
val guideStart = remember(guideTimes) { guideTimes.first() }
var focusedItem by rememberPosition(RowColumn(0, 0))
val focusedChannelIndex = focusedItem.row
var focusedProgramIndex by remember { mutableIntStateOf(0) }
LaunchedEffect(onFocus, focusedProgramIndex) {
withContext(Dispatchers.Default) {
val program = programs.programs.getOrNull(focusedProgramIndex)
if (program != null) {
val channelIndex = channels.indexOfFirst { it.id == program.channelId }
val programsBefore =
(0..<channelIndex)
.mapNotNull {
val channel = channels[it]
channelProgramCount[channel.id]
}.sum()
val programIndex = focusedProgramIndex - programsBefore
focusedItem = RowColumn(channelIndex, programIndex)
onFocus.invoke(focusedItem)
}
}
}
val programFocusRequester = remember { FocusRequester() }
BackHandler(focusedItem.row > 0) {
scope.launch {
focusedProgramIndex = 0
state.animateToProgram(0, Alignment.Center)
programFocusRequester.tryRequestFocus()
}
}
BackHandler(focusedItem.column > 0) {
scope.launch {
val programIndex =
(0..<focusedItem.row)
.mapNotNull {
val channel = channels[it]
channelProgramCount[channel.id]
}.sum()
focusedProgramIndex = programIndex
state.animateToProgram(programIndex, Alignment.Center)
programFocusRequester.tryRequestFocus()
}
}
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
Box(modifier = modifier) {
ProgramGuide(
state = state,
@ -306,208 +365,8 @@ fun TvGuideGridContent(
modifier =
Modifier
.fillMaxSize()
.onFocusChanged {
gridHasFocus = it.hasFocus
}.focusProperties {
up = buttonFocusRequester
}.focusable()
.onPreviewKeyEvent {
if (it.type == KeyEventType.KeyUp) {
return@onPreviewKeyEvent false
}
val item = focusedItem
val newFocusedItem =
when (it.key) {
Key.Back -> {
if (item.column > 0) {
// Not at beginning of row, so move to beginning
item.copy(column = 0)
} else if (item.row > 0) {
item.copy(row = 0)
} else {
// At beginning, so allow normal back button behavior
return@onPreviewKeyEvent false
}
}
Key.DirectionRight -> {
if (channelColumnFocused) {
channelColumnFocused = false
item.copy(column = 0)
} else {
item.copy(column = item.column + 1)
}
}
Key.DirectionLeft -> {
if (channelColumnFocused) {
focusManager.moveFocus(FocusDirection.Left)
null
} else if (item.column == 0) {
channelColumnFocused = true
item
} else {
item.copy(column = item.column - 1)
}
}
Key.DirectionUp -> {
if (item.row <= 0) {
// focusManager.moveFocus(FocusDirection.Up)
null
} else {
val newChannelIndex = item.row - 1
if (channelColumnFocused) {
RowColumn(newChannelIndex, 0)
} else {
val currentChannel = channels[item.row].id
val currentProgram =
programs.programsByChannel[currentChannel]?.get(item.column)
val newChannelId = channels[newChannelIndex].id
val newChannelPrograms =
programs.programsByChannel[newChannelId]
if (newChannelPrograms == null) {
return@onPreviewKeyEvent true
}
if (currentProgram == null) {
item
} else {
val start = currentProgram.startHours
val pIndex =
newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
RowColumn(newChannelIndex, pIndex)
} else {
val pIndex =
newChannelPrograms.indexOfFirst { it.startHours >= start }
if (pIndex >= 0) {
RowColumn(newChannelIndex, pIndex)
} else {
RowColumn(newChannelIndex, 0)
}
}
}
}
}
}
Key.DirectionDown -> {
// Move channel focus down
val newChannelIndex = item.row + 1
if (newChannelIndex >= channels.size) {
// If trying to move below the final channel, then move focus out of the grid
focusManager.moveFocus(FocusDirection.Down)
null
} else {
// Otherwise, moving to a new row
// Get the new row/channel's programs
val newChannelId = channels[newChannelIndex].id
val newChannelPrograms =
programs.programsByChannel[newChannelId]
if (newChannelPrograms == null) {
// This means there is no data for the new channel and it is loading in the background
return@onPreviewKeyEvent true
}
if (channelColumnFocused) {
// If focused on the channel column, move down a channel
RowColumn(newChannelIndex, 0)
} else {
// Get current program & its start time
val currentChannel = channels[item.row].id
val currentProgram =
programs.programsByChannel[currentChannel]?.get(item.column)
if (currentProgram == null) {
// Data is loading in the background
item
} else {
val start = currentProgram.startHours
// Find the first program where the start time (of the previously focused program) is in the middle of a program
val pIndex =
newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
// Found one, so focus on it
RowColumn(newChannelIndex, 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 =
newChannelPrograms.indexOfFirst { it.startHours >= start }
if (pIndex >= 0) {
// Found one, so focus on it
RowColumn(newChannelIndex, pIndex)
} else {
// Did not find one, so focus on the final program in the list
RowColumn(
newChannelIndex,
newChannelPrograms.size - 1,
)
}
}
}
}
}
}
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
if (channelColumnFocused) {
val channel = channels[focusedChannelIndex]
Timber.v("Clicked on %s", channel)
onClickChannel.invoke(focusedChannelIndex, channel)
} else {
val currentChannel = channels[item.row].id
val currentProgram =
programs.programsByChannel[currentChannel]?.get(item.column)
if (currentProgram == null) {
// Data is loading in the background
return@onPreviewKeyEvent true
}
Timber.v("Clicked on %s", currentProgram)
onClickProgram.invoke(
focusedProgramIndex,
currentProgram,
)
}
null
}
else -> {
null
}
}
if (newFocusedItem != null) {
val channel = channels[newFocusedItem.row]
val channelPrograms = programs.programsByChannel[channel.id].orEmpty()
// Ensure it isn't going out of range
val toFocus =
newFocusedItem
.copy(
row = newFocusedItem.row.coerceIn(0, channels.size - 1),
column =
newFocusedItem.column.coerceIn(
0,
(channelPrograms.size - 1).coerceAtLeast(0),
),
)
focusedItem = toFocus
focusedProgramIndex =
toFocus.let { focus ->
(programs.range.first..<focus.row).sumOf {
val channelId = channels[it].id
channelProgramCount[channelId] ?: 0
} + focus.column
}
scope.launch {
try {
state.animateToProgram(focusedProgramIndex, Alignment.Center)
} catch (ex: Exception) {
Timber.e(ex, "Couldn't scroll to $focusedProgramIndex")
}
}
onFocus(toFocus)
return@onPreviewKeyEvent true
}
return@onPreviewKeyEvent false
.focusProperties {
onEnter = { programFocusRequester.tryRequestFocus() }
},
) {
guideStartHour = 0f
@ -593,9 +452,35 @@ fun TvGuideGridContent(
},
) { programIndex ->
val program = programs.programs[programIndex]
val focused =
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
Program(guideStart, program, focused, preferences.colorCodePrograms, Modifier)
Program(
guideStart = guideStart,
program = program,
colorCode = preferences.colorCodePrograms,
onClick = {
onClickProgram.invoke(programIndex, program)
},
onLongClick = {},
modifier =
Modifier
.ifElse(
programIndex == focusedProgramIndex,
Modifier.focusRequester(programFocusRequester),
).onFocusChanged {
if (it.isFocused) {
focusedProgramIndex = programIndex
scope.launch {
try {
state.animateToProgram(
programIndex,
Alignment.Center,
)
} catch (ex: Exception) {
Timber.e(ex, "Couldn't scroll to $programIndex")
}
}
}
},
)
}
channels(
@ -605,13 +490,29 @@ fun TvGuideGridContent(
},
) { channelIndex ->
val channel = channels[channelIndex]
val focused =
gridHasFocus && channelColumnFocused && focusedChannelIndex == channelIndex
Channel(
channel = channel,
channelIndex = channelIndex,
focused = focused,
modifier = Modifier,
onClick = { onClickChannel.invoke(channelIndex, channel) },
onLongClick = {},
modifier =
Modifier
.onFocusChanged {
if (it.isFocused) {
scope.launch {
try {
state.animateToChannel(
channelIndex,
Alignment.CenterVertically,
)
} catch (ex: Exception) {
Timber.e(ex, "Couldn't scroll to $channelIndex")
}
}
focusedItem = RowColumn(channelIndex, 0)
onFocus.invoke(focusedItem)
}
},
)
}
@ -623,7 +524,7 @@ fun TvGuideGridContent(
)
}
}
if (loading) {
if (refreshing) {
CircularProgress(
Modifier
.background(

View file

@ -4,56 +4,73 @@ import androidx.compose.foundation.layout.Arrangement
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
import coil3.compose.useExistingImageAsPlaceholder
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.components.EpisodeName
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.StreamLabel
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalCoilApi::class)
@Composable
fun TvGuideHeader(
program: TvProgram?,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.Start,
modifier = modifier,
) {
Text(
text = program?.name ?: program?.id.toString(),
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f),
TitleOrLogo(
title = program?.name ?: program?.id.toString(),
logoImageUrl = null,
showLogo = false,
modifier =
Modifier
.padding(start = HeaderUtils.startPadding)
.fillMaxWidth(.75f),
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(.6f),
) {
program?.subtitle?.let {
Text(
text = program.subtitle,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
EpisodeName(
episodeName = program.subtitle,
modifier = Modifier.padding(start = HeaderUtils.startPadding),
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = HeaderUtils.startPadding),
) {
program?.quickDetails?.let { QuickDetails(it, null) }
program?.quickDetails?.let {
QuickDetails(
it,
null,
modifier = Modifier.padding(vertical = 4.dp),
)
}
if (program?.isRepeat == true) {
StreamLabel(stringResource(R.string.live_tv_repeat))
}
@ -66,4 +83,16 @@ fun TvGuideHeader(
)
}
}
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(program?.imageUrl)
.transitionFactory(CrossFadeFactory(300.milliseconds))
.useExistingImageAsPlaceholder(true)
.build(),
contentDescription = null,
)
}
}