diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index d21f7e02..95b2bb54 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -90,6 +90,12 @@ class LiveTvViewModel } } + private suspend fun fetchProgramsWithLoading(channels: List) { + loading.setValueOnMain(LoadingState.Loading) + fetchPrograms(channels) + loading.setValueOnMain(LoadingState.Success) + } + private suspend fun fetchPrograms(channels: List) = mutex.withLock { val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1) @@ -203,7 +209,7 @@ class LiveTvViewModel viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { if (series) { api.liveTvApi.cancelSeriesTimer(timerId) - fetchPrograms(channels.value.orEmpty()) + fetchProgramsWithLoading(channels.value.orEmpty()) } else { api.liveTvApi.cancelTimer(timerId) refreshProgram(programIndex, programId) @@ -221,7 +227,7 @@ class LiveTvViewModel val d by api.liveTvApi.getDefaultTimer(programId.toServerString()) if (series) { api.liveTvApi.createSeriesTimer(d) - fetchPrograms(channels.value.orEmpty()) + fetchProgramsWithLoading(channels.value.orEmpty()) } else { val payload = TimerInfoDto( @@ -256,6 +262,7 @@ class LiveTvViewModel programIndex: Int, programId: UUID, ) = mutex.withLock { + loading.setValueOnMain(LoadingState.Loading) val program by api.liveTvApi.getProgram(programId.toServerString()) val newProgram = programs.value?.getOrNull(programIndex)?.copy( @@ -272,6 +279,7 @@ class LiveTvViewModel this@LiveTvViewModel.programs.setValueOnMain(it) } } + loading.setValueOnMain(LoadingState.Success) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt index 8323c223..d1349c8c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -129,74 +130,87 @@ fun ProgramDialog( maxLines = 3, ) } - if (dto.isSeries ?: false) { - Button( - onClick = { - if (isSeriesRecording) { - onCancelRecord.invoke(true) - } else { - onRecord.invoke(true) - } - }, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (isSeriesRecording) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - tint = Color.Red, - ) - } - Text( - text = if (isSeriesRecording) "Cancel Series Recording" else "Record Series", - ) - } - } - } - Button( - onClick = { - if (isRecording) { - onCancelRecord.invoke(false) - } else { - onRecord.invoke(false) - } - }, + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .padding(top = 8.dp) + .fillMaxWidth(), ) { Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - if (isRecording) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - tint = Color.Red, - ) + if (dto.isSeries ?: false) { + Button( + onClick = { + if (isSeriesRecording) { + onCancelRecord.invoke(true) + } else { + onRecord.invoke(true) + } + }, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSeriesRecording) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = Color.Red, + ) + } + Text( + text = if (isSeriesRecording) "Cancel Series Recording" else "Record Series", + ) + } + } } - 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, + Button( + onClick = { + if (isRecording) { + onCancelRecord.invoke(false) + } else { + onRecord.invoke(false) + } + }, ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = "Delete", - tint = Color.Green.copy(alpha = .75f), - ) - Text( - text = "Watch live", - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isRecording) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = Color.Red, + ) + } + Text( + text = if (isRecording) "Cancel Recording" else "Record Program", + ) + } + } + } + if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) { + Button( + onClick = onWatch, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Delete", + tint = Color.Green.copy(alpha = .75f), + ) + Text( + text = "Watch live", + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index 5a4e1040..b0503e1d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,6 +45,7 @@ import androidx.tv.material3.Text import androidx.tv.material3.contentColorFor import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.enableMarquee @@ -78,12 +81,12 @@ fun TvGuideGrid( val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf()) when (val state = loading) { is LoadingState.Error -> ErrorMessage(state, modifier) - - LoadingState.Loading, LoadingState.Pending, -> LoadingPage(modifier) - LoadingState.Success -> { + LoadingState.Loading, + LoadingState.Success, + -> { val context = LocalContext.current val fetchedItem by viewModel.fetchedItem.observeAsState(null) val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending) @@ -96,6 +99,7 @@ fun TvGuideGrid( } Column(modifier = modifier) { TvGuideGrid( + loading = state is LoadingState.Loading, channels = channels, programList = programs, programs = programsByChannel, @@ -183,6 +187,7 @@ const val CHANNEL_COLUMN = -1 @Composable fun TvGuideGrid( + loading: Boolean, channels: List, programList: List, programs: Map>, @@ -225,352 +230,365 @@ fun TvGuideGrid( var gridHasFocus by rememberSaveable { mutableStateOf(false) } var channelColumnFocused by rememberSaveable { mutableStateOf(false) } - - ProgramGuide( - state = state, - dimensions = dimensions, - modifier = - modifier - .onFocusChanged { - gridHasFocus = it.hasFocus - }.focusable() - .onPreviewKeyEvent { - if (it.type == KeyEventType.KeyUp) { - return@onPreviewKeyEvent false - } - val newIndex = - when (it.key) { - Key.Back -> { - val pos = programsBeforeChannel.get(focusedChannelIndex) - if (focusedProgramIndex - pos > 0) { - // Not at beginning of row, so move to beginning - pos - } else { - // At beginning, so allow normal back button behavior - return@onPreviewKeyEvent false - } - } - - Key.DirectionRight -> { - if (channelColumnFocused) { - channelColumnFocused = false - focusedProgramIndex - } else { - val nextProgramIndex = focusedProgramIndex + 1 - val programsBefore = - programsBeforeChannel.get(focusedChannelIndex) - val relativePosition = nextProgramIndex - programsBefore - val channelPrograms = - programs[channels[focusedChannelIndex].id].orEmpty() - if (relativePosition >= channelPrograms.size) { - focusManager.moveFocus(FocusDirection.Right) - null + Box(modifier = modifier) { + ProgramGuide( + state = state, + dimensions = dimensions, + modifier = + Modifier + .fillMaxSize() + .onFocusChanged { + gridHasFocus = it.hasFocus + }.focusable() + .onPreviewKeyEvent { + if (it.type == KeyEventType.KeyUp) { + return@onPreviewKeyEvent false + } + val newIndex = + when (it.key) { + Key.Back -> { + val pos = programsBeforeChannel.get(focusedChannelIndex) + if (focusedProgramIndex - pos > 0) { + // Not at beginning of row, so move to beginning + pos } else { - nextProgramIndex + // At beginning, so allow normal back button behavior + return@onPreviewKeyEvent false } } - } - Key.DirectionLeft -> { - if (channelColumnFocused) { - focusManager.moveFocus(FocusDirection.Left) - null - } else { - val nextProgramIndex = focusedProgramIndex - 1 - val programsBefore = - programsBeforeChannel.get(focusedChannelIndex) - val relativePosition = nextProgramIndex - programsBefore -// val channelPrograms = -// programs[channels[focusedChannel].id].orEmpty() - if (relativePosition >= 0) { - nextProgramIndex - } else if (relativePosition == CHANNEL_COLUMN) { - channelColumnFocused = true + Key.DirectionRight -> { + if (channelColumnFocused) { + channelColumnFocused = false focusedProgramIndex } 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) 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..= 0) { - programsBeforeChannel.get(focusedChannelIndex) + pIndex } else { - val pIndex = pro.indexOfFirst { it.startHours >= start } - if (pIndex >= 0) { - programsBeforeChannel.get(focusedChannelIndex) + pIndex + val nextProgramIndex = focusedProgramIndex - 1 + val programsBefore = + programsBeforeChannel.get(focusedChannelIndex) + val relativePosition = nextProgramIndex - programsBefore +// val channelPrograms = +// programs[channels[focusedChannel].id].orEmpty() + if (relativePosition >= 0) { + nextProgramIndex + } else if (relativePosition == CHANNEL_COLUMN) { + channelColumnFocused = true + focusedProgramIndex } else { - programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size + Timber.w("Move left to relativePosition=$relativePosition should not occur") + focusManager.moveFocus(FocusDirection.Left) + null } } } - } - 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..= 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 + 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 { - // Didn't find one, probably due to missing data - // So now first the first one that starts after the previously focused program - val pIndex = pro.indexOfFirst { it.startHours >= start } + 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..= 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..= 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 { - // Did not find one, so focus on the final program in the list - programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size + // Didn't find one, probably due to missing data + // So now first the first one that starts after the previously focused program + val pIndex = pro.indexOfFirst { it.startHours >= start } + if (pIndex >= 0) { + // Found one, so focus on it + programsBeforeChannel.get(focusedChannelIndex) + pIndex + } else { + // Did not find one, so focus on the final program in the list + programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size + } } } } - } - Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { - if (channelColumnFocused) { - val channel = channels[focusedChannelIndex] - Timber.v("Clicked on %s", channel) - onClickChannel.invoke(focusedChannelIndex, channel) - } else { - val program = programList[focusedProgramIndex] - Timber.v("Clicked on %s", program) - onClickProgram.invoke(focusedProgramIndex, program) + Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { + if (channelColumnFocused) { + val channel = channels[focusedChannelIndex] + Timber.v("Clicked on %s", channel) + onClickChannel.invoke(focusedChannelIndex, channel) + } else { + val program = programList[focusedProgramIndex] + Timber.v("Clicked on %s", program) + onClickProgram.invoke(focusedProgramIndex, program) + } + null } - null - } - else -> { - null + else -> { + null + } } - } - if (newIndex != null) { + if (newIndex != null) { // Timber.v("newIndex=$newIndex") - if (newIndex >= 0) { - val before = programsBeforeChannel.get(focusedChannelIndex) - val max = - before + channels[focusedChannelIndex].let { programs[it.id]!!.size } - 1 - val index = newIndex.coerceIn(before, max) - scope.launch(ExceptionHandler()) { - focusedProgramIndex = index - state.animateToProgram(index, Alignment.Center) + if (newIndex >= 0) { + val before = programsBeforeChannel.get(focusedChannelIndex) + val max = + before + channels[focusedChannelIndex].let { programs[it.id]!!.size } - 1 + val index = newIndex.coerceIn(before, max) + scope.launch(ExceptionHandler()) { + focusedProgramIndex = index + state.animateToProgram(index, Alignment.Center) + } + return@onPreviewKeyEvent true + } else { + Timber.w("newIndex is <0: $newIndex") + return@onPreviewKeyEvent true } - return@onPreviewKeyEvent true - } else { - Timber.w("newIndex is <0: $newIndex") - return@onPreviewKeyEvent true } - } - return@onPreviewKeyEvent false - }, - ) { - guideStartHour = 0f - currentTime( - layoutInfo = { - ProgramGuideItem.CurrentTime( - hoursBetween(start, LocalDateTime.now()), - ) - }, + return@onPreviewKeyEvent false + }, ) { - Surface( - colors = SurfaceDefaults.colors(MaterialTheme.colorScheme.tertiary.copy(alpha = .25f)), - modifier = Modifier, - ) { - // Empty - } - } - timeline( - count = MAX_HOURS.toInt(), - layoutInfo = { index -> - ProgramGuideItem.Timeline( - startHour = index.toFloat(), - endHour = index + 1f, - ) - }, - ) { index -> - Box( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), - ) { - val differentDay = - start.toLocalDate() != - start - .plusHours(index.toLong()) - .toLocalDate() - val time = - DateUtils.formatDateTime( - context, - start - .plusHours(index.toLong()) - .toInstant(OffsetDateTime.now().offset) - .epochSecond * 1000, - DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, + guideStartHour = 0f + currentTime( + layoutInfo = { + ProgramGuideItem.CurrentTime( + hoursBetween(start, LocalDateTime.now()), ) - 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( - verticalArrangement = Arrangement.spacedBy(4.dp), + Surface( + colors = SurfaceDefaults.colors(MaterialTheme.colorScheme.tertiary.copy(alpha = .25f)), + modifier = Modifier, + ) { + // Empty + } + } + timeline( + count = MAX_HOURS.toInt(), + layoutInfo = { index -> + ProgramGuideItem.Timeline( + startHour = index.toFloat(), + endHour = index + 1f, + ) + }, + ) { index -> + Box( modifier = Modifier .fillMaxSize() - .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 = program.name ?: program.id.toString(), - color = textColor, - maxLines = 1, - modifier = Modifier.enableMarquee(focused), + text = time.toString(), + modifier = Modifier.background(MaterialTheme.colorScheme.background), ) - 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), + } + + 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( + 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), ) } } - - 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( + if (loading) { + CircularProgress( Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), + .background( + MaterialTheme.colorScheme.background.copy(alpha = .5f), + shape = CircleShape, + ).size(64.dp) + .padding(16.dp) + .align(Alignment.BottomEnd), ) } }