mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Enhancements to live TV guide/EPG (#1393)
## Description This PR has some UI/UX updates for live tv epg/guide. - Show program images, if provided by the EPG source - Show channel name under logo - Show & set favorite channels - Adjusts the header to match the rest of the app (font, padding, etc) Additionally, navigation around the guide has been rewritten. It is more efficient and feels a bit more smooth to scroll around. ### Related issues Closes #529 ### Testing Emulator mostly ## Screenshots <img width="1280" height="720" alt="epg_new Large" src="https://github.com/user-attachments/assets/c53c01be-9209-4c59-82cb-47f268811d3e" /> ## AI or LLM usage None
This commit is contained in:
parent
87a109c6b6
commit
da7f7048e2
7 changed files with 748 additions and 715 deletions
|
|
@ -142,7 +142,6 @@ fun CollectionFolderLiveTv(
|
||||||
when (selectedTabIndex) {
|
when (selectedTabIndex) {
|
||||||
0 -> {
|
0 -> {
|
||||||
TvGuideGrid(
|
TvGuideGrid(
|
||||||
true,
|
|
||||||
onRowPosition = {
|
onRowPosition = {
|
||||||
showHeader = it <= 0
|
showHeader = it <= 0
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,26 @@ import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
|
import androidx.tv.material3.LocalContentColor
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.contentColorFor
|
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
|
@ -30,20 +35,11 @@ import java.time.LocalDateTime
|
||||||
fun Program(
|
fun Program(
|
||||||
guideStart: LocalDateTime,
|
guideStart: LocalDateTime,
|
||||||
program: TvProgram,
|
program: TvProgram,
|
||||||
focused: Boolean,
|
|
||||||
colorCode: Boolean,
|
colorCode: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: (() -> Unit)?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val background =
|
|
||||||
if (focused) {
|
|
||||||
MaterialTheme.colorScheme.inverseSurface
|
|
||||||
} else if (colorCode) {
|
|
||||||
program.category?.color
|
|
||||||
?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
|
|
||||||
}
|
|
||||||
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
|
|
||||||
val startedBeforeGuide = program.start.isBefore(guideStart)
|
val startedBeforeGuide = program.start.isBefore(guideStart)
|
||||||
val shape =
|
val shape =
|
||||||
remember(startedBeforeGuide) {
|
remember(startedBeforeGuide) {
|
||||||
|
|
@ -60,15 +56,28 @@ fun Program(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val title = program.name ?: program.id.toString()
|
val title = program.name ?: program.id.toString()
|
||||||
Box(
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(1f, 1f, .95f),
|
||||||
|
colors =
|
||||||
|
ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor =
|
||||||
|
if (colorCode) {
|
||||||
|
program.category?.color
|
||||||
|
?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
|
||||||
|
},
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.inverseSurface,
|
||||||
|
focusedContentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||||
|
),
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.padding(2.dp)
|
.padding(2.dp)
|
||||||
.fillMaxSize()
|
.fillMaxSize(),
|
||||||
.background(
|
|
||||||
color = background,
|
|
||||||
shape = shape,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
@ -77,7 +86,7 @@ fun Program(
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.fa_caret_left),
|
text = stringResource(R.string.fa_caret_left),
|
||||||
fontFamily = FontAwesome,
|
fontFamily = FontAwesome,
|
||||||
color = textColor,
|
color = LocalContentColor.current,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -94,21 +103,23 @@ fun Program(
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
color = textColor,
|
color = LocalContentColor.current,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
|
val subtitle =
|
||||||
|
remember(program) {
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
program.seasonEpisode?.let { "S${it.season} E${it.episode}" },
|
program.seasonEpisode?.let { "S${it.season} E${it.episode}" },
|
||||||
program.subtitle,
|
program.subtitle,
|
||||||
).joinToString(" - ")
|
).joinToString(" - ").ifBlank { null }
|
||||||
.ifBlank { null }
|
}
|
||||||
?.let {
|
subtitle?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
color = textColor,
|
color = LocalContentColor.current,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
|
|
@ -128,43 +139,84 @@ fun Program(
|
||||||
fun Channel(
|
fun Channel(
|
||||||
channel: TvChannel,
|
channel: TvChannel,
|
||||||
channelIndex: Int,
|
channelIndex: Int,
|
||||||
focused: Boolean,
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val background =
|
Surface(
|
||||||
if (focused) {
|
onClick = onClick,
|
||||||
MaterialTheme.colorScheme.inverseSurface
|
onLongClick = onLongClick,
|
||||||
} else {
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)),
|
||||||
MaterialTheme.colorScheme.surface
|
scale = ClickableSurfaceDefaults.scale(1f, 1f, .95f),
|
||||||
}
|
colors =
|
||||||
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
|
ClickableSurfaceDefaults.colors(
|
||||||
Box(
|
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.inverseSurface,
|
||||||
|
),
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxSize()
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.background(
|
.padding(2.dp)
|
||||||
background,
|
.fillMaxSize(),
|
||||||
shape = RoundedCornerShape(4.dp),
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Box(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(4.dp)
|
.padding(4.dp)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = channel.number ?: channel.name ?: channelIndex.toString(),
|
text = channel.number ?: "",
|
||||||
color = textColor,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var showImage by remember { mutableStateOf(true) }
|
||||||
|
if (showImage) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = channel.imageUrl,
|
model = channel.imageUrl,
|
||||||
contentDescription = null,
|
contentDescription = channel.name,
|
||||||
modifier = Modifier.fillMaxHeight(.66f),
|
modifier = Modifier.weight(1f),
|
||||||
|
onError = {
|
||||||
|
showImage = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
channel.name?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontSize = if (showImage) 10.sp else 16.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (channel.favorite) {
|
||||||
|
Text(
|
||||||
|
color = colorResource(android.R.color.holo_red_light),
|
||||||
|
text = stringResource(R.string.fa_heart),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(2.dp)
|
||||||
|
.align(Alignment.TopEnd),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
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
|
||||||
|
|
@ -159,14 +160,13 @@ fun DvrSchedule(
|
||||||
)
|
)
|
||||||
showDialog?.let { item ->
|
showDialog?.let { item ->
|
||||||
ProgramDialog(
|
ProgramDialog(
|
||||||
item = item,
|
state = DataLoadingState.Success(item),
|
||||||
canRecord = true,
|
canRecord = true,
|
||||||
loading = LoadingState.Success,
|
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
showDialog = null
|
showDialog = null
|
||||||
},
|
},
|
||||||
onWatch = {
|
onWatch = { program ->
|
||||||
item.data.channelId?.let {
|
program.data.channelId?.let {
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
itemId = it,
|
itemId = it,
|
||||||
|
|
@ -175,12 +175,13 @@ fun DvrSchedule(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRecord = {
|
onRecord = { _, _ ->
|
||||||
// no-op
|
// no-op
|
||||||
},
|
},
|
||||||
onCancelRecord = { series ->
|
onCancelRecord = { program, series ->
|
||||||
showDialog = null
|
showDialog = null
|
||||||
val timerId = if (series) item.data.seriesTimerId else item.data.timerId
|
val timerId =
|
||||||
|
if (series) program.data.seriesTimerId else program.data.timerId
|
||||||
if (timerId != null) {
|
if (timerId != null) {
|
||||||
viewModel.cancelRecording(timerId, series)
|
viewModel.cancelRecording(timerId, series)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
|
@ -14,35 +13,36 @@ import com.github.damontecres.wholphin.WholphinApplication
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
|
||||||
import com.github.damontecres.wholphin.ui.dot
|
import com.github.damontecres.wholphin.ui.dot
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
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.LoadingState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.GetProgramsDto
|
import org.jellyfin.sdk.model.api.GetProgramsDto
|
||||||
|
|
@ -78,24 +78,22 @@ class LiveTvViewModel
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val preferences: DataStore<AppPreferences>,
|
val preferences: DataStore<AppPreferences>,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
|
||||||
|
|
||||||
private lateinit var channelsIdToIndex: Map<UUID, Int>
|
private lateinit var channelsIdToIndex: Map<UUID, Int>
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
val guideTimes = MutableLiveData<List<LocalDateTime>>(buildGuideTimes())
|
private val _state = MutableStateFlow(LiveTvState())
|
||||||
val channels = MutableLiveData<List<TvChannel>>()
|
val state: StateFlow<LiveTvState> = _state
|
||||||
val channelProgramCount = mutableMapOf<UUID, Int>()
|
|
||||||
val programs = MutableLiveData<FetchedPrograms>()
|
|
||||||
|
|
||||||
val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending)
|
private val _programDialogState = MutableStateFlow(ProgramDialogState())
|
||||||
val fetchedItem = MutableLiveData<BaseItem?>(null)
|
val programDialogState: StateFlow<ProgramDialogState> = _programDialogState
|
||||||
|
|
||||||
private val range = 100
|
private val range = 100
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchDefault {
|
||||||
preferences.data
|
preferences.data
|
||||||
.map {
|
.map {
|
||||||
it.interfacePreferences.liveTvPreferences.let {
|
it.interfacePreferences.liveTvPreferences.let {
|
||||||
|
|
@ -105,37 +103,34 @@ class LiveTvViewModel
|
||||||
.debounce { 500.milliseconds }
|
.debounce { 500.milliseconds }
|
||||||
.collectLatest {
|
.collectLatest {
|
||||||
Timber.v("Init due to pref change")
|
Timber.v("Init due to pref change")
|
||||||
loading.setValueOnMain(LoadingState.Pending)
|
_state.update { LiveTvState() }
|
||||||
init()
|
init(it.first, it.second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init() {
|
private fun init(
|
||||||
val guideStart = guideTimes.value!!.first()
|
sortByRecentlyWatched: Boolean,
|
||||||
viewModelScope.launch(
|
favoriteChannelsAtBeginning: Boolean,
|
||||||
Dispatchers.IO +
|
|
||||||
LoadingExceptionHandler(
|
|
||||||
loading,
|
|
||||||
"Could not fetch channels",
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
val prefs =
|
viewModelScope.launchDefault {
|
||||||
(preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance())
|
try {
|
||||||
.interfacePreferences.liveTvPreferences
|
val guideTimes = buildGuideTimes()
|
||||||
|
_state.update { it.copy(guideTimes = guideTimes) }
|
||||||
|
val guideStart = guideTimes.first()
|
||||||
val channelData by api.liveTvApi.getLiveTvChannels(
|
val channelData by api.liveTvApi.getLiveTvChannels(
|
||||||
GetLiveTvChannelsRequest(
|
GetLiveTvChannelsRequest(
|
||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser.value?.id,
|
||||||
enableFavoriteSorting = prefs.favoriteChannelsAtBeginning,
|
enableFavoriteSorting = favoriteChannelsAtBeginning,
|
||||||
sortBy =
|
sortBy =
|
||||||
if (prefs.sortByRecentlyWatched) {
|
if (sortByRecentlyWatched) {
|
||||||
listOf(ItemSortBy.DATE_PLAYED)
|
listOf(ItemSortBy.DATE_PLAYED)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
sortOrder =
|
sortOrder =
|
||||||
if (prefs.sortByRecentlyWatched) {
|
if (sortByRecentlyWatched) {
|
||||||
SortOrder.DESCENDING
|
SortOrder.DESCENDING
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
@ -147,10 +142,12 @@ class LiveTvViewModel
|
||||||
channelData.items
|
channelData.items
|
||||||
.map {
|
.map {
|
||||||
TvChannel(
|
TvChannel(
|
||||||
it.id,
|
id = it.id,
|
||||||
it.channelNumber,
|
number = it.channelNumber,
|
||||||
it.channelName,
|
name = it.channelName ?: it.name,
|
||||||
api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY),
|
imageUrl =
|
||||||
|
imageUrlService.getItemImageUrl(it.id, ImageType.PRIMARY),
|
||||||
|
favorite = it.userData?.isFavorite == true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Timber.d("Got ${channels.size} channels")
|
Timber.d("Got ${channels.size} channels")
|
||||||
|
|
@ -161,14 +158,20 @@ class LiveTvViewModel
|
||||||
val initial = 10
|
val initial = 10
|
||||||
fetchPrograms(guideStart, channels, 0..<initial.coerceAtMost(channels.size))
|
fetchPrograms(guideStart, channels, 0..<initial.coerceAtMost(channels.size))
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
_state.update {
|
||||||
this@LiveTvViewModel.channels.value = channels
|
it.copy(
|
||||||
loading.value = LoadingState.Success
|
channels = channels,
|
||||||
|
loading = LoadingState.Success,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// Now load the full range
|
// Now load the full range
|
||||||
if (channels.size > initial) {
|
if (channels.size > initial) {
|
||||||
fetchPrograms(guideStart, channels, 0..<range.coerceAtMost(channels.size))
|
fetchPrograms(guideStart, channels, 0..<range.coerceAtMost(channels.size))
|
||||||
}
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Erroring during init")
|
||||||
|
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,14 +190,21 @@ class LiveTvViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchProgramsWithLoading(
|
private suspend fun refreshPrograms(
|
||||||
channels: List<TvChannel>,
|
channels: List<TvChannel>,
|
||||||
range: IntRange,
|
range: IntRange,
|
||||||
) {
|
) {
|
||||||
loading.setValueOnMain(LoadingState.Loading)
|
_state.update { it.copy(refreshing = true) }
|
||||||
val guideStart = guideTimes.value!!.first()
|
val guideStart = _state.value.guideTimes.first()
|
||||||
|
try {
|
||||||
fetchPrograms(guideStart, channels, range)
|
fetchPrograms(guideStart, channels, range)
|
||||||
loading.setValueOnMain(LoadingState.Success)
|
_state.update { it.copy(refreshing = false) }
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
// no-op
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching programs")
|
||||||
|
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -282,6 +292,11 @@ class LiveTvViewModel
|
||||||
isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank(),
|
isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank(),
|
||||||
isRepeat = dto.isRepeat ?: false,
|
isRepeat = dto.isRepeat ?: false,
|
||||||
category = category,
|
category = category,
|
||||||
|
imageUrl =
|
||||||
|
imageUrlService.getItemImageUrl(
|
||||||
|
dto.id,
|
||||||
|
ImageType.PRIMARY,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
if (p.startHours > 0) {
|
if (p.startHours > 0) {
|
||||||
|
|
@ -382,13 +397,12 @@ class LiveTvViewModel
|
||||||
category = ProgramCategory.FAKE,
|
category = ProgramCategory.FAKE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
programsByChannel.put(channel.id, fakePrograms)
|
programsByChannel[channel.id] = fakePrograms
|
||||||
fake.addAll(fakePrograms)
|
fake.addAll(fakePrograms)
|
||||||
}
|
}
|
||||||
|
|
||||||
programsByChannel.forEach { (channelId, programs) ->
|
val channelProgramCount = programsByChannel.map { it.key to it.value.size }.toMap()
|
||||||
channelProgramCount[channelId] = programs.size
|
|
||||||
}
|
|
||||||
val finalProgramList =
|
val finalProgramList =
|
||||||
(programsByChannel.values.flatten())
|
(programsByChannel.values.flatten())
|
||||||
.sortedWith(
|
.sortedWith(
|
||||||
|
|
@ -398,30 +412,32 @@ class LiveTvViewModel
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs")
|
Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs")
|
||||||
withContext(Dispatchers.Main) {
|
_state.update {
|
||||||
this@LiveTvViewModel.programs.value =
|
it.copy(
|
||||||
FetchedPrograms(channelIndices, finalProgramList, programsByChannel)
|
channelProgramCount = channelProgramCount,
|
||||||
|
programs =
|
||||||
|
FetchedPrograms(
|
||||||
|
channelIndices,
|
||||||
|
finalProgramList,
|
||||||
|
programsByChannel,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getItem(programId: UUID) {
|
fun fetchProgramForDialog(programId: UUID) {
|
||||||
fetchingItem.value = LoadingState.Loading
|
_programDialogState.update { it.copy(loading = DataLoadingState.Loading) }
|
||||||
viewModelScope.launchIO(LoadingExceptionHandler(fetchingItem, "Error")) {
|
viewModelScope.launchDefault {
|
||||||
|
try {
|
||||||
val result =
|
val result =
|
||||||
api.liveTvApi
|
api.liveTvApi
|
||||||
.getProgram(programId.toServerString())
|
.getProgram(programId.toServerString())
|
||||||
.content
|
.content
|
||||||
.let { BaseItem.from(it, api) }
|
.let { BaseItem(it) }
|
||||||
withContext(Dispatchers.Main) {
|
_programDialogState.update { it.copy(loading = DataLoadingState.Success(result)) }
|
||||||
fetchedItem.value = result
|
} catch (ex: Exception) {
|
||||||
fetchingItem.value = LoadingState.Success
|
Timber.e(ex, "Error fetching program $programId")
|
||||||
}
|
_programDialogState.update { it.copy(loading = DataLoadingState.Error(ex)) }
|
||||||
if (result.data.seriesTimerId != null) {
|
|
||||||
val items =
|
|
||||||
api.liveTvApi
|
|
||||||
.getPrograms(GetProgramsDto(seriesTimerId = result.data.seriesTimerId))
|
|
||||||
.content.items
|
|
||||||
Timber.v("items=$items")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -437,7 +453,9 @@ class LiveTvViewModel
|
||||||
} else {
|
} else {
|
||||||
api.liveTvApi.cancelTimer(timerId)
|
api.liveTvApi.cancelTimer(timerId)
|
||||||
}
|
}
|
||||||
fetchProgramsWithLoading(channels.value.orEmpty(), programs.value!!.range)
|
state.value.let {
|
||||||
|
refreshPrograms(it.channels, it.programs.range)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -476,7 +494,9 @@ class LiveTvViewModel
|
||||||
)
|
)
|
||||||
api.liveTvApi.createTimer(payload)
|
api.liveTvApi.createTimer(payload)
|
||||||
}
|
}
|
||||||
fetchProgramsWithLoading(channels.value.orEmpty(), programs.value!!.range)
|
state.value.let {
|
||||||
|
refreshPrograms(it.channels, it.programs.range)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -488,8 +508,9 @@ class LiveTvViewModel
|
||||||
* This determines if more programs/channels should be fetched based on the current position
|
* This determines if more programs/channels should be fetched based on the current position
|
||||||
*/
|
*/
|
||||||
fun onFocusChannel(position: RowColumn) {
|
fun onFocusChannel(position: RowColumn) {
|
||||||
channels.value?.let { channels ->
|
val state = state.value
|
||||||
val fetchedRange = programs.value!!.range
|
val channels = state.channels
|
||||||
|
val fetchedRange = state.programs.range
|
||||||
val quarter = range / 4
|
val quarter = range / 4
|
||||||
var rangeStart = fetchedRange.start + quarter
|
var rangeStart = fetchedRange.start + quarter
|
||||||
var rangeEnd = fetchedRange.last - quarter
|
var rangeEnd = fetchedRange.last - quarter
|
||||||
|
|
@ -523,10 +544,26 @@ class LiveTvViewModel
|
||||||
focusLoadingJob?.cancel()
|
focusLoadingJob?.cancel()
|
||||||
focusLoadingJob =
|
focusLoadingJob =
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
fetchProgramsWithLoading(channels, newFetchRange)
|
refreshPrograms(channels, newFetchRange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun toggleFavorite(
|
||||||
|
index: Int,
|
||||||
|
channel: TvChannel,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
favoriteWatchManager.setFavorite(channel.id, !channel.favorite)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
channels =
|
||||||
|
it.channels.toMutableList().apply {
|
||||||
|
set(index, channel.copy(favorite = !channel.favorite))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -543,11 +580,25 @@ fun hoursBetween(
|
||||||
.between(start, target)
|
.between(start, target)
|
||||||
.seconds / (60f * 60f)
|
.seconds / (60f * 60f)
|
||||||
|
|
||||||
|
data class LiveTvState(
|
||||||
|
val loading: LoadingState = LoadingState.Pending,
|
||||||
|
val refreshing: Boolean = false,
|
||||||
|
val guideTimes: List<LocalDateTime> = emptyList(),
|
||||||
|
val channels: List<TvChannel> = emptyList(),
|
||||||
|
val channelProgramCount: Map<UUID, Int> = emptyMap(),
|
||||||
|
val programs: FetchedPrograms = FetchedPrograms(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ProgramDialogState(
|
||||||
|
val loading: DataLoadingState<BaseItem> = DataLoadingState.Pending,
|
||||||
|
)
|
||||||
|
|
||||||
data class TvChannel(
|
data class TvChannel(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val number: String?,
|
val number: String?,
|
||||||
val name: String?,
|
val name: String?,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
|
val favorite: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
|
|
@ -568,6 +619,7 @@ data class TvProgram(
|
||||||
val isRepeat: Boolean,
|
val isRepeat: Boolean,
|
||||||
val category: ProgramCategory?,
|
val category: ProgramCategory?,
|
||||||
val officialRating: String? = null,
|
val officialRating: String? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
) {
|
) {
|
||||||
val isFake = category == ProgramCategory.FAKE
|
val isFake = category == ProgramCategory.FAKE
|
||||||
|
|
||||||
|
|
@ -589,27 +641,29 @@ data class TvProgram(
|
||||||
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
|
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
|
||||||
)
|
)
|
||||||
append(time)
|
append(time)
|
||||||
dot()
|
|
||||||
|
|
||||||
if (!isFake) {
|
if (!isFake) {
|
||||||
|
dot()
|
||||||
duration
|
duration
|
||||||
.roundMinutes
|
.roundMinutes
|
||||||
.toString()
|
.toString()
|
||||||
.let(::append)
|
.let(::append)
|
||||||
dot()
|
|
||||||
if (now.isAfter(start) && now.isBefore(end)) {
|
if (now.isAfter(start) && now.isBefore(end)) {
|
||||||
|
dot()
|
||||||
java.time.Duration
|
java.time.Duration
|
||||||
.between(now, end)
|
.between(now, end)
|
||||||
.toKotlinDuration()
|
.toKotlinDuration()
|
||||||
.roundMinutes
|
.roundMinutes
|
||||||
.let { append("$it left") }
|
.let { append("$it left") }
|
||||||
dot()
|
|
||||||
}
|
}
|
||||||
seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let {
|
seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let {
|
||||||
append(it)
|
|
||||||
dot()
|
dot()
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
officialRating?.let {
|
||||||
|
dot()
|
||||||
|
append(it)
|
||||||
}
|
}
|
||||||
officialRating?.let(::append)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -653,9 +707,9 @@ enum class ProgramCategory(
|
||||||
}
|
}
|
||||||
|
|
||||||
data class FetchedPrograms(
|
data class FetchedPrograms(
|
||||||
val range: IntRange,
|
val range: IntRange = 0..0,
|
||||||
val programs: List<TvProgram>,
|
val programs: List<TvProgram> = emptyList(),
|
||||||
val programsByChannel: Map<UUID, List<TvProgram>>,
|
val programsByChannel: Map<UUID, List<TvProgram>> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun LocalDateTime.roundDownToHalfHour(): LocalDateTime {
|
fun LocalDateTime.roundDownToHalfHour(): LocalDateTime {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.ui.components.Button
|
|
||||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||||
|
|
@ -42,19 +41,18 @@ import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ProgramDialog(
|
fun ProgramDialog(
|
||||||
item: BaseItem?,
|
state: DataLoadingState<BaseItem>,
|
||||||
canRecord: Boolean,
|
canRecord: Boolean,
|
||||||
loading: LoadingState,
|
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onWatch: () -> Unit,
|
onWatch: (BaseItem) -> Unit,
|
||||||
onRecord: (series: Boolean) -> Unit,
|
onRecord: (BaseItem, series: Boolean) -> Unit,
|
||||||
onCancelRecord: (series: Boolean) -> Unit,
|
onCancelRecord: (BaseItem, series: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
Dialog(
|
Dialog(
|
||||||
|
|
@ -65,7 +63,7 @@ fun ProgramDialog(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.background(
|
.background(
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp),
|
MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
).focusRequester(focusRequester),
|
).focusRequester(focusRequester),
|
||||||
) {
|
) {
|
||||||
|
|
@ -75,13 +73,13 @@ fun ProgramDialog(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
when (val st = loading) {
|
when (val st = state) {
|
||||||
is LoadingState.Error -> {
|
is DataLoadingState.Error -> {
|
||||||
ErrorMessage(st)
|
ErrorMessage(st)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
LoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
CircularProgress(
|
CircularProgress(
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -90,8 +88,8 @@ fun ProgramDialog(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
is DataLoadingState.Success<BaseItem> -> {
|
||||||
item?.let { item ->
|
val item = st.data
|
||||||
val now = LocalDateTime.now()
|
val now = LocalDateTime.now()
|
||||||
val dto = item.data
|
val dto = item.data
|
||||||
val isRecording = dto.timerId.isNotNullOrBlank()
|
val isRecording = dto.timerId.isNotNullOrBlank()
|
||||||
|
|
@ -151,7 +149,7 @@ fun ProgramDialog(
|
||||||
) {
|
) {
|
||||||
if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) {
|
if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onWatch,
|
onClick = { onWatch.invoke(item) },
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -187,9 +185,9 @@ fun ProgramDialog(
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isSeriesRecording) {
|
if (isSeriesRecording) {
|
||||||
onCancelRecord.invoke(true)
|
onCancelRecord.invoke(item, true)
|
||||||
} else {
|
} else {
|
||||||
onRecord.invoke(true)
|
onRecord.invoke(item, true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -229,9 +227,9 @@ fun ProgramDialog(
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
onCancelRecord.invoke(false)
|
onCancelRecord.invoke(item, false)
|
||||||
} else {
|
} else {
|
||||||
onRecord.invoke(false)
|
onRecord.invoke(item, false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -281,4 +279,3 @@ fun ProgramDialog(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ package com.github.damontecres.wholphin.ui.detail.livetv
|
||||||
|
|
||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -16,31 +16,26 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.graphics.Color
|
||||||
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.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
|
@ -51,10 +46,15 @@ import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.LiveTvPreferences
|
import com.github.damontecres.wholphin.preferences.LiveTvPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
|
|
@ -64,7 +64,9 @@ import eu.wewox.programguide.ProgramGuide
|
||||||
import eu.wewox.programguide.ProgramGuideDimensions
|
import eu.wewox.programguide.ProgramGuideDimensions
|
||||||
import eu.wewox.programguide.ProgramGuideItem
|
import eu.wewox.programguide.ProgramGuideItem
|
||||||
import eu.wewox.programguide.rememberSaveableProgramGuideState
|
import eu.wewox.programguide.rememberSaveableProgramGuideState
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
@ -73,29 +75,21 @@ import kotlin.math.abs
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvGuideGrid(
|
fun TvGuideGrid(
|
||||||
requestFocusAfterLoading: Boolean,
|
|
||||||
onRowPosition: (Int) -> Unit,
|
onRowPosition: (Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: LiveTvViewModel = hiltViewModel(),
|
viewModel: LiveTvViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// var firstLoad by rememberSaveable { mutableStateOf(true) }
|
val state by viewModel.state.collectAsState()
|
||||||
// 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(FetchedPrograms(0..0, listOf(), mapOf()))
|
|
||||||
// val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
|
|
||||||
// val fetchedRange by viewModel.fetchedRange.observeAsState(0..0)
|
|
||||||
val preferences by viewModel.preferences.data
|
val preferences by viewModel.preferences.data
|
||||||
.collectAsState(AppPreferences.getDefaultInstance())
|
.collectAsState(AppPreferences.getDefaultInstance())
|
||||||
val tvPrefs = preferences.interfacePreferences.liveTvPreferences
|
val tvPrefs = preferences.interfacePreferences.liveTvPreferences
|
||||||
var showViewOptions by remember { mutableStateOf(false) }
|
var showViewOptions by remember { mutableStateOf(false) }
|
||||||
when (val state = loading) {
|
var showChannelDialog by remember { mutableStateOf<Pair<Int, TvChannel>?>(null) }
|
||||||
|
|
||||||
|
when (val st = state.loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state, modifier)
|
ErrorMessage(st, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Pending,
|
LoadingState.Pending,
|
||||||
|
|
@ -107,39 +101,46 @@ fun TvGuideGrid(
|
||||||
LoadingState.Success,
|
LoadingState.Success,
|
||||||
-> {
|
-> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val guideTimes by viewModel.guideTimes.observeAsState(listOf())
|
val programDialogState =
|
||||||
val fetchedItem by viewModel.fetchedItem.observeAsState(null)
|
viewModel.programDialogState
|
||||||
val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending)
|
.collectAsState()
|
||||||
|
.value.loading
|
||||||
|
|
||||||
var showItemDialog by remember { mutableStateOf<Int?>(null) }
|
var showItemDialog by remember { mutableStateOf<Int?>(null) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val buttonFocusRequester = remember { FocusRequester() }
|
val buttonFocusRequester = remember { FocusRequester() }
|
||||||
if (requestFocusAfterLoading) {
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequester.tryRequestFocus()
|
focusRequester.tryRequestFocus()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
var focusedPosition by rememberPosition(0, 0)
|
var focusedPosition by rememberPosition(0, 0)
|
||||||
val focusedProgram =
|
val focusedProgram =
|
||||||
remember(focusedPosition) {
|
remember(focusedPosition) {
|
||||||
focusedPosition.let {
|
focusedPosition.let {
|
||||||
val channelId = channels.getOrNull(it.row)?.id
|
val channelId = state.channels.getOrNull(it.row)?.id
|
||||||
programs.programsByChannel[channelId]?.getOrNull(it.column)
|
state.programs.programsByChannel[channelId]?.getOrNull(it.column)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
if (channels.isEmpty()) {
|
if (state.channels.isEmpty()) {
|
||||||
ErrorMessage("Live TV is enabled, but no channels were found.", null)
|
ErrorMessage("Live TV is enabled, but no channels were found.", null)
|
||||||
} else {
|
} else {
|
||||||
AnimatedVisibility(tvPrefs.showHeader) {
|
AnimatedVisibility(
|
||||||
|
visible = tvPrefs.showHeader,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically(),
|
||||||
|
) {
|
||||||
TvGuideHeader(
|
TvGuideHeader(
|
||||||
program = focusedProgram,
|
program = focusedProgram,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
|
.padding(
|
||||||
.fillMaxHeight(.30f),
|
top = HeaderUtils.topPadding,
|
||||||
|
bottom = 0.dp,
|
||||||
|
start = HeaderUtils.startPadding,
|
||||||
|
).fillMaxHeight(.30f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
|
|
@ -159,18 +160,13 @@ fun TvGuideGrid(
|
||||||
}
|
}
|
||||||
TvGuideGridContent(
|
TvGuideGridContent(
|
||||||
preferences = tvPrefs,
|
preferences = tvPrefs,
|
||||||
loading = state is LoadingState.Loading,
|
refreshing = state.refreshing,
|
||||||
channels = channels,
|
channels = state.channels,
|
||||||
programs = programs,
|
programs = state.programs,
|
||||||
channelProgramCount = viewModel.channelProgramCount,
|
channelProgramCount = state.channelProgramCount,
|
||||||
guideTimes = guideTimes,
|
guideTimes = state.guideTimes,
|
||||||
onClickChannel = { index, channel ->
|
onClickChannel = { index, channel ->
|
||||||
viewModel.navigationManager.navigateTo(
|
showChannelDialog = index to channel
|
||||||
Destination.Playback(
|
|
||||||
itemId = channel.id,
|
|
||||||
positionMs = 0L,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onFocus = {
|
onFocus = {
|
||||||
focusedPosition = it
|
focusedPosition = it
|
||||||
|
|
@ -196,15 +192,14 @@ fun TvGuideGrid(
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.getItem(program.id)
|
viewModel.fetchProgramForDialog(program.id)
|
||||||
showItemDialog = index
|
showItemDialog = index
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
buttonFocusRequester = buttonFocusRequester,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.focusRequester(focusRequester),
|
.focusRequester(focusRequester),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -212,13 +207,12 @@ fun TvGuideGrid(
|
||||||
if (showItemDialog != null) {
|
if (showItemDialog != null) {
|
||||||
val onDismissRequest = { showItemDialog = null }
|
val onDismissRequest = { showItemDialog = null }
|
||||||
ProgramDialog(
|
ProgramDialog(
|
||||||
item = fetchedItem,
|
state = programDialogState,
|
||||||
canRecord = true,
|
canRecord = true,
|
||||||
loading = loadingItem,
|
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
onWatch = {
|
onWatch = {
|
||||||
onDismissRequest.invoke()
|
onDismissRequest.invoke()
|
||||||
fetchedItem?.data?.channelId?.let {
|
it.data.channelId?.let {
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
itemId = it,
|
itemId = it,
|
||||||
|
|
@ -227,22 +221,18 @@ fun TvGuideGrid(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRecord = { series ->
|
onRecord = { program, series ->
|
||||||
fetchedItem?.let {
|
|
||||||
viewModel.record(
|
viewModel.record(
|
||||||
programId = it.id,
|
programId = program.id,
|
||||||
series = series,
|
series = series,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
onDismissRequest.invoke()
|
onDismissRequest.invoke()
|
||||||
},
|
},
|
||||||
onCancelRecord = { series ->
|
onCancelRecord = { program, series ->
|
||||||
fetchedItem?.data?.let {
|
|
||||||
viewModel.cancelRecording(
|
viewModel.cancelRecording(
|
||||||
series = series,
|
series = series,
|
||||||
timerId = if (series) it.seriesTimerId else it.timerId,
|
timerId = if (series) program.data.seriesTimerId else program.data.timerId,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
onDismissRequest.invoke()
|
onDismissRequest.invoke()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -262,6 +252,40 @@ fun TvGuideGrid(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
showChannelDialog?.let { (index, channel) ->
|
||||||
|
val watchLiveStr = stringResource(R.string.watch_live)
|
||||||
|
val dialogItems =
|
||||||
|
remember {
|
||||||
|
listOf(
|
||||||
|
DialogItem(
|
||||||
|
watchLiveStr,
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
iconColor = Color.Green.copy(alpha = .8f),
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
viewModel.navigationManager.navigateTo(
|
||||||
|
Destination.Playback(
|
||||||
|
itemId = channel.id,
|
||||||
|
positionMs = 0L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DialogItem(
|
||||||
|
text = if (channel.favorite) R.string.remove_favorite else R.string.add_favorite,
|
||||||
|
iconStringRes = R.string.fa_heart,
|
||||||
|
iconColor = if (channel.favorite) Color.Red else Color.Unspecified,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
viewModel.toggleFavorite(index, channel)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DialogPopup(
|
||||||
|
onDismissRequest = { showChannelDialog = null },
|
||||||
|
params = DialogParams(false, channel.name ?: "", dialogItems),
|
||||||
|
elevation = 3.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val tvGuideDimensions =
|
val tvGuideDimensions =
|
||||||
|
|
@ -276,7 +300,7 @@ val tvGuideDimensions =
|
||||||
@Composable
|
@Composable
|
||||||
fun TvGuideGridContent(
|
fun TvGuideGridContent(
|
||||||
preferences: LiveTvPreferences,
|
preferences: LiveTvPreferences,
|
||||||
loading: Boolean,
|
refreshing: Boolean,
|
||||||
channels: List<TvChannel>,
|
channels: List<TvChannel>,
|
||||||
programs: FetchedPrograms,
|
programs: FetchedPrograms,
|
||||||
channelProgramCount: Map<UUID, Int>,
|
channelProgramCount: Map<UUID, Int>,
|
||||||
|
|
@ -284,21 +308,56 @@ fun TvGuideGridContent(
|
||||||
onClickChannel: (Int, TvChannel) -> Unit,
|
onClickChannel: (Int, TvChannel) -> Unit,
|
||||||
onClickProgram: (Int, TvProgram) -> Unit,
|
onClickProgram: (Int, TvProgram) -> Unit,
|
||||||
onFocus: (RowColumn) -> Unit,
|
onFocus: (RowColumn) -> Unit,
|
||||||
buttonFocusRequester: FocusRequester,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
val state = rememberSaveableProgramGuideState()
|
val state = rememberSaveableProgramGuideState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val guideStart = guideTimes.first()
|
val guideStart = remember(guideTimes) { guideTimes.first() }
|
||||||
|
|
||||||
var focusedItem by rememberPosition(RowColumn(0, 0))
|
var focusedItem by rememberPosition(RowColumn(0, 0))
|
||||||
val focusedChannelIndex = focusedItem.row
|
|
||||||
var focusedProgramIndex by remember { mutableIntStateOf(0) }
|
var focusedProgramIndex by remember { mutableIntStateOf(0) }
|
||||||
|
LaunchedEffect(onFocus, focusedProgramIndex) {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val program = programs.programs.getOrNull(focusedProgramIndex)
|
||||||
|
if (program != null) {
|
||||||
|
val channelIndex = channels.indexOfFirst { it.id == program.channelId }
|
||||||
|
val programsBefore =
|
||||||
|
(0..<channelIndex)
|
||||||
|
.mapNotNull {
|
||||||
|
val channel = channels[it]
|
||||||
|
channelProgramCount[channel.id]
|
||||||
|
}.sum()
|
||||||
|
val programIndex = focusedProgramIndex - programsBefore
|
||||||
|
focusedItem = RowColumn(channelIndex, programIndex)
|
||||||
|
onFocus.invoke(focusedItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val programFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
BackHandler(focusedItem.row > 0) {
|
||||||
|
scope.launch {
|
||||||
|
focusedProgramIndex = 0
|
||||||
|
state.animateToProgram(0, Alignment.Center)
|
||||||
|
programFocusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(focusedItem.column > 0) {
|
||||||
|
scope.launch {
|
||||||
|
val programIndex =
|
||||||
|
(0..<focusedItem.row)
|
||||||
|
.mapNotNull {
|
||||||
|
val channel = channels[it]
|
||||||
|
channelProgramCount[channel.id]
|
||||||
|
}.sum()
|
||||||
|
focusedProgramIndex = programIndex
|
||||||
|
state.animateToProgram(programIndex, Alignment.Center)
|
||||||
|
programFocusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
ProgramGuide(
|
ProgramGuide(
|
||||||
state = state,
|
state = state,
|
||||||
|
|
@ -306,208 +365,8 @@ fun TvGuideGridContent(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.onFocusChanged {
|
.focusProperties {
|
||||||
gridHasFocus = it.hasFocus
|
onEnter = { programFocusRequester.tryRequestFocus() }
|
||||||
}.focusProperties {
|
|
||||||
up = buttonFocusRequester
|
|
||||||
}.focusable()
|
|
||||||
.onPreviewKeyEvent {
|
|
||||||
if (it.type == KeyEventType.KeyUp) {
|
|
||||||
return@onPreviewKeyEvent false
|
|
||||||
}
|
|
||||||
val item = focusedItem
|
|
||||||
val newFocusedItem =
|
|
||||||
when (it.key) {
|
|
||||||
Key.Back -> {
|
|
||||||
if (item.column > 0) {
|
|
||||||
// Not at beginning of row, so move to beginning
|
|
||||||
item.copy(column = 0)
|
|
||||||
} else if (item.row > 0) {
|
|
||||||
item.copy(row = 0)
|
|
||||||
} else {
|
|
||||||
// At beginning, so allow normal back button behavior
|
|
||||||
return@onPreviewKeyEvent false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.DirectionRight -> {
|
|
||||||
if (channelColumnFocused) {
|
|
||||||
channelColumnFocused = false
|
|
||||||
item.copy(column = 0)
|
|
||||||
} else {
|
|
||||||
item.copy(column = item.column + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.DirectionLeft -> {
|
|
||||||
if (channelColumnFocused) {
|
|
||||||
focusManager.moveFocus(FocusDirection.Left)
|
|
||||||
null
|
|
||||||
} else if (item.column == 0) {
|
|
||||||
channelColumnFocused = true
|
|
||||||
item
|
|
||||||
} else {
|
|
||||||
item.copy(column = item.column - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.DirectionUp -> {
|
|
||||||
if (item.row <= 0) {
|
|
||||||
// focusManager.moveFocus(FocusDirection.Up)
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
val newChannelIndex = item.row - 1
|
|
||||||
if (channelColumnFocused) {
|
|
||||||
RowColumn(newChannelIndex, 0)
|
|
||||||
} else {
|
|
||||||
val currentChannel = channels[item.row].id
|
|
||||||
val currentProgram =
|
|
||||||
programs.programsByChannel[currentChannel]?.get(item.column)
|
|
||||||
val newChannelId = channels[newChannelIndex].id
|
|
||||||
val newChannelPrograms =
|
|
||||||
programs.programsByChannel[newChannelId]
|
|
||||||
if (newChannelPrograms == null) {
|
|
||||||
return@onPreviewKeyEvent true
|
|
||||||
}
|
|
||||||
if (currentProgram == null) {
|
|
||||||
item
|
|
||||||
} else {
|
|
||||||
val start = currentProgram.startHours
|
|
||||||
val pIndex =
|
|
||||||
newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) }
|
|
||||||
if (pIndex >= 0) {
|
|
||||||
RowColumn(newChannelIndex, pIndex)
|
|
||||||
} else {
|
|
||||||
val pIndex =
|
|
||||||
newChannelPrograms.indexOfFirst { it.startHours >= start }
|
|
||||||
if (pIndex >= 0) {
|
|
||||||
RowColumn(newChannelIndex, pIndex)
|
|
||||||
} else {
|
|
||||||
RowColumn(newChannelIndex, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.DirectionDown -> {
|
|
||||||
// Move channel focus down
|
|
||||||
val newChannelIndex = item.row + 1
|
|
||||||
if (newChannelIndex >= channels.size) {
|
|
||||||
// If trying to move below the final channel, then move focus out of the grid
|
|
||||||
focusManager.moveFocus(FocusDirection.Down)
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
// Otherwise, moving to a new row
|
|
||||||
// Get the new row/channel's programs
|
|
||||||
val newChannelId = channels[newChannelIndex].id
|
|
||||||
val newChannelPrograms =
|
|
||||||
programs.programsByChannel[newChannelId]
|
|
||||||
if (newChannelPrograms == null) {
|
|
||||||
// This means there is no data for the new channel and it is loading in the background
|
|
||||||
return@onPreviewKeyEvent true
|
|
||||||
}
|
|
||||||
if (channelColumnFocused) {
|
|
||||||
// If focused on the channel column, move down a channel
|
|
||||||
RowColumn(newChannelIndex, 0)
|
|
||||||
} else {
|
|
||||||
// Get current program & its start time
|
|
||||||
val currentChannel = channels[item.row].id
|
|
||||||
val currentProgram =
|
|
||||||
programs.programsByChannel[currentChannel]?.get(item.column)
|
|
||||||
if (currentProgram == null) {
|
|
||||||
// Data is loading in the background
|
|
||||||
item
|
|
||||||
} else {
|
|
||||||
val start = currentProgram.startHours
|
|
||||||
// Find the first program where the start time (of the previously focused program) is in the middle of a program
|
|
||||||
val pIndex =
|
|
||||||
newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) }
|
|
||||||
if (pIndex >= 0) {
|
|
||||||
// Found one, so focus on it
|
|
||||||
RowColumn(newChannelIndex, 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 =
|
|
||||||
newChannelPrograms.indexOfFirst { it.startHours >= start }
|
|
||||||
if (pIndex >= 0) {
|
|
||||||
// Found one, so focus on it
|
|
||||||
RowColumn(newChannelIndex, pIndex)
|
|
||||||
} else {
|
|
||||||
// Did not find one, so focus on the final program in the list
|
|
||||||
RowColumn(
|
|
||||||
newChannelIndex,
|
|
||||||
newChannelPrograms.size - 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
|
|
||||||
if (channelColumnFocused) {
|
|
||||||
val channel = channels[focusedChannelIndex]
|
|
||||||
Timber.v("Clicked on %s", channel)
|
|
||||||
onClickChannel.invoke(focusedChannelIndex, channel)
|
|
||||||
} else {
|
|
||||||
val currentChannel = channels[item.row].id
|
|
||||||
val currentProgram =
|
|
||||||
programs.programsByChannel[currentChannel]?.get(item.column)
|
|
||||||
if (currentProgram == null) {
|
|
||||||
// Data is loading in the background
|
|
||||||
return@onPreviewKeyEvent true
|
|
||||||
}
|
|
||||||
Timber.v("Clicked on %s", currentProgram)
|
|
||||||
onClickProgram.invoke(
|
|
||||||
focusedProgramIndex,
|
|
||||||
currentProgram,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newFocusedItem != null) {
|
|
||||||
val channel = channels[newFocusedItem.row]
|
|
||||||
val channelPrograms = programs.programsByChannel[channel.id].orEmpty()
|
|
||||||
// Ensure it isn't going out of range
|
|
||||||
val toFocus =
|
|
||||||
newFocusedItem
|
|
||||||
.copy(
|
|
||||||
row = newFocusedItem.row.coerceIn(0, channels.size - 1),
|
|
||||||
column =
|
|
||||||
newFocusedItem.column.coerceIn(
|
|
||||||
0,
|
|
||||||
(channelPrograms.size - 1).coerceAtLeast(0),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
focusedItem = toFocus
|
|
||||||
focusedProgramIndex =
|
|
||||||
toFocus.let { focus ->
|
|
||||||
(programs.range.first..<focus.row).sumOf {
|
|
||||||
val channelId = channels[it].id
|
|
||||||
channelProgramCount[channelId] ?: 0
|
|
||||||
} + focus.column
|
|
||||||
}
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
state.animateToProgram(focusedProgramIndex, Alignment.Center)
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Couldn't scroll to $focusedProgramIndex")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onFocus(toFocus)
|
|
||||||
return@onPreviewKeyEvent true
|
|
||||||
}
|
|
||||||
return@onPreviewKeyEvent false
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
guideStartHour = 0f
|
guideStartHour = 0f
|
||||||
|
|
@ -593,9 +452,35 @@ fun TvGuideGridContent(
|
||||||
},
|
},
|
||||||
) { programIndex ->
|
) { programIndex ->
|
||||||
val program = programs.programs[programIndex]
|
val program = programs.programs[programIndex]
|
||||||
val focused =
|
Program(
|
||||||
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
|
guideStart = guideStart,
|
||||||
Program(guideStart, program, focused, preferences.colorCodePrograms, Modifier)
|
program = program,
|
||||||
|
colorCode = preferences.colorCodePrograms,
|
||||||
|
onClick = {
|
||||||
|
onClickProgram.invoke(programIndex, program)
|
||||||
|
},
|
||||||
|
onLongClick = {},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.ifElse(
|
||||||
|
programIndex == focusedProgramIndex,
|
||||||
|
Modifier.focusRequester(programFocusRequester),
|
||||||
|
).onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
focusedProgramIndex = programIndex
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
state.animateToProgram(
|
||||||
|
programIndex,
|
||||||
|
Alignment.Center,
|
||||||
|
)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Couldn't scroll to $programIndex")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
channels(
|
channels(
|
||||||
|
|
@ -605,13 +490,29 @@ fun TvGuideGridContent(
|
||||||
},
|
},
|
||||||
) { channelIndex ->
|
) { channelIndex ->
|
||||||
val channel = channels[channelIndex]
|
val channel = channels[channelIndex]
|
||||||
val focused =
|
|
||||||
gridHasFocus && channelColumnFocused && focusedChannelIndex == channelIndex
|
|
||||||
Channel(
|
Channel(
|
||||||
channel = channel,
|
channel = channel,
|
||||||
channelIndex = channelIndex,
|
channelIndex = channelIndex,
|
||||||
focused = focused,
|
onClick = { onClickChannel.invoke(channelIndex, channel) },
|
||||||
modifier = Modifier,
|
onLongClick = {},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
state.animateToChannel(
|
||||||
|
channelIndex,
|
||||||
|
Alignment.CenterVertically,
|
||||||
|
)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Couldn't scroll to $channelIndex")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
focusedItem = RowColumn(channelIndex, 0)
|
||||||
|
onFocus.invoke(focusedItem)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -623,7 +524,7 @@ fun TvGuideGridContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (loading) {
|
if (refreshing) {
|
||||||
CircularProgress(
|
CircularProgress(
|
||||||
Modifier
|
Modifier
|
||||||
.background(
|
.background(
|
||||||
|
|
|
||||||
|
|
@ -4,56 +4,73 @@ import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import coil3.annotation.ExperimentalCoilApi
|
||||||
import androidx.tv.material3.Text
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.compose.useExistingImageAsPlaceholder
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.transitionFactory
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
||||||
|
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.components.StreamLabel
|
import com.github.damontecres.wholphin.ui.components.StreamLabel
|
||||||
|
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoilApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TvGuideHeader(
|
fun TvGuideHeader(
|
||||||
program: TvProgram?,
|
program: TvProgram?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
horizontalAlignment = Alignment.Start,
|
horizontalAlignment = Alignment.Start,
|
||||||
modifier = modifier,
|
|
||||||
) {
|
) {
|
||||||
Text(
|
TitleOrLogo(
|
||||||
text = program?.name ?: program?.id.toString(),
|
title = program?.name ?: program?.id.toString(),
|
||||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
logoImageUrl = null,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
showLogo = false,
|
||||||
maxLines = 1,
|
modifier =
|
||||||
overflow = TextOverflow.Ellipsis,
|
Modifier
|
||||||
modifier = Modifier.fillMaxWidth(.75f),
|
.padding(start = HeaderUtils.startPadding)
|
||||||
|
.fillMaxWidth(.75f),
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth(.6f),
|
modifier = Modifier.fillMaxWidth(.6f),
|
||||||
) {
|
) {
|
||||||
program?.subtitle?.let {
|
program?.subtitle?.let {
|
||||||
Text(
|
EpisodeName(
|
||||||
text = program.subtitle,
|
episodeName = program.subtitle,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
modifier = Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
) {
|
) {
|
||||||
program?.quickDetails?.let { QuickDetails(it, null) }
|
program?.quickDetails?.let {
|
||||||
|
QuickDetails(
|
||||||
|
it,
|
||||||
|
null,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (program?.isRepeat == true) {
|
if (program?.isRepeat == true) {
|
||||||
StreamLabel(stringResource(R.string.live_tv_repeat))
|
StreamLabel(stringResource(R.string.live_tv_repeat))
|
||||||
}
|
}
|
||||||
|
|
@ -66,4 +83,16 @@ fun TvGuideHeader(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AsyncImage(
|
||||||
|
model =
|
||||||
|
ImageRequest
|
||||||
|
.Builder(LocalContext.current)
|
||||||
|
.data(program?.imageUrl)
|
||||||
|
.transitionFactory(CrossFadeFactory(300.milliseconds))
|
||||||
|
.useExistingImageAsPlaceholder(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue