mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Styling guide
This commit is contained in:
parent
8af376cf63
commit
d42f24806a
2 changed files with 144 additions and 569 deletions
|
|
@ -21,7 +21,6 @@ import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneOffset
|
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -85,15 +84,6 @@ class LiveTvViewModel
|
||||||
.execute(api, request)
|
.execute(api, request)
|
||||||
.content.items
|
.content.items
|
||||||
.map { dto ->
|
.map { dto ->
|
||||||
val utcStart = dto.startDate!!.toEpochSecond(ZoneOffset.UTC)
|
|
||||||
utcStart
|
|
||||||
val ttt =
|
|
||||||
java.time.Duration
|
|
||||||
.between(
|
|
||||||
start,
|
|
||||||
dto.startDate!!,
|
|
||||||
)
|
|
||||||
ttt
|
|
||||||
TvProgram(
|
TvProgram(
|
||||||
id = dto.id,
|
id = dto.id,
|
||||||
channelId = dto.channelId!!,
|
channelId = dto.channelId!!,
|
||||||
|
|
@ -103,30 +93,19 @@ class LiveTvViewModel
|
||||||
java.time.Duration
|
java.time.Duration
|
||||||
.between(start, dto.startDate!!)
|
.between(start, dto.startDate!!)
|
||||||
.seconds / (60f * 60f),
|
.seconds / (60f * 60f),
|
||||||
// dto.startDate!!.hours +
|
|
||||||
// (
|
|
||||||
// java.time.Duration
|
|
||||||
// .between(
|
|
||||||
// start,
|
|
||||||
// dto.startDate!!,
|
|
||||||
// ).toDaysPart() / 24
|
|
||||||
// ).coerceAtLeast(0),
|
|
||||||
endHours =
|
endHours =
|
||||||
java.time.Duration
|
java.time.Duration
|
||||||
.between(start, dto.endDate!!)
|
.between(start, dto.endDate!!)
|
||||||
.seconds / (60f * 60f),
|
.seconds / (60f * 60f),
|
||||||
// dto.endDate!!.hours +
|
|
||||||
// (
|
|
||||||
// java.time.Duration
|
|
||||||
// .between(
|
|
||||||
// start,
|
|
||||||
// dto.endDate!!,
|
|
||||||
// ).toHours() / 24
|
|
||||||
// ).coerceAtLeast(0),
|
|
||||||
duration = dto.runTimeTicks!!.ticks,
|
duration = dto.runTimeTicks!!.ticks,
|
||||||
name = dto.seriesName ?: dto.name,
|
name = dto.seriesName ?: dto.name,
|
||||||
subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() },
|
subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false },
|
||||||
seasonEpisode = null, // TODO
|
seasonEpisode =
|
||||||
|
if (dto.indexNumber != null && dto.parentIndexNumber != null) {
|
||||||
|
SeasonEpisode(dto.parentIndexNumber!!, dto.indexNumber!!)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}.filter { it.startHours >= 0 && it.endHours >= 0 } // TODO shouldn't need to filter client side
|
}.filter { it.startHours >= 0 && it.endHours >= 0 } // TODO shouldn't need to filter client side
|
||||||
|
|
||||||
|
|
@ -146,7 +125,7 @@ class LiveTvViewModel
|
||||||
startHours = it.toFloat(),
|
startHours = it.toFloat(),
|
||||||
endHours = (it + 1).toFloat(),
|
endHours = (it + 1).toFloat(),
|
||||||
duration = 60.seconds,
|
duration = 60.seconds,
|
||||||
name = "No date",
|
name = "No data",
|
||||||
subtitle = null,
|
subtitle = null,
|
||||||
seasonEpisode = null,
|
seasonEpisode = null,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,41 @@
|
||||||
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.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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
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.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
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.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
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.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.times
|
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
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.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.contentColorFor
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||||
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.rememberInt
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import eu.wewox.programguide.ProgramGuide
|
import eu.wewox.programguide.ProgramGuide
|
||||||
|
|
@ -64,16 +46,6 @@ import kotlinx.coroutines.launch
|
||||||
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
|
|
||||||
|
|
||||||
fun translate(
|
|
||||||
channelIndex: Int,
|
|
||||||
programIndex: Int,
|
|
||||||
) {
|
|
||||||
val before =
|
|
||||||
channels.subList(0, channelIndex + 1).map { it.id }.sumOf { programs[it]?.size ?: 0 }
|
|
||||||
programIndex - before
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvGrid(
|
fun TvGrid(
|
||||||
|
|
@ -96,19 +68,15 @@ fun TvGrid(
|
||||||
|
|
||||||
LoadingState.Success ->
|
LoadingState.Success ->
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
Button(
|
|
||||||
onClick = {},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Button",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TvGrid(
|
TvGrid(
|
||||||
channels = channels,
|
channels = channels,
|
||||||
programList = programs,
|
programList = programs,
|
||||||
programs = programsByChannel,
|
programs = programsByChannel,
|
||||||
start = viewModel.start,
|
start = viewModel.start,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -126,15 +94,15 @@ fun TvGrid(
|
||||||
val state = rememberSaveableProgramGuideState()
|
val state = rememberSaveableProgramGuideState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// val timeline = 0..<24
|
// val timeline = 0..<24
|
||||||
var focusedProgram by rememberInt(0)
|
var focusedProgramIndex by rememberInt(0)
|
||||||
var focusedChannel by rememberInt(0)
|
var focusedChannelIndex by rememberInt(0)
|
||||||
|
|
||||||
val dimensions =
|
val dimensions =
|
||||||
ProgramGuideDimensions(
|
ProgramGuideDimensions(
|
||||||
timelineHourWidth = 240.dp,
|
timelineHourWidth = 240.dp,
|
||||||
timelineHeight = 32.dp,
|
timelineHeight = 32.dp,
|
||||||
channelWidth = 64.dp,
|
channelWidth = 120.dp,
|
||||||
channelHeight = 64.dp,
|
channelHeight = 80.dp,
|
||||||
currentTimeWidth = 2.dp,
|
currentTimeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -152,44 +120,75 @@ fun TvGrid(
|
||||||
}
|
}
|
||||||
val newIndex =
|
val newIndex =
|
||||||
when (it.key) {
|
when (it.key) {
|
||||||
Key.DirectionRight -> focusedProgram + 1
|
Key.DirectionRight -> {
|
||||||
Key.DirectionLeft -> focusedProgram - 1
|
val nextProgramIndex = focusedProgramIndex + 1
|
||||||
|
val programsBefore = programsBeforeChannel(focusedChannelIndex)
|
||||||
|
val relativePosition = nextProgramIndex - programsBefore
|
||||||
|
val channelPrograms =
|
||||||
|
programs[channels[focusedChannelIndex].id].orEmpty()
|
||||||
|
if (relativePosition >= channelPrograms.size) {
|
||||||
|
focusManager.moveFocus(FocusDirection.Right)
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
nextProgramIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.DirectionLeft -> {
|
||||||
|
val nextProgramIndex = focusedProgramIndex - 1
|
||||||
|
if (nextProgramIndex >= 0) {
|
||||||
|
val programsBefore = programsBeforeChannel(focusedChannelIndex)
|
||||||
|
val relativePosition = nextProgramIndex - programsBefore
|
||||||
|
// val channelPrograms =
|
||||||
|
// programs[channels[focusedChannel].id].orEmpty()
|
||||||
|
if (relativePosition < 0) {
|
||||||
|
focusManager.moveFocus(FocusDirection.Left)
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
nextProgramIndex
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
focusManager.moveFocus(FocusDirection.Left)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Key.DirectionUp -> {
|
Key.DirectionUp -> {
|
||||||
val start = programList[focusedProgram].startHours
|
val start = programList[focusedProgramIndex].startHours
|
||||||
focusedChannel = (focusedChannel - 1)
|
focusedChannelIndex = (focusedChannelIndex - 1)
|
||||||
if (focusedChannel < 0) {
|
if (focusedChannelIndex < 0) {
|
||||||
focusManager.moveFocus(FocusDirection.Up)
|
focusManager.moveFocus(FocusDirection.Up)
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
val channelId = channels[focusedChannel].id
|
val channelId = channels[focusedChannelIndex].id
|
||||||
val pIndex =
|
val pIndex =
|
||||||
programs[channelId]?.indexOfFirst { start in (it.startHours..<it.endHours) }
|
programs[channelId]?.indexOfFirst { start in (it.startHours..<it.endHours) }
|
||||||
?: -1
|
?: -1
|
||||||
if (pIndex >= 0) {
|
if (pIndex >= 0) {
|
||||||
programsBeforeChannel(focusedChannel) + pIndex
|
programsBeforeChannel(focusedChannelIndex) + pIndex
|
||||||
} else {
|
} else {
|
||||||
programsBeforeChannel(focusedChannel) + programs[channelId]!!.size
|
programsBeforeChannel(focusedChannelIndex) + programs[channelId]!!.size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Key.DirectionDown -> {
|
Key.DirectionDown -> {
|
||||||
val start = programList[focusedProgram].startHours
|
val start = programList[focusedProgramIndex].startHours
|
||||||
focusedChannel =
|
focusedChannelIndex =
|
||||||
(focusedChannel + 1).coerceAtMost(channels.size - 1)
|
(focusedChannelIndex + 1).coerceAtMost(channels.size - 1)
|
||||||
val channelId = channels[focusedChannel].id
|
val channelId = channels[focusedChannelIndex].id
|
||||||
val pro = programs[channelId].orEmpty()
|
val pro = programs[channelId].orEmpty()
|
||||||
val pIndex =
|
val pIndex =
|
||||||
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
|
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
|
||||||
if (pIndex >= 0) {
|
if (pIndex >= 0) {
|
||||||
programsBeforeChannel(focusedChannel) + pIndex
|
programsBeforeChannel(focusedChannelIndex) + pIndex
|
||||||
} else {
|
} else {
|
||||||
programsBeforeChannel(focusedChannel) + programs[channelId]!!.size
|
programsBeforeChannel(focusedChannelIndex) + programs[channelId]!!.size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
|
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
|
||||||
Timber.v("Clicked on ${programList[focusedProgram]}")
|
Timber.v("Clicked on ${programList[focusedProgramIndex]}")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,13 +197,13 @@ fun TvGrid(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (newIndex != null) {
|
if (newIndex != null) {
|
||||||
val before = programsBeforeChannel(focusedChannel)
|
val before = programsBeforeChannel(focusedChannelIndex)
|
||||||
val max =
|
val max =
|
||||||
before + channels[focusedChannel].let { programs[it.id]!!.size } - 1
|
before + channels[focusedChannelIndex].let { programs[it.id]!!.size } - 1
|
||||||
val index = newIndex.coerceIn(before, max)
|
val index = newIndex.coerceIn(before, max)
|
||||||
scope.launch(ExceptionHandler()) {
|
scope.launch(ExceptionHandler()) {
|
||||||
state.animateToProgram(index, Alignment.Center)
|
state.animateToProgram(index, Alignment.Center)
|
||||||
focusedProgram = index
|
focusedProgramIndex = index
|
||||||
}
|
}
|
||||||
return@onPreviewKeyEvent true
|
return@onPreviewKeyEvent true
|
||||||
}
|
}
|
||||||
|
|
@ -221,10 +220,13 @@ fun TvGrid(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { index ->
|
) { index ->
|
||||||
val time = start.plusHours(index.toLong()).toLocalTime()
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Text(
|
val time = start.plusHours(index.toLong()).toLocalTime()
|
||||||
text = time.toString(),
|
Text(
|
||||||
)
|
text = time.toString(),
|
||||||
|
modifier = Modifier.background(MaterialTheme.colorScheme.background),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
programs(
|
programs(
|
||||||
|
|
@ -236,14 +238,52 @@ fun TvGrid(
|
||||||
},
|
},
|
||||||
) { programIndex ->
|
) { programIndex ->
|
||||||
val program = programList[programIndex]
|
val program = programList[programIndex]
|
||||||
Text(
|
val focused = programIndex == focusedProgramIndex
|
||||||
text = program.name ?: program.id.toString(),
|
val background =
|
||||||
|
if (focused) {
|
||||||
|
MaterialTheme.colorScheme.inverseSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
|
||||||
|
}
|
||||||
|
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
|
||||||
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.ifElse(
|
Modifier
|
||||||
programIndex == focusedProgram,
|
// .scale(if (focused) 1.1f else 1f)
|
||||||
Modifier.background(MaterialTheme.colorScheme.error),
|
.padding(2.dp)
|
||||||
),
|
.fillMaxSize()
|
||||||
)
|
.background(
|
||||||
|
background,
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = program.name ?: program.id.toString(),
|
||||||
|
color = textColor,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier.enableMarquee(focused),
|
||||||
|
)
|
||||||
|
listOfNotNull(
|
||||||
|
program.seasonEpisode?.let { "S${it.season} E${it.episode}" },
|
||||||
|
program.subtitle,
|
||||||
|
).joinToString(" - ")
|
||||||
|
.ifBlank { null }
|
||||||
|
?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
color = textColor,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
channels(
|
channels(
|
||||||
|
|
@ -252,479 +292,35 @@ fun TvGrid(
|
||||||
ProgramGuideItem.Channel(channelIndex)
|
ProgramGuideItem.Channel(channelIndex)
|
||||||
},
|
},
|
||||||
) { channelIndex ->
|
) { channelIndex ->
|
||||||
Text(
|
val channel = channels[channelIndex]
|
||||||
text = channels[channelIndex].number ?: channelIndex.toString(),
|
Box(
|
||||||
modifier = Modifier.background(MaterialTheme.colorScheme.background),
|
modifier =
|
||||||
)
|
Modifier
|
||||||
}
|
.fillMaxSize()
|
||||||
}
|
.background(
|
||||||
}
|
MaterialTheme.colorScheme.background,
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
@Composable
|
),
|
||||||
fun TvGridBox(
|
) {
|
||||||
channels: List<TvChannel>,
|
|
||||||
programs: Map<UUID, List<TvProgram>>,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val startHour = 18f
|
|
||||||
var hour by remember { mutableFloatStateOf(startHour) }
|
|
||||||
Timber.v("hour=$hour")
|
|
||||||
val hoursToShow = 4f
|
|
||||||
val widthPerHour = 200.dp
|
|
||||||
|
|
||||||
var focusedProgram by remember { mutableStateOf<TvProgram?>(null) }
|
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
// Header
|
|
||||||
Text(
|
|
||||||
text = hour.toString(),
|
|
||||||
)
|
|
||||||
focusedProgram?.let {
|
|
||||||
Text(
|
|
||||||
text = it.name + " " + it.channelId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row {
|
|
||||||
Spacer(Modifier.width(220.dp))
|
|
||||||
var start = hour
|
|
||||||
while (start < (hour + hoursToShow)) {
|
|
||||||
Text(
|
|
||||||
text = start.toString(),
|
|
||||||
modifier = Modifier.width(widthPerHour),
|
|
||||||
)
|
|
||||||
start += 1f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LazyColumn(
|
|
||||||
modifier =
|
|
||||||
Modifier,
|
|
||||||
) {
|
|
||||||
itemsIndexed(channels) { channelIndex, channel ->
|
|
||||||
val programs = programs[channel.id].orEmpty()
|
|
||||||
var rowHasFocus by remember { mutableStateOf(false) }
|
|
||||||
Row(
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.height(120.dp)
|
.padding(4.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxSize(),
|
||||||
.background(
|
|
||||||
if (rowHasFocus) {
|
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(
|
|
||||||
3.dp,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surface
|
|
||||||
},
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
val channelFocusRequester = remember { FocusRequester() }
|
Text(
|
||||||
// Channel info
|
text = channel.number ?: channel.name ?: channelIndex.toString(),
|
||||||
ListItem(
|
modifier = Modifier,
|
||||||
selected = false,
|
)
|
||||||
onClick = {
|
AsyncImage(
|
||||||
Timber.v("Clicked channel $channel")
|
model = channel.imageUrl,
|
||||||
},
|
contentDescription = null,
|
||||||
leadingContent = {
|
modifier = Modifier.fillMaxHeight(.66f),
|
||||||
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()
|
|
||||||
.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
|
|
||||||
}
|
|
||||||
val delta =
|
|
||||||
focusedProgram?.let { p ->
|
|
||||||
if (p.start.hours < hour) {
|
|
||||||
(p.end.hours - hour).coerceAtMost(.5f)
|
|
||||||
} else {
|
|
||||||
.5f
|
|
||||||
}
|
|
||||||
} ?: .5f
|
|
||||||
if (it.type == KeyEventType.KeyUp && it.key == Key.DirectionLeft && hour > startHour) {
|
|
||||||
hour = (hour - delta).coerceAtLeast(startHour)
|
|
||||||
return@onKeyEvent true
|
|
||||||
} else if (it.type == KeyEventType.KeyUp && it.key == Key.DirectionRight) {
|
|
||||||
hour += delta
|
|
||||||
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
|
|
||||||
.forEach { p ->
|
|
||||||
Timber.v("Showing program: $p")
|
|
||||||
val width =
|
|
||||||
(p.duration.inWholeMinutes) / 60f * widthPerHour *
|
|
||||||
if (p.start.hours < hour) {
|
|
||||||
(hour - p.start.hours).coerceIn(0f, 1f)
|
|
||||||
} else {
|
|
||||||
1f
|
|
||||||
}
|
|
||||||
val backgroundColor =
|
|
||||||
if (rowHasFocus && p.start.hours <= hour && p.end.hours > (hour)) {
|
|
||||||
focusedProgram = p
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalDateTime.hours get() = hour + minute / 60f
|
|
||||||
|
|
||||||
val channel1Id = UUID.randomUUID()
|
|
||||||
val channel2Id = UUID.randomUUID()
|
|
||||||
val channel3Id = UUID.randomUUID()
|
|
||||||
val channel4Id = UUID.randomUUID()
|
|
||||||
|
|
||||||
val channels =
|
|
||||||
listOf(
|
|
||||||
TvChannel(
|
|
||||||
id = channel1Id,
|
|
||||||
number = "2.1",
|
|
||||||
name = "WJTV",
|
|
||||||
imageUrl = "",
|
|
||||||
),
|
|
||||||
TvChannel(
|
|
||||||
id = channel2Id,
|
|
||||||
number = "3.1",
|
|
||||||
name = "JYTV",
|
|
||||||
imageUrl = "",
|
|
||||||
),
|
|
||||||
TvChannel(
|
|
||||||
id = channel3Id,
|
|
||||||
number = "4.1",
|
|
||||||
name = "AQTV",
|
|
||||||
imageUrl = "",
|
|
||||||
),
|
|
||||||
TvChannel(
|
|
||||||
id = channel4Id,
|
|
||||||
number = "4.1",
|
|
||||||
name = "4444",
|
|
||||||
imageUrl = "",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
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),
|
|
||||||
startHours = 18f,
|
|
||||||
endHours = 19f,
|
|
||||||
duration = 60.minutes,
|
|
||||||
name = "C1 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),
|
|
||||||
startHours = 19f,
|
|
||||||
endHours = 19.5f,
|
|
||||||
duration = 30.minutes,
|
|
||||||
name = "C1 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),
|
|
||||||
startHours = 19.5f,
|
|
||||||
endHours = 20f,
|
|
||||||
duration = 30.minutes,
|
|
||||||
name = "C1 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),
|
|
||||||
startHours = 20f,
|
|
||||||
endHours = 21f,
|
|
||||||
duration = 60.minutes,
|
|
||||||
name = "C1 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),
|
|
||||||
startHours = 21f,
|
|
||||||
endHours = 22f,
|
|
||||||
duration = 60.minutes,
|
|
||||||
name = "C1 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),
|
|
||||||
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(
|
|
||||||
TvProgram(
|
|
||||||
id = UUID.randomUUID(),
|
|
||||||
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,
|
|
||||||
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),
|
|
||||||
startHours = 18.5f,
|
|
||||||
endHours = 19f,
|
|
||||||
duration = 60.minutes,
|
|
||||||
name = "C2 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),
|
|
||||||
startHours = 19.5f,
|
|
||||||
endHours = 20f,
|
|
||||||
duration = 30.minutes,
|
|
||||||
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),
|
|
||||||
startHours = 21f,
|
|
||||||
endHours = 22f,
|
|
||||||
duration = 60.minutes,
|
|
||||||
name = "C2 Program #4",
|
|
||||||
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, 18, 15, 0),
|
|
||||||
duration = 15.minutes,
|
|
||||||
startHours = 18f,
|
|
||||||
endHours = 18.25f,
|
|
||||||
name = "C3 Program #1",
|
|
||||||
subtitle = null,
|
|
||||||
seasonEpisode = null,
|
|
||||||
),
|
|
||||||
TvProgram(
|
|
||||||
id = UUID.randomUUID(),
|
|
||||||
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,
|
|
||||||
seasonEpisode = null,
|
|
||||||
),
|
|
||||||
TvProgram(
|
|
||||||
id = UUID.randomUUID(),
|
|
||||||
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,
|
|
||||||
seasonEpisode = null,
|
|
||||||
),
|
|
||||||
TvProgram(
|
|
||||||
id = UUID.randomUUID(),
|
|
||||||
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,
|
|
||||||
seasonEpisode = null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
channel4Id to
|
|
||||||
listOf(
|
|
||||||
TvProgram(
|
|
||||||
id = UUID.randomUUID(),
|
|
||||||
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,
|
|
||||||
seasonEpisode = null,
|
|
||||||
),
|
|
||||||
TvProgram(
|
|
||||||
id = UUID.randomUUID(),
|
|
||||||
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,
|
|
||||||
seasonEpisode = null,
|
|
||||||
),
|
|
||||||
TvProgram(
|
|
||||||
id = UUID.randomUUID(),
|
|
||||||
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,
|
|
||||||
seasonEpisode = null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val programList = programs.values.flatten()
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue