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 -> { 0 -> {
TvGuideGrid( TvGuideGrid(
true, true,
onRowPosition = {
showHeader = it <= 0
},
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .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 androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication 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.data.model.BaseItem
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
@ -56,6 +57,7 @@ class LiveTvViewModel
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
val api: ApiClient, val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val serverRepository: ServerRepository,
) : ViewModel() { ) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
@ -88,6 +90,9 @@ class LiveTvViewModel
val channelData by api.liveTvApi.getLiveTvChannels( val channelData by api.liveTvApi.getLiveTvChannels(
GetLiveTvChannelsRequest( GetLiveTvChannelsRequest(
startIndex = 0, startIndex = 0,
userId = serverRepository.currentUser.value?.id,
enableFavoriteSorting = true,
addCurrentProgram = false,
), ),
) )
val channels = val channels =
@ -103,8 +108,8 @@ class LiveTvViewModel
Timber.d("Got ${channels.size} channels") Timber.d("Got ${channels.size} channels")
channelsIdToIndex = channelsIdToIndex =
channels.withIndex().associateBy({ it.value.id }, { it.index }) 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 // 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 useable data in the background // This makes the guide appear faster, and load more usable data in the background
val initial = 10 val initial = 10
fetchPrograms(channels, 0..<initial.coerceAtMost(channels.size)) fetchPrograms(channels, 0..<initial.coerceAtMost(channels.size))
@ -212,9 +217,6 @@ class LiveTvViewModel
} }
listing.add(p) listing.add(p)
} else if (index > 0) { } else if (index > 0) {
if (channelId == UUID.fromString("638232b7-7ef7-7e98-903d-2249ee3fd2cd")) {
Timber.v("Found ")
}
var previous = listing.last() var previous = listing.last()
while (previous.endHours < p.startHours) { while (previous.endHours < p.startHours) {
// Fill gaps between programs // 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.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.lazy.LazyRow
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close 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.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.CircularProgress 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.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -141,86 +144,10 @@ fun ProgramDialog(
.padding(top = 8.dp) .padding(top = 8.dp)
.fillMaxWidth(), .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!!)) { if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) {
Button( Button(
onClick = onWatch, onClick = onWatch,
modifier = Modifier,
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), 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 android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
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.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.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
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
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -42,14 +38,10 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.SurfaceDefaults
import androidx.tv.material3.Text 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.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.data.RowColumn 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -69,6 +61,7 @@ import kotlin.math.abs
@Composable @Composable
fun TvGuideGrid( fun TvGuideGrid(
requestFocusAfterLoading: Boolean, requestFocusAfterLoading: Boolean,
onRowPosition: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: LiveTvViewModel = hiltViewModel(), viewModel: LiveTvViewModel = hiltViewModel(),
) { ) {
@ -101,7 +94,7 @@ fun TvGuideGrid(
} }
} }
Column(modifier = modifier) { Column(modifier = modifier) {
TvGuideGrid( TvGuideGridContent(
loading = state is LoadingState.Loading, loading = state is LoadingState.Loading,
channels = channels, channels = channels,
programs = programs, programs = programs,
@ -115,7 +108,10 @@ fun TvGuideGrid(
), ),
) )
}, },
onFocus = viewModel::onFocusChannel, onFocus = {
onRowPosition.invoke(it.row)
viewModel.onFocusChannel(it)
},
onClickProgram = { index, program -> onClickProgram = { index, program ->
if (program.isFake) { if (program.isFake) {
val now = LocalDateTime.now() val now = LocalDateTime.now()
@ -186,7 +182,7 @@ fun TvGuideGrid(
} }
@Composable @Composable
fun TvGuideGrid( fun TvGuideGridContent(
loading: Boolean, loading: Boolean,
channels: List<TvChannel>, channels: List<TvChannel>,
programs: FetchedPrograms, programs: FetchedPrograms,
@ -207,7 +203,7 @@ fun TvGuideGrid(
val focusedProgramIndex = val focusedProgramIndex =
remember(programs.range, focusedItem) { remember(programs.range, focusedItem) {
focusedItem.let { focus -> focusedItem.let { focus ->
(programs.range.start..<focus.row).sumOf { (programs.range.first..<focus.row).sumOf {
val channelId = channels[it].id val channelId = channels[it].id
channelProgramCount[channelId] ?: 0 channelProgramCount[channelId] ?: 0
} + focus.column } + focus.column
@ -225,7 +221,7 @@ fun TvGuideGrid(
timelineHourWidth = 240.dp, timelineHourWidth = 240.dp,
timelineHeight = 32.dp, timelineHeight = 32.dp,
channelWidth = 120.dp, channelWidth = 120.dp,
channelHeight = 80.dp, channelHeight = 64.dp,
currentTimeWidth = 2.dp, currentTimeWidth = 2.dp,
) )
var gridHasFocus by rememberSaveable { mutableStateOf(false) } var gridHasFocus by rememberSaveable { mutableStateOf(false) }
@ -251,6 +247,8 @@ fun TvGuideGrid(
if (item.column > 0) { if (item.column > 0) {
// Not at beginning of row, so move to beginning // Not at beginning of row, so move to beginning
item.copy(column = 0) item.copy(column = 0)
} else if (item.row > 0) {
item.copy(row = 0)
} else { } else {
// At beginning, so allow normal back button behavior // At beginning, so allow normal back button behavior
return@onPreviewKeyEvent false return@onPreviewKeyEvent false
@ -496,56 +494,7 @@ fun TvGuideGrid(
val program = programs.programs[programIndex] val program = programs.programs[programIndex]
val focused = val focused =
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
val background = Program(program, focused, Modifier)
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),
)
}
} }
channels( channels(
@ -557,42 +506,12 @@ fun TvGuideGrid(
val channel = channels[channelIndex] val channel = channels[channelIndex]
val focused = val focused =
gridHasFocus && channelColumnFocused && focusedChannelIndex == channelIndex gridHasFocus && channelColumnFocused && focusedChannelIndex == channelIndex
val background = Channel(
if (focused) { channel = channel,
MaterialTheme.colorScheme.inverseSurface channelIndex = channelIndex,
} else { focused = focused,
MaterialTheme.colorScheme.surface modifier = Modifier,
} )
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 { topCorner {