mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
WIP
This commit is contained in:
parent
77aaa87126
commit
548f942609
10 changed files with 723 additions and 0 deletions
|
|
@ -228,6 +228,7 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.livetv
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChannelRow() {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.livetv
|
||||||
|
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
|
||||||
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
|
||||||
|
import com.github.damontecres.wholphin.util.LazyList
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
|
import org.jellyfin.sdk.model.api.GetProgramsDto
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.AbstractList
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class LiveTvViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
val api: ApiClient,
|
||||||
|
) : ViewModel() {
|
||||||
|
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
|
|
||||||
|
val start = LocalDateTime.now()
|
||||||
|
val channels = MutableLiveData<List<TvChannel>>()
|
||||||
|
val programs = MutableLiveData<List<TvProgram?>>()
|
||||||
|
|
||||||
|
private val programMap = mutableMapOf<UUID, MutableLiveData<List<TvProgram?>>>()
|
||||||
|
|
||||||
|
fun init() {
|
||||||
|
loading.value = LoadingState.Loading
|
||||||
|
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Could not fetch channels")) {
|
||||||
|
val channelData by api.liveTvApi.getLiveTvChannels(
|
||||||
|
GetLiveTvChannelsRequest(
|
||||||
|
startIndex = 8,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val channels =
|
||||||
|
LazyList(channelData.items) {
|
||||||
|
TvChannel(
|
||||||
|
it.id,
|
||||||
|
it.channelNumber,
|
||||||
|
it.channelName,
|
||||||
|
api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val request =
|
||||||
|
GetProgramsDto(
|
||||||
|
minStartDate = start,
|
||||||
|
channelIds = channels.map { it.id },
|
||||||
|
)
|
||||||
|
val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init()
|
||||||
|
(0..<pager.size).forEach { pager.getBlocking(it) }
|
||||||
|
val programs =
|
||||||
|
LazyList(pager) { item ->
|
||||||
|
item?.data?.let { dto ->
|
||||||
|
TvProgram(
|
||||||
|
id = dto.id,
|
||||||
|
channelId = dto.channelId!!,
|
||||||
|
start = dto.startDate!!,
|
||||||
|
end = dto.endDate!!,
|
||||||
|
duration = dto.runTimeTicks!!.ticks,
|
||||||
|
name = dto.seriesName ?: dto.name,
|
||||||
|
subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() },
|
||||||
|
seasonEpisode = null, // TODO
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
this@LiveTvViewModel.channels.value = channels
|
||||||
|
this@LiveTvViewModel.programs.value = programs
|
||||||
|
loading.value = LoadingState.Success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPrograms(channelId: UUID): MutableLiveData<List<TvProgram?>> =
|
||||||
|
programMap.getOrPut(channelId) {
|
||||||
|
val data = MutableLiveData<List<TvProgram?>>(listOf())
|
||||||
|
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||||
|
val request =
|
||||||
|
GetProgramsDto(
|
||||||
|
minStartDate = start,
|
||||||
|
channelIds = listOf(channelId),
|
||||||
|
)
|
||||||
|
val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init()
|
||||||
|
val programList =
|
||||||
|
if (pager.isEmpty()) {
|
||||||
|
object : AbstractList<TvProgram?>() {
|
||||||
|
override fun get(index: Int): TvProgram? {
|
||||||
|
val start = start.plusHours(index.toLong())
|
||||||
|
val end = start.plusHours(1L)
|
||||||
|
return TvProgram(
|
||||||
|
id = UUID.randomUUID(),
|
||||||
|
channelId = channelId,
|
||||||
|
start = start,
|
||||||
|
end = start,
|
||||||
|
duration = 60.minutes,
|
||||||
|
name = "Unknown",
|
||||||
|
subtitle = null,
|
||||||
|
seasonEpisode = null, // TODO
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override val size: Int
|
||||||
|
get() = Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyList(pager) { item ->
|
||||||
|
item?.data?.let { dto ->
|
||||||
|
TvProgram(
|
||||||
|
id = dto.id,
|
||||||
|
channelId = channelId,
|
||||||
|
start = dto.startDate!!,
|
||||||
|
end = dto.endDate!!,
|
||||||
|
duration = dto.runTimeTicks!!.ticks,
|
||||||
|
name = dto.seriesName ?: dto.name,
|
||||||
|
subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() },
|
||||||
|
seasonEpisode = null, // TODO
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
data.value = programList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TvChannel(
|
||||||
|
val id: UUID,
|
||||||
|
val number: String?,
|
||||||
|
val name: String?,
|
||||||
|
val imageUrl: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TvProgram(
|
||||||
|
val id: UUID,
|
||||||
|
val channelId: UUID,
|
||||||
|
val start: LocalDateTime,
|
||||||
|
val end: LocalDateTime,
|
||||||
|
val duration: Duration,
|
||||||
|
val name: String?,
|
||||||
|
val subtitle: String?,
|
||||||
|
val seasonEpisode: SeasonEpisode?,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,490 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.livetv
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
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.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.onFocusChanged
|
||||||
|
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 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 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<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
|
||||||
|
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(
|
||||||
|
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())
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ProgramGuideItem.Program(0, 0f, 0f)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) { 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<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
|
||||||
|
@Composable
|
||||||
|
private fun ChannelRowPreview() {
|
||||||
|
val channelId = UUID.randomUUID()
|
||||||
|
val channel =
|
||||||
|
TvChannel(
|
||||||
|
id = channelId,
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTvSpec
|
||||||
|
@Composable
|
||||||
|
private fun TvGridPreview() {
|
||||||
|
}
|
||||||
|
|
@ -85,6 +85,9 @@ sealed class Destination(
|
||||||
val recursive: Boolean,
|
val recursive: Boolean,
|
||||||
) : Destination(false)
|
) : Destination(false)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object LiveTvGuide : Destination(false)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object UpdateApp : Destination(true)
|
data object UpdateApp : Destination(true)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv
|
||||||
import com.github.damontecres.wholphin.ui.detail.DebugPage
|
import com.github.damontecres.wholphin.ui.detail.DebugPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.SeriesDetails
|
import com.github.damontecres.wholphin.ui.detail.SeriesDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.livetv.TvGrid
|
||||||
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePage
|
import com.github.damontecres.wholphin.ui.main.HomePage
|
||||||
|
|
@ -166,6 +167,11 @@ fun DestinationContent(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Destination.LiveTvGuide ->
|
||||||
|
TvGrid(
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
|
||||||
Destination.UpdateApp -> InstallUpdatePage(preferences, modifier)
|
Destination.UpdateApp -> InstallUpdatePage(preferences, modifier)
|
||||||
|
|
||||||
Destination.License -> LicenseInfo(modifier)
|
Destination.License -> LicenseInfo(modifier)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AccountCircle
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
|
import androidx.compose.material.icons.filled.Build
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
|
@ -212,6 +213,24 @@ fun NavDrawer(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO placeholder
|
||||||
|
item {
|
||||||
|
IconNavItem(
|
||||||
|
text = "Live TV",
|
||||||
|
icon = Icons.Default.Build,
|
||||||
|
selected = selectedIndex == -3,
|
||||||
|
onClick = {
|
||||||
|
viewModel.setIndex(-3)
|
||||||
|
viewModel.navigationManager.navigateToFromDrawer(Destination.LiveTvGuide)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier.ifElse(
|
||||||
|
selectedIndex == -2,
|
||||||
|
Modifier.focusRequester(focusRequester),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
itemsIndexed(libraries) { index, it ->
|
itemsIndexed(libraries) { index, it ->
|
||||||
LibraryNavItem(
|
LibraryNavItem(
|
||||||
library = it,
|
library = it,
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,12 @@ import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.Response
|
import org.jellyfin.sdk.api.client.Response
|
||||||
import org.jellyfin.sdk.api.client.extensions.genresApi
|
import org.jellyfin.sdk.api.client.extensions.genresApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
|
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||||
|
import org.jellyfin.sdk.model.api.GetProgramsDto
|
||||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
|
@ -317,3 +319,22 @@ val GetGenresRequestHandler =
|
||||||
request: GetGenresRequest,
|
request: GetGenresRequest,
|
||||||
): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request)
|
): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val GetProgramsDtoHandler =
|
||||||
|
object : RequestHandler<GetProgramsDto> {
|
||||||
|
override fun prepare(
|
||||||
|
request: GetProgramsDto,
|
||||||
|
startIndex: Int,
|
||||||
|
limit: Int,
|
||||||
|
enableTotalRecordCount: Boolean,
|
||||||
|
): GetProgramsDto =
|
||||||
|
request.copy(
|
||||||
|
startIndex = startIndex,
|
||||||
|
limit = limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun execute(
|
||||||
|
api: ApiClient,
|
||||||
|
request: GetProgramsDto,
|
||||||
|
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
class LazyList<Source, Transform>(
|
||||||
|
private val source: List<Source>,
|
||||||
|
private val transform: (Source) -> Transform,
|
||||||
|
) : AbstractList<Transform>() {
|
||||||
|
override fun get(index: Int): Transform = transform.invoke(source[index])
|
||||||
|
|
||||||
|
override val size: Int
|
||||||
|
get() = source.size
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ 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"
|
||||||
|
|
@ -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" }
|
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