mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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:
parent
693398536e
commit
5c5db4c1ef
3 changed files with 79 additions and 26 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue