mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Show loading indicator when refreshing guide data
This commit is contained in:
parent
be7bb65a7e
commit
36287be5d4
3 changed files with 411 additions and 371 deletions
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue