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.aboutlibraries.compose.m3)
|
||||||
implementation(libs.multiplatform.markdown.renderer)
|
implementation(libs.multiplatform.markdown.renderer)
|
||||||
implementation(libs.multiplatform.markdown.renderer.m3)
|
implementation(libs.multiplatform.markdown.renderer.m3)
|
||||||
|
implementation(libs.programguide)
|
||||||
|
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ sealed class AppColors private constructor() {
|
||||||
val TransparentBlack25 = Color(0x40000000)
|
val TransparentBlack25 = Color(0x40000000)
|
||||||
val TransparentBlack50 = Color(0x80000000)
|
val TransparentBlack50 = Color(0x80000000)
|
||||||
val TransparentBlack75 = Color(0xBF000000)
|
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.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
@ -21,10 +20,12 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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.itemsApi
|
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.tvShowsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
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.UserDto
|
||||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
|
|
@ -169,15 +170,30 @@ class HomeViewModel
|
||||||
val rows =
|
val rows =
|
||||||
latestMediaIncludes
|
latestMediaIncludes
|
||||||
.mapNotNull { viewId -> views.items.firstOrNull { it.id == viewId } }
|
.mapNotNull { viewId -> views.items.firstOrNull { it.id == viewId } }
|
||||||
.filter { it.collectionType in supportedCollectionTypes }
|
.filter { it.collectionType in supportedLatestCollectionTypes }
|
||||||
.map { view ->
|
.map { view ->
|
||||||
val title =
|
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 =
|
val request =
|
||||||
GetLatestMediaRequest(
|
GetLatestMediaRequest(
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
imageTypeLimit = 1,
|
imageTypeLimit = 1,
|
||||||
parentId = view.id,
|
parentId = viewId,
|
||||||
groupItems = true,
|
groupItems = true,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
isPlayed = null, // Server will handle user's preference
|
isPlayed = null, // Server will handle user's preference
|
||||||
|
|
@ -196,3 +212,10 @@ class HomeViewModel
|
||||||
return rows
|
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.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
|
||||||
|
|
@ -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.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chose the page for the [Destination]
|
* Chose the page for the [Destination]
|
||||||
|
|
@ -144,15 +146,35 @@ fun DestinationContent(
|
||||||
)
|
)
|
||||||
|
|
||||||
BaseItemKind.USER_VIEW ->
|
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(
|
CollectionFolderGeneric(
|
||||||
preferences,
|
preferences,
|
||||||
destination.itemId,
|
destination.itemId,
|
||||||
destination.item,
|
destination.item,
|
||||||
true,
|
false,
|
||||||
modifier,
|
modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
|
Timber.w("Unsupported item type: ${destination.type}")
|
||||||
Text("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.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -403,6 +404,7 @@ fun NavigationDrawerScope.NavItem(
|
||||||
containerColor: Color = Color.Unspecified,
|
containerColor: Color = Color.Unspecified,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val useFont = library !is ServerNavDrawerItem || library.type != CollectionType.LIVETV
|
||||||
val icon =
|
val icon =
|
||||||
when (library) {
|
when (library) {
|
||||||
NavDrawerItem.Favorites -> R.string.fa_heart
|
NavDrawerItem.Favorites -> R.string.fa_heart
|
||||||
|
|
@ -413,7 +415,7 @@ fun NavigationDrawerScope.NavItem(
|
||||||
CollectionType.MOVIES -> R.string.fa_film
|
CollectionType.MOVIES -> R.string.fa_film
|
||||||
CollectionType.TVSHOWS -> R.string.fa_tv
|
CollectionType.TVSHOWS -> R.string.fa_tv
|
||||||
CollectionType.HOMEVIDEOS -> R.string.fa_video
|
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.MUSIC -> R.string.fa_music
|
||||||
CollectionType.BOXSETS -> R.string.fa_open_folder
|
CollectionType.BOXSETS -> R.string.fa_open_folder
|
||||||
CollectionType.PLAYLISTS -> R.string.fa_list_ul
|
CollectionType.PLAYLISTS -> R.string.fa_list_ul
|
||||||
|
|
@ -437,13 +439,20 @@ fun NavigationDrawerScope.NavItem(
|
||||||
),
|
),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
if (useFont) {
|
||||||
text = stringResource(icon),
|
Text(
|
||||||
textAlign = TextAlign.Center,
|
text = stringResource(icon),
|
||||||
fontSize = 16.sp,
|
textAlign = TextAlign.Center,
|
||||||
fontFamily = FontAwesome,
|
fontSize = 16.sp,
|
||||||
color = color,
|
fontFamily = FontAwesome,
|
||||||
)
|
color = color,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(icon),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
trailingContent = {
|
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.nav.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.seekBack
|
import com.github.damontecres.wholphin.ui.seekBack
|
||||||
import com.github.damontecres.wholphin.ui.seekForward
|
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.showToast
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
||||||
|
|
@ -224,6 +225,7 @@ class PlaybackViewModel
|
||||||
)
|
)
|
||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
|
val isLiveTv = base.type == BaseItemKind.TV_CHANNEL
|
||||||
|
|
||||||
dto = base
|
dto = base
|
||||||
val title =
|
val title =
|
||||||
|
|
@ -324,7 +326,7 @@ class PlaybackViewModel
|
||||||
rowId = -1,
|
rowId = -1,
|
||||||
userId = -1,
|
userId = -1,
|
||||||
itemId = base.id,
|
itemId = base.id,
|
||||||
sourceId = mediaSource.id?.toUUIDOrNull(),
|
sourceId = if (!isLiveTv) mediaSource.id?.toUUIDOrNull() else null,
|
||||||
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
|
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
|
||||||
subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED,
|
subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED,
|
||||||
)
|
)
|
||||||
|
|
@ -394,12 +396,16 @@ class PlaybackViewModel
|
||||||
subtitleStreamIndex = subtitleIndex,
|
subtitleStreamIndex = subtitleIndex,
|
||||||
allowVideoStreamCopy = true,
|
allowVideoStreamCopy = true,
|
||||||
allowAudioStreamCopy = true,
|
allowAudioStreamCopy = true,
|
||||||
autoOpenLiveStream = false,
|
autoOpenLiveStream = true,
|
||||||
mediaSourceId = currentItemPlayback.sourceId?.toServerString(),
|
mediaSourceId = currentItemPlayback.sourceId?.toServerString(),
|
||||||
alwaysBurnInSubtitleWhenTranscoding = null,
|
alwaysBurnInSubtitleWhenTranscoding = null,
|
||||||
maxStreamingBitrate = maxBitrate.toInt(),
|
maxStreamingBitrate = maxBitrate.toInt(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if (response.errorCode != null) {
|
||||||
|
loading.setValueOnMain(LoadingState.Error(response.errorCode?.serialName))
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
val source = response.mediaSources.firstOrNull()
|
val source = response.mediaSources.firstOrNull()
|
||||||
source?.let { source ->
|
source?.let { source ->
|
||||||
val mediaUrl =
|
val mediaUrl =
|
||||||
|
|
@ -462,6 +468,7 @@ class PlaybackViewModel
|
||||||
listOf(),
|
listOf(),
|
||||||
playMethod = transcodeType,
|
playMethod = transcodeType,
|
||||||
playSessionId = response.playSessionId,
|
playSessionId = response.playSessionId,
|
||||||
|
liveStreamId = source.liveStreamId,
|
||||||
)
|
)
|
||||||
val itemPlayback =
|
val itemPlayback =
|
||||||
currentItemPlayback.copy(
|
currentItemPlayback.copy(
|
||||||
|
|
@ -780,6 +787,7 @@ data class CurrentPlayback(
|
||||||
val tracks: List<TrackSupport>,
|
val tracks: List<TrackSupport>,
|
||||||
val playMethod: PlayMethod,
|
val playMethod: PlayMethod,
|
||||||
val playSessionId: String?,
|
val playSessionId: String?,
|
||||||
|
val liveStreamId: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
val Format.idAsInt: Int?
|
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.Response
|
||||||
import org.jellyfin.sdk.api.client.extensions.genresApi
|
import org.jellyfin.sdk.api.client.extensions.genresApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
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.playlistsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
|
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
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.GetEpisodesRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
|
@ -317,3 +319,22 @@ val GetGenresRequestHandler =
|
||||||
request: GetGenresRequest,
|
request: GetGenresRequest,
|
||||||
): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request)
|
): 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.VIDEO,
|
||||||
BaseItemKind.SEASON,
|
BaseItemKind.SEASON,
|
||||||
BaseItemKind.COLLECTION_FOLDER,
|
BaseItemKind.COLLECTION_FOLDER,
|
||||||
|
BaseItemKind.FOLDER,
|
||||||
BaseItemKind.USER_VIEW,
|
BaseItemKind.USER_VIEW,
|
||||||
BaseItemKind.TRAILER,
|
BaseItemKind.TRAILER,
|
||||||
|
BaseItemKind.TV_CHANNEL,
|
||||||
|
BaseItemKind.TV_PROGRAM,
|
||||||
|
BaseItemKind.LIVE_TV_CHANNEL,
|
||||||
|
BaseItemKind.LIVE_TV_PROGRAM,
|
||||||
|
BaseItemKind.RECORDING,
|
||||||
)
|
)
|
||||||
|
|
||||||
val supportedCollectionTypes =
|
val supportedCollectionTypes =
|
||||||
|
|
@ -30,4 +36,5 @@ val supportedCollectionTypes =
|
||||||
CollectionType.HOMEVIDEOS,
|
CollectionType.HOMEVIDEOS,
|
||||||
CollectionType.PLAYLISTS,
|
CollectionType.PLAYLISTS,
|
||||||
CollectionType.BOXSETS,
|
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,
|
isMuted = false,
|
||||||
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
|
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
|
||||||
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
|
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
|
||||||
|
liveStreamId = playback.liveStreamId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +79,7 @@ class TrackActivityPlaybackListener(
|
||||||
positionTicks = position,
|
positionTicks = position,
|
||||||
failed = false,
|
failed = false,
|
||||||
playSessionId = playback.playSessionId,
|
playSessionId = playback.playSessionId,
|
||||||
|
liveStreamId = playback.liveStreamId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +116,7 @@ class TrackActivityPlaybackListener(
|
||||||
playSessionId = playback.playSessionId,
|
playSessionId = playback.playSessionId,
|
||||||
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
|
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
|
||||||
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
|
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"
|
composeBom = "2025.10.00"
|
||||||
compose-runtime = "1.9.3"
|
compose-runtime = "1.9.3"
|
||||||
multiplatformMarkdownRenderer = "0.37.0"
|
multiplatformMarkdownRenderer = "0.37.0"
|
||||||
|
programguide = "1.6.0"
|
||||||
timber = "5.0.1"
|
timber = "5.0.1"
|
||||||
tvFoundation = "1.0.0-alpha12"
|
tvFoundation = "1.0.0-alpha12"
|
||||||
tvMaterial = "1.0.1"
|
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" }
|
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 = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
|
||||||
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", 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" }
|
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-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" }
|
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