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) { when (selectedTabIndex) {
0 -> { 0 -> {
TvGuideGrid( TvGuideGrid(
true,
onRowPosition = { onRowPosition = {
showHeader = it <= 0 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.contentColorFor
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
@ -30,20 +35,11 @@ import java.time.LocalDateTime
fun Program( fun Program(
guideStart: LocalDateTime, guideStart: LocalDateTime,
program: TvProgram, program: TvProgram,
focused: Boolean,
colorCode: Boolean, colorCode: Boolean,
onClick: () -> Unit,
onLongClick: (() -> Unit)?,
modifier: Modifier = Modifier, 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 startedBeforeGuide = program.start.isBefore(guideStart)
val shape = val shape =
remember(startedBeforeGuide) { remember(startedBeforeGuide) {
@ -60,15 +56,28 @@ fun Program(
} }
} }
val title = program.name ?: program.id.toString() 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 =
modifier modifier
.padding(2.dp) .padding(2.dp)
.fillMaxSize() .fillMaxSize(),
.background(
color = background,
shape = shape,
),
) { ) {
Row( Row(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -77,7 +86,7 @@ fun Program(
Text( Text(
text = stringResource(R.string.fa_caret_left), text = stringResource(R.string.fa_caret_left),
fontFamily = FontAwesome, fontFamily = FontAwesome,
color = textColor, color = LocalContentColor.current,
fontSize = 16.sp, fontSize = 16.sp,
modifier = modifier =
Modifier Modifier
@ -94,21 +103,23 @@ fun Program(
) { ) {
Text( Text(
text = title, text = title,
color = textColor, color = LocalContentColor.current,
fontSize = 16.sp, fontSize = 16.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier, modifier = Modifier,
) )
val subtitle =
remember(program) {
listOfNotNull( listOfNotNull(
program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, program.seasonEpisode?.let { "S${it.season} E${it.episode}" },
program.subtitle, program.subtitle,
).joinToString(" - ") ).joinToString(" - ").ifBlank { null }
.ifBlank { null } }
?.let { subtitle?.let {
Text( Text(
text = it, text = it,
color = textColor, color = LocalContentColor.current,
fontSize = 14.sp, fontSize = 14.sp,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier, modifier = Modifier,
@ -128,43 +139,84 @@ fun Program(
fun Channel( fun Channel(
channel: TvChannel, channel: TvChannel,
channelIndex: Int, channelIndex: Int,
focused: Boolean, onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val background = Surface(
if (focused) { onClick = onClick,
MaterialTheme.colorScheme.inverseSurface onLongClick = onLongClick,
} else { shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)),
MaterialTheme.colorScheme.surface scale = ClickableSurfaceDefaults.scale(1f, 1f, .95f),
} colors =
val textColor = MaterialTheme.colorScheme.contentColorFor(background) ClickableSurfaceDefaults.colors(
Box( containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
focusedContainerColor = MaterialTheme.colorScheme.inverseSurface,
),
modifier = modifier =
modifier modifier
.fillMaxSize() .background(MaterialTheme.colorScheme.background)
.background( .padding(2.dp)
background, .fillMaxSize(),
shape = RoundedCornerShape(4.dp),
),
) { ) {
Row( Box(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier =
Modifier Modifier
.padding(4.dp) .padding(4.dp)
.fillMaxSize(), .fillMaxSize(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(),
) { ) {
Text( Text(
text = channel.number ?: channel.name ?: channelIndex.toString(), text = channel.number ?: "",
color = textColor, style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier, modifier = Modifier,
) )
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier,
) {
var showImage by remember { mutableStateOf(true) }
if (showImage) {
AsyncImage( AsyncImage(
model = channel.imageUrl, model = channel.imageUrl,
contentDescription = null, contentDescription = channel.name,
modifier = Modifier.fillMaxHeight(.66f), 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.nav.Destination
import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.ExceptionHandler
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
@ -159,14 +160,13 @@ fun DvrSchedule(
) )
showDialog?.let { item -> showDialog?.let { item ->
ProgramDialog( ProgramDialog(
item = item, state = DataLoadingState.Success(item),
canRecord = true, canRecord = true,
loading = LoadingState.Success,
onDismissRequest = { onDismissRequest = {
showDialog = null showDialog = null
}, },
onWatch = { onWatch = { program ->
item.data.channelId?.let { program.data.channelId?.let {
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.Playback( Destination.Playback(
itemId = it, itemId = it,
@ -175,12 +175,13 @@ fun DvrSchedule(
) )
} }
}, },
onRecord = { onRecord = { _, _ ->
// no-op // no-op
}, },
onCancelRecord = { series -> onCancelRecord = { program, series ->
showDialog = null 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) { if (timerId != null) {
viewModel.cancelRecording(timerId, series) 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.graphics.Color
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R 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.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences 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.services.NavigationManager
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.isNotNullOrBlank 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.launchIO
import com.github.damontecres.wholphin.ui.roundMinutes 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.ui.toServerString
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler 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.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
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.imageApi
import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.GetProgramsDto import org.jellyfin.sdk.model.api.GetProgramsDto
@ -78,24 +78,22 @@ class LiveTvViewModel
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val preferences: DataStore<AppPreferences>, val preferences: DataStore<AppPreferences>,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService,
private val favoriteWatchManager: FavoriteWatchManager,
) : ViewModel() { ) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
private lateinit var channelsIdToIndex: Map<UUID, Int> private lateinit var channelsIdToIndex: Map<UUID, Int>
private val mutex = Mutex() private val mutex = Mutex()
val guideTimes = MutableLiveData<List<LocalDateTime>>(buildGuideTimes()) private val _state = MutableStateFlow(LiveTvState())
val channels = MutableLiveData<List<TvChannel>>() val state: StateFlow<LiveTvState> = _state
val channelProgramCount = mutableMapOf<UUID, Int>()
val programs = MutableLiveData<FetchedPrograms>()
val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending) private val _programDialogState = MutableStateFlow(ProgramDialogState())
val fetchedItem = MutableLiveData<BaseItem?>(null) val programDialogState: StateFlow<ProgramDialogState> = _programDialogState
private val range = 100 private val range = 100
init { init {
viewModelScope.launchIO { viewModelScope.launchDefault {
preferences.data preferences.data
.map { .map {
it.interfacePreferences.liveTvPreferences.let { it.interfacePreferences.liveTvPreferences.let {
@ -105,37 +103,34 @@ class LiveTvViewModel
.debounce { 500.milliseconds } .debounce { 500.milliseconds }
.collectLatest { .collectLatest {
Timber.v("Init due to pref change") Timber.v("Init due to pref change")
loading.setValueOnMain(LoadingState.Pending) _state.update { LiveTvState() }
init() init(it.first, it.second)
} }
} }
} }
fun init() { private fun init(
val guideStart = guideTimes.value!!.first() sortByRecentlyWatched: Boolean,
viewModelScope.launch( favoriteChannelsAtBeginning: Boolean,
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Could not fetch channels",
),
) { ) {
val prefs = viewModelScope.launchDefault {
(preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()) try {
.interfacePreferences.liveTvPreferences val guideTimes = buildGuideTimes()
_state.update { it.copy(guideTimes = guideTimes) }
val guideStart = guideTimes.first()
val channelData by api.liveTvApi.getLiveTvChannels( val channelData by api.liveTvApi.getLiveTvChannels(
GetLiveTvChannelsRequest( GetLiveTvChannelsRequest(
startIndex = 0, startIndex = 0,
userId = serverRepository.currentUser.value?.id, userId = serverRepository.currentUser.value?.id,
enableFavoriteSorting = prefs.favoriteChannelsAtBeginning, enableFavoriteSorting = favoriteChannelsAtBeginning,
sortBy = sortBy =
if (prefs.sortByRecentlyWatched) { if (sortByRecentlyWatched) {
listOf(ItemSortBy.DATE_PLAYED) listOf(ItemSortBy.DATE_PLAYED)
} else { } else {
null null
}, },
sortOrder = sortOrder =
if (prefs.sortByRecentlyWatched) { if (sortByRecentlyWatched) {
SortOrder.DESCENDING SortOrder.DESCENDING
} else { } else {
null null
@ -147,10 +142,12 @@ class LiveTvViewModel
channelData.items channelData.items
.map { .map {
TvChannel( TvChannel(
it.id, id = it.id,
it.channelNumber, number = it.channelNumber,
it.channelName, name = it.channelName ?: it.name,
api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY), imageUrl =
imageUrlService.getItemImageUrl(it.id, ImageType.PRIMARY),
favorite = it.userData?.isFavorite == true,
) )
} }
Timber.d("Got ${channels.size} channels") Timber.d("Got ${channels.size} channels")
@ -161,14 +158,20 @@ class LiveTvViewModel
val initial = 10 val initial = 10
fetchPrograms(guideStart, channels, 0..<initial.coerceAtMost(channels.size)) fetchPrograms(guideStart, channels, 0..<initial.coerceAtMost(channels.size))
withContext(Dispatchers.Main) { _state.update {
this@LiveTvViewModel.channels.value = channels it.copy(
loading.value = LoadingState.Success channels = channels,
loading = LoadingState.Success,
)
} }
// Now load the full range // Now load the full range
if (channels.size > initial) { if (channels.size > initial) {
fetchPrograms(guideStart, channels, 0..<range.coerceAtMost(channels.size)) 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>, channels: List<TvChannel>,
range: IntRange, range: IntRange,
) { ) {
loading.setValueOnMain(LoadingState.Loading) _state.update { it.copy(refreshing = true) }
val guideStart = guideTimes.value!!.first() val guideStart = _state.value.guideTimes.first()
try {
fetchPrograms(guideStart, channels, range) 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(), isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank(),
isRepeat = dto.isRepeat ?: false, isRepeat = dto.isRepeat ?: false,
category = category, category = category,
imageUrl =
imageUrlService.getItemImageUrl(
dto.id,
ImageType.PRIMARY,
),
) )
if (index == 0) { if (index == 0) {
if (p.startHours > 0) { if (p.startHours > 0) {
@ -382,13 +397,12 @@ class LiveTvViewModel
category = ProgramCategory.FAKE, category = ProgramCategory.FAKE,
) )
} }
programsByChannel.put(channel.id, fakePrograms) programsByChannel[channel.id] = fakePrograms
fake.addAll(fakePrograms) fake.addAll(fakePrograms)
} }
programsByChannel.forEach { (channelId, programs) -> val channelProgramCount = programsByChannel.map { it.key to it.value.size }.toMap()
channelProgramCount[channelId] = programs.size
}
val finalProgramList = val finalProgramList =
(programsByChannel.values.flatten()) (programsByChannel.values.flatten())
.sortedWith( .sortedWith(
@ -398,30 +412,32 @@ class LiveTvViewModel
), ),
) )
Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs") Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs")
withContext(Dispatchers.Main) { _state.update {
this@LiveTvViewModel.programs.value = it.copy(
FetchedPrograms(channelIndices, finalProgramList, programsByChannel) channelProgramCount = channelProgramCount,
programs =
FetchedPrograms(
channelIndices,
finalProgramList,
programsByChannel,
),
)
} }
} }
fun getItem(programId: UUID) { fun fetchProgramForDialog(programId: UUID) {
fetchingItem.value = LoadingState.Loading _programDialogState.update { it.copy(loading = DataLoadingState.Loading) }
viewModelScope.launchIO(LoadingExceptionHandler(fetchingItem, "Error")) { viewModelScope.launchDefault {
try {
val result = val result =
api.liveTvApi api.liveTvApi
.getProgram(programId.toServerString()) .getProgram(programId.toServerString())
.content .content
.let { BaseItem.from(it, api) } .let { BaseItem(it) }
withContext(Dispatchers.Main) { _programDialogState.update { it.copy(loading = DataLoadingState.Success(result)) }
fetchedItem.value = result } catch (ex: Exception) {
fetchingItem.value = LoadingState.Success Timber.e(ex, "Error fetching program $programId")
} _programDialogState.update { it.copy(loading = DataLoadingState.Error(ex)) }
if (result.data.seriesTimerId != null) {
val items =
api.liveTvApi
.getPrograms(GetProgramsDto(seriesTimerId = result.data.seriesTimerId))
.content.items
Timber.v("items=$items")
} }
} }
} }
@ -437,7 +453,9 @@ class LiveTvViewModel
} else { } else {
api.liveTvApi.cancelTimer(timerId) 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) 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 * This determines if more programs/channels should be fetched based on the current position
*/ */
fun onFocusChannel(position: RowColumn) { fun onFocusChannel(position: RowColumn) {
channels.value?.let { channels -> val state = state.value
val fetchedRange = programs.value!!.range val channels = state.channels
val fetchedRange = state.programs.range
val quarter = range / 4 val quarter = range / 4
var rangeStart = fetchedRange.start + quarter var rangeStart = fetchedRange.start + quarter
var rangeEnd = fetchedRange.last - quarter var rangeEnd = fetchedRange.last - quarter
@ -523,10 +544,26 @@ class LiveTvViewModel
focusLoadingJob?.cancel() focusLoadingJob?.cancel()
focusLoadingJob = focusLoadingJob =
viewModelScope.launchIO { 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) .between(start, target)
.seconds / (60f * 60f) .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( data class TvChannel(
val id: UUID, val id: UUID,
val number: String?, val number: String?,
val name: String?, val name: String?,
val imageUrl: String?, val imageUrl: String?,
val favorite: Boolean,
) )
@Stable @Stable
@ -568,6 +619,7 @@ data class TvProgram(
val isRepeat: Boolean, val isRepeat: Boolean,
val category: ProgramCategory?, val category: ProgramCategory?,
val officialRating: String? = null, val officialRating: String? = null,
val imageUrl: String? = null,
) { ) {
val isFake = category == ProgramCategory.FAKE 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, DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
) )
append(time) append(time)
dot()
if (!isFake) { if (!isFake) {
dot()
duration duration
.roundMinutes .roundMinutes
.toString() .toString()
.let(::append) .let(::append)
dot()
if (now.isAfter(start) && now.isBefore(end)) { if (now.isAfter(start) && now.isBefore(end)) {
dot()
java.time.Duration java.time.Duration
.between(now, end) .between(now, end)
.toKotlinDuration() .toKotlinDuration()
.roundMinutes .roundMinutes
.let { append("$it left") } .let { append("$it left") }
dot()
} }
seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let { seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let {
append(it)
dot() dot()
append(it)
}
officialRating?.let {
dot()
append(it)
} }
officialRating?.let(::append)
} }
} }
} }
@ -653,9 +707,9 @@ enum class ProgramCategory(
} }
data class FetchedPrograms( data class FetchedPrograms(
val range: IntRange, val range: IntRange = 0..0,
val programs: List<TvProgram>, val programs: List<TvProgram> = emptyList(),
val programsByChannel: Map<UUID, List<TvProgram>>, val programsByChannel: Map<UUID, List<TvProgram>> = emptyMap(),
) )
fun LocalDateTime.roundDownToHalfHour(): LocalDateTime { fun LocalDateTime.roundDownToHalfHour(): LocalDateTime {

View file

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

View file

@ -2,11 +2,11 @@ package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils import android.text.format.DateUtils
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key import androidx.compose.ui.graphics.Color
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.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme 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.AppPreferences
import com.github.damontecres.wholphin.preferences.LiveTvPreferences import com.github.damontecres.wholphin.preferences.LiveTvPreferences
import com.github.damontecres.wholphin.ui.components.CircularProgress 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton 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.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.RowColumn 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.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition 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.ProgramGuideDimensions
import eu.wewox.programguide.ProgramGuideItem import eu.wewox.programguide.ProgramGuideItem
import eu.wewox.programguide.rememberSaveableProgramGuideState import eu.wewox.programguide.rememberSaveableProgramGuideState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
@ -73,29 +75,21 @@ import kotlin.math.abs
@Composable @Composable
fun TvGuideGrid( fun TvGuideGrid(
requestFocusAfterLoading: Boolean,
onRowPosition: (Int) -> Unit, onRowPosition: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: LiveTvViewModel = hiltViewModel(), viewModel: LiveTvViewModel = hiltViewModel(),
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// var firstLoad by rememberSaveable { mutableStateOf(true) } val state by viewModel.state.collectAsState()
// 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 preferences by viewModel.preferences.data val preferences by viewModel.preferences.data
.collectAsState(AppPreferences.getDefaultInstance()) .collectAsState(AppPreferences.getDefaultInstance())
val tvPrefs = preferences.interfacePreferences.liveTvPreferences val tvPrefs = preferences.interfacePreferences.liveTvPreferences
var showViewOptions by remember { mutableStateOf(false) } 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 -> { is LoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(st, modifier)
} }
LoadingState.Pending, LoadingState.Pending,
@ -107,39 +101,46 @@ fun TvGuideGrid(
LoadingState.Success, LoadingState.Success,
-> { -> {
val context = LocalContext.current val context = LocalContext.current
val guideTimes by viewModel.guideTimes.observeAsState(listOf()) val programDialogState =
val fetchedItem by viewModel.fetchedItem.observeAsState(null) viewModel.programDialogState
val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending) .collectAsState()
.value.loading
var showItemDialog by remember { mutableStateOf<Int?>(null) } var showItemDialog by remember { mutableStateOf<Int?>(null) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val buttonFocusRequester = remember { FocusRequester() } val buttonFocusRequester = remember { FocusRequester() }
if (requestFocusAfterLoading) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.tryRequestFocus() focusRequester.tryRequestFocus()
} }
}
var focusedPosition by rememberPosition(0, 0) var focusedPosition by rememberPosition(0, 0)
val focusedProgram = val focusedProgram =
remember(focusedPosition) { remember(focusedPosition) {
focusedPosition.let { focusedPosition.let {
val channelId = channels.getOrNull(it.row)?.id val channelId = state.channels.getOrNull(it.row)?.id
programs.programsByChannel[channelId]?.getOrNull(it.column) state.programs.programsByChannel[channelId]?.getOrNull(it.column)
} }
} }
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier, modifier = modifier,
) { ) {
if (channels.isEmpty()) { if (state.channels.isEmpty()) {
ErrorMessage("Live TV is enabled, but no channels were found.", null) ErrorMessage("Live TV is enabled, but no channels were found.", null)
} else { } else {
AnimatedVisibility(tvPrefs.showHeader) { AnimatedVisibility(
visible = tvPrefs.showHeader,
enter = expandVertically(),
exit = shrinkVertically(),
) {
TvGuideHeader( TvGuideHeader(
program = focusedProgram, program = focusedProgram,
modifier = modifier =
Modifier Modifier
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp) .padding(
.fillMaxHeight(.30f), top = HeaderUtils.topPadding,
bottom = 0.dp,
start = HeaderUtils.startPadding,
).fillMaxHeight(.30f),
) )
} }
AnimatedVisibility( AnimatedVisibility(
@ -159,18 +160,13 @@ fun TvGuideGrid(
} }
TvGuideGridContent( TvGuideGridContent(
preferences = tvPrefs, preferences = tvPrefs,
loading = state is LoadingState.Loading, refreshing = state.refreshing,
channels = channels, channels = state.channels,
programs = programs, programs = state.programs,
channelProgramCount = viewModel.channelProgramCount, channelProgramCount = state.channelProgramCount,
guideTimes = guideTimes, guideTimes = state.guideTimes,
onClickChannel = { index, channel -> onClickChannel = { index, channel ->
viewModel.navigationManager.navigateTo( showChannelDialog = index to channel
Destination.Playback(
itemId = channel.id,
positionMs = 0L,
),
)
}, },
onFocus = { onFocus = {
focusedPosition = it focusedPosition = it
@ -196,15 +192,14 @@ fun TvGuideGrid(
).show() ).show()
} }
} else { } else {
viewModel.getItem(program.id) viewModel.fetchProgramForDialog(program.id)
showItemDialog = index showItemDialog = index
} }
}, },
buttonFocusRequester = buttonFocusRequester,
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.background)
.focusRequester(focusRequester), .focusRequester(focusRequester),
) )
} }
@ -212,13 +207,12 @@ fun TvGuideGrid(
if (showItemDialog != null) { if (showItemDialog != null) {
val onDismissRequest = { showItemDialog = null } val onDismissRequest = { showItemDialog = null }
ProgramDialog( ProgramDialog(
item = fetchedItem, state = programDialogState,
canRecord = true, canRecord = true,
loading = loadingItem,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onWatch = { onWatch = {
onDismissRequest.invoke() onDismissRequest.invoke()
fetchedItem?.data?.channelId?.let { it.data.channelId?.let {
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.Playback( Destination.Playback(
itemId = it, itemId = it,
@ -227,22 +221,18 @@ fun TvGuideGrid(
) )
} }
}, },
onRecord = { series -> onRecord = { program, series ->
fetchedItem?.let {
viewModel.record( viewModel.record(
programId = it.id, programId = program.id,
series = series, series = series,
) )
}
onDismissRequest.invoke() onDismissRequest.invoke()
}, },
onCancelRecord = { series -> onCancelRecord = { program, series ->
fetchedItem?.data?.let {
viewModel.cancelRecording( viewModel.cancelRecording(
series = series, series = series,
timerId = if (series) it.seriesTimerId else it.timerId, timerId = if (series) program.data.seriesTimerId else program.data.timerId,
) )
}
onDismissRequest.invoke() 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 = val tvGuideDimensions =
@ -276,7 +300,7 @@ val tvGuideDimensions =
@Composable @Composable
fun TvGuideGridContent( fun TvGuideGridContent(
preferences: LiveTvPreferences, preferences: LiveTvPreferences,
loading: Boolean, refreshing: Boolean,
channels: List<TvChannel>, channels: List<TvChannel>,
programs: FetchedPrograms, programs: FetchedPrograms,
channelProgramCount: Map<UUID, Int>, channelProgramCount: Map<UUID, Int>,
@ -284,21 +308,56 @@ fun TvGuideGridContent(
onClickChannel: (Int, TvChannel) -> Unit, onClickChannel: (Int, TvChannel) -> Unit,
onClickProgram: (Int, TvProgram) -> Unit, onClickProgram: (Int, TvProgram) -> Unit,
onFocus: (RowColumn) -> Unit, onFocus: (RowColumn) -> Unit,
buttonFocusRequester: FocusRequester,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current
val state = rememberSaveableProgramGuideState() val state = rememberSaveableProgramGuideState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val guideStart = guideTimes.first() val guideStart = remember(guideTimes) { guideTimes.first() }
var focusedItem by rememberPosition(RowColumn(0, 0)) var focusedItem by rememberPosition(RowColumn(0, 0))
val focusedChannelIndex = focusedItem.row
var focusedProgramIndex by remember { mutableIntStateOf(0) } 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) { Box(modifier = modifier) {
ProgramGuide( ProgramGuide(
state = state, state = state,
@ -306,208 +365,8 @@ fun TvGuideGridContent(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.onFocusChanged { .focusProperties {
gridHasFocus = it.hasFocus onEnter = { programFocusRequester.tryRequestFocus() }
}.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
}, },
) { ) {
guideStartHour = 0f guideStartHour = 0f
@ -593,9 +452,35 @@ fun TvGuideGridContent(
}, },
) { programIndex -> ) { programIndex ->
val program = programs.programs[programIndex] val program = programs.programs[programIndex]
val focused = Program(
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex guideStart = guideStart,
Program(guideStart, program, focused, preferences.colorCodePrograms, Modifier) 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( channels(
@ -605,13 +490,29 @@ fun TvGuideGridContent(
}, },
) { channelIndex -> ) { channelIndex ->
val channel = channels[channelIndex] val channel = channels[channelIndex]
val focused =
gridHasFocus && channelColumnFocused && focusedChannelIndex == channelIndex
Channel( Channel(
channel = channel, channel = channel,
channelIndex = channelIndex, channelIndex = channelIndex,
focused = focused, onClick = { onClickChannel.invoke(channelIndex, channel) },
modifier = Modifier, 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( CircularProgress(
Modifier Modifier
.background( .background(

View file

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