Minimal UI implementation

This commit is contained in:
Damontecres 2025-10-20 14:09:34 -04:00
parent 24763d6d8c
commit 8af376cf63
No known key found for this signature in database
2 changed files with 301 additions and 107 deletions

View file

@ -4,10 +4,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
import com.github.damontecres.wholphin.util.LazyList
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
@ -19,14 +16,19 @@ import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.time.LocalDateTime
import java.util.AbstractList
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
const val MAX_HOURS = 24L
@HiltViewModel
class LiveTvViewModel
@ -36,9 +38,12 @@ class LiveTvViewModel
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val start = LocalDateTime.now()
val start =
LocalDateTime.now().minusMinutes(30).truncatedTo(ChronoUnit.HOURS)
val channels = MutableLiveData<List<TvChannel>>()
val programs = MutableLiveData<List<TvProgram?>>()
val programs = MutableLiveData<List<TvProgram>>()
val programsByChannel = MutableLiveData<Map<UUID, List<TvProgram>>>(mapOf())
private val programMap = mutableMapOf<UUID, MutableLiveData<List<TvProgram?>>>()
@ -47,11 +52,12 @@ class LiveTvViewModel
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Could not fetch channels")) {
val channelData by api.liveTvApi.getLiveTvChannels(
GetLiveTvChannelsRequest(
startIndex = 8,
startIndex = 0,
),
)
val channels =
LazyList(channelData.items) {
channelData.items
.map {
TvChannel(
it.id,
it.channelNumber,
@ -59,89 +65,167 @@ class LiveTvViewModel
api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY),
)
}
Timber.d("Got ${channels.size} channels")
val channelsIdToIndex =
channels.withIndex().associateBy({ it.value.id }, { it.index })
val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1)
val minEndDate = start.plusMinutes(1L)
val request =
GetProgramsDto(
minStartDate = start,
maxStartDate = maxStartDate,
minEndDate = minEndDate,
// maxEndDate = start.plusHours(25),
channelIds = channels.map { it.id },
sortBy = listOf(ItemSortBy.START_DATE),
)
val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init()
(0..<pager.size).forEach { pager.getBlocking(it) }
// val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init()
// (0..<pager.size).forEach { pager.getBlocking(it) }
val programs =
LazyList(pager) { item ->
item?.data?.let { dto ->
GetProgramsDtoHandler
.execute(api, request)
.content.items
.map { dto ->
val utcStart = dto.startDate!!.toEpochSecond(ZoneOffset.UTC)
utcStart
val ttt =
java.time.Duration
.between(
start,
dto.startDate!!,
)
ttt
TvProgram(
id = dto.id,
channelId = dto.channelId!!,
start = dto.startDate!!,
end = dto.endDate!!,
startHours =
java.time.Duration
.between(start, dto.startDate!!)
.seconds / (60f * 60f),
// dto.startDate!!.hours +
// (
// java.time.Duration
// .between(
// start,
// dto.startDate!!,
// ).toDaysPart() / 24
// ).coerceAtLeast(0),
endHours =
java.time.Duration
.between(start, dto.endDate!!)
.seconds / (60f * 60f),
// dto.endDate!!.hours +
// (
// java.time.Duration
// .between(
// start,
// dto.endDate!!,
// ).toHours() / 24
// ).coerceAtLeast(0),
duration = dto.runTimeTicks!!.ticks,
name = dto.seriesName ?: dto.name,
subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() },
seasonEpisode = null, // TODO
)
}.filter { it.startHours >= 0 && it.endHours >= 0 } // TODO shouldn't need to filter client side
val programsByChannel = programs.groupBy { it.channelId }
val emptyChannels = channels.filter { programsByChannel[it.id].orEmpty().isEmpty() }
val fake = mutableListOf<TvProgram>()
val finalProgramsByChannel =
programsByChannel.toMutableMap().apply {
emptyChannels.forEach { channel ->
val fakePrograms =
(0..<MAX_HOURS).map {
TvProgram(
id = UUID.randomUUID(), // TODO
channelId = channel.id,
start = start.plusHours(it),
end = start.plusHours(it + 1),
startHours = it.toFloat(),
endHours = (it + 1).toFloat(),
duration = 60.seconds,
name = "No date",
subtitle = null,
seasonEpisode = null,
)
}
put(channel.id, fakePrograms)
fake.addAll(fakePrograms)
}
}
val finalProgramList =
(programs + fake).sortedWith(
compareBy(
{ channelsIdToIndex[it.channelId]!! },
{ it.start },
),
)
Timber.d("Got ${programs.size} programs & ${fake.size} fake programs")
withContext(Dispatchers.Main) {
this@LiveTvViewModel.channels.value = channels
this@LiveTvViewModel.programs.value = programs
this@LiveTvViewModel.programs.value = finalProgramList
this@LiveTvViewModel.programsByChannel.value = finalProgramsByChannel
loading.value = LoadingState.Success
}
}
}
fun getPrograms(channelId: UUID): MutableLiveData<List<TvProgram?>> =
programMap.getOrPut(channelId) {
val data = MutableLiveData<List<TvProgram?>>(listOf())
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val request =
GetProgramsDto(
minStartDate = start,
channelIds = listOf(channelId),
)
val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init()
val programList =
if (pager.isEmpty()) {
object : AbstractList<TvProgram?>() {
override fun get(index: Int): TvProgram? {
val start = start.plusHours(index.toLong())
val end = start.plusHours(1L)
return TvProgram(
id = UUID.randomUUID(),
channelId = channelId,
start = start,
end = start,
duration = 60.minutes,
name = "Unknown",
subtitle = null,
seasonEpisode = null, // TODO
)
}
override val size: Int
get() = Int.MAX_VALUE
}
} else {
LazyList(pager) { item ->
item?.data?.let { dto ->
TvProgram(
id = dto.id,
channelId = channelId,
start = dto.startDate!!,
end = dto.endDate!!,
duration = dto.runTimeTicks!!.ticks,
name = dto.seriesName ?: dto.name,
subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() },
seasonEpisode = null, // TODO
)
}
}
}
withContext(Dispatchers.Main) {
data.value = programList
}
}
data
}
// fun getPrograms(channelId: UUID): MutableLiveData<List<TvProgram?>> =
// programMap.getOrPut(channelId) {
// val data = MutableLiveData<List<TvProgram?>>(listOf())
// viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
// val request =
// GetProgramsDto(
// minStartDate = start,
// channelIds = listOf(channelId),
// )
// val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init()
// val programList =
// if (pager.isEmpty()) {
// object : AbstractList<TvProgram?>() {
// override fun get(index: Int): TvProgram? {
// val start = start.plusHours(index.toLong())
// val end = start.plusHours(1L)
// return TvProgram(
// id = UUID.randomUUID(),
// channelId = channelId,
// start = start,
// end = start,
// duration = 60.minutes,
// name = "Unknown",
// subtitle = null,
// seasonEpisode = null, // TODO
// )
// }
//
// override val size: Int
// get() = Int.MAX_VALUE
// }
// } else {
// LazyList(pager) { item ->
// item?.data?.let { dto ->
// TvProgram(
// id = dto.id,
// channelId = channelId,
// start = dto.startDate!!,
// end = dto.endDate!!,
// duration = dto.runTimeTicks!!.ticks,
// name = dto.seriesName ?: dto.name,
// subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() },
// seasonEpisode = null, // TODO
// )
// }
// }
// }
// withContext(Dispatchers.Main) {
// data.value = programList
// }
// }
// data
// }
}
data class TvChannel(
@ -156,6 +240,8 @@ data class TvProgram(
val channelId: UUID,
val start: LocalDateTime,
val end: LocalDateTime,
val startHours: Float,
val endHours: Float,
val duration: Duration,
val name: String?,
val subtitle: String?,

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
@ -16,6 +17,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -23,6 +25,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
@ -34,19 +37,25 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.isDown
import com.github.damontecres.wholphin.ui.playback.isUp
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import eu.wewox.programguide.ProgramGuide
import eu.wewox.programguide.ProgramGuideDimensions
import eu.wewox.programguide.ProgramGuideItem
@ -66,14 +75,57 @@ fun translate(
programIndex - before
}
fun programsBeforeChannel(channelIndex: Int): Int = channels.subList(0, channelIndex).map { it.id }.sumOf { programs[it]?.size ?: 0 }
@Composable
fun TvGrid(
modifier: Modifier = Modifier,
viewModel: LiveTvViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init()
}
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val channels by viewModel.channels.observeAsState(listOf())
val programs by viewModel.programs.observeAsState(listOf())
val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success ->
Column(modifier = modifier) {
Button(
onClick = {},
) {
Text(
text = "Button",
)
}
TvGrid(
channels = channels,
programList = programs,
programs = programsByChannel,
start = viewModel.start,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Composable
fun TvGrid(modifier: Modifier = Modifier) {
fun TvGrid(
channels: List<TvChannel>,
programList: List<TvProgram>,
programs: Map<UUID, List<TvProgram>>,
start: LocalDateTime,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
val state = rememberSaveableProgramGuideState()
val scope = rememberCoroutineScope()
val startHours = 6
val timeline = 16..24
// val timeline = 0..<24
var focusedProgram by rememberInt(0)
var focusedChannel by rememberInt(0)
@ -86,6 +138,8 @@ fun TvGrid(modifier: Modifier = Modifier) {
currentTimeWidth = 2.dp,
)
fun programsBeforeChannel(channelIndex: Int): Int = channels.subList(0, channelIndex).map { it.id }.sumOf { programs[it]?.size ?: 0 }
ProgramGuide(
state = state,
dimensions = dimensions,
@ -101,11 +155,15 @@ fun TvGrid(modifier: Modifier = Modifier) {
Key.DirectionRight -> focusedProgram + 1
Key.DirectionLeft -> focusedProgram - 1
Key.DirectionUp -> {
val start = programList[focusedProgram].start.hours
focusedChannel = (focusedChannel - 1).coerceAtLeast(0)
val start = programList[focusedProgram].startHours
focusedChannel = (focusedChannel - 1)
if (focusedChannel < 0) {
focusManager.moveFocus(FocusDirection.Up)
null
} else {
val channelId = channels[focusedChannel].id
val pIndex =
programs[channelId]?.indexOfFirst { start in (it.start.hours..<it.end.hours) }
programs[channelId]?.indexOfFirst { start in (it.startHours..<it.endHours) }
?: -1
if (pIndex >= 0) {
programsBeforeChannel(focusedChannel) + pIndex
@ -113,21 +171,26 @@ fun TvGrid(modifier: Modifier = Modifier) {
programsBeforeChannel(focusedChannel) + programs[channelId]!!.size
}
}
}
Key.DirectionDown -> {
val start = programList[focusedProgram].startHours
focusedChannel =
(focusedChannel + 1).coerceAtMost(channels.size - 1)
val channelId = channels[focusedChannel].id
val pro = programs[channelId].orEmpty()
val pIndex =
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
if (pIndex >= 0) {
programsBeforeChannel(focusedChannel) + pIndex
} else {
programsBeforeChannel(focusedChannel) + programs[channelId]!!.size
}
}
Key.DirectionDown -> {
val start = programList[focusedProgram].start.hours
focusedChannel =
(focusedChannel + 1).coerceAtMost(channels.size - 1)
val channelId = channels[focusedChannel].id
val pro = programs[channelId]!!
val pIndex =
pro.indexOfFirst { start in (it.start.hours..<it.end.hours) }
?: -1
if (pIndex >= 0) {
programsBeforeChannel(focusedChannel) + pIndex
} else {
programsBeforeChannel(focusedChannel) + programs[channelId]!!.size
}
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
Timber.v("Clicked on ${programList[focusedProgram]}")
null
}
else -> {
@ -148,20 +211,19 @@ fun TvGrid(modifier: Modifier = Modifier) {
return@onPreviewKeyEvent false
},
) {
guideStartHour = timeline.first.toFloat()
guideStartHour = 0f
timeline(
count = timeline.count(),
layoutInfo = {
val start = timeline.toList()[it].toFloat()
count = MAX_HOURS.toInt(),
layoutInfo = { index ->
ProgramGuideItem.Timeline(
startHour = start,
endHour = start + 1f,
startHour = index.toFloat(),
endHour = index + 1f,
)
},
) { index ->
val start = timeline.toList()[index].toFloat()
val time = start.plusHours(index.toLong()).toLocalTime()
Text(
text = "$start o'clock",
text = time.toString(),
)
}
@ -170,7 +232,7 @@ fun TvGrid(modifier: Modifier = Modifier) {
layoutInfo = { programIndex ->
val program = programList[programIndex]
val channelIndex = channels.indexOfFirst { it.id == program.channelId }
ProgramGuideItem.Program(channelIndex, program.start.hours, program.end.hours)
ProgramGuideItem.Program(channelIndex, program.startHours, program.endHours)
},
) { programIndex ->
val program = programList[programIndex]
@ -191,7 +253,7 @@ fun TvGrid(modifier: Modifier = Modifier) {
},
) { channelIndex ->
Text(
text = channels[channelIndex].name ?: channelIndex.toString(),
text = channels[channelIndex].number ?: channelIndex.toString(),
modifier = Modifier.background(MaterialTheme.colorScheme.background),
)
}
@ -442,6 +504,8 @@ val programs =
channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 18, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
startHours = 18f,
endHours = 19f,
duration = 60.minutes,
name = "C1 Program #1",
subtitle = null,
@ -452,6 +516,8 @@ val programs =
channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
startHours = 19f,
endHours = 19.5f,
duration = 30.minutes,
name = "C1 Program #2",
subtitle = null,
@ -462,6 +528,8 @@ val programs =
channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
end = LocalDateTime.of(2025, 10, 16, 20, 0, 0),
startHours = 19.5f,
endHours = 20f,
duration = 30.minutes,
name = "C1 Program #3",
subtitle = null,
@ -472,6 +540,8 @@ val programs =
channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 20, 0, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 21, 0, 0),
startHours = 20f,
endHours = 21f,
duration = 60.minutes,
name = "C1 Program #3",
subtitle = null,
@ -482,6 +552,8 @@ val programs =
channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 21, 0, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 22, 0, 0),
startHours = 21f,
endHours = 22f,
duration = 60.minutes,
name = "C1 Program #3",
subtitle = null,
@ -492,11 +564,25 @@ val programs =
channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 22, 0, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 23, 0, 0),
startHours = 22f,
endHours = 23f,
duration = 60.minutes,
name = "C1 Program #3",
subtitle = null,
seasonEpisode = null,
),
TvProgram(
id = UUID.randomUUID(),
channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 17, 1, 0, 0, 0),
end = LocalDateTime.of(2025, 10, 17, 2, 0, 0),
startHours = 25f,
endHours = 26f,
duration = 60.minutes,
name = "C1 Program #4",
subtitle = null,
seasonEpisode = null,
),
),
channel2Id to
listOf(
@ -505,6 +591,8 @@ val programs =
channelId = channel2Id,
start = LocalDateTime.of(2025, 10, 16, 18, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 18, 30, 0),
startHours = 18f,
endHours = 18.5f,
duration = 30.minutes,
name = "C2 Program #1",
subtitle = null,
@ -515,6 +603,8 @@ val programs =
channelId = channel2Id,
start = LocalDateTime.of(2025, 10, 16, 18, 30, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
startHours = 18.5f,
endHours = 19f,
duration = 60.minutes,
name = "C2 Program #2",
subtitle = null,
@ -525,6 +615,8 @@ val programs =
channelId = channel2Id,
start = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
end = LocalDateTime.of(2025, 10, 16, 20, 0, 0),
startHours = 19.5f,
endHours = 20f,
duration = 30.minutes,
name = "C2 Program #3",
subtitle = null,
@ -535,6 +627,8 @@ val programs =
channelId = channel2Id,
start = LocalDateTime.of(2025, 10, 16, 21, 0, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 22, 0, 0),
startHours = 21f,
endHours = 22f,
duration = 60.minutes,
name = "C2 Program #4",
subtitle = null,
@ -549,6 +643,8 @@ val programs =
start = LocalDateTime.of(2025, 10, 16, 18, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 18, 15, 0),
duration = 15.minutes,
startHours = 18f,
endHours = 18.25f,
name = "C3 Program #1",
subtitle = null,
seasonEpisode = null,
@ -558,6 +654,8 @@ val programs =
channelId = channel3Id,
start = LocalDateTime.of(2025, 10, 16, 18, 15, 0),
end = LocalDateTime.of(2025, 10, 16, 18, 45, 0),
startHours = 18.25f,
endHours = 18.75f,
duration = 30.minutes,
name = "C3 Program #2",
subtitle = null,
@ -568,6 +666,8 @@ val programs =
channelId = channel3Id,
start = LocalDateTime.of(2025, 10, 16, 18, 45, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
startHours = 18.75f,
endHours = 19f,
duration = 15.minutes,
name = "C3 Program #3",
subtitle = null,
@ -578,6 +678,8 @@ val programs =
channelId = channel3Id,
start = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 20, 0, 0),
startHours = 19f,
endHours = 20f,
duration = 60.minutes,
name = "C3 Program #3",
subtitle = null,
@ -591,6 +693,8 @@ val programs =
channelId = channel4Id,
start = LocalDateTime.of(2025, 10, 16, 18, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
startHours = 18f,
endHours = 19f,
duration = 60.minutes,
name = "C4 Program #1",
subtitle = null,
@ -601,6 +705,8 @@ val programs =
channelId = channel4Id,
start = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
startHours = 19f,
endHours = 19.5f,
duration = 30.minutes,
name = "C4 Program #2",
subtitle = null,
@ -611,6 +717,8 @@ val programs =
channelId = channel4Id,
start = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
end = LocalDateTime.of(2025, 10, 16, 20, 0, 0),
startHours = 19.5f,
endHours = 20f,
duration = 30.minutes,
name = "C4 Program #3",
subtitle = null,