mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 08:01:20 +02:00
Wire up live tv pages
This commit is contained in:
parent
fb4e36168c
commit
dc6a921693
7 changed files with 413 additions and 106 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,13 @@ package com.github.damontecres.wholphin.ui.detail.livetv
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.detail.series.SeasonEpisode
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
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.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.GetProgramsDtoHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
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.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
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.GetProgramsDto
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
|
@ -47,6 +52,9 @@ class LiveTvViewModel
|
||||||
val programs = MutableLiveData<List<TvProgram>>()
|
val programs = MutableLiveData<List<TvProgram>>()
|
||||||
val programsByChannel = MutableLiveData<Map<UUID, List<TvProgram>>>(mapOf())
|
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?>>>()
|
private val programMap = mutableMapOf<UUID, MutableLiveData<List<TvProgram?>>>()
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
|
|
@ -153,59 +161,55 @@ class LiveTvViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fun getPrograms(channelId: UUID): MutableLiveData<List<TvProgram?>> =
|
fun getItem(itemId: UUID) {
|
||||||
// programMap.getOrPut(channelId) {
|
fetchingItem.value = LoadingState.Loading
|
||||||
// val data = MutableLiveData<List<TvProgram?>>(listOf())
|
viewModelScope.launchIO(LoadingExceptionHandler(fetchingItem, "Error")) {
|
||||||
// viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
val result =
|
||||||
// val request =
|
api.userLibraryApi
|
||||||
// GetProgramsDto(
|
.getItem(itemId = itemId)
|
||||||
// minStartDate = start,
|
.content
|
||||||
// channelIds = listOf(channelId),
|
.let { BaseItem.from(it, api) }
|
||||||
// )
|
withContext(Dispatchers.Main) {
|
||||||
// val pager = ApiRequestPager(api, request, GetProgramsDtoHandler, viewModelScope).init()
|
fetchedItem.value = result
|
||||||
// val programList =
|
fetchingItem.value = LoadingState.Success
|
||||||
// if (pager.isEmpty()) {
|
}
|
||||||
// object : AbstractList<TvProgram?>() {
|
}
|
||||||
// override fun get(index: Int): TvProgram? {
|
}
|
||||||
// val start = start.plusHours(index.toLong())
|
|
||||||
// val end = start.plusHours(1L)
|
fun cancelRecording(
|
||||||
// return TvProgram(
|
series: Boolean,
|
||||||
// id = UUID.randomUUID(),
|
timerId: String?,
|
||||||
// channelId = channelId,
|
) {
|
||||||
// start = start,
|
if (timerId != null) {
|
||||||
// end = start,
|
viewModelScope.launch(ExceptionHandler(autoToast = true)) {
|
||||||
// duration = 60.minutes,
|
if (series) {
|
||||||
// name = "Unknown",
|
api.liveTvApi.cancelSeriesTimer(timerId)
|
||||||
// subtitle = null,
|
} else {
|
||||||
// seasonEpisode = null, // TODO
|
api.liveTvApi.cancelTimer(timerId)
|
||||||
// )
|
}
|
||||||
// }
|
}
|
||||||
//
|
}
|
||||||
// override val size: Int
|
}
|
||||||
// get() = Int.MAX_VALUE
|
|
||||||
// }
|
suspend fun refreshProgram(
|
||||||
// } else {
|
index: Int,
|
||||||
// LazyList(pager) { item ->
|
timerId: String?,
|
||||||
// item?.data?.let { dto ->
|
seriesTimerId: String?,
|
||||||
// TvProgram(
|
) {
|
||||||
// id = dto.id,
|
val newProgram =
|
||||||
// channelId = channelId,
|
programs.value?.getOrNull(index)?.copy(
|
||||||
// start = dto.startDate!!,
|
isRecording = timerId.isNotNullOrBlank(),
|
||||||
// end = dto.endDate!!,
|
isSeriesRecording = seriesTimerId.isNotNullOrBlank(),
|
||||||
// duration = dto.runTimeTicks!!.ticks,
|
)
|
||||||
// name = dto.seriesName ?: dto.name,
|
if (newProgram != null) {
|
||||||
// subtitle = dto.name.takeIf { dto.seriesName.isNullOrBlank() },
|
programs.value
|
||||||
// seasonEpisode = null, // TODO
|
?.toMutableList()
|
||||||
// )
|
?.apply { set(index, newProgram) }
|
||||||
// }
|
?.let {
|
||||||
// }
|
programs.setValueOnMain(it)
|
||||||
// }
|
}
|
||||||
// withContext(Dispatchers.Main) {
|
}
|
||||||
// data.value = programList
|
}
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// data
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,15 @@ 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.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
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
|
||||||
|
|
@ -29,8 +32,11 @@ import androidx.compose.ui.input.key.key
|
||||||
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.tv.material3.Button
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.SurfaceDefaults
|
import androidx.tv.material3.SurfaceDefaults
|
||||||
|
|
@ -38,13 +44,18 @@ import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.contentColorFor
|
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.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.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
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.nav.Destination
|
||||||
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 com.github.damontecres.wholphin.util.seasonEpisode
|
||||||
import com.google.common.cache.CacheBuilder
|
import com.google.common.cache.CacheBuilder
|
||||||
import com.google.common.cache.CacheLoader
|
import com.google.common.cache.CacheLoader
|
||||||
import eu.wewox.programguide.ProgramGuide
|
import eu.wewox.programguide.ProgramGuide
|
||||||
|
|
@ -57,7 +68,8 @@ import java.time.LocalDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvGrid(
|
fun TvGuideGrid(
|
||||||
|
requestFocusAfterLoading: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: LiveTvViewModel = hiltViewModel(),
|
viewModel: LiveTvViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -69,43 +81,193 @@ fun TvGrid(
|
||||||
val programs by viewModel.programs.observeAsState(listOf())
|
val programs by viewModel.programs.observeAsState(listOf())
|
||||||
val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
|
val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> ErrorMessage(state)
|
is LoadingState.Error -> ErrorMessage(state, modifier)
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
LoadingState.Pending,
|
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) {
|
Column(modifier = modifier) {
|
||||||
TvGrid(
|
TvGuideGrid(
|
||||||
channels = channels,
|
channels = channels,
|
||||||
programList = programs,
|
programList = programs,
|
||||||
programs = programsByChannel,
|
programs = programsByChannel,
|
||||||
start = viewModel.start,
|
start = viewModel.start,
|
||||||
onClick = { program ->
|
onClick = { index, program ->
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.getItem(program.id)
|
||||||
Destination.Playback(
|
showItemDialog = index
|
||||||
itemId = program.channelId,
|
|
||||||
positionMs = 0L,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.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
|
@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>,
|
channels: List<TvChannel>,
|
||||||
programList: List<TvProgram>,
|
programList: List<TvProgram>,
|
||||||
programs: Map<UUID, List<TvProgram>>,
|
programs: Map<UUID, List<TvProgram>>,
|
||||||
start: LocalDateTime,
|
start: LocalDateTime,
|
||||||
onClick: (TvProgram) -> Unit,
|
onClick: (Int, TvProgram) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
@ -224,7 +386,7 @@ fun TvGrid(
|
||||||
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
|
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
|
||||||
val program = programList[focusedProgramIndex]
|
val program = programList[focusedProgramIndex]
|
||||||
Timber.v("Clicked on %s", program)
|
Timber.v("Clicked on %s", program)
|
||||||
onClick.invoke(program)
|
onClick.invoke(focusedProgramIndex, program)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,7 +417,7 @@ fun TvGrid(
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
colors = SurfaceDefaults.colors(MaterialTheme.colorScheme.tertiary),
|
colors = SurfaceDefaults.colors(MaterialTheme.colorScheme.tertiary.copy(alpha = .5f)),
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
// Empty
|
// Empty
|
||||||
|
|
@ -293,7 +455,7 @@ fun TvGrid(
|
||||||
if (focused) {
|
if (focused) {
|
||||||
MaterialTheme.colorScheme.inverseSurface
|
MaterialTheme.colorScheme.inverseSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
|
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
|
||||||
}
|
}
|
||||||
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
|
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -85,9 +85,6 @@ 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.LicenseInfo
|
import com.github.damontecres.wholphin.ui.components.LicenseInfo
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
|
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.CollectionFolderMovie
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv
|
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
|
||||||
|
|
@ -144,13 +144,23 @@ fun DestinationContent(
|
||||||
)
|
)
|
||||||
|
|
||||||
BaseItemKind.USER_VIEW ->
|
BaseItemKind.USER_VIEW ->
|
||||||
CollectionFolderGeneric(
|
when (destination.item?.data?.collectionType) {
|
||||||
preferences,
|
CollectionType.LIVETV ->
|
||||||
destination.itemId,
|
CollectionFolderLiveTv(
|
||||||
destination.item,
|
preferences,
|
||||||
true,
|
destination,
|
||||||
modifier,
|
modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
else ->
|
||||||
|
CollectionFolderGeneric(
|
||||||
|
preferences,
|
||||||
|
destination.itemId,
|
||||||
|
destination.item,
|
||||||
|
true,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Text("Unsupported item type: ${destination.type}")
|
Text("Unsupported item type: ${destination.type}")
|
||||||
|
|
@ -167,11 +177,6 @@ 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,7 +15,6 @@ 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
|
||||||
|
|
@ -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 ->
|
itemsIndexed(libraries) { index, it ->
|
||||||
LibraryNavItem(
|
LibraryNavItem(
|
||||||
library = it,
|
library = it,
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,5 @@ val supportedCollectionTypes =
|
||||||
CollectionType.HOMEVIDEOS,
|
CollectionType.HOMEVIDEOS,
|
||||||
CollectionType.PLAYLISTS,
|
CollectionType.PLAYLISTS,
|
||||||
CollectionType.BOXSETS,
|
CollectionType.BOXSETS,
|
||||||
|
CollectionType.LIVETV,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue