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
This commit is contained in:
damontecres 2025-11-19 17:06:00 -05:00 committed by GitHub
parent 461fbe846c
commit 59fc88f395
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 266 additions and 181 deletions

View file

@ -135,6 +135,9 @@ fun CollectionFolderLiveTv(
0 -> {
TvGuideGrid(
true,
onRowPosition = {
showHeader = it <= 0
},
Modifier
.fillMaxSize()
.focusRequester(focusRequester),

View file

@ -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),
)
}
}
}

View file

@ -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>(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..<initial.coerceAtMost(channels.size))
@ -212,9 +217,6 @@ class LiveTvViewModel
}
listing.add(p)
} else if (index > 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

View file

@ -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,
)
},
)
}
}
}
}
}
}
}
}
}

View file

@ -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<TvChannel>,
programs: FetchedPrograms,
@ -207,7 +203,7 @@ fun TvGuideGrid(
val focusedProgramIndex =
remember(programs.range, focusedItem) {
focusedItem.let { focus ->
(programs.range.start..<focus.row).sumOf {
(programs.range.first..<focus.row).sumOf {
val channelId = channels[it].id
channelProgramCount[channelId] ?: 0
} + focus.column
@ -225,7 +221,7 @@ fun TvGuideGrid(
timelineHourWidth = 240.dp,
timelineHeight = 32.dp,
channelWidth = 120.dp,
channelHeight = 80.dp,
channelHeight = 64.dp,
currentTimeWidth = 2.dp,
)
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
@ -251,6 +247,8 @@ fun TvGuideGrid(
if (item.column > 0) {
// Not at beginning of row, so move to beginning
item.copy(column = 0)
} else if (item.row > 0) {
item.copy(row = 0)
} else {
// At beginning, so allow normal back button behavior
return@onPreviewKeyEvent false
@ -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 {