TV Guide title clean up & align start to half hours (#517)

## Description
- Cleans up program titles & subtitles by removing newline characters
- Align the start of the guide to half hour, rounded down, ie 9:52->9:30
or 11:05->11:00
- Add some visual indication that a program has started before the guide
start time
- This is done by adding a `<` prefix to the title and using sharp 90
degree corners at the start instead of rounded

### Related issues
Closes #434
Closes #435
Closes #436
This commit is contained in:
Ray 2025-12-20 11:54:41 -05:00 committed by GitHub
parent 693398536e
commit 5c5db4c1ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 79 additions and 26 deletions

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
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.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -20,9 +21,11 @@ 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 java.time.LocalDateTime
@Composable @Composable
fun Program( fun Program(
guideStart: LocalDateTime,
program: TvProgram, program: TvProgram,
focused: Boolean, focused: Boolean,
colorCode: Boolean, colorCode: Boolean,
@ -38,14 +41,37 @@ fun Program(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
} }
val textColor = MaterialTheme.colorScheme.contentColorFor(background) val textColor = MaterialTheme.colorScheme.contentColorFor(background)
val startedBeforeGuide = program.start.isBefore(guideStart)
val shape =
remember(startedBeforeGuide) {
val cornerSize = 4.dp
if (startedBeforeGuide) {
RoundedCornerShape(
topEnd = cornerSize,
bottomEnd = cornerSize,
topStart = 0.dp,
bottomStart = 0.dp,
)
} else {
RoundedCornerShape(cornerSize)
}
}
val title =
remember(startedBeforeGuide) {
if (startedBeforeGuide) {
"< "
} else {
""
} + (program.name ?: program.id.toString())
}
Box( Box(
modifier = modifier =
modifier modifier
.padding(2.dp) .padding(2.dp)
.fillMaxSize() .fillMaxSize()
.background( .background(
background, color = background,
shape = RoundedCornerShape(4.dp), shape = shape,
), ),
) { ) {
Column( Column(
@ -56,7 +82,7 @@ fun Program(
.padding(4.dp), .padding(4.dp),
) { ) {
Text( Text(
text = program.name ?: program.id.toString(), text = title,
color = textColor, color = textColor,
fontSize = 16.sp, fontSize = 16.sp,
maxLines = 1, maxLines = 1,

View file

@ -74,11 +74,10 @@ class LiveTvViewModel
) : ViewModel() { ) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
lateinit var guideStart: LocalDateTime
private set
private lateinit var channelsIdToIndex: Map<UUID, Int> private lateinit var channelsIdToIndex: Map<UUID, Int>
private val mutex = Mutex() private val mutex = Mutex()
val guideTimes = MutableLiveData<List<LocalDateTime>>(buildGuideTimes())
val channels = MutableLiveData<List<TvChannel>>() val channels = MutableLiveData<List<TvChannel>>()
val channelProgramCount = mutableMapOf<UUID, Int>() val channelProgramCount = mutableMapOf<UUID, Int>()
val programs = MutableLiveData<FetchedPrograms>() val programs = MutableLiveData<FetchedPrograms>()
@ -106,7 +105,7 @@ class LiveTvViewModel
} }
fun init() { fun init() {
guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) val guideStart = guideTimes.value!!.first()
viewModelScope.launch( viewModelScope.launch(
Dispatchers.IO + Dispatchers.IO +
LoadingExceptionHandler( LoadingExceptionHandler(
@ -153,7 +152,7 @@ class LiveTvViewModel
// Initially, quickly load the first 10 channels (only some 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 usable 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(guideStart, channels, 0..<initial.coerceAtMost(channels.size))
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@LiveTvViewModel.channels.value = channels this@LiveTvViewModel.channels.value = channels
@ -161,21 +160,35 @@ class LiveTvViewModel
} }
// Now load the full range // Now load the full range
if (channels.size > initial) { if (channels.size > initial) {
fetchPrograms(channels, 0..<range.coerceAtMost(channels.size)) fetchPrograms(guideStart, channels, 0..<range.coerceAtMost(channels.size))
} }
} }
} }
private fun buildGuideTimes() =
buildList {
val start = LocalDateTime.now().roundDownToHalfHour()
add(start)
if (start.minute == 30) {
add(start.plusMinutes(30))
}
repeat(MAX_HOURS.toInt() - 1) {
add(last().plusHours(1))
}
}
private suspend fun fetchProgramsWithLoading( private suspend fun fetchProgramsWithLoading(
channels: List<TvChannel>, channels: List<TvChannel>,
range: IntRange, range: IntRange,
) { ) {
loading.setValueOnMain(LoadingState.Loading) loading.setValueOnMain(LoadingState.Loading)
fetchPrograms(channels, range) val guideStart = guideTimes.value!!.first()
fetchPrograms(guideStart, channels, range)
loading.setValueOnMain(LoadingState.Success) loading.setValueOnMain(LoadingState.Success)
} }
private suspend fun fetchPrograms( private suspend fun fetchPrograms(
guideStart: LocalDateTime,
channels: List<TvChannel>, channels: List<TvChannel>,
range: IntRange, range: IntRange,
) = mutex.withLock { ) = mutex.withLock {
@ -216,7 +229,11 @@ class LiveTvViewModel
} else { } else {
null null
} }
val name = (dto.seriesName ?: dto.name)?.replace(Regex("[\n\r]"), "")
val subtitle =
dto.episodeTitle
.takeIf { dto.isSeries ?: false }
?.replace(Regex("[\n\r]"), "")
val p = val p =
TvProgram( TvProgram(
id = dto.id, id = dto.id,
@ -230,8 +247,8 @@ class LiveTvViewModel
).coerceAtLeast(0f), ).coerceAtLeast(0f),
endHours = hoursBetween(guideStart, dto.endDate!!), endHours = hoursBetween(guideStart, dto.endDate!!),
duration = dto.runTimeTicks!!.ticks, duration = dto.runTimeTicks!!.ticks,
name = dto.seriesName ?: dto.name, name = name,
subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false }, subtitle = subtitle,
overview = dto.overview, overview = dto.overview,
officialRating = dto.officialRating, officialRating = dto.officialRating,
seasonEpisode = seasonEpisode =
@ -573,3 +590,8 @@ data class FetchedPrograms(
val programs: List<TvProgram>, val programs: List<TvProgram>,
val programsByChannel: Map<UUID, List<TvProgram>>, val programsByChannel: Map<UUID, List<TvProgram>>,
) )
fun LocalDateTime.roundDownToHalfHour(): LocalDateTime {
val min = minute % 30L
return minusMinutes(min).truncatedTo(ChronoUnit.MINUTES)
}

View file

@ -105,6 +105,7 @@ fun TvGuideGrid(
LoadingState.Success, LoadingState.Success,
-> { -> {
val context = LocalContext.current val context = LocalContext.current
val guideTimes by viewModel.guideTimes.observeAsState(listOf())
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)
var showItemDialog by remember { mutableStateOf<Int?>(null) } var showItemDialog by remember { mutableStateOf<Int?>(null) }
@ -156,7 +157,7 @@ fun TvGuideGrid(
channels = channels, channels = channels,
programs = programs, programs = programs,
channelProgramCount = viewModel.channelProgramCount, channelProgramCount = viewModel.channelProgramCount,
start = viewModel.guideStart, guideTimes = guideTimes,
onClickChannel = { index, channel -> onClickChannel = { index, channel ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.Playback( Destination.Playback(
@ -273,7 +274,7 @@ fun TvGuideGridContent(
channels: List<TvChannel>, channels: List<TvChannel>,
programs: FetchedPrograms, programs: FetchedPrograms,
channelProgramCount: Map<UUID, Int>, channelProgramCount: Map<UUID, Int>,
start: LocalDateTime, guideTimes: List<LocalDateTime>,
onClickChannel: (Int, TvChannel) -> Unit, onClickChannel: (Int, TvChannel) -> Unit,
onClickProgram: (Int, TvProgram) -> Unit, onClickProgram: (Int, TvProgram) -> Unit,
onFocus: (RowColumn) -> Unit, onFocus: (RowColumn) -> Unit,
@ -284,6 +285,7 @@ fun TvGuideGridContent(
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val state = rememberSaveableProgramGuideState() val state = rememberSaveableProgramGuideState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val guideStart = guideTimes.first()
var focusedItem by rememberPosition(RowColumn(0, 0)) var focusedItem by rememberPosition(RowColumn(0, 0))
val focusedChannelIndex = focusedItem.row val focusedChannelIndex = focusedItem.row
@ -506,7 +508,7 @@ fun TvGuideGridContent(
currentTime( currentTime(
layoutInfo = { layoutInfo = {
ProgramGuideItem.CurrentTime( ProgramGuideItem.CurrentTime(
hoursBetween(start, LocalDateTime.now()), hoursBetween(guideStart, LocalDateTime.now()),
) )
}, },
) { ) {
@ -523,11 +525,18 @@ fun TvGuideGridContent(
} }
} }
timeline( timeline(
count = MAX_HOURS.toInt(), count = guideTimes.size,
layoutInfo = { index -> layoutInfo = { index ->
val start = guideTimes[index]
val end =
if (index < guideTimes.lastIndex) {
guideTimes[index + 1]
} else {
start.plusHours(1)
}
ProgramGuideItem.Timeline( ProgramGuideItem.Timeline(
startHour = index.toFloat(), startHour = hoursBetween(guideStart, start),
endHour = index + 1f, endHour = hoursBetween(guideStart, end),
) )
}, },
) { index -> ) { index ->
@ -545,16 +554,12 @@ fun TvGuideGridContent(
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
), ),
) { ) {
val differentDay = val guideTime = guideTimes[index]
start.toLocalDate() != val differentDay = guideTime.toLocalDate() != guideStart.toLocalDate()
start
.plusHours(index.toLong())
.toLocalDate()
val time = val time =
DateUtils.formatDateTime( DateUtils.formatDateTime(
context, context,
start guideTime
.plusHours(index.toLong())
.toInstant(OffsetDateTime.now().offset) .toInstant(OffsetDateTime.now().offset)
.epochSecond * 1000, .epochSecond * 1000,
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
@ -584,7 +589,7 @@ fun TvGuideGridContent(
val program = programs.programs[programIndex] val program = programs.programs[programIndex]
val focused = val focused =
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
Program(program, focused, preferences.colorCodePrograms, Modifier) Program(guideStart, program, focused, preferences.colorCodePrograms, Modifier)
} }
channels( channels(