Add dvr schedule page

This commit is contained in:
Damontecres 2025-10-20 19:00:31 -04:00
parent 132e4f46b3
commit a908f0d6e5
No known key found for this signature in database
3 changed files with 267 additions and 3 deletions

View file

@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.rememberTab
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule
import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination
@ -146,8 +147,11 @@ fun CollectionFolderLiveTv(
)
}
1 -> {
Text(
text = "Not yet implemented",
DvrSchedule(
true,
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
)
}

View file

@ -0,0 +1,255 @@
package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.ListItem
import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.seasonEpisode
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneId
import javax.inject.Inject
@HiltViewModel
class DvrScheduleViewModel
@Inject
constructor(
private val api: ApiClient,
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val active = MutableLiveData<List<BaseItem>>()
val scheduled = MutableLiveData<Map<LocalDate, List<BaseItem>>>()
fun init() {
loading.value = LoadingState.Loading
viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching DVR Schedule")) {
val active =
api.liveTvApi
.getRecordings(
isInProgress = true,
fields = SlimItemFields,
limit = 100,
).content.items
.map { BaseItem.from(it, api, true) }
val scheduled =
api.liveTvApi
.getTimers(
isActive = false,
isScheduled = true,
).content.items
.map { BaseItem.from(it.programInfo!!, api, true) } // TODO this probably breaks for time based recordings
.groupBy {
it.data.startDate!!.toLocalDate()
}
withContext(Dispatchers.Main) {
this@DvrScheduleViewModel.active.value = active
this@DvrScheduleViewModel.scheduled.value = scheduled
loading.value = LoadingState.Success
}
}
}
}
@Composable
fun DvrSchedule(
requestFocusAfterLoading: Boolean,
modifier: Modifier = Modifier,
viewModel: DvrScheduleViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init()
}
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val active by viewModel.active.observeAsState(listOf())
val recordings by viewModel.scheduled.observeAsState(mapOf())
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state, modifier)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage(modifier)
LoadingState.Success -> {
val focusRequester = remember { FocusRequester() }
if (requestFocusAfterLoading) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
}
DvrScheduleContent(
activeRecordings = active,
scheduledRecordings = recordings,
onClickItem = {},
modifier =
modifier
.focusRequester(focusRequester),
)
}
}
}
@Composable
fun DvrScheduleContent(
activeRecordings: List<BaseItem>,
scheduledRecordings: Map<LocalDate, List<BaseItem>>,
onClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
Box(
contentAlignment = Alignment.TopCenter,
modifier = modifier,
) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth(.6f)
.background(MaterialTheme.colorScheme.surface),
) {
if (activeRecordings.isNotEmpty()) {
item {
Text(
text = "Active",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
}
items(activeRecordings) { item ->
Recording(
item = item,
onClick = { onClickItem.invoke(item) },
modifier = Modifier,
)
}
}
scheduledRecordings.keys.sorted().forEach { date ->
val formattedDate =
DateUtils.formatDateTime(
context,
date
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.epochSecond * 1000,
DateUtils.FORMAT_SHOW_WEEKDAY or DateUtils.FORMAT_SHOW_DATE,
)
item {
Text(
text = formattedDate,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
}
val programs = scheduledRecordings[date].orEmpty()
items(programs) { item ->
Recording(
item = item,
onClick = { onClickItem.invoke(item) },
modifier = Modifier,
)
}
}
}
}
}
@Composable
fun Recording(
item: BaseItem,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
ListItem(
selected = false,
onClick = onClick,
modifier = modifier,
colors =
ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
),
leadingContent = {
// TODO
// AsyncImage(
// model = item.imageUrl,
// contentDescription = null,
// )
},
headlineContent = {
Text(
text = item.title ?: "",
)
},
supportingContent = {
if (item.data.isSeries ?: false) {
listOfNotNull(
item.data.seasonEpisode,
item.data.episodeTitle,
).joinToString(" - ")
.ifBlank { null }
?.let {
Text(
text = it,
modifier = Modifier,
)
}
}
},
trailingContent = {
val time =
DateUtils.formatDateRange(
context,
item.data.startDate!!
.toInstant(OffsetDateTime.now().offset)
.epochSecond * 1000,
item.data.endDate!!
.toInstant(OffsetDateTime.now().offset)
.epochSecond * 1000,
DateUtils.FORMAT_SHOW_TIME,
)
Text(
text = time,
)
},
)
}

View file

@ -468,7 +468,12 @@ fun TvGuideGrid(
)
},
) { index ->
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
val differentDay =
start.toLocalDate() !=
start