diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2948e841..68dbd17b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -228,7 +228,6 @@ dependencies { implementation(libs.timber) implementation(libs.aboutlibraries.core) implementation(libs.aboutlibraries.compose.m3) - implementation(libs.programguide) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGrid.kt index 49458cc2..e34c767f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGrid.kt @@ -1,490 +1,377 @@ package com.github.damontecres.wholphin.ui.detail.livetv +import androidx.compose.foundation.background +import androidx.compose.foundation.border 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState 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 -import androidx.compose.runtime.saveable.listSaver -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.OneTimeLaunchedEffect -import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.util.LoadingState -import eu.wewox.programguide.ProgramGuide -import eu.wewox.programguide.ProgramGuideItem +import com.github.damontecres.wholphin.ui.playback.isDown +import com.github.damontecres.wholphin.ui.playback.isUp +import com.github.damontecres.wholphin.ui.tryRequestFocus import timber.log.Timber import java.time.LocalDateTime import java.util.UUID import kotlin.time.Duration.Companion.minutes -private val hourWidth = 200.dp - -data class Program( - val channel: Int, - val start: Float, - val end: Float, - val title: String, -) - -/** - * Formats time. - */ -fun formatTime( - from: Float, - to: Float? = null, -): String { - fun Float.hour(): String = toInt().formatWithPrefix(2) - - fun Float.minutes(): String = ((this % 1) * 60).toInt().formatWithPrefix(2) - - val fromFormatted = "${from.hour()}:${from.minutes()}" - return if (to != null) { - val toFormatted = "${to.hour()}:${to.minutes()}" - "$fromFormatted - $toFormatted" - } else { - fromFormatted - } -} - -/** - * Creates a list of programs to view in program guide. - */ -fun createPrograms( - channels: Int = CHANNELS_COUNT, - timeline: IntRange = 0..HOURS_COUNT, -): List { - var channel = 0 - var hour = timeline.first + HOURS.random() - return buildList { - while (channel < channels) { - while (true) { - val end = hour + HOURS.random() - if (end > timeline.last) { - break - } - - add(Program(channel, hour, end, "Program #$size")) - hour = end - } - hour = timeline.first + HOURS.random() - channel += 1 - } - } -} - -private fun Int.formatWithPrefix( - length: Int, - prefix: Char = '0', -): String { - val number = toString() - val prefixLength = (length - number.length).coerceAtLeast(0) - val prefixFull = List(prefixLength) { prefix }.joinToString(separator = "") - return "$prefixFull$number" -} - -private val HOURS = listOf(0.5f, 1f, 1.25f, 1.5f, 2f, 2.25f, 2.5f) - -private const val CHANNELS_COUNT = 30 -private const val HOURS_COUNT = 24 +@Composable +fun TvGrid(modifier: Modifier = Modifier) = TvGridBox(channels, programs, modifier) @Composable -fun TvGrid(modifier: Modifier = Modifier) { - val channels = 20 - val timeline = 8..22 - val programs = remember { createPrograms(channels, timeline) } - ProgramGuide( - modifier = - modifier, - ) { - guideStartHour = timeline.first.toFloat() - - programs( - items = programs, - layoutInfo = { - ProgramGuideItem.Program( - channelIndex = it.channel, - startHour = it.start, - endHour = it.end, - ) - }, - itemContent = { - ListItem( - selected = false, - onClick = {}, - headlineContent = { - Text( - text = it.title, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier, - ) - }, - modifier = Modifier, - ) - }, - ) - - channels( - count = channels, - layoutInfo = { - ProgramGuideItem.Channel( - index = it, - ) - }, - itemContent = { - Text( - text = it.toString(), - color = MaterialTheme.colorScheme.onSurface, - ) - }, - ) - - timeline( - count = timeline.count(), - layoutInfo = { - val start = timeline.toList()[it].toFloat() - ProgramGuideItem.Timeline( - startHour = start, - endHour = start + 1f, - ) - }, - itemContent = { - Text( - text = "${timeline.toList()[it].toFloat()} o'clock", - color = MaterialTheme.colorScheme.onSurface, - ) - }, - ) - } -} - -@Composable -fun TvGrid3( +fun TvGridBox( + channels: List, + programs: Map>, modifier: Modifier = Modifier, - viewModel: LiveTvViewModel = hiltViewModel(), ) { - OneTimeLaunchedEffect { - viewModel.init() - } - val loading by viewModel.loading.observeAsState(LoadingState.Pending) + val startHour = 18f + var hour by remember { mutableFloatStateOf(startHour) } + Timber.v("hour=$hour") + val hoursToShow = 3f + val widthPerHour = 200.dp - when (val state = loading) { - is LoadingState.Error -> ErrorMessage(state) - - LoadingState.Loading, - LoadingState.Pending, - -> LoadingPage() - - LoadingState.Success -> { - val channels by viewModel.channels.observeAsState(listOf()) - val programs by viewModel.programs.observeAsState(listOf()) - ProgramGuide( - modifier = modifier, - ) { - guideStartHour = 22f - programs( - items = programs, - layoutInfo = { p -> - if (p != null) { - Timber.v("${p.id}: ${p.start.hour + p.start.minute / 60f}") - ProgramGuideItem.Program( - channelIndex = channels.indexOfFirst { it.id == p.channelId }, - startHour = p.start.hour + p.start.minute / 60f, - endHour = p.start.hour + p.start.minute / 60f, + Column(modifier = modifier) { + // Header + Text( + text = hour.toString(), + ) + LazyColumn( + modifier = + Modifier, + ) { + itemsIndexed(channels) { channelIndex, channel -> + val programs = programs[channel.id].orEmpty() + var rowHasFocus by remember { mutableStateOf(false) } + Row( + modifier = + Modifier + .height(120.dp) + .fillMaxWidth() + .background( + if (rowHasFocus) { + MaterialTheme.colorScheme.surfaceColorAtElevation( + 3.dp, + ) + } else { + MaterialTheme.colorScheme.surface + }, + ), + ) { + val channelFocusRequester = remember { FocusRequester() } + // Channel info + ListItem( + selected = false, + onClick = { + Timber.v("Clicked channel $channel") + }, + leadingContent = { + Text( + text = channel.number ?: "", + modifier = Modifier.width(40.dp), ) - } else { - ProgramGuideItem.Program(0, 0f, 0f) + }, + headlineContent = { + Text( + text = channel.name ?: "", + modifier = Modifier.width(80.dp), + ) + }, + trailingContent = { + AsyncImage( + model = channel.imageUrl, + contentDescription = null, + modifier = + Modifier + .width(64.dp) + .fillMaxHeight(), + ) + }, + modifier = + Modifier + .width(220.dp) + .fillMaxHeight() + .focusRequester(channelFocusRequester), + ) + val focusRequester = remember { FocusRequester() } + var capturedFocus by remember { mutableStateOf(false) } + Row( + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onKeyEvent { + Timber.v("onKeyEvent=$it") + if (it.type == KeyEventType.KeyUp) { + if (rowHasFocus) { + if (it.type == KeyEventType.KeyUp && it.key == Key.DirectionLeft && hour <= startHour) { + focusRequester.freeFocus() + channelFocusRequester.tryRequestFocus() + capturedFocus = false + return@onKeyEvent false + } + if (isUp(it) || isDown(it)) { + focusRequester.freeFocus() + capturedFocus = false + return@onKeyEvent false + } + if (it.type == KeyEventType.KeyUp && it.key == Key.DirectionLeft && hour > startHour) { + hour -= .5f + return@onKeyEvent true + } else if (it.type == KeyEventType.KeyUp && it.key == Key.DirectionRight) { + hour += .5f + return@onKeyEvent true + } + } + } + return@onKeyEvent false + }.onFocusChanged { + Timber.v("onFocusChanged=$it") + rowHasFocus = it.hasFocus + if (it.isFocused && !capturedFocus) { + focusRequester.captureFocus() + capturedFocus = true + } + }.focusProperties { + onEnter = { + Timber.v("onEnter") + focusRequester.captureFocus() + } + onExit = { + Timber.v("onExit") + capturedFocus = false + } + }.focusable(), + ) { + val filtered = + programs + .filter { + it.start.hours <= hour && it.end.hours > hour || + (it.start.hours > hour && it.start.hours < (hour + hoursToShow)) + }.sortedBy { it.start } + LaunchedEffect(filtered) { + Timber.v("Got ${filtered.size} for ${channel.number}") } - }, - itemContent = { program -> -// Timber.v("Render $program") - Text( - text = program?.name ?: "", - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.focusable(), - ) -// ListItem( -// selected = false, -// onClick = {}, -// headlineContent = { -// Text( -// text = program?.name ?: "", -// color = MaterialTheme.colorScheme.onSurface, -// modifier = Modifier, -// ) -// }, -// supportingContent = { -// program?.subtitle?.let { -// Text( -// text = program.subtitle, -// color = MaterialTheme.colorScheme.onSurface, -// ) -// } -// }, -// modifier = Modifier, -// ) - }, - ) - channels( - count = channels.size, - layoutInfo = { - ProgramGuideItem.Channel( - index = it, - ) - }, - itemContent = { - val channel = channels[it] - Text( - text = channel.number ?: "", - ) - }, - ) - timeline( - count = 12, - layoutInfo = { - val start = 22f + it - ProgramGuideItem.Timeline( - startHour = start, - endHour = start + 1f, - ) - }, - itemContent = { - Text( - text = "${22f + it} o'clock", - ) - }, - ) - } - } - } -} - -@Composable -fun TvGrid2( - modifier: Modifier = Modifier, - viewModel: LiveTvViewModel = hiltViewModel(), -) { - OneTimeLaunchedEffect { - viewModel.init() - } - val loading by viewModel.loading.observeAsState(LoadingState.Pending) - - when (val state = loading) { - is LoadingState.Error -> ErrorMessage(state) - - LoadingState.Loading, - LoadingState.Pending, - -> LoadingPage() - - LoadingState.Success -> { - val channels by viewModel.channels.observeAsState(listOf()) - var focusedRowIndex by rememberInt() - - val columnState = rememberLazyListState() - val rowStates = - rememberSaveable( - saver = - listSaver( - save = { it.map { listOf(it.firstVisibleItemIndex, it.firstVisibleItemScrollOffset) } }, - restore = { - it.map { - LazyListState( - firstVisibleItemIndex = it[0], - firstVisibleItemScrollOffset = it[1], + filtered + .forEach { p -> + Timber.v("Showing program: $p") + val width = p.duration.inWholeMinutes / 60f * widthPerHour + val backgroundColor = + if (rowHasFocus && p.start.hours <= hour && p.end.hours > (hour)) { + MaterialTheme.colorScheme.error + } else { + Color.Unspecified + } + Box( + modifier = + Modifier + .width(width) + .fillMaxHeight() + .background(backgroundColor) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.inverseSurface, + ), + ) { + Text( + text = p.name ?: "Unknown", + color = MaterialTheme.colorScheme.onSurface, ) } - }, - ), - ) { List(channels.size) { LazyListState(0, 0) } } - - val currentRowState = rowStates[focusedRowIndex] - LaunchedEffect(currentRowState.firstVisibleItemScrollOffset) { - rowStates.forEachIndexed { index, state -> - if (index != focusedRowIndex) { - currentRowState.layoutInfo.visibleItemsInfo - state.scrollToItem(currentRowState.firstVisibleItemIndex, currentRowState.firstVisibleItemScrollOffset) + } } } } - - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - state = columnState, - modifier = modifier, - ) { - itemsIndexed(channels) { channelIndex, channel -> - val programs by viewModel.getPrograms(channel.id).observeAsState(listOf()) - ChannelRow( - lazyListState = rowStates[channelIndex], - channel = channel, - programs = programs, - modifier = - Modifier - .height(56.dp) - .fillMaxWidth() - .onFocusChanged { - if (it.hasFocus) { - focusedRowIndex = channelIndex - } - }, - ) - } - } } } } -@Composable -fun ChannelRow( - lazyListState: LazyListState, - channel: TvChannel, - programs: List, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier, - ) { - ListItem( - selected = false, - onClick = { - Timber.v("Clicked channel $channel") - }, - leadingContent = { - Text( - text = channel.number ?: "", - modifier = Modifier.width(40.dp), - ) - }, - headlineContent = { - Text( - text = channel.name ?: "", - modifier = Modifier.width(80.dp), - ) - }, - trailingContent = { - AsyncImage( - model = channel.imageUrl, - contentDescription = null, - modifier = Modifier.width(64.dp).fillMaxHeight(), - ) - }, - modifier = Modifier.width(220.dp).fillMaxHeight(), - ) - LazyRow( - state = lazyListState, - modifier = - Modifier - .weight(1f) - .fillMaxSize(), - ) { - itemsIndexed(programs) { programIndex, program -> - Timber.v("program=$program") - val duration = (program?.duration ?: 30.minutes).coerceAtLeast(2.minutes) - val width = duration.inWholeMinutes / 60f * hourWidth - ListItem( - selected = false, - onClick = {}, - headlineContent = { - Text( - text = program?.name ?: "", - modifier = Modifier, - ) - }, - supportingContent = { - program?.subtitle?.let { - Text( - text = program.subtitle, - ) - } - }, - modifier = Modifier.width(width).fillMaxHeight(), - ) - } - } - } -} +val LocalDateTime.hours get() = hour + minute / 60f -@PreviewTvSpec -@Composable -private fun ChannelRowPreview() { - val channelId = UUID.randomUUID() - val channel = +val channel1Id = UUID.randomUUID() +val channel2Id = UUID.randomUUID() +val channel3Id = UUID.randomUUID() + +val channels = + listOf( TvChannel( - id = channelId, + id = channel1Id, number = "2.1", name = "WJTV", imageUrl = "", - ) - val programs = - listOf( - TvProgram( - id = UUID.randomUUID(), - channelId = channelId, - start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), - end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), - duration = 60.minutes, - name = "Program #1", - subtitle = null, - seasonEpisode = null, - ), - TvProgram( - id = UUID.randomUUID(), - channelId = channelId, - start = LocalDateTime.of(2025, 10, 16, 19, 0, 0), - end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), - duration = 30.minutes, - name = "Program #1", - subtitle = null, - seasonEpisode = null, - ), - TvProgram( - id = UUID.randomUUID(), - channelId = channelId, - start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), - end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), - duration = 30.minutes, - name = "Program #1", - subtitle = null, - seasonEpisode = null, - ), - ) - WholphinTheme { - ChannelRow( - lazyListState = rememberLazyListState(), - channel = channel, - programs = programs, - modifier = Modifier.height(140.dp), - ) - } -} + ), + TvChannel( + id = channel2Id, + number = "3.1", + name = "JYTV", + imageUrl = "", + ), + TvChannel( + id = channel3Id, + number = "4.1", + name = "AQTV", + imageUrl = "", + ), + ) -@PreviewTvSpec -@Composable -private fun TvGridPreview() { -} +val programs = + mapOf( + channel1Id to + listOf( + TvProgram( + id = UUID.randomUUID(), + channelId = channel1Id, + start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), + end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), + duration = 60.minutes, + name = "Program #1", + subtitle = null, + seasonEpisode = null, + ), + TvProgram( + id = UUID.randomUUID(), + channelId = channel1Id, + start = LocalDateTime.of(2025, 10, 16, 19, 0, 0), + end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), + duration = 30.minutes, + name = "Program #2", + subtitle = null, + seasonEpisode = null, + ), + TvProgram( + id = UUID.randomUUID(), + channelId = channel1Id, + start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), + end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), + duration = 30.minutes, + name = "Program #3", + subtitle = null, + seasonEpisode = null, + ), + TvProgram( + id = UUID.randomUUID(), + channelId = channel1Id, + start = LocalDateTime.of(2025, 10, 16, 20, 0, 0, 0), + end = LocalDateTime.of(2025, 10, 16, 21, 0, 0), + duration = 60.minutes, + name = "Program #3", + subtitle = null, + seasonEpisode = null, + ), + TvProgram( + id = UUID.randomUUID(), + channelId = channel1Id, + start = LocalDateTime.of(2025, 10, 16, 21, 0, 0, 0), + end = LocalDateTime.of(2025, 10, 16, 22, 0, 0), + duration = 60.minutes, + name = "Program #3", + subtitle = null, + seasonEpisode = null, + ), + TvProgram( + id = UUID.randomUUID(), + channelId = channel1Id, + start = LocalDateTime.of(2025, 10, 16, 22, 0, 0, 0), + end = LocalDateTime.of(2025, 10, 16, 23, 0, 0), + duration = 60.minutes, + name = "Program #3", + subtitle = null, + seasonEpisode = null, + ), + ), + channel2Id to + listOf( + TvProgram( + id = UUID.randomUUID(), + channelId = channel2Id, + start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), + end = LocalDateTime.of(2025, 10, 16, 18, 30, 0), + duration = 30.minutes, + name = "Program #1", + subtitle = null, + seasonEpisode = null, + ), + TvProgram( + id = UUID.randomUUID(), + channelId = channel2Id, + start = LocalDateTime.of(2025, 10, 16, 18, 30, 0), + end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), + duration = 60.minutes, + name = "Program #2", + subtitle = null, + seasonEpisode = null, + ), + TvProgram( + id = UUID.randomUUID(), + channelId = channel2Id, + start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), + end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), + duration = 30.minutes, + name = "Program #3", + subtitle = null, + seasonEpisode = null, + ), + ), + channel3Id to + listOf( + TvProgram( + id = UUID.randomUUID(), + channelId = channel3Id, + start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), + end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), + duration = 60.minutes, + name = "Program #1", + subtitle = null, + seasonEpisode = null, + ), + TvProgram( + id = UUID.randomUUID(), + channelId = channel3Id, + start = LocalDateTime.of(2025, 10, 16, 19, 0, 0), + end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), + duration = 30.minutes, + name = "Program #2", + subtitle = null, + seasonEpisode = null, + ), + TvProgram( + id = UUID.randomUUID(), + channelId = channel3Id, + start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), + end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), + duration = 30.minutes, + name = "Program #3", + subtitle = null, + seasonEpisode = null, + ), + ), + ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cbe514f3..8e585bdb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,6 @@ coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2025.10.00" compose-runtime = "1.9.3" -programguide = "1.6.0" timber = "5.0.1" tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" @@ -57,7 +56,6 @@ androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "d desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } -programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }