Show loading indicator when refreshing guide data

This commit is contained in:
Damontecres 2025-10-22 12:32:31 -04:00
parent be7bb65a7e
commit 36287be5d4
No known key found for this signature in database
3 changed files with 411 additions and 371 deletions

View file

@ -90,6 +90,12 @@ class LiveTvViewModel
} }
} }
private suspend fun fetchProgramsWithLoading(channels: List<TvChannel>) {
loading.setValueOnMain(LoadingState.Loading)
fetchPrograms(channels)
loading.setValueOnMain(LoadingState.Success)
}
private suspend fun fetchPrograms(channels: List<TvChannel>) = private suspend fun fetchPrograms(channels: List<TvChannel>) =
mutex.withLock { mutex.withLock {
val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1) val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1)
@ -203,7 +209,7 @@ class LiveTvViewModel
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
if (series) { if (series) {
api.liveTvApi.cancelSeriesTimer(timerId) api.liveTvApi.cancelSeriesTimer(timerId)
fetchPrograms(channels.value.orEmpty()) fetchProgramsWithLoading(channels.value.orEmpty())
} else { } else {
api.liveTvApi.cancelTimer(timerId) api.liveTvApi.cancelTimer(timerId)
refreshProgram(programIndex, programId) refreshProgram(programIndex, programId)
@ -221,7 +227,7 @@ class LiveTvViewModel
val d by api.liveTvApi.getDefaultTimer(programId.toServerString()) val d by api.liveTvApi.getDefaultTimer(programId.toServerString())
if (series) { if (series) {
api.liveTvApi.createSeriesTimer(d) api.liveTvApi.createSeriesTimer(d)
fetchPrograms(channels.value.orEmpty()) fetchProgramsWithLoading(channels.value.orEmpty())
} else { } else {
val payload = val payload =
TimerInfoDto( TimerInfoDto(
@ -256,6 +262,7 @@ class LiveTvViewModel
programIndex: Int, programIndex: Int,
programId: UUID, programId: UUID,
) = mutex.withLock { ) = mutex.withLock {
loading.setValueOnMain(LoadingState.Loading)
val program by api.liveTvApi.getProgram(programId.toServerString()) val program by api.liveTvApi.getProgram(programId.toServerString())
val newProgram = val newProgram =
programs.value?.getOrNull(programIndex)?.copy( programs.value?.getOrNull(programIndex)?.copy(
@ -272,6 +279,7 @@ class LiveTvViewModel
this@LiveTvViewModel.programs.setValueOnMain(it) this@LiveTvViewModel.programs.setValueOnMain(it)
} }
} }
loading.setValueOnMain(LoadingState.Success)
} }
} }

View file

@ -6,6 +6,7 @@ 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -129,74 +130,87 @@ fun ProgramDialog(
maxLines = 3, maxLines = 3,
) )
} }
if (dto.isSeries ?: false) { Column(
Button( verticalArrangement = Arrangement.spacedBy(16.dp),
onClick = { horizontalAlignment = Alignment.CenterHorizontally,
if (isSeriesRecording) { modifier =
onCancelRecord.invoke(true) Modifier
} else { .padding(top = 8.dp)
onRecord.invoke(true) .fillMaxWidth(),
}
},
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isSeriesRecording) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color.Red,
)
}
Text(
text = if (isSeriesRecording) "Cancel Series Recording" else "Record Series",
)
}
}
}
Button(
onClick = {
if (isRecording) {
onCancelRecord.invoke(false)
} else {
onRecord.invoke(false)
}
},
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
if (isRecording) { if (dto.isSeries ?: false) {
Icon( Button(
imageVector = Icons.Default.Close, onClick = {
contentDescription = null, if (isSeriesRecording) {
tint = Color.Red, onCancelRecord.invoke(true)
) } else {
onRecord.invoke(true)
}
},
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isSeriesRecording) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color.Red,
)
}
Text(
text = if (isSeriesRecording) "Cancel Series Recording" else "Record Series",
)
}
}
} }
Text( Button(
text = if (isRecording) "Cancel Recording" else "Record Program", onClick = {
) if (isRecording) {
} onCancelRecord.invoke(false)
} } else {
if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) { onRecord.invoke(false)
Button( }
onClick = onWatch, },
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Row(
imageVector = Icons.Default.PlayArrow, horizontalArrangement = Arrangement.spacedBy(4.dp),
contentDescription = "Delete", verticalAlignment = Alignment.CenterVertically,
tint = Color.Green.copy(alpha = .75f), ) {
) if (isRecording) {
Text( Icon(
text = "Watch live", imageVector = Icons.Default.Close,
) contentDescription = null,
tint = Color.Red,
)
}
Text(
text = if (isRecording) "Cancel Recording" else "Record Program",
)
}
}
}
if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) {
Button(
onClick = onWatch,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Delete",
tint = Color.Green.copy(alpha = .75f),
)
Text(
text = "Watch live",
)
}
} }
} }
} }

