mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Add Live TV & DVR Support (#49)
Closes #1 Adds support for Live TV & DVR functionality. TODO: - [x] EPG / TV Guide - [x] View DVR schedule - [x] Play live TV channel - [x] Record program - [x] Record Series - [x] Cancel recordings - [x] Access previously recorded content
This commit is contained in:
parent
a35ecb6570
commit
05f3811149
18 changed files with 1915 additions and 15 deletions
|
|
@ -230,6 +230,7 @@ dependencies {
|
|||
implementation(libs.aboutlibraries.compose.m3)
|
||||
implementation(libs.multiplatform.markdown.renderer)
|
||||
implementation(libs.multiplatform.markdown.renderer.m3)
|
||||
implementation(libs.programguide)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ sealed class AppColors private constructor() {
|
|||
val TransparentBlack25 = Color(0x40000000)
|
||||
val TransparentBlack50 = Color(0x80000000)
|
||||
val TransparentBlack75 = Color(0xBF000000)
|
||||
|
||||
val DarkGreen = Color(0xFF114000)
|
||||
val DarkRed = Color(0xFF400000)
|
||||
val DarkCyan = Color(0xFF21556E)
|
||||
val DarkPurple = Color(0xFF261370)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
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.livedata.observeAsState
|
||||
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.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
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.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.nav.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class LiveTvCollectionViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
val api: ApiClient,
|
||||
val serverRepository: ServerRepository,
|
||||
val navigationManager: NavigationManager,
|
||||
val rememberTabManager: RememberTabManager,
|
||||
) : ViewModel(),
|
||||
RememberTabManager by rememberTabManager {
|
||||
val recordingFolders = MutableLiveData<List<TabId>>()
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
val folders =
|
||||
api.liveTvApi
|
||||
.getRecordingFolders(userId = serverRepository.currentUser?.id)
|
||||
.content.items
|
||||
.map { TabId(it.name ?: "Recordings", it.id) }
|
||||
this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class TabId(
|
||||
val title: String,
|
||||
val id: UUID,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CollectionFolderLiveTv(
|
||||
preferences: UserPreferences,
|
||||
destination: Destination.MediaItem,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LiveTvCollectionViewModel = hiltViewModel(),
|
||||
) {
|
||||
val rememberedTabIndex =
|
||||
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
|
||||
val folders by viewModel.recordingFolders.observeAsState(listOf())
|
||||
|
||||
val tabs =
|
||||
listOf(
|
||||
TabId("Guide", UUID.randomUUID()),
|
||||
TabId("DVR Schedule", UUID.randomUUID()),
|
||||
) + folders
|
||||
|
||||
var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
val firstTabFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||
|
||||
LaunchedEffect(selectedTabIndex) {
|
||||
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
||||
}
|
||||
val onClickItem = { item: BaseItem ->
|
||||
viewModel.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, tabId ->
|
||||
Tab(
|
||||
selected = focusTabIndex == index,
|
||||
onClick = { selectedTabIndex = index },
|
||||
onFocus = { focusTabIndex = index },
|
||||
colors =
|
||||
TabDefaults.pillIndicatorTabColors(
|
||||
focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = tabId.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 -> {
|
||||
DvrSchedule(
|
||||
true,
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val folderIndex = selectedTabIndex - 2
|
||||
if (folderIndex in folders.indices) {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = onClickItem,
|
||||
itemId = folders[folderIndex].id,
|
||||
item = null,
|
||||
initialFilter = GetItemsFilter(),
|
||||
showTitle = showHeader,
|
||||
recursive = false,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp),
|
||||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.text.style.TextAlign
|
||||
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.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.nav.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
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,
|
||||
val navigationManager: NavigationManager,
|
||||
) : ViewModel() {
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRecording(
|
||||
timerId: String,
|
||||
series: Boolean,
|
||||
) {
|
||||
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||
if (series) {
|
||||
api.liveTvApi.cancelSeriesTimer(timerId)
|
||||
} else {
|
||||
api.liveTvApi.cancelTimer(timerId)
|
||||
}
|
||||
init()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 -> {
|
||||
var showDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
if (requestFocusAfterLoading) {
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
}
|
||||
DvrScheduleContent(
|
||||
activeRecordings = active,
|
||||
scheduledRecordings = recordings,
|
||||
onClickItem = {
|
||||
showDialog = it
|
||||
},
|
||||
modifier =
|
||||
modifier
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
showDialog?.let { item ->
|
||||
ProgramDialog(
|
||||
item = item,
|
||||
canRecord = true,
|
||||
loading = LoadingState.Success,
|
||||
onDismissRequest = {
|
||||
showDialog = null
|
||||
},
|
||||
onWatch = {
|
||||
item.data.channelId?.let {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.Playback(
|
||||
itemId = it,
|
||||
positionMs = 0L,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onRecord = {
|
||||
// no-op
|
||||
},
|
||||
onCancelRecord = { series ->
|
||||
showDialog = null
|
||||
val timerId = if (series) item.data.seriesTimerId else item.data.timerId
|
||||
if (timerId != null) {
|
||||
viewModel.cancelRecording(timerId, series)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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.isEmpty() && scheduledRecordings.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No scheduled recordings",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.livetv
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.AppColors
|
||||
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.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
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.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
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.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.TimerInfoDto
|
||||
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetLiveTvProgramsRequest
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
const val MAX_HOURS = 48L
|
||||
|
||||
@HiltViewModel
|
||||
class LiveTvViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
val api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
) : ViewModel() {
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
|
||||
lateinit var start: LocalDateTime
|
||||
private set
|
||||
private lateinit var channelsIdToIndex: Map<UUID, Int>
|
||||
private val mutex = Mutex()
|
||||
|
||||
val channels = MutableLiveData<List<TvChannel>>()
|
||||
val programs = MutableLiveData<List<TvProgram>>()
|
||||
val programsByChannel = MutableLiveData<Map<UUID, List<TvProgram>>>(mapOf())
|
||||
|
||||
val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val fetchedItem = MutableLiveData<BaseItem?>(null)
|
||||
|
||||
fun init(firstLoad: Boolean) {
|
||||
start = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
|
||||
if (!firstLoad) {
|
||||
loading.value = LoadingState.Loading
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Could not fetch channels")) {
|
||||
val channelData by api.liveTvApi.getLiveTvChannels(
|
||||
GetLiveTvChannelsRequest(
|
||||
startIndex = 0,
|
||||
),
|
||||
)
|
||||
val channels =
|
||||
channelData.items
|
||||
.map {
|
||||
TvChannel(
|
||||
it.id,
|
||||
it.channelNumber,
|
||||
it.channelName,
|
||||
api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY),
|
||||
)
|
||||
}
|
||||
Timber.d("Got ${channels.size} channels")
|
||||
channelsIdToIndex =
|
||||
channels.withIndex().associateBy({ it.value.id }, { it.index })
|
||||
fetchPrograms(channels)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
this@LiveTvViewModel.channels.value = channels
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchProgramsWithLoading(channels: List<TvChannel>) {
|
||||
loading.setValueOnMain(LoadingState.Loading)
|
||||
fetchPrograms(channels)
|
||||
loading.setValueOnMain(LoadingState.Success)
|
||||
}
|
||||
|
||||
private suspend fun fetchPrograms(channels: List<TvChannel>) =
|
||||
mutex.withLock {
|
||||
val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1)
|
||||
val minEndDate = start.plusMinutes(1L)
|
||||
|
||||
val request =
|
||||
GetLiveTvProgramsRequest(
|
||||
maxStartDate = maxStartDate,
|
||||
minEndDate = minEndDate,
|
||||
channelIds = channels.map { it.id },
|
||||
sortBy = listOf(ItemSortBy.START_DATE),
|
||||
)
|
||||
val programs =
|
||||
api.liveTvApi
|
||||
.getLiveTvPrograms(request)
|
||||
.content.items
|
||||
.map { dto ->
|
||||
val category =
|
||||
if (dto.isKids ?: false) {
|
||||
ProgramCategory.KIDS
|
||||
} else if (dto.isMovie ?: false) {
|
||||
ProgramCategory.MOVIE
|
||||
} else if (dto.isNews ?: false) {
|
||||
ProgramCategory.NEWS
|
||||
} else if (dto.isSports ?: false) {
|
||||
ProgramCategory.SPORTS
|
||||
} else {
|
||||
null
|
||||
}
|
||||
TvProgram(
|
||||
id = dto.id,
|
||||
channelId = dto.channelId!!,
|
||||
start = dto.startDate!!,
|
||||
end = dto.endDate!!,
|
||||
startHours = hoursBetween(start, dto.startDate!!).coerceAtLeast(0f),
|
||||
endHours = hoursBetween(start, dto.endDate!!),
|
||||
duration = dto.runTimeTicks!!.ticks,
|
||||
name = dto.seriesName ?: dto.name,
|
||||
subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false },
|
||||
seasonEpisode =
|
||||
if (dto.indexNumber != null && dto.parentIndexNumber != null) {
|
||||
SeasonEpisode(dto.parentIndexNumber!!, dto.indexNumber!!)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
isRecording = dto.timerId.isNotNullOrBlank(),
|
||||
isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank(),
|
||||
isRepeat = dto.isRepeat ?: false,
|
||||
category = category,
|
||||
)
|
||||
}
|
||||
|
||||
val programsByChannel = programs.groupBy { it.channelId }
|
||||
val emptyChannels = channels.filter { programsByChannel[it.id].orEmpty().isEmpty() }
|
||||
val fake = mutableListOf<TvProgram>()
|
||||
val finalProgramsByChannel =
|
||||
programsByChannel.toMutableMap().apply {
|
||||
emptyChannels.forEach { channel ->
|
||||
val fakePrograms =
|
||||
(0..<MAX_HOURS).map {
|
||||
TvProgram(
|
||||
id = UUID.randomUUID(), // TODO
|
||||
channelId = channel.id,
|
||||
start = start.plusHours(it),
|
||||
end = start.plusHours(it + 1),
|
||||
startHours = it.toFloat(),
|
||||
endHours = (it + 1).toFloat(),
|
||||
duration = 60.seconds,
|
||||
name = "No data",
|
||||
subtitle = null,
|
||||
seasonEpisode = null,
|
||||
isRecording = false,
|
||||
isSeriesRecording = false,
|
||||
isRepeat = false,
|
||||
category = ProgramCategory.FAKE,
|
||||
)
|
||||
}
|
||||
put(channel.id, fakePrograms)
|
||||
fake.addAll(fakePrograms)
|
||||
}
|
||||
}
|
||||
val finalProgramList =
|
||||
(programs + fake).sortedWith(
|
||||
compareBy(
|
||||
{ channelsIdToIndex[it.channelId]!! },
|
||||
{ it.start },
|
||||
),
|
||||
)
|
||||
Timber.d("Got ${programs.size} programs & ${fake.size} fake programs")
|
||||
withContext(Dispatchers.Main) {
|
||||
this@LiveTvViewModel.programs.value = finalProgramList
|
||||
this@LiveTvViewModel.programsByChannel.value = finalProgramsByChannel
|
||||
}
|
||||
}
|
||||
|
||||
fun getItem(programId: UUID) {
|
||||
fetchingItem.value = LoadingState.Loading
|
||||
viewModelScope.launchIO(LoadingExceptionHandler(fetchingItem, "Error")) {
|
||||
val result =
|
||||
api.liveTvApi
|
||||
.getProgram(programId.toServerString())
|
||||
.content
|
||||
.let { BaseItem.from(it, api) }
|
||||
withContext(Dispatchers.Main) {
|
||||
fetchedItem.value = result
|
||||
fetchingItem.value = LoadingState.Success
|
||||
}
|
||||
if (result.data.seriesTimerId != null) {
|
||||
val items =
|
||||
api.liveTvApi
|
||||
.getPrograms(GetProgramsDto(seriesTimerId = result.data.seriesTimerId))
|
||||
.content.items
|
||||
Timber.v("items=$items")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRecording(
|
||||
programIndex: Int,
|
||||
programId: UUID,
|
||||
series: Boolean,
|
||||
timerId: String?,
|
||||
) {
|
||||
if (timerId != null) {
|
||||
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||
if (series) {
|
||||
api.liveTvApi.cancelSeriesTimer(timerId)
|
||||
fetchProgramsWithLoading(channels.value.orEmpty())
|
||||
} else {
|
||||
api.liveTvApi.cancelTimer(timerId)
|
||||
refreshProgram(programIndex, programId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun record(
|
||||
programIndex: Int,
|
||||
programId: UUID,
|
||||
series: Boolean,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val d by api.liveTvApi.getDefaultTimer(programId.toServerString())
|
||||
if (series) {
|
||||
api.liveTvApi.createSeriesTimer(d)
|
||||
fetchProgramsWithLoading(channels.value.orEmpty())
|
||||
} else {
|
||||
val payload =
|
||||
TimerInfoDto(
|
||||
id = d.id,
|
||||
type = d.type,
|
||||
serverId = d.serverId,
|
||||
externalId = d.externalId,
|
||||
channelId = d.channelId,
|
||||
externalChannelId = d.externalChannelId,
|
||||
channelName = d.channelName,
|
||||
programId = d.programId,
|
||||
externalProgramId = d.externalProgramId,
|
||||
name = d.name,
|
||||
overview = d.overview,
|
||||
startDate = d.startDate,
|
||||
endDate = d.endDate,
|
||||
serviceName = d.serviceName,
|
||||
priority = d.priority,
|
||||
prePaddingSeconds = d.prePaddingSeconds,
|
||||
postPaddingSeconds = d.postPaddingSeconds,
|
||||
isPrePaddingRequired = d.isPrePaddingRequired,
|
||||
isPostPaddingRequired = d.isPostPaddingRequired,
|
||||
keepUntil = d.keepUntil,
|
||||
)
|
||||
api.liveTvApi.createTimer(payload)
|
||||
refreshProgram(programIndex, programId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshProgram(
|
||||
programIndex: Int,
|
||||
programId: UUID,
|
||||
) = mutex.withLock {
|
||||
loading.setValueOnMain(LoadingState.Loading)
|
||||
val program by api.liveTvApi.getProgram(programId.toServerString())
|
||||
val newProgram =
|
||||
programs.value?.getOrNull(programIndex)?.copy(
|
||||
isRecording = program.timerId.isNotNullOrBlank(),
|
||||
isSeriesRecording = program.seriesTimerId.isNotNullOrBlank(),
|
||||
)
|
||||
Timber.v("new program %s", newProgram)
|
||||
if (newProgram != null) {
|
||||
programs.value
|
||||
?.toMutableList()
|
||||
?.apply {
|
||||
this[programIndex] = newProgram
|
||||
}?.let {
|
||||
this@LiveTvViewModel.programs.setValueOnMain(it)
|
||||
}
|
||||
}
|
||||
loading.setValueOnMain(LoadingState.Success)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of hours between two [LocalDateTime]
|
||||
*/
|
||||
fun hoursBetween(
|
||||
start: LocalDateTime,
|
||||
target: LocalDateTime,
|
||||
): Float =
|
||||
java.time.Duration
|
||||
.between(start, target)
|
||||
.seconds / (60f * 60f)
|
||||
|
||||
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 startHours: Float,
|
||||
val endHours: Float,
|
||||
val duration: Duration,
|
||||
val name: String?,
|
||||
val subtitle: String?,
|
||||
val seasonEpisode: SeasonEpisode?,
|
||||
val isRecording: Boolean,
|
||||
val isSeriesRecording: Boolean,
|
||||
val isRepeat: Boolean,
|
||||
val category: ProgramCategory?,
|
||||
) {
|
||||
val isFake = category == ProgramCategory.FAKE
|
||||
}
|
||||
|
||||
enum class ProgramCategory(
|
||||
val color: Color?,
|
||||
) {
|
||||
KIDS(AppColors.DarkCyan),
|
||||
NEWS(AppColors.DarkGreen),
|
||||
MOVIE(AppColors.DarkPurple),
|
||||
SPORTS(AppColors.DarkRed),
|
||||
FAKE(null),
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Icon
|
||||
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.components.CircularProgress
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.seasonEpisode
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
@Composable
|
||||
fun ProgramDialog(
|
||||
item: BaseItem?,
|
||||
canRecord: Boolean,
|
||||
loading: LoadingState,
|
||||
onDismissRequest: () -> Unit,
|
||||
onWatch: () -> Unit,
|
||||
onRecord: (series: Boolean) -> Unit,
|
||||
onCancelRecord: (series: Boolean) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
).focusRequester(focusRequester),
|
||||
) {
|
||||
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()
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
val time =
|
||||
DateUtils.formatDateRange(
|
||||
context,
|
||||
dto.startDate!!
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.epochSecond * 1000,
|
||||
dto.endDate!!
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.epochSecond * 1000,
|
||||
DateUtils.FORMAT_SHOW_TIME,
|
||||
)
|
||||
Text(
|
||||
text = time,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
dto.overview?.let { overview ->
|
||||
Text(
|
||||
text = overview,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 3,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
if (canRecord) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (dto.isSeries ?: false) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (isSeriesRecording) {
|
||||
onCancelRecord.invoke(true)
|
||||
} else {
|
||||
onRecord.invoke(true)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isSeriesRecording) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
tint = Color.Red,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (isSeriesRecording) "Cancel Series Recording" else "Record Series",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dto.endDate?.isAfter(LocalDateTime.now()) ?: true) {
|
||||
// Only show program specific recording button if it hasn't finished yet
|
||||
Button(
|
||||
onClick = {
|
||||
if (isRecording) {
|
||||
onCancelRecord.invoke(false)
|
||||
} else {
|
||||
onRecord.invoke(false)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isRecording) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
tint = Color.Red,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (isRecording) "Cancel Recording" else "Record Program",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) {
|
||||
Button(
|
||||
onClick = onWatch,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = "Delete",
|
||||
tint = Color.Green.copy(alpha = .75f),
|
||||
)
|
||||
Text(
|
||||
text = "Watch live",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.livetv
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
|
||||
// .align(Alignment.BottomEnd)
|
||||
@Composable
|
||||
fun RecordingMarker(
|
||||
isRecording: Boolean,
|
||||
isSeriesRecording: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
if (isSeriesRecording) {
|
||||
val color = if (isRecording) Color.Red else Color.Gray
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(4.dp)
|
||||
.size(16.dp)
|
||||
.background(color, shape = CircleShape),
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 4.dp, top = 4.dp, bottom = 4.dp, end = 10.dp)
|
||||
.offset(6.dp)
|
||||
.size(16.dp)
|
||||
.background(color.copy(alpha = .5f), shape = CircleShape),
|
||||
)
|
||||
} else if (isRecording) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(4.dp)
|
||||
.size(16.dp)
|
||||
.background(Color.Red, shape = CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun RecordingMarkerPreview() {
|
||||
WholphinTheme {
|
||||
Column {
|
||||
RecordingMarker(true, false)
|
||||
RecordingMarker(true, true)
|
||||
RecordingMarker(false, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,599 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.livetv
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.saveable.rememberSaveable
|
||||
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.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
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.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.SurfaceDefaults
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.contentColorFor
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import coil3.compose.AsyncImage
|
||||
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.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.google.common.cache.CacheBuilder
|
||||
import com.google.common.cache.CacheLoader
|
||||
import eu.wewox.programguide.ProgramGuide
|
||||
import eu.wewox.programguide.ProgramGuideDimensions
|
||||
import eu.wewox.programguide.ProgramGuideItem
|
||||
import eu.wewox.programguide.rememberSaveableProgramGuideState
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun TvGuideGrid(
|
||||
requestFocusAfterLoading: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LiveTvViewModel = hiltViewModel(),
|
||||
) {
|
||||
var firstLoad by rememberSaveable { mutableStateOf(true) }
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.init(firstLoad)
|
||||
firstLoad = false
|
||||
}
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||
val channels by viewModel.channels.observeAsState(listOf())
|
||||
val programs by viewModel.programs.observeAsState(listOf())
|
||||
val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> ErrorMessage(state, modifier)
|
||||
LoadingState.Pending,
|
||||
-> LoadingPage(modifier)
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Success,
|
||||
-> {
|
||||
val context = LocalContext.current
|
||||
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) {
|
||||
TvGuideGrid(
|
||||
loading = state is LoadingState.Loading,
|
||||
channels = channels,
|
||||
programList = programs,
|
||||
programs = programsByChannel,
|
||||
start = viewModel.start,
|
||||
onClickChannel = { index, channel ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.Playback(
|
||||
itemId = channel.id,
|
||||
positionMs = 0L,
|
||||
),
|
||||
)
|
||||
},
|
||||
onClickProgram = { index, program ->
|
||||
if (program.isFake) {
|
||||
val now = LocalDateTime.now()
|
||||
if (now.isAfter(program.start) && now.isBefore(program.end)) {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.Playback(
|
||||
itemId = program.channelId,
|
||||
positionMs = 0L,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Toast
|
||||
.makeText(context, "No guide data found!", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
viewModel.getItem(program.id)
|
||||
showItemDialog = index
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
if (showItemDialog != null) {
|
||||
val onDismissRequest = { showItemDialog = null }
|
||||
ProgramDialog(
|
||||
item = fetchedItem,
|
||||
canRecord = true,
|
||||
loading = loadingItem,
|
||||
onDismissRequest = onDismissRequest,
|
||||
onWatch = {
|
||||
onDismissRequest.invoke()
|
||||
fetchedItem?.data?.channelId?.let {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.Playback(
|
||||
itemId = it,
|
||||
positionMs = 0L,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onRecord = { series ->
|
||||
fetchedItem?.let {
|
||||
viewModel.record(
|
||||
programIndex = showItemDialog!!,
|
||||
programId = it.id,
|
||||
series = series,
|
||||
)
|
||||
}
|
||||
onDismissRequest.invoke()
|
||||
},
|
||||
onCancelRecord = { series ->
|
||||
fetchedItem?.data?.let {
|
||||
viewModel.cancelRecording(
|
||||
programIndex = showItemDialog!!,
|
||||
programId = it.id,
|
||||
series = series,
|
||||
timerId = if (series) it.seriesTimerId else it.timerId,
|
||||
)
|
||||
}
|
||||
onDismissRequest.invoke()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val CHANNEL_COLUMN = -1
|
||||
|
||||
@Composable
|
||||
fun TvGuideGrid(
|
||||
loading: Boolean,
|
||||
channels: List<TvChannel>,
|
||||
programList: List<TvProgram>,
|
||||
programs: Map<UUID, List<TvProgram>>,
|
||||
start: LocalDateTime,
|
||||
onClickChannel: (Int, TvChannel) -> Unit,
|
||||
onClickProgram: (Int, TvProgram) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val state = rememberSaveableProgramGuideState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var focusedProgramIndex by rememberInt(0)
|
||||
var focusedChannelIndex by rememberInt(0)
|
||||
|
||||
val dimensions =
|
||||
ProgramGuideDimensions(
|
||||
timelineHourWidth = 240.dp,
|
||||
timelineHeight = 32.dp,
|
||||
channelWidth = 120.dp,
|
||||
channelHeight = 80.dp,
|
||||
currentTimeWidth = 2.dp,
|
||||
)
|
||||
|
||||
val programsBeforeChannel =
|
||||
remember {
|
||||
CacheBuilder
|
||||
.newBuilder()
|
||||
.maximumSize(200)
|
||||
.build<Int, Int>(
|
||||
object : CacheLoader<Int, Int>() {
|
||||
override fun load(key: Int): Int =
|
||||
channels
|
||||
.subList(0, key)
|
||||
.map { it.id }
|
||||
.sumOf { programs[it]?.size ?: 0 }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
|
||||
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
|
||||
Box(modifier = modifier) {
|
||||
ProgramGuide(
|
||||
state = state,
|
||||
dimensions = dimensions,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.onFocusChanged {
|
||||
gridHasFocus = it.hasFocus
|
||||
}.focusable()
|
||||
.onPreviewKeyEvent {
|
||||
if (it.type == KeyEventType.KeyUp) {
|
||||
return@onPreviewKeyEvent false
|
||||
}
|
||||
val newIndex =
|
||||
when (it.key) {
|
||||
Key.Back -> {
|
||||
val pos = programsBeforeChannel.get(focusedChannelIndex)
|
||||
if (focusedProgramIndex - pos > 0) {
|
||||
// Not at beginning of row, so move to beginning
|
||||
pos
|
||||
} else {
|
||||
// At beginning, so allow normal back button behavior
|
||||
return@onPreviewKeyEvent false
|
||||
}
|
||||
}
|
||||
|
||||
Key.DirectionRight -> {
|
||||
if (channelColumnFocused) {
|
||||
channelColumnFocused = false
|
||||
focusedProgramIndex
|
||||
} else {
|
||||
val nextProgramIndex = focusedProgramIndex + 1
|
||||
val programsBefore =
|
||||
programsBeforeChannel.get(focusedChannelIndex)
|
||||
val relativePosition = nextProgramIndex - programsBefore
|
||||
val channelPrograms =
|
||||
programs[channels[focusedChannelIndex].id].orEmpty()
|
||||
if (relativePosition >= channelPrograms.size) {
|
||||
focusManager.moveFocus(FocusDirection.Right)
|
||||
null
|
||||
} else {
|
||||
nextProgramIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Key.DirectionLeft -> {
|
||||
if (channelColumnFocused) {
|
||||
focusManager.moveFocus(FocusDirection.Left)
|
||||
null
|
||||
} else {
|
||||
val nextProgramIndex = focusedProgramIndex - 1
|
||||
val programsBefore =
|
||||
programsBeforeChannel.get(focusedChannelIndex)
|
||||
val relativePosition = nextProgramIndex - programsBefore
|
||||
// val channelPrograms =
|
||||
// programs[channels[focusedChannel].id].orEmpty()
|
||||
if (relativePosition >= 0) {
|
||||
nextProgramIndex
|
||||
} else if (relativePosition == CHANNEL_COLUMN) {
|
||||
channelColumnFocused = true
|
||||
focusedProgramIndex
|
||||
} else {
|
||||
Timber.w("Move left to relativePosition=$relativePosition should not occur")
|
||||
focusManager.moveFocus(FocusDirection.Left)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Key.DirectionUp -> {
|
||||
val newFocusedChannelIndex = (focusedChannelIndex - 1)
|
||||
// Timber.v("newFocusedChannelIndex=$newFocusedChannelIndex")
|
||||
if (newFocusedChannelIndex < 0) {
|
||||
focusManager.moveFocus(FocusDirection.Up)
|
||||
null
|
||||
} else if (channelColumnFocused) {
|
||||
focusedChannelIndex = newFocusedChannelIndex
|
||||
programsBeforeChannel.get(focusedChannelIndex)
|
||||
} else {
|
||||
val start = programList[focusedProgramIndex].startHours
|
||||
focusedChannelIndex =
|
||||
newFocusedChannelIndex.coerceAtLeast(0)
|
||||
val channelId = channels[focusedChannelIndex].id
|
||||
val pro = programs[channelId].orEmpty()
|
||||
val pIndex =
|
||||
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
|
||||
if (pIndex >= 0) {
|
||||
programsBeforeChannel.get(focusedChannelIndex) + pIndex
|
||||
} else {
|
||||
val pIndex = pro.indexOfFirst { it.startHours >= start }
|
||||
if (pIndex >= 0) {
|
||||
programsBeforeChannel.get(focusedChannelIndex) + pIndex
|
||||
} else {
|
||||
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Key.DirectionDown -> {
|
||||
// Move channel focus down
|
||||
val newFocusedChannelIndex = (focusedChannelIndex + 1)
|
||||
if (newFocusedChannelIndex >= channels.size) {
|
||||
// If trying to move below the final channel, then move focus out of the grid
|
||||
focusManager.moveFocus(FocusDirection.Down)
|
||||
null
|
||||
} else if (channelColumnFocused) {
|
||||
// If focused on the channel column, move down a channel
|
||||
focusedChannelIndex =
|
||||
newFocusedChannelIndex.coerceAtMost(channels.size - 1)
|
||||
programsBeforeChannel.get(focusedChannelIndex)
|
||||
} else {
|
||||
// Otherwise, moving to a new row
|
||||
focusedChannelIndex =
|
||||
newFocusedChannelIndex.coerceAtMost(channels.size - 1)
|
||||
// Get the new row/channel's programs
|
||||
val channelId = channels[focusedChannelIndex].id
|
||||
val pro = programs[channelId].orEmpty()
|
||||
// When the currently focused program starts
|
||||
val start = programList[focusedProgramIndex].startHours
|
||||
// Find the first program where the start time (of the previously focused program) is in the middle of a program
|
||||
val pIndex =
|
||||
pro.indexOfFirst { start in (it.startHours..<it.endHours) }
|
||||
if (pIndex >= 0) {
|
||||
// Found one, so focus on it
|
||||
// Get the sum of all of the previous channels' program sizes, plus the index found to convert a relative program index into absolute
|
||||
programsBeforeChannel.get(focusedChannelIndex) + pIndex
|
||||
} else {
|
||||
// Didn't find one, probably due to missing data
|
||||
// So now first the first one that starts after the previously focused program
|
||||
val pIndex = pro.indexOfFirst { it.startHours >= start }
|
||||
if (pIndex >= 0) {
|
||||
// Found one, so focus on it
|
||||
programsBeforeChannel.get(focusedChannelIndex) + pIndex
|
||||
} else {
|
||||
// Did not find one, so focus on the final program in the list
|
||||
programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
|
||||
if (channelColumnFocused) {
|
||||
val channel = channels[focusedChannelIndex]
|
||||
Timber.v("Clicked on %s", channel)
|
||||
onClickChannel.invoke(focusedChannelIndex, channel)
|
||||
} else {
|
||||
val program = programList[focusedProgramIndex]
|
||||
Timber.v("Clicked on %s", program)
|
||||
onClickProgram.invoke(focusedProgramIndex, program)
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (newIndex != null) {
|
||||
// Timber.v("newIndex=$newIndex")
|
||||
if (newIndex >= 0) {
|
||||
val before = programsBeforeChannel.get(focusedChannelIndex)
|
||||
val max =
|
||||
before + channels[focusedChannelIndex].let { programs[it.id]!!.size } - 1
|
||||
val index = newIndex.coerceIn(before, max)
|
||||
scope.launch(ExceptionHandler()) {
|
||||
focusedProgramIndex = index
|
||||
state.animateToProgram(index, Alignment.Center)
|
||||
}
|
||||
return@onPreviewKeyEvent true
|
||||
} else {
|
||||
Timber.w("newIndex is <0: $newIndex")
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
}
|
||||
return@onPreviewKeyEvent false
|
||||
},
|
||||
) {
|
||||
guideStartHour = 0f
|
||||
currentTime(
|
||||
layoutInfo = {
|
||||
ProgramGuideItem.CurrentTime(
|
||||
hoursBetween(start, LocalDateTime.now()),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Surface(
|
||||
colors = SurfaceDefaults.colors(MaterialTheme.colorScheme.tertiary.copy(alpha = .25f)),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
// Empty
|
||||
}
|
||||
}
|
||||
timeline(
|
||||
count = MAX_HOURS.toInt(),
|
||||
layoutInfo = { index ->
|
||||
ProgramGuideItem.Timeline(
|
||||
startHour = index.toFloat(),
|
||||
endHour = index + 1f,
|
||||
)
|
||||
},
|
||||
) { index ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
val differentDay =
|
||||
start.toLocalDate() !=
|
||||
start
|
||||
.plusHours(index.toLong())
|
||||
.toLocalDate()
|
||||
val time =
|
||||
DateUtils.formatDateTime(
|
||||
context,
|
||||
start
|
||||
.plusHours(index.toLong())
|
||||
.toInstant(OffsetDateTime.now().offset)
|
||||
.epochSecond * 1000,
|
||||
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
|
||||
)
|
||||
Text(
|
||||
text = time.toString(),
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.background),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
programs(
|
||||
count = programList.size,
|
||||
layoutInfo = { programIndex ->
|
||||
val program = programList[programIndex]
|
||||
val channelIndex = channels.indexOfFirst { it.id == program.channelId }
|
||||
ProgramGuideItem.Program(channelIndex, program.startHours, program.endHours)
|
||||
},
|
||||
) { programIndex ->
|
||||
val program = programList[programIndex]
|
||||
val focused =
|
||||
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
|
||||
val background =
|
||||
if (focused) {
|
||||
MaterialTheme.colorScheme.inverseSurface
|
||||
} else {
|
||||
program.category?.color
|
||||
?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
|
||||
}
|
||||
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
// .scale(if (focused) 1.1f else 1f)
|
||||
.padding(2.dp)
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
background,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = program.name ?: program.id.toString(),
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.enableMarquee(focused),
|
||||
)
|
||||
listOfNotNull(
|
||||
program.seasonEpisode?.let { "S${it.season} E${it.episode}" },
|
||||
program.subtitle,
|
||||
).joinToString(" - ")
|
||||
.ifBlank { null }
|
||||
?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = textColor,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
RecordingMarker(
|
||||
isRecording = program.isRecording,
|
||||
isSeriesRecording = program.isSeriesRecording,
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
channels(
|
||||
count = channels.size,
|
||||
layoutInfo = { channelIndex ->
|
||||
ProgramGuideItem.Channel(channelIndex)
|
||||
},
|
||||
) { channelIndex ->
|
||||
val channel = channels[channelIndex]
|
||||
val focused =
|
||||
gridHasFocus && channelColumnFocused && focusedChannelIndex == channelIndex
|
||||
val background =
|
||||
if (focused) {
|
||||
MaterialTheme.colorScheme.inverseSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
background,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(4.dp)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Text(
|
||||
text = channel.number ?: channel.name ?: channelIndex.toString(),
|
||||
color = textColor,
|
||||
modifier = Modifier,
|
||||
)
|
||||
AsyncImage(
|
||||
model = channel.imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxHeight(.66f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
topCorner {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (loading) {
|
||||
CircularProgress(
|
||||
Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.background.copy(alpha = .5f),
|
||||
shape = CircleShape,
|
||||
).size(64.dp)
|
||||
.padding(16.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@ import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
|||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -21,10 +20,12 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
|
|
@ -169,15 +170,30 @@ class HomeViewModel
|
|||
val rows =
|
||||
latestMediaIncludes
|
||||
.mapNotNull { viewId -> views.items.firstOrNull { it.id == viewId } }
|
||||
.filter { it.collectionType in supportedCollectionTypes }
|
||||
.filter { it.collectionType in supportedLatestCollectionTypes }
|
||||
.map { view ->
|
||||
val title =
|
||||
view.name?.let { "Recently Added in $it" }
|
||||
if (view.collectionType == CollectionType.LIVETV) {
|
||||
"Recently Recorded"
|
||||
} else {
|
||||
view.name?.let { "Recently Added in $it" }
|
||||
}
|
||||
val viewId =
|
||||
if (view.collectionType == CollectionType.LIVETV) {
|
||||
api.liveTvApi
|
||||
.getRecordingFolders(
|
||||
userId = user.id,
|
||||
).content.items
|
||||
.firstOrNull()
|
||||
?.id
|
||||
} else {
|
||||
view.id
|
||||
}
|
||||
val request =
|
||||
GetLatestMediaRequest(
|
||||
fields = SlimItemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = view.id,
|
||||
parentId = viewId,
|
||||
groupItems = true,
|
||||
limit = limit,
|
||||
isPlayed = null, // Server will handle user's preference
|
||||
|
|
@ -196,3 +212,10 @@ class HomeViewModel
|
|||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
val supportedLatestCollectionTypes =
|
||||
setOf(
|
||||
CollectionType.MOVIES,
|
||||
CollectionType.TVSHOWS,
|
||||
CollectionType.LIVETV,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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
|
||||
|
|
@ -24,6 +25,7 @@ import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
|||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Chose the page for the [Destination]
|
||||
|
|
@ -144,15 +146,35 @@ fun DestinationContent(
|
|||
)
|
||||
|
||||
BaseItemKind.USER_VIEW ->
|
||||
when (destination.item?.data?.collectionType) {
|
||||
CollectionType.LIVETV ->
|
||||
CollectionFolderLiveTv(
|
||||
preferences,
|
||||
destination,
|
||||
modifier,
|
||||
)
|
||||
|
||||
else ->
|
||||
CollectionFolderGeneric(
|
||||
preferences,
|
||||
destination.itemId,
|
||||
destination.item,
|
||||
true,
|
||||
modifier,
|
||||
)
|
||||
}
|
||||
|
||||
BaseItemKind.FOLDER ->
|
||||
CollectionFolderGeneric(
|
||||
preferences,
|
||||
destination.itemId,
|
||||
destination.item,
|
||||
true,
|
||||
false,
|
||||
modifier,
|
||||
)
|
||||
|
||||
else -> {
|
||||
Timber.w("Unsupported item type: ${destination.type}")
|
||||
Text("Unsupported item type: ${destination.type}")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -403,6 +404,7 @@ fun NavigationDrawerScope.NavItem(
|
|||
containerColor: Color = Color.Unspecified,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val useFont = library !is ServerNavDrawerItem || library.type != CollectionType.LIVETV
|
||||
val icon =
|
||||
when (library) {
|
||||
NavDrawerItem.Favorites -> R.string.fa_heart
|
||||
|
|
@ -413,7 +415,7 @@ fun NavigationDrawerScope.NavItem(
|
|||
CollectionType.MOVIES -> R.string.fa_film
|
||||
CollectionType.TVSHOWS -> R.string.fa_tv
|
||||
CollectionType.HOMEVIDEOS -> R.string.fa_video
|
||||
CollectionType.LIVETV -> R.string.fa_tv
|
||||
CollectionType.LIVETV -> R.drawable.gf_dvr
|
||||
CollectionType.MUSIC -> R.string.fa_music
|
||||
CollectionType.BOXSETS -> R.string.fa_open_folder
|
||||
CollectionType.PLAYLISTS -> R.string.fa_list_ul
|
||||
|
|
@ -437,13 +439,20 @@ fun NavigationDrawerScope.NavItem(
|
|||
),
|
||||
leadingContent = {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringResource(icon),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontAwesome,
|
||||
color = color,
|
||||
)
|
||||
if (useFont) {
|
||||
Text(
|
||||
text = stringResource(icon),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontAwesome,
|
||||
color = color,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
|||
import com.github.damontecres.wholphin.ui.nav.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.seekBack
|
||||
import com.github.damontecres.wholphin.ui.seekForward
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
||||
|
|
@ -224,6 +225,7 @@ class PlaybackViewModel
|
|||
)
|
||||
return@withContext false
|
||||
}
|
||||
val isLiveTv = base.type == BaseItemKind.TV_CHANNEL
|
||||
|
||||
dto = base
|
||||
val title =
|
||||
|
|
@ -324,7 +326,7 @@ class PlaybackViewModel
|
|||
rowId = -1,
|
||||
userId = -1,
|
||||
itemId = base.id,
|
||||
sourceId = mediaSource.id?.toUUIDOrNull(),
|
||||
sourceId = if (!isLiveTv) mediaSource.id?.toUUIDOrNull() else null,
|
||||
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
|
||||
subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED,
|
||||
)
|
||||
|
|
@ -394,12 +396,16 @@ class PlaybackViewModel
|
|||
subtitleStreamIndex = subtitleIndex,
|
||||
allowVideoStreamCopy = true,
|
||||
allowAudioStreamCopy = true,
|
||||
autoOpenLiveStream = false,
|
||||
autoOpenLiveStream = true,
|
||||
mediaSourceId = currentItemPlayback.sourceId?.toServerString(),
|
||||
alwaysBurnInSubtitleWhenTranscoding = null,
|
||||
maxStreamingBitrate = maxBitrate.toInt(),
|
||||
),
|
||||
)
|
||||
if (response.errorCode != null) {
|
||||
loading.setValueOnMain(LoadingState.Error(response.errorCode?.serialName))
|
||||
return@withContext
|
||||
}
|
||||
val source = response.mediaSources.firstOrNull()
|
||||
source?.let { source ->
|
||||
val mediaUrl =
|
||||
|
|
@ -462,6 +468,7 @@ class PlaybackViewModel
|
|||
listOf(),
|
||||
playMethod = transcodeType,
|
||||
playSessionId = response.playSessionId,
|
||||
liveStreamId = source.liveStreamId,
|
||||
)
|
||||
val itemPlayback =
|
||||
currentItemPlayback.copy(
|
||||
|
|
@ -780,6 +787,7 @@ data class CurrentPlayback(
|
|||
val tracks: List<TrackSupport>,
|
||||
val playMethod: PlayMethod,
|
||||
val playSessionId: String?,
|
||||
val liveStreamId: String?,
|
||||
)
|
||||
|
||||
val Format.idAsInt: Int?
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,8 +19,14 @@ val supportItemKinds =
|
|||
BaseItemKind.VIDEO,
|
||||
BaseItemKind.SEASON,
|
||||
BaseItemKind.COLLECTION_FOLDER,
|
||||
BaseItemKind.FOLDER,
|
||||
BaseItemKind.USER_VIEW,
|
||||
BaseItemKind.TRAILER,
|
||||
BaseItemKind.TV_CHANNEL,
|
||||
BaseItemKind.TV_PROGRAM,
|
||||
BaseItemKind.LIVE_TV_CHANNEL,
|
||||
BaseItemKind.LIVE_TV_PROGRAM,
|
||||
BaseItemKind.RECORDING,
|
||||
)
|
||||
|
||||
val supportedCollectionTypes =
|
||||
|
|
@ -30,4 +36,5 @@ val supportedCollectionTypes =
|
|||
CollectionType.HOMEVIDEOS,
|
||||
CollectionType.PLAYLISTS,
|
||||
CollectionType.BOXSETS,
|
||||
CollectionType.LIVETV,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ class TrackActivityPlaybackListener(
|
|||
isMuted = false,
|
||||
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
|
||||
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
|
||||
liveStreamId = playback.liveStreamId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -78,6 +79,7 @@ class TrackActivityPlaybackListener(
|
|||
positionTicks = position,
|
||||
failed = false,
|
||||
playSessionId = playback.playSessionId,
|
||||
liveStreamId = playback.liveStreamId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -114,6 +116,7 @@ class TrackActivityPlaybackListener(
|
|||
playSessionId = playback.playSessionId,
|
||||
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
|
||||
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
|
||||
liveStreamId = playback.liveStreamId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
5
app/src/main/res/drawable/gf_dvr.xml
Normal file
5
app/src/main/res/drawable/gf_dvr.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="64dp" android:viewportHeight="960" android:viewportWidth="960" android:width="64dp">
|
||||
|
||||
<path android:fillColor="#5f6368" android:pathData="M279.98,553q14.02,0 23.52,-9.48t9.5,-23.5q0,-14.02 -9.48,-23.52t-23.5,-9.5q-14.02,0 -23.52,9.48t-9.5,23.5q0,14.02 9.48,23.52t23.5,9.5ZM279.98,393q14.02,0 23.52,-9.48t9.5,-23.5q0,-14.02 -9.48,-23.52t-23.5,-9.5q-14.02,0 -23.52,9.48t-9.5,23.5q0,14.02 9.48,23.52t23.5,9.5ZM360,550h360v-60L360,490v60ZM360,390h360v-60L360,330v60ZM330,840v-80L140,760q-24,0 -42,-18t-18,-42v-520q0,-24 18,-42t42,-18h680q24,0 42,18t18,42v520q0,24 -18,42t-42,18L630,760v80L330,840ZM140,700h680v-520L140,180v520ZM140,700v-520,520Z"/>
|
||||
|
||||
</vector>
|
||||
|
|
@ -10,6 +10,7 @@ appcompat = "1.7.1"
|
|||
composeBom = "2025.10.00"
|
||||
compose-runtime = "1.9.3"
|
||||
multiplatformMarkdownRenderer = "0.37.0"
|
||||
programguide = "1.6.0"
|
||||
timber = "5.0.1"
|
||||
tvFoundation = "1.0.0-alpha12"
|
||||
tvMaterial = "1.0.1"
|
||||
|
|
@ -59,6 +60,7 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt"
|
|||
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
||||
multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
|
||||
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
|
||||
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