mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
WIP
This commit is contained in:
parent
548f942609
commit
5f6eb7d313
3 changed files with 327 additions and 443 deletions
|
|
@ -228,7 +228,6 @@ dependencies {
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
implementation(libs.aboutlibraries.core)
|
implementation(libs.aboutlibraries.core)
|
||||||
implementation(libs.aboutlibraries.compose.m3)
|
implementation(libs.aboutlibraries.compose.m3)
|
||||||
implementation(libs.programguide)
|
|
||||||
|
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
|
|
||||||
|
|
@ -1,490 +1,377 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.livetv
|
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.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.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.remember
|
||||||
import androidx.compose.runtime.saveable.listSaver
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.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.dp
|
||||||
import androidx.compose.ui.unit.times
|
import androidx.compose.ui.unit.times
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.tv.material3.ListItem
|
import androidx.tv.material3.ListItem
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.playback.isDown
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.playback.isUp
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
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 timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
private val hourWidth = 200.dp
|
@Composable
|
||||||
|
fun TvGrid(modifier: Modifier = Modifier) = TvGridBox(channels, programs, modifier)
|
||||||
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<Program> {
|
|
||||||
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
|
@Composable
|
||||||
fun TvGrid(modifier: Modifier = Modifier) {
|
fun TvGridBox(
|
||||||
val channels = 20
|
channels: List<TvChannel>,
|
||||||
val timeline = 8..22
|
programs: Map<UUID, List<TvProgram>>,
|
||||||
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(
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: LiveTvViewModel = hiltViewModel(),
|
|
||||||
) {
|
) {
|
||||||
OneTimeLaunchedEffect {
|
val startHour = 18f
|
||||||
viewModel.init()
|
var hour by remember { mutableFloatStateOf(startHour) }
|
||||||
}
|
Timber.v("hour=$hour")
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
val hoursToShow = 3f
|
||||||
|
val widthPerHour = 200.dp
|
||||||
|
|
||||||
when (val state = loading) {
|
Column(modifier = modifier) {
|
||||||
is LoadingState.Error -> ErrorMessage(state)
|
// Header
|
||||||
|
Text(
|
||||||
LoadingState.Loading,
|
text = hour.toString(),
|
||||||
LoadingState.Pending,
|
)
|
||||||
-> LoadingPage()
|
LazyColumn(
|
||||||
|
modifier =
|
||||||
LoadingState.Success -> {
|
Modifier,
|
||||||
val channels by viewModel.channels.observeAsState(listOf())
|
) {
|
||||||
val programs by viewModel.programs.observeAsState(listOf())
|
itemsIndexed(channels) { channelIndex, channel ->
|
||||||
ProgramGuide(
|
val programs = programs[channel.id].orEmpty()
|
||||||
modifier = modifier,
|
var rowHasFocus by remember { mutableStateOf(false) }
|
||||||
) {
|
Row(
|
||||||
guideStartHour = 22f
|
modifier =
|
||||||
programs(
|
Modifier
|
||||||
items = programs,
|
.height(120.dp)
|
||||||
layoutInfo = { p ->
|
.fillMaxWidth()
|
||||||
if (p != null) {
|
.background(
|
||||||
Timber.v("${p.id}: ${p.start.hour + p.start.minute / 60f}")
|
if (rowHasFocus) {
|
||||||
ProgramGuideItem.Program(
|
MaterialTheme.colorScheme.surfaceColorAtElevation(
|
||||||
channelIndex = channels.indexOfFirst { it.id == p.channelId },
|
3.dp,
|
||||||
startHour = p.start.hour + p.start.minute / 60f,
|
)
|
||||||
endHour = p.start.hour + p.start.minute / 60f,
|
} 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}")
|
||||||
}
|
}
|
||||||
},
|
filtered
|
||||||
itemContent = { program ->
|
.forEach { p ->
|
||||||
// Timber.v("Render $program")
|
Timber.v("Showing program: $p")
|
||||||
Text(
|
val width = p.duration.inWholeMinutes / 60f * widthPerHour
|
||||||
text = program?.name ?: "",
|
val backgroundColor =
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
if (rowHasFocus && p.start.hours <= hour && p.end.hours > (hour)) {
|
||||||
modifier = Modifier.focusable(),
|
MaterialTheme.colorScheme.error
|
||||||
)
|
} else {
|
||||||
// ListItem(
|
Color.Unspecified
|
||||||
// selected = false,
|
}
|
||||||
// onClick = {},
|
Box(
|
||||||
// headlineContent = {
|
modifier =
|
||||||
// Text(
|
Modifier
|
||||||
// text = program?.name ?: "",
|
.width(width)
|
||||||
// color = MaterialTheme.colorScheme.onSurface,
|
.fillMaxHeight()
|
||||||
// modifier = Modifier,
|
.background(backgroundColor)
|
||||||
// )
|
.border(
|
||||||
// },
|
width = 2.dp,
|
||||||
// supportingContent = {
|
color = MaterialTheme.colorScheme.inverseSurface,
|
||||||
// program?.subtitle?.let {
|
),
|
||||||
// Text(
|
) {
|
||||||
// text = program.subtitle,
|
Text(
|
||||||
// color = MaterialTheme.colorScheme.onSurface,
|
text = p.name ?: "Unknown",
|
||||||
// )
|
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],
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
),
|
|
||||||
) { 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
|
val LocalDateTime.hours get() = hour + minute / 60f
|
||||||
fun ChannelRow(
|
|
||||||
lazyListState: LazyListState,
|
|
||||||
channel: TvChannel,
|
|
||||||
programs: List<TvProgram?>,
|
|
||||||
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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewTvSpec
|
val channel1Id = UUID.randomUUID()
|
||||||
@Composable
|
val channel2Id = UUID.randomUUID()
|
||||||
private fun ChannelRowPreview() {
|
val channel3Id = UUID.randomUUID()
|
||||||
val channelId = UUID.randomUUID()
|
|
||||||
val channel =
|
val channels =
|
||||||
|
listOf(
|
||||||
TvChannel(
|
TvChannel(
|
||||||
id = channelId,
|
id = channel1Id,
|
||||||
number = "2.1",
|
number = "2.1",
|
||||||
name = "WJTV",
|
name = "WJTV",
|
||||||
imageUrl = "",
|
imageUrl = "",
|
||||||
)
|
),
|
||||||
val programs =
|
TvChannel(
|
||||||
listOf(
|
id = channel2Id,
|
||||||
TvProgram(
|
number = "3.1",
|
||||||
id = UUID.randomUUID(),
|
name = "JYTV",
|
||||||
channelId = channelId,
|
imageUrl = "",
|
||||||
start = LocalDateTime.of(2025, 10, 16, 18, 0, 0),
|
),
|
||||||
end = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
|
TvChannel(
|
||||||
duration = 60.minutes,
|
id = channel3Id,
|
||||||
name = "Program #1",
|
number = "4.1",
|
||||||
subtitle = null,
|
name = "AQTV",
|
||||||
seasonEpisode = null,
|
imageUrl = "",
|
||||||
),
|
),
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewTvSpec
|
val programs =
|
||||||
@Composable
|
mapOf(
|
||||||
private fun TvGridPreview() {
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ coreKtx = "1.17.0"
|
||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
composeBom = "2025.10.00"
|
composeBom = "2025.10.00"
|
||||||
compose-runtime = "1.9.3"
|
compose-runtime = "1.9.3"
|
||||||
programguide = "1.6.0"
|
|
||||||
timber = "5.0.1"
|
timber = "5.0.1"
|
||||||
tvFoundation = "1.0.0-alpha12"
|
tvFoundation = "1.0.0-alpha12"
|
||||||
tvMaterial = "1.0.1"
|
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" }
|
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 = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||||
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", 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" }
|
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-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" }
|
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue