Wire up live tv pages

This commit is contained in:
Damontecres 2025-10-20 17:00:24 -04:00
parent fb4e36168c
commit dc6a921693
No known key found for this signature in database
7 changed files with 413 additions and 106 deletions

View file

@ -0,0 +1,157 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Tab
import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text
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.TvGuideGrid
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun CollectionFolderLiveTv(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
val uiPrefs = preferences.appPreferences.interfacePreferences
val rememberedTabIndex =
if (uiPrefs.rememberSelectedTab) {
uiPrefs.getRememberedTabsOrDefault(destination.itemId.toString(), 0)
} else {
0
}
val tabs = listOf("Guide", "DVR Schedule")
var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() }
val firstTabFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
if (uiPrefs.rememberSelectedTab) {
LaunchedEffect(selectedTabIndex) {
preferencesViewModel.preferenceDataStore.updateData {
preferences.appPreferences.rememberTab(destination.itemId, selectedTabIndex)
}
}
}
val onClickItem = { item: BaseItem ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
}
var showHeader by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
modifier = modifier,
) {
AnimatedVisibility(
showHeader,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
TabRow(
selectedTabIndex = focusTabIndex,
modifier =
Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
.focusRestorer(firstTabFocusRequester)
.onFocusChanged {
if (!it.isFocused) {
focusTabIndex = selectedTabIndex
}
},
indicator =
@Composable { tabPositions, doesTabRowHaveFocus ->
tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition ->
// TabRowDefaults.PillIndicator(
// currentTabPosition = currentTabPosition,
// doesTabRowHaveFocus = doesTabRowHaveFocus,
// )
TabRowDefaults.UnderlinedIndicator(
currentTabPosition = currentTabPosition,
doesTabRowHaveFocus = doesTabRowHaveFocus,
activeColor = MaterialTheme.colorScheme.border,
)
}
},
tabs = {
tabs.forEachIndexed { index, title ->
Tab(
selected = focusTabIndex == index,
onClick = { selectedTabIndex = index },
onFocus = { focusTabIndex = index },
colors =
TabDefaults.pillIndicatorTabColors(
focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier =
Modifier
.padding(8.dp)
.ifElse(
index == selectedTabIndex,
Modifier.focusRequester(firstTabFocusRequester),
),
)
}
}
},
)
}
when (selectedTabIndex) {
0 -> {
TvGuideGrid(
true,
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
)
}
1 -> {
Text(
text = "Not yet implemented",
)
}
else -> ErrorMessage("Invalid tab index $selectedTabIndex", null)
}
}
}

View file

@ -3,9 +3,13 @@ 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.data.model.BaseItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
@ -16,6 +20,7 @@ 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.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
@ -47,6 +52,9 @@ class LiveTvViewModel
val programs = MutableLiveData<List<TvProgram>>()
val programsByChannel = MutableLiveData<Map<UUID, List<TvProgram>>>(mapOf())
val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending)
val fetchedItem = MutableLiveData<BaseItem?>(null)
private val programMap = mutableMapOf<UUID, MutableLiveData<List<TvProgram?>>>()
fun init() {
@ -153,59 +161,55 @@ class LiveTvViewModel
}
}
// 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
// }
fun getItem(itemId: UUID) {
fetchingItem.value = LoadingState.Loading
viewModelScope.launchIO(LoadingExceptionHandler(fetchingItem, "Error")) {
val result =
api.userLibraryApi
.getItem(itemId = itemId)
.content
.let { BaseItem.from(it, api) }
withContext(Dispatchers.Main) {
fetchedItem.value = result
fetchingItem.value = LoadingState.Success
}
}
}
fun cancelRecording(
series: Boolean,
timerId: String?,
) {
if (timerId != null) {
viewModelScope.launch(ExceptionHandler(autoToast = true)) {
if (series) {
api.liveTvApi.cancelSeriesTimer(timerId)
} else {
api.liveTvApi.cancelTimer(timerId)
}
}
}
}
suspend fun refreshProgram(
index: Int,
timerId: String?,
seriesTimerId: String?,
) {
val newProgram =
programs.value?.getOrNull(index)?.copy(
isRecording = timerId.isNotNullOrBlank(),
isSeriesRecording = seriesTimerId.isNotNullOrBlank(),
)
if (newProgram != null) {
programs.value
?.toMutableList()
?.apply { set(index, newProgram) }
?.let {
programs.setValueOnMain(it)
}
}
}
}
/**

View file

@ -16,12 +16,15 @@ 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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
@ -29,8 +32,11 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.SurfaceDefaults
@ -38,13 +44,18 @@ import androidx.tv.material3.Text
import androidx.tv.material3.contentColorFor
import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
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.LoadingState
import com.github.damontecres.wholphin.util.seasonEpisode
import com.google.common.cache.CacheBuilder
import com.google.common.cache.CacheLoader
import eu.wewox.programguide.ProgramGuide
@ -57,7 +68,8 @@ import java.time.LocalDateTime
import java.util.UUID
@Composable
fun TvGrid(
fun TvGuideGrid(
requestFocusAfterLoading: Boolean,
modifier: Modifier = Modifier,
viewModel: LiveTvViewModel = hiltViewModel(),
) {
@ -69,43 +81,193 @@ fun TvGrid(
val programs by viewModel.programs.observeAsState(listOf())
val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
is LoadingState.Error -> ErrorMessage(state, modifier)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
-> LoadingPage(modifier)
LoadingState.Success ->
LoadingState.Success -> {
val fetchedItem by viewModel.fetchedItem.observeAsState(null)
val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending)
var showItemDialog by remember { mutableStateOf<Int?>(null) }
val focusRequester = remember { FocusRequester() }
if (requestFocusAfterLoading) {
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
}
Column(modifier = modifier) {
TvGrid(
TvGuideGrid(
channels = channels,
programList = programs,
programs = programsByChannel,
start = viewModel.start,
onClick = { program ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = program.channelId,
positionMs = 0L,
),
)
onClick = { index, program ->
viewModel.getItem(program.id)
showItemDialog = index
},
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
.background(MaterialTheme.colorScheme.surface)
.focusRequester(focusRequester),
)
}
if (showItemDialog != null) {
val onDismissRequest = { showItemDialog = null }
ProgramDialog(
item = fetchedItem,
loading = loadingItem,
onDismissRequest = onDismissRequest,
onWatch = {
onDismissRequest.invoke()
fetchedItem?.data?.channelId?.let {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it,
positionMs = 0L,
),
)
}
},
onRecord = { series ->
onDismissRequest.invoke()
},
onCancelRecord = { series ->
onDismissRequest.invoke()
fetchedItem?.data?.let {
viewModel.cancelRecording(
series,
if (series) it.seriesTimerId else it.timerId,
)
}
},
)
}
}
}
}
@Composable
fun TvGrid(
fun ProgramDialog(
item: BaseItem?,
loading: LoadingState,
onDismissRequest: () -> Unit,
onWatch: () -> Unit,
onRecord: (series: Boolean) -> Unit,
onCancelRecord: (series: Boolean) -> Unit,
) {
Dialog(
onDismissRequest = onDismissRequest,
) {
Box(
modifier =
Modifier.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp),
shape = RoundedCornerShape(16.dp),
),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(16.dp),
) {
when (val st = loading) {
is LoadingState.Error -> ErrorMessage(st)
LoadingState.Loading,
LoadingState.Pending,
->
CircularProgress(
Modifier
.padding(8.dp)
.size(48.dp),
)
LoadingState.Success ->
item?.let { item ->
val now = LocalDateTime.now()
val dto = item.data
val isRecording = dto.timerId.isNotNullOrBlank()
val isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank()
Text(
text = item.name ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleLarge,
)
if (dto.isSeries ?: false) {
listOfNotNull(dto.seasonEpisode, dto.episodeTitle)
.joinToString(" - ")
.ifBlank { null }
?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium,
)
}
}
dto.overview?.let { overview ->
Text(
text = overview,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
maxLines = 3,
)
}
if (dto.isSeries ?: false) {
Button(
onClick = {
if (isSeriesRecording) {
onCancelRecord.invoke(true)
} else {
onRecord.invoke(true)
}
},
) {
Text(
text = if (isSeriesRecording) "Cancel Series Recording" else "Record Series",
)
}
}
Button(
onClick = {
if (isRecording) {
onCancelRecord.invoke(false)
} else {
onRecord.invoke(false)
}
},
) {
Text(
text = if (isRecording) "Cancel Recording" else "Record Program",
)
}
if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) {
Button(
onClick = onWatch,
) {
Text(
text = "Watch live",
)
}
}
}
}
}
}
}
}
@Composable
fun TvGuideGrid(
channels: List<TvChannel>,
programList: List<TvProgram>,
programs: Map<UUID, List<TvProgram>>,
start: LocalDateTime,
onClick: (TvProgram) -> Unit,
onClick: (Int, TvProgram) -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
@ -224,7 +386,7 @@ fun TvGrid(
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
val program = programList[focusedProgramIndex]
Timber.v("Clicked on %s", program)
onClick.invoke(program)
onClick.invoke(focusedProgramIndex, program)
null
}
@ -255,7 +417,7 @@ fun TvGrid(
},
) {
Surface(
colors = SurfaceDefaults.colors(MaterialTheme.colorScheme.tertiary),
colors = SurfaceDefaults.colors(MaterialTheme.colorScheme.tertiary.copy(alpha = .5f)),
modifier = Modifier,
) {
// Empty
@ -293,7 +455,7 @@ fun TvGrid(
if (focused) {
MaterialTheme.colorScheme.inverseSurface
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
}
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
Box(

View file

@ -85,9 +85,6 @@ sealed class Destination(
val recursive: Boolean,
) : Destination(false)
@Serializable
data object LiveTvGuide : Destination(false)
@Serializable
data object UpdateApp : Destination(true)

View file

@ -6,12 +6,12 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.LicenseInfo
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
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
@ -144,13 +144,23 @@ fun DestinationContent(
)
BaseItemKind.USER_VIEW ->
CollectionFolderGeneric(
preferences,
destination.itemId,
destination.item,
true,
modifier,
)
when (destination.item?.data?.collectionType) {
CollectionType.LIVETV ->
CollectionFolderLiveTv(
preferences,
destination,
modifier,
)
else ->
CollectionFolderGeneric(
preferences,
destination.itemId,
destination.item,
true,
modifier,
)
}
else -> {
Text("Unsupported item type: ${destination.type}")
@ -167,11 +177,6 @@ fun DestinationContent(
modifier = modifier,
)
Destination.LiveTvGuide ->
TvGrid(
modifier = modifier,
)
Destination.UpdateApp -> InstallUpdatePage(preferences, modifier)
Destination.License -> LicenseInfo(modifier)

View file

@ -15,7 +15,6 @@ 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
@ -213,24 +212,6 @@ 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,

View file

@ -35,4 +35,5 @@ val supportedCollectionTypes =
CollectionType.HOMEVIDEOS,
CollectionType.PLAYLISTS,
CollectionType.BOXSETS,
CollectionType.LIVETV,
)