View file

@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight 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.layout.size
import androidx.compose.foundation.shape.CircleShape
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.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -43,6 +45,7 @@ import androidx.tv.material3.Text
import androidx.tv.material3.contentColorFor 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.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.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
@ -78,12 +81,12 @@ fun TvGuideGrid(
val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf()) val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state, modifier) is LoadingState.Error -> ErrorMessage(state, modifier)
LoadingState.Loading,
LoadingState.Pending, LoadingState.Pending,
-> LoadingPage(modifier) -> LoadingPage(modifier)
LoadingState.Success -> { LoadingState.Loading,
LoadingState.Success,
-> {
val context = LocalContext.current val context = LocalContext.current
val fetchedItem by viewModel.fetchedItem.observeAsState(null) val fetchedItem by viewModel.fetchedItem.observeAsState(null)
val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending) val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending)
@ -96,6 +99,7 @@ fun TvGuideGrid(
} }
Column(modifier = modifier) { Column(modifier = modifier) {
TvGuideGrid( TvGuideGrid(
loading = state is LoadingState.Loading,
channels = channels, channels = channels,
programList = programs, programList = programs,
programs = programsByChannel, programs = programsByChannel,
@ -183,6 +187,7 @@ const val CHANNEL_COLUMN = -1
@Composable @Composable
fun TvGuideGrid( fun TvGuideGrid(
loading: Boolean,
channels: List<TvChannel>, channels: List<TvChannel>,
programList: List<TvProgram>, programList: List<TvProgram>,
programs: Map<UUID, List<TvProgram>>, programs: Map<UUID, List<TvProgram>>,
@ -225,352 +230,365 @@ fun TvGuideGrid(
var gridHasFocus by rememberSaveable { mutableStateOf(false) } var gridHasFocus by rememberSaveable { mutableStateOf(false) }
var channelColumnFocused by rememberSaveable { mutableStateOf(false) } var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
Box(modifier = modifier) {
ProgramGuide( ProgramGuide(
state = state, state = state,
dimensions = dimensions, dimensions = dimensions,
modifier = modifier =
modifier Modifier
.onFocusChanged { .fillMaxSize()
gridHasFocus = it.hasFocus .onFocusChanged {
}.focusable() gridHasFocus = it.hasFocus
.onPreviewKeyEvent { }.focusable()
if (it.type == KeyEventType.KeyUp) { .onPreviewKeyEvent {
return@onPreviewKeyEvent false if (it.type == KeyEventType.KeyUp) {
} return@onPreviewKeyEvent false
val newIndex = }
when (it.key) { val newIndex =
Key.Back -> { when (it.key) {
val pos = programsBeforeChannel.get(focusedChannelIndex) Key.Back -> {
if (focusedProgramIndex - pos > 0) { val pos = programsBeforeChannel.get(focusedChannelIndex)
// Not at beginning of row, so move to beginning if (focusedProgramIndex - pos > 0) {
pos // Not at beginning of row, so move to beginning
} else { pos
// At beginning, so allow normal back button behavior
return@onPreviewKeyEvent false
}
}
Key.DirectionRight -> {
if (channelColumnFocused) {
channelColumnFocused = false
focusedProgramIndex
} else {
val nextProgramIndex = focusedProgramIndex + 1
val programsBefore =
programsBeforeChannel.get(focusedChannelIndex)
val relativePosition = nextProgramIndex - programsBefore
val channelPrograms =
programs[channels[focusedChannelIndex].id].orEmpty()
if (relativePosition >= channelPrograms.size) {
focusManager.moveFocus(FocusDirection.Right)
null
} else { } else {
nextProgramIndex // At beginning, so allow normal back button behavior
return@onPreviewKeyEvent false
} }
} }
}
Key.DirectionLeft -> { Key.DirectionRight -> {
if (channelColumnFocused) { if (channelColumnFocused) {
focusManager.moveFocus(FocusDirection.Left) channelColumnFocused = false
null
} else {
val nextProgramIndex = focusedProgramIndex - 1
val programsBefore =
programsBeforeChannel.get(focusedChannelIndex)
val relativePosition = nextProgramIndex - programsBefore
// val channelPrograms =
// programs[channels[focusedChannel].id].orEmpty()
if (relativePosition >= 0) {
nextProgramIndex
} else if (relativePosition == CHANNEL_COLUMN) {
channelColumnFocused = true
focusedProgramIndex focusedProgramIndex
} else { } else {
Timber.w("Move left to relativePosition=$relativePosition should not occur") val nextProgramIndex = focusedProgramIndex + 1
val programsBefore =
programsBeforeChannel.get(focusedChannelIndex)
val relativePosition = nextProgramIndex - programsBefore
val channelPrograms =
programs[channels[focusedChannelIndex].id].orEmpty()
if (relativePosition >= channelPrograms.size) {
focusManager.moveFocus(FocusDirection.Right)
null
} else {
nextProgramIndex
}
}
}
Key.DirectionLeft -> {
if (channelColumnFocused) {
focusManager.moveFocus(FocusDirection.Left) focusManager.moveFocus(FocusDirection.Left)
null null
}
}
}
Key.DirectionUp -> {
val newFocusedChannelIndex = (focusedChannelIndex - 1)
// Timber.v("newFocusedChannelIndex=$newFocusedChannelIndex")
if (newFocusedChannelIndex < 0) {
focusManager.moveFocus(FocusDirection.Up)
null
} else if (channelColumnFocused) {
focusedChannelIndex = newFocusedChannelIndex
programsBeforeChannel.get(focusedChannelIndex)
} else {
val start = programList[focusedProgramIndex].startHours
focusedChannelIndex =
newFocusedChannelIndex.coerceAtLeast(0)
val channelId = channels[focusedChannelIndex].id
val pro = programs[channelId].orEmpty()
val pIndex =
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else { } else {
val pIndex = pro.indexOfFirst { it.startHours >= start } val nextProgramIndex = focusedProgramIndex - 1
if (pIndex >= 0) { val programsBefore =
programsBeforeChannel.get(focusedChannelIndex) + pIndex programsBeforeChannel.get(focusedChannelIndex)
val relativePosition = nextProgramIndex - programsBefore
// val channelPrograms =
// programs[channels[focusedChannel].id].orEmpty()
if (relativePosition >= 0) {
nextProgramIndex
} else if (relativePosition == CHANNEL_COLUMN) {
channelColumnFocused = true
focusedProgramIndex
} else { } else {
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size Timber.w("Move left to relativePosition=$relativePosition should not occur")
focusManager.moveFocus(FocusDirection.Left)
null
} }
} }
} }
}
Key.DirectionDown -> { Key.DirectionUp -> {
// Move channel focus down val newFocusedChannelIndex = (focusedChannelIndex - 1)
val newFocusedChannelIndex = (focusedChannelIndex + 1) // Timber.v("newFocusedChannelIndex=$newFocusedChannelIndex")
if (newFocusedChannelIndex >= channels.size) { if (newFocusedChannelIndex < 0) {
// If trying to move below the final channel, then move focus out of the grid focusManager.moveFocus(FocusDirection.Up)
focusManager.moveFocus(FocusDirection.Down) null
null } else if (channelColumnFocused) {
} else if (channelColumnFocused) { focusedChannelIndex = newFocusedChannelIndex
// If focused on the channel column, move down a channel programsBeforeChannel.get(focusedChannelIndex)
focusedChannelIndex =
newFocusedChannelIndex.coerceAtMost(channels.size - 1)
programsBeforeChannel.get(focusedChannelIndex)
} else {
// Otherwise, moving to a new row
focusedChannelIndex =
newFocusedChannelIndex.coerceAtMost(channels.size - 1)
// Get the new row/channel's programs
val channelId = channels[focusedChannelIndex].id
val pro = programs[channelId].orEmpty()
// When the currently focused program starts
val start = programList[focusedProgramIndex].startHours
// Find the first program where the start time (of the previously focused program) is in the middle of a program
val pIndex =
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
// Found one, so focus on it
// Get the sum of all of the previous channels' program sizes, plus the index found to convert a relative program index into absolute
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else { } else {
// Didn't find one, probably due to missing data val start = programList[focusedProgramIndex].startHours
// So now first the first one that starts after the previously focused program focusedChannelIndex =
val pIndex = pro.indexOfFirst { it.startHours >= start } newFocusedChannelIndex.coerceAtLeast(0)
val channelId = channels[focusedChannelIndex].id
val pro = programs[channelId].orEmpty()
val pIndex =
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else {
val pIndex = pro.indexOfFirst { it.startHours >= start }
if (pIndex >= 0) {
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else {
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size
}
}
}
}
Key.DirectionDown -> {
// Move channel focus down
val newFocusedChannelIndex = (focusedChannelIndex + 1)
if (newFocusedChannelIndex >= channels.size) {
// If trying to move below the final channel, then move focus out of the grid
focusManager.moveFocus(FocusDirection.Down)
null
} else if (channelColumnFocused) {
// If focused on the channel column, move down a channel
focusedChannelIndex =
newFocusedChannelIndex.coerceAtMost(channels.size - 1)
programsBeforeChannel.get(focusedChannelIndex)
} else {
// Otherwise, moving to a new row
focusedChannelIndex =
newFocusedChannelIndex.coerceAtMost(channels.size - 1)
// Get the new row/channel's programs
val channelId = channels[focusedChannelIndex].id
val pro = programs[channelId].orEmpty()
// When the currently focused program starts
val start = programList[focusedProgramIndex].startHours
// Find the first program where the start time (of the previously focused program) is in the middle of a program
val pIndex =
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) { if (pIndex >= 0) {
// Found one, so focus on it // Found one, so focus on it
// Get the sum of all of the previous channels' program sizes, plus the index found to convert a relative program index into absolute
programsBeforeChannel.get(focusedChannelIndex) + pIndex programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else { } else {
// Did not find one, so focus on the final program in the list // Didn't find one, probably due to missing data
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size // So now first the first one that starts after the previously focused program
val pIndex = pro.indexOfFirst { it.startHours >= start }
if (pIndex >= 0) {
// Found one, so focus on it
programsBeforeChannel.get(focusedChannelIndex) + pIndex
} else {
// Did not find one, so focus on the final program in the list
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size
}
} }
} }
} }
}
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
if (channelColumnFocused) { if (channelColumnFocused) {
val channel = channels[focusedChannelIndex] val channel = channels[focusedChannelIndex]
Timber.v("Clicked on %s", channel) Timber.v("Clicked on %s", channel)
onClickChannel.invoke(focusedChannelIndex, channel) onClickChannel.invoke(focusedChannelIndex, channel)
} else { } else {
val program = programList[focusedProgramIndex] val program = programList[focusedProgramIndex]
Timber.v("Clicked on %s", program) Timber.v("Clicked on %s", program)
onClickProgram.invoke(focusedProgramIndex, program) onClickProgram.invoke(focusedProgramIndex, program)
}
null
} }
null
}
else -> { else -> {
null null
}
} }
} if (newIndex != null) {
if (newIndex != null) {
// Timber.v("newIndex=$newIndex") // Timber.v("newIndex=$newIndex")
if (newIndex >= 0) { if (newIndex >= 0) {
val before = programsBeforeChannel.get(focusedChannelIndex) val before = programsBeforeChannel.get(focusedChannelIndex)
val max = val max =
before + channels[focusedChannelIndex].let { programs[it.id]!!.size } - 1 before + channels[focusedChannelIndex].let { programs[it.id]!!.size } - 1
val index = newIndex.coerceIn(before, max) val index = newIndex.coerceIn(before, max)
scope.launch(ExceptionHandler()) { scope.launch(ExceptionHandler()) {
focusedProgramIndex = index focusedProgramIndex = index
state.animateToProgram(index, Alignment.Center) state.animateToProgram(index, Alignment.Center)
}
return@onPreviewKeyEvent true
} else {
Timber.w("newIndex is <0: $newIndex")
return@onPreviewKeyEvent true
} }
return@onPreviewKeyEvent true
} else {
Timber.w("newIndex is <0: $newIndex")
return@onPreviewKeyEvent true
} }
} return@onPreviewKeyEvent false
return@onPreviewKeyEvent false },
},
) {
guideStartHour = 0f
currentTime(
layoutInfo = {
ProgramGuideItem.CurrentTime(
hoursBetween(start, LocalDateTime.now()),
)
},
) { ) {
Surface( guideStartHour = 0f
colors = SurfaceDefaults.colors(MaterialTheme.colorScheme.tertiary.copy(alpha = .25f)), currentTime(
modifier = Modifier, layoutInfo = {
) { ProgramGuideItem.CurrentTime(
// Empty hoursBetween(start, LocalDateTime.now()),
}
}
timeline(
count = MAX_HOURS.toInt(),
layoutInfo = { index ->
ProgramGuideItem.Timeline(
startHour = index.toFloat(),
endHour = index + 1f,
)
},
) { index ->
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
) {
val differentDay =
start.toLocalDate() !=
start
.plusHours(index.toLong())
.toLocalDate()
val time =
DateUtils.formatDateTime(
context,
start
.plusHours(index.toLong())
.toInstant(OffsetDateTime.now().offset)
.epochSecond * 1000,
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
) )
Text( },
text = time.toString(),
modifier = Modifier.background(MaterialTheme.colorScheme.background),
)
}
}
programs(
count = programList.size,
layoutInfo = { programIndex ->
val program = programList[programIndex]
val channelIndex = channels.indexOfFirst { it.id == program.channelId }
ProgramGuideItem.Program(channelIndex, program.startHours, program.endHours)
},
) { programIndex ->
val program = programList[programIndex]
val focused =
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
val background =
if (focused) {
MaterialTheme.colorScheme.inverseSurface
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
}
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
Box(
modifier =
Modifier
// .scale(if (focused) 1.1f else 1f)
.padding(2.dp)
.fillMaxSize()
.background(
background,
shape = RoundedCornerShape(4.dp),
),
) { ) {
Column( Surface(
verticalArrangement = Arrangement.spacedBy(4.dp), colors = SurfaceDefaults.colors(MaterialTheme.colorScheme.tertiary.copy(alpha = .25f)),
modifier = Modifier,
) {
// Empty
}
}
timeline(
count = MAX_HOURS.toInt(),
layoutInfo = { index ->
ProgramGuideItem.Timeline(
startHour = index.toFloat(),
endHour = index + 1f,
)
},
) { index ->
Box(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(4.dp), .background(MaterialTheme.colorScheme.surface),
) { ) {
val differentDay =
start.toLocalDate() !=
start
.plusHours(index.toLong())
.toLocalDate()
val time =
DateUtils.formatDateTime(
context,
start
.plusHours(index.toLong())
.toInstant(OffsetDateTime.now().offset)
.epochSecond * 1000,
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
)
Text( Text(
text = program.name ?: program.id.toString(), text = time.toString(),
color = textColor, modifier = Modifier.background(MaterialTheme.colorScheme.background),
maxLines = 1,
modifier = Modifier.enableMarquee(focused),
) )
listOfNotNull(
program.seasonEpisode?.let { "S${it.season} E${it.episode}" },
program.subtitle,
).joinToString(" - ")
.ifBlank { null }
?.let {
Text(
text = it,
color = textColor,
modifier = Modifier,
)
}
} }
RecordingMarker( }
isRecording = program.isRecording,
isSeriesRecording = program.isSeriesRecording, programs(
modifier = Modifier.align(Alignment.BottomEnd), count = programList.size,
layoutInfo = { programIndex ->
val program = programList[programIndex]
val channelIndex = channels.indexOfFirst { it.id == program.channelId }
ProgramGuideItem.Program(channelIndex, program.startHours, program.endHours)
},
) { programIndex ->
val program = programList[programIndex]
val focused =
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
val background =
if (focused) {
MaterialTheme.colorScheme.inverseSurface
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
}
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
Box(
modifier =
Modifier
// .scale(if (focused) 1.1f else 1f)
.padding(2.dp)
.fillMaxSize()
.background(
background,
shape = RoundedCornerShape(4.dp),
),
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier =
Modifier
.fillMaxSize()
.padding(4.dp),
) {
Text(
text = program.name ?: program.id.toString(),
color = textColor,
maxLines = 1,
modifier = Modifier.enableMarquee(focused),
)
listOfNotNull(
program.seasonEpisode?.let { "S${it.season} E${it.episode}" },
program.subtitle,
).joinToString(" - ")
.ifBlank { null }
?.let {
Text(
text = it,
color = textColor,
modifier = Modifier,
)
}
}
RecordingMarker(
isRecording = program.isRecording,
isSeriesRecording = program.isSeriesRecording,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
}
channels(
count = channels.size,
layoutInfo = { channelIndex ->
ProgramGuideItem.Channel(channelIndex)
},
) { channelIndex ->
val channel = channels[channelIndex]
val focused =
gridHasFocus && channelColumnFocused && focusedChannelIndex == channelIndex
val background =
if (focused) {
MaterialTheme.colorScheme.inverseSurface
} else {
MaterialTheme.colorScheme.surface
}
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
Box(
modifier =
Modifier
.fillMaxSize()
.background(
background,
shape = RoundedCornerShape(4.dp),
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(4.dp)
.fillMaxSize(),
) {
Text(
text = channel.number ?: channel.name ?: channelIndex.toString(),
color = textColor,
modifier = Modifier,
)
AsyncImage(
model = channel.imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxHeight(.66f),
)
}
}
}
topCorner {
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
) )
} }
} }
if (loading) {
channels( CircularProgress(
count = channels.size,
layoutInfo = { channelIndex ->
ProgramGuideItem.Channel(channelIndex)
},
) { channelIndex ->
val channel = channels[channelIndex]
val focused =
gridHasFocus && channelColumnFocused && focusedChannelIndex == channelIndex
val background =
if (focused) {
MaterialTheme.colorScheme.inverseSurface
} else {
MaterialTheme.colorScheme.surface
}
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
Box(
modifier =
Modifier
.fillMaxSize()
.background(
background,
shape = RoundedCornerShape(4.dp),
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(4.dp)
.fillMaxSize(),
) {
Text(
text = channel.number ?: channel.name ?: channelIndex.toString(),
color = textColor,
modifier = Modifier,
)
AsyncImage(
model = channel.imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxHeight(.66f),
)
}
}
}
topCorner {
Box(
Modifier Modifier
.fillMaxSize() .background(
.background(MaterialTheme.colorScheme.surface), MaterialTheme.colorScheme.background.copy(alpha = .5f),
shape = CircleShape,
).size(64.dp)
.padding(16.dp)
.align(Alignment.BottomEnd),
) )
} }
} }