mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +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.aboutlibraries.core)
|
||||
implementation(libs.aboutlibraries.compose.m3)
|
||||
implementation(libs.programguide)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
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,
|
||||
) : Destination(false)
|
||||
|
||||
@Serializable
|
||||
data object LiveTvGuide : Destination(false)
|
||||
|
||||
@Serializable
|
||||
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.PlaylistDetails
|
||||
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.series.SeriesOverview
|
||||
import com.github.damontecres.wholphin.ui.main.HomePage
|
||||
|
|
@ -166,6 +167,11 @@ fun DestinationContent(
|
|||
modifier = modifier,
|
||||
)
|
||||
|
||||
Destination.LiveTvGuide ->
|
||||
TvGrid(
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
Destination.UpdateApp -> InstallUpdatePage(preferences, modifier)
|
||||
|
||||
Destination.License -> LicenseInfo(modifier)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.Search
|
||||
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 ->
|
||||
LibraryNavItem(
|
||||
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.extensions.genresApi
|
||||
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.suggestionsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
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.GetGenresRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
|
|
@ -317,3 +319,22 @@ val GetGenresRequestHandler =
|
|||
request: GetGenresRequest,
|
||||
): 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"
|
||||
composeBom = "2025.10.00"
|
||||
compose-runtime = "1.9.3"
|
||||
programguide = "1.6.0"
|
||||
timber = "5.0.1"
|
||||
tvFoundation = "1.0.0-alpha12"
|
||||
tvMaterial = "1.0.1"
|
||||
|
|
@ -56,6 +57,7 @@ androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "d
|
|||
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
||||
programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" }
|
||||
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" }
|
||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue