From 59fc88f395725da143b5838630fd02ce843de5ab Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Wed, 19 Nov 2025 17:06:00 -0500 Subject: [PATCH] Updates to TV guide UI (#263) Reduces the size of the rows on the TV guide allowing for 7-8 rows to show. Also hide the tab row if scrolled down on the guide. Move the "watch live" button to be initially focused Channels are sorted with favorites appearing first Back button behavior is tweaked to move to start of row first, then top of the guide, then normal back behavior Closes #259 Related to #250 --- .../ui/detail/CollectionFolderLiveTv.kt | 3 + .../wholphin/ui/detail/livetv/Components.kt | 129 ++++++++++++ .../ui/detail/livetv/LiveTvViewModel.kt | 12 +- .../ui/detail/livetv/ProgramDialog.kt | 186 ++++++++++-------- .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 117 ++--------- 5 files changed, 266 insertions(+), 181 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 83974283..1727774a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -135,6 +135,9 @@ fun CollectionFolderLiveTv( 0 -> { TvGuideGrid( true, + onRowPosition = { + showHeader = it <= 0 + }, Modifier .fillMaxSize() .focusRequester(focusRequester), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt new file mode 100644 index 00000000..7011975f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt @@ -0,0 +1,129 @@ +package com.github.damontecres.wholphin.ui.detail.livetv + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.contentColorFor +import androidx.tv.material3.surfaceColorAtElevation +import coil3.compose.AsyncImage + +@Composable +fun Program( + program: TvProgram, + focused: Boolean, + modifier: Modifier = Modifier, +) { + val background = + if (focused) { + MaterialTheme.colorScheme.inverseSurface + } else { + program.category?.color + ?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) + } + val textColor = MaterialTheme.colorScheme.contentColorFor(background) + Box( + modifier = + modifier + .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, + fontSize = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + listOfNotNull( + program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, + program.subtitle, + ).joinToString(" - ") + .ifBlank { null } + ?.let { + Text( + text = it, + color = textColor, + fontSize = 14.sp, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + } + } + RecordingMarker( + isRecording = program.isRecording, + isSeriesRecording = program.isSeriesRecording, + modifier = Modifier.align(Alignment.BottomEnd), + ) + } +} + +@Composable +fun Channel( + channel: TvChannel, + channelIndex: Int, + focused: Boolean, + modifier: Modifier = Modifier, +) { + 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), + ) + } + } +} 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 6e4be864..199c6ce4 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 @@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AppColors @@ -56,6 +57,7 @@ class LiveTvViewModel @param:ApplicationContext private val context: Context, val api: ApiClient, val navigationManager: NavigationManager, + private val serverRepository: ServerRepository, ) : ViewModel() { val loading = MutableLiveData(LoadingState.Pending) @@ -88,6 +90,9 @@ class LiveTvViewModel val channelData by api.liveTvApi.getLiveTvChannels( GetLiveTvChannelsRequest( startIndex = 0, + userId = serverRepository.currentUser.value?.id, + enableFavoriteSorting = true, + addCurrentProgram = false, ), ) val channels = @@ -103,8 +108,8 @@ class LiveTvViewModel Timber.d("Got ${channels.size} channels") channelsIdToIndex = channels.withIndex().associateBy({ it.value.id }, { it.index }) - // Initially, quickly load the first 10 channels (only ~6 are visible immediately), then below will load more - // This makes the guide appear faster, and load more useable data in the background + // Initially, quickly load the first 10 channels (only some are visible immediately), then below will load more + // This makes the guide appear faster, and load more usable data in the background val initial = 10 fetchPrograms(channels, 0.. 0) { - if (channelId == UUID.fromString("638232b7-7ef7-7e98-903d-2249ee3fd2cd")) { - Timber.v("Found ") - } var previous = listing.last() while (previous.endHours < p.startHours) { // Fill gaps between programs 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 c8f39df7..9f1d5215 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 @@ -9,6 +9,7 @@ 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.lazy.LazyRow import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close @@ -20,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -35,6 +37,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -141,86 +144,10 @@ fun ProgramDialog( .padding(top = 8.dp) .fillMaxWidth(), ) { - if (canRecord) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - 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) { - stringResource( - R.string.cancel_series_recording, - ) - } else { - stringResource(R.string.record_series) - }, - ) - } - } - } - if (dto.endDate?.isAfter(LocalDateTime.now()) ?: true) { - // Only show program specific recording button if it hasn't finished yet - Button( - onClick = { - if (isRecording) { - onCancelRecord.invoke(false) - } else { - onRecord.invoke(false) - } - }, - ) { - 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) { - stringResource( - R.string.cancel_recording, - ) - } else { - stringResource( - R.string.record_program, - ) - }, - ) - } - } - } - } - } if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) { Button( onClick = onWatch, + modifier = Modifier, ) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -237,6 +164,111 @@ fun ProgramDialog( } } } + if (canRecord) { + val recordFocusRequester = remember { FocusRequester() } + LazyRow( + horizontalArrangement = + Arrangement.spacedBy( + 16.dp, + Alignment.CenterHorizontally, + ), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(recordFocusRequester), + ) { + if (dto.isSeries ?: false) { + item { + Button( + onClick = { + if (isSeriesRecording) { + onCancelRecord.invoke(true) + } else { + onRecord.invoke(true) + } + }, + modifier = + Modifier.focusRequester(recordFocusRequester), + ) { + 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) { + stringResource( + R.string.cancel_series_recording, + ) + } else { + stringResource(R.string.record_series) + }, + ) + } + } + } + } + if (dto.endDate?.isAfter(LocalDateTime.now()) ?: true) { + // Only show program specific recording button if it hasn't finished yet + item { + Button( + onClick = { + if (isRecording) { + onCancelRecord.invoke(false) + } else { + onRecord.invoke(false) + } + }, + modifier = + Modifier.ifElse( + !(dto.isSeries ?: false), + Modifier.focusRequester( + recordFocusRequester, + ), + ), + ) { + 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) { + stringResource( + R.string.cancel_recording, + ) + } else { + stringResource( + R.string.record_program, + ) + }, + ) + } + } + } + } + } + } } } } 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 7db259d3..f4b91430 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 @@ -4,16 +4,12 @@ import android.text.format.DateUtils import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -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 import androidx.compose.runtime.getValue @@ -42,14 +38,10 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults 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.data.RowColumn -import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -69,6 +61,7 @@ import kotlin.math.abs @Composable fun TvGuideGrid( requestFocusAfterLoading: Boolean, + onRowPosition: (Int) -> Unit, modifier: Modifier = Modifier, viewModel: LiveTvViewModel = hiltViewModel(), ) { @@ -101,7 +94,7 @@ fun TvGuideGrid( } } Column(modifier = modifier) { - TvGuideGrid( + TvGuideGridContent( loading = state is LoadingState.Loading, channels = channels, programs = programs, @@ -115,7 +108,10 @@ fun TvGuideGrid( ), ) }, - onFocus = viewModel::onFocusChannel, + onFocus = { + onRowPosition.invoke(it.row) + viewModel.onFocusChannel(it) + }, onClickProgram = { index, program -> if (program.isFake) { val now = LocalDateTime.now() @@ -186,7 +182,7 @@ fun TvGuideGrid( } @Composable -fun TvGuideGrid( +fun TvGuideGridContent( loading: Boolean, channels: List, programs: FetchedPrograms, @@ -207,7 +203,7 @@ fun TvGuideGrid( val focusedProgramIndex = remember(programs.range, focusedItem) { focusedItem.let { focus -> - (programs.range.start.. 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 @@ -496,56 +494,7 @@ fun TvGuideGrid( val program = programs.programs[programIndex] val focused = gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex - val background = - if (focused) { - MaterialTheme.colorScheme.inverseSurface - } else { - program.category?.color - ?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) - } - val textColor = MaterialTheme.colorScheme.contentColorFor(background) - Box( - modifier = - Modifier - .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), - ) - } + Program(program, focused, Modifier) } channels( @@ -557,42 +506,12 @@ fun TvGuideGrid( 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), - ) - } - } + Channel( + channel = channel, + channelIndex = channelIndex, + focused = focused, + modifier = Modifier, + ) } topCorner {