diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 68dbd17b..2948e841 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -228,6 +228,7 @@ 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 b950a6cd..3bc9d2f6 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 @@ -19,7 +19,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.FocusRequester import androidx.compose.ui.focus.focusProperties @@ -30,6 +32,7 @@ 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.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times @@ -38,16 +41,162 @@ 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.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 eu.wewox.programguide.ProgramGuide +import eu.wewox.programguide.ProgramGuideDimensions +import eu.wewox.programguide.ProgramGuideItem +import eu.wewox.programguide.rememberSaveableProgramGuideState +import kotlinx.coroutines.launch import timber.log.Timber import java.time.LocalDateTime import java.util.UUID import kotlin.time.Duration.Companion.minutes +fun translate( + channelIndex: Int, + programIndex: Int, +) { + val before = + channels.subList(0, channelIndex + 1).map { it.id }.sumOf { programs[it]?.size ?: 0 } + 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) = TvGridBox(channels, programs, modifier) +fun TvGrid(modifier: Modifier = Modifier) { + val state = rememberSaveableProgramGuideState() + val scope = rememberCoroutineScope() + val startHours = 6 + val timeline = 16..24 + var focusedProgram by rememberInt(0) + var focusedChannel by rememberInt(0) + + val dimensions = + ProgramGuideDimensions( + timelineHourWidth = 240.dp, + timelineHeight = 32.dp, + channelWidth = 64.dp, + channelHeight = 64.dp, + currentTimeWidth = 2.dp, + ) + + ProgramGuide( + state = state, + dimensions = dimensions, + modifier = + modifier + .focusable() + .onPreviewKeyEvent { + if (it.type == KeyEventType.KeyUp) { + return@onPreviewKeyEvent false + } + val newIndex = + when (it.key) { + Key.DirectionRight -> focusedProgram + 1 + Key.DirectionLeft -> focusedProgram - 1 + Key.DirectionUp -> { + val start = programList[focusedProgram].start.hours + focusedChannel = (focusedChannel - 1).coerceAtLeast(0) + val channelId = channels[focusedChannel].id + val pIndex = + programs[channelId]?.indexOfFirst { start in (it.start.hours..= 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..= 0) { + programsBeforeChannel(focusedChannel) + pIndex + } else { + programsBeforeChannel(focusedChannel) + programs[channelId]!!.size + } + } + + else -> { + null + } + } + if (newIndex != null) { + val before = programsBeforeChannel(focusedChannel) + val max = + before + channels[focusedChannel].let { programs[it.id]!!.size } - 1 + val index = newIndex.coerceIn(before, max) + scope.launch(ExceptionHandler()) { + state.animateToProgram(index, Alignment.Center) + focusedProgram = index + } + return@onPreviewKeyEvent true + } + return@onPreviewKeyEvent false + }, + ) { + guideStartHour = timeline.first.toFloat() + timeline( + count = timeline.count(), + layoutInfo = { + val start = timeline.toList()[it].toFloat() + ProgramGuideItem.Timeline( + startHour = start, + endHour = start + 1f, + ) + }, + ) { index -> + val start = timeline.toList()[index].toFloat() + Text( + text = "$start o'clock", + ) + } + + programs( + count = programList.size, + layoutInfo = { programIndex -> + val program = programList[programIndex] + val channelIndex = channels.indexOfFirst { it.id == program.channelId } + ProgramGuideItem.Program(channelIndex, program.start.hours, program.end.hours) + }, + ) { programIndex -> + val program = programList[programIndex] + Text( + text = program.name ?: program.id.toString(), + modifier = + Modifier.ifElse( + programIndex == focusedProgram, + Modifier.background(MaterialTheme.colorScheme.error), + ), + ) + } + + channels( + count = channels.size, + layoutInfo = { channelIndex -> + ProgramGuideItem.Channel(channelIndex) + }, + ) { channelIndex -> + Text( + text = channels[channelIndex].name ?: channelIndex.toString(), + modifier = Modifier.background(MaterialTheme.colorScheme.background), + ) + } + } +} @Composable fun TvGridBox( @@ -294,7 +443,7 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), duration = 60.minutes, - name = "Program #1", + name = "C1 Program #1", subtitle = null, seasonEpisode = null, ), @@ -304,7 +453,7 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 19, 0, 0), end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), duration = 30.minutes, - name = "Program #2", + name = "C1 Program #2", subtitle = null, seasonEpisode = null, ), @@ -314,7 +463,7 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), duration = 30.minutes, - name = "Program #3", + name = "C1 Program #3", subtitle = null, seasonEpisode = null, ), @@ -324,7 +473,7 @@ val programs = 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", + name = "C1 Program #3", subtitle = null, seasonEpisode = null, ), @@ -334,7 +483,7 @@ val programs = 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", + name = "C1 Program #3", subtitle = null, seasonEpisode = null, ), @@ -344,7 +493,7 @@ val programs = 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", + name = "C1 Program #3", subtitle = null, seasonEpisode = null, ), @@ -357,7 +506,7 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), end = LocalDateTime.of(2025, 10, 16, 18, 30, 0), duration = 30.minutes, - name = "Program #1", + name = "C2 Program #1", subtitle = null, seasonEpisode = null, ), @@ -367,7 +516,7 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 18, 30, 0), end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), duration = 60.minutes, - name = "Program #2", + name = "C2 Program #2", subtitle = null, seasonEpisode = null, ), @@ -377,7 +526,17 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), duration = 30.minutes, - name = "Program #3", + name = "C2 Program #3", + subtitle = null, + seasonEpisode = null, + ), + TvProgram( + id = UUID.randomUUID(), + channelId = channel2Id, + start = LocalDateTime.of(2025, 10, 16, 21, 0, 0, 0), + end = LocalDateTime.of(2025, 10, 16, 22, 0, 0), + duration = 60.minutes, + name = "C2 Program #4", subtitle = null, seasonEpisode = null, ), @@ -390,7 +549,7 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), end = LocalDateTime.of(2025, 10, 16, 18, 15, 0), duration = 15.minutes, - name = "Program #1", + name = "C3 Program #1", subtitle = null, seasonEpisode = null, ), @@ -400,7 +559,7 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 18, 15, 0), end = LocalDateTime.of(2025, 10, 16, 18, 45, 0), duration = 30.minutes, - name = "Program #2", + name = "C3 Program #2", subtitle = null, seasonEpisode = null, ), @@ -410,7 +569,7 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 18, 45, 0), end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), duration = 15.minutes, - name = "Program #3", + name = "C3 Program #3", subtitle = null, seasonEpisode = null, ), @@ -420,7 +579,7 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 19, 0, 0), end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), duration = 60.minutes, - name = "Program #3", + name = "C3 Program #3", subtitle = null, seasonEpisode = null, ), @@ -433,7 +592,7 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), duration = 60.minutes, - name = "Program #1", + name = "C4 Program #1", subtitle = null, seasonEpisode = null, ), @@ -443,7 +602,7 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 19, 0, 0), end = LocalDateTime.of(2025, 10, 16, 19, 30, 0), duration = 30.minutes, - name = "Program #2", + name = "C4 Program #2", subtitle = null, seasonEpisode = null, ), @@ -453,9 +612,11 @@ val programs = start = LocalDateTime.of(2025, 10, 16, 19, 30, 0), end = LocalDateTime.of(2025, 10, 16, 20, 0, 0), duration = 30.minutes, - name = "Program #3", + name = "C4 Program #3", subtitle = null, seasonEpisode = null, ), ), ) + +val programList = programs.values.flatten() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8e585bdb..cbe514f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ 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" @@ -56,6 +57,7 @@ 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" }