Customize TV guide display (#443)

Adds a view options button for the TV guide to allow some basic
customization:
* Show details header
* Adjust how channels are sorted
* Toggle color coding for programs

Related to #250 
Closes #288 
Fixes #439
This commit is contained in:
damontecres 2025-12-12 22:05:01 -05:00 committed by GitHub
parent 0d8800863b
commit 6edcfb1067
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 456 additions and 23 deletions

View file

@ -784,6 +784,51 @@ sealed interface AppPreference<Pref, T> {
}
},
)
val LiveTvShowHeader =
AppSwitchPreference<AppPreferences>(
title = R.string.show_details,
defaultValue = true,
getter = { it.interfacePreferences.liveTvPreferences.showHeader },
setter = { prefs, value ->
prefs.updateLiveTvPreferences { showHeader = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val LiveTvFavoriteChannelsBeginning =
AppSwitchPreference<AppPreferences>(
title = R.string.favorite_channels_at_beginning,
defaultValue = true,
getter = { it.interfacePreferences.liveTvPreferences.favoriteChannelsAtBeginning },
setter = { prefs, value ->
prefs.updateLiveTvPreferences { favoriteChannelsAtBeginning = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val LiveTvChannelSortByWatched =
AppSwitchPreference<AppPreferences>(
title = R.string.sort_channels_recently_watched,
defaultValue = false,
getter = { it.interfacePreferences.liveTvPreferences.sortByRecentlyWatched },
setter = { prefs, value ->
prefs.updateLiveTvPreferences { sortByRecentlyWatched = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val LiveTvColorCodePrograms =
AppSwitchPreference<AppPreferences>(
title = R.string.color_code_programs,
defaultValue = true,
getter = { it.interfacePreferences.liveTvPreferences.colorCodePrograms },
setter = { prefs, value ->
prefs.updateLiveTvPreferences { colorCodePrograms = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
}
}
@ -948,6 +993,14 @@ val advancedPreferences =
)
}
val liveTvPreferences =
listOf(
AppPreference.LiveTvShowHeader,
AppPreference.LiveTvFavoriteChannelsBeginning,
AppPreference.LiveTvChannelSortByWatched,
AppPreference.LiveTvColorCodePrograms,
)
data class AppSwitchPreference<Pref>(
@get:StringRes override val title: Int,
override val defaultValue: Boolean,

View file

@ -98,6 +98,19 @@ class AppPreferencesSerializer
.apply {
resetSubtitles()
}.build()
liveTvPreferences =
LiveTvPreferences
.newBuilder()
.apply {
showHeader = AppPreference.LiveTvShowHeader.defaultValue
favoriteChannelsAtBeginning =
AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
sortByRecentlyWatched =
AppPreference.LiveTvChannelSortByWatched.defaultValue
colorCodePrograms =
AppPreference.LiveTvColorCodePrograms.defaultValue
}.build()
}.build()
advancedPreferences =
@ -155,6 +168,11 @@ inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.B
subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build()
}
inline fun AppPreferences.updateLiveTvPreferences(block: LiveTvPreferences.Builder.() -> Unit): AppPreferences =
updateInterfacePreferences {
liveTvPreferences = liveTvPreferences.toBuilder().apply(block).build()
}
inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.Builder.() -> Unit): AppPreferences =
update {
advancedPreferences = advancedPreferences.toBuilder().apply(block).build()

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
@ -173,4 +174,17 @@ suspend fun upgradeApp(
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) {
appPreferences.updateData {
it.updateLiveTvPreferences {
showHeader = AppPreference.LiveTvShowHeader.defaultValue
favoriteChannelsAtBeginning =
AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
sortByRecentlyWatched =
AppPreference.LiveTvChannelSortByWatched.defaultValue
colorCodePrograms =
AppPreference.LiveTvColorCodePrograms.defaultValue
}
}
}
}

View file

@ -25,14 +25,17 @@ import coil3.compose.AsyncImage
fun Program(
program: TvProgram,
focused: Boolean,
colorCode: Boolean,
modifier: Modifier = Modifier,
) {
val background =
if (focused) {
MaterialTheme.colorScheme.inverseSurface
} else {
} else if (colorCode) {
program.category?.color
?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
}
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
Box(

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.livetv
import android.content.Context
import androidx.compose.ui.graphics.Color
import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -9,6 +10,7 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.data.RowColumn
@ -23,7 +25,13 @@ import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -34,7 +42,9 @@ import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.TimerInfoDto
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.extensions.ticks
@ -45,11 +55,13 @@ import java.util.UUID
import javax.inject.Inject
import kotlin.math.min
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
const val MAX_HOURS = 48L
@OptIn(FlowPreview::class)
@HiltViewModel
class LiveTvViewModel
@Inject
@ -57,6 +69,7 @@ class LiveTvViewModel
@param:ApplicationContext private val context: Context,
val api: ApiClient,
val navigationManager: NavigationManager,
val preferences: DataStore<AppPreferences>,
private val serverRepository: ServerRepository,
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
@ -75,11 +88,25 @@ class LiveTvViewModel
private val range = 100
fun init(firstLoad: Boolean) {
guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
if (!firstLoad) {
loading.value = LoadingState.Loading
init {
viewModelScope.launchIO {
preferences.data
.map {
it.interfacePreferences.liveTvPreferences.let {
Pair(it.sortByRecentlyWatched, it.favoriteChannelsAtBeginning)
}
}.distinctUntilChanged()
.debounce { 500.milliseconds }
.collectLatest {
Timber.v("Init due to pref change")
loading.setValueOnMain(LoadingState.Pending)
init()
}
}
}
fun init() {
guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
@ -87,11 +114,26 @@ class LiveTvViewModel
"Could not fetch channels",
),
) {
val prefs =
(preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance())
.interfacePreferences.liveTvPreferences
val channelData by api.liveTvApi.getLiveTvChannels(
GetLiveTvChannelsRequest(
startIndex = 0,
userId = serverRepository.currentUser.value?.id,
enableFavoriteSorting = true,
enableFavoriteSorting = prefs.favoriteChannelsAtBeginning,
sortBy =
if (prefs.sortByRecentlyWatched) {
listOf(ItemSortBy.DATE_PLAYED)
} else {
null
},
sortOrder =
if (prefs.sortByRecentlyWatched) {
SortOrder.DESCENDING
} else {
null
},
addCurrentProgram = false,
),
)
@ -148,6 +190,7 @@ class LiveTvViewModel
channelIds = channelsToFetch.map { it.id },
sortBy = listOf(ItemSortBy.START_DATE),
userId = serverRepository.currentUser.value?.id,
fields = listOf(ItemFields.OVERVIEW),
)
val fetchedPrograms =
api.liveTvApi
@ -173,6 +216,7 @@ class LiveTvViewModel
} else {
null
}
val p =
TvProgram(
id = dto.id,
@ -188,6 +232,8 @@ class LiveTvViewModel
duration = dto.runTimeTicks!!.ticks,
name = dto.seriesName ?: dto.name,
subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false },
overview = dto.overview,
officialRating = dto.officialRating,
seasonEpisode =
if (dto.indexNumber != null && dto.parentIndexNumber != null) {
SeasonEpisode(
@ -474,11 +520,13 @@ data class TvProgram(
val duration: Duration,
val name: String?,
val subtitle: String?,
val overview: String? = null,
val seasonEpisode: SeasonEpisode?,
val isRecording: Boolean,
val isSeriesRecording: Boolean,
val isRepeat: Boolean,
val category: ProgramCategory?,
val officialRating: String? = null,
) {
val isFake = category == ProgramCategory.FAKE
@ -500,6 +548,7 @@ data class TvProgram(
duration = ((endHours - startHours) * 60).toInt().minutes,
name = NO_DATA,
subtitle = null,
overview = null,
seasonEpisode = null,
isRecording = false,
isSeriesRecording = false,

View file

@ -0,0 +1,94 @@
package com.github.damontecres.wholphin.ui.detail.livetv
import android.view.Gravity
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.liveTvPreferences
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun LiveTvViewOptionsDialog(
preferences: AppPreferences,
onDismissRequest: () -> Unit,
onViewOptionsChange: (AppPreferences) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
val columnState = rememberLazyListState()
Dialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.let { window ->
window.setGravity(Gravity.END)
window.setDimAmount(0f)
}
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.width(256.dp)
.heightIn(max = 380.dp)
.focusRequester(focusRequester)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
shape = RoundedCornerShape(8.dp),
),
) {
stickyHeader {
Text(
text = stringResource(R.string.view_options),
style = MaterialTheme.typography.titleMedium,
)
}
items(liveTvPreferences) { pref ->
pref as AppPreference<AppPreferences, Any>
val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(preferences)
ComposablePreference(
preference = pref,
value = value,
onNavigate = {},
onValueChange = { newValue ->
onViewOptionsChange.invoke(pref.setter(preferences, newValue))
},
interactionSource = interactionSource,
modifier = Modifier,
)
}
}
}
}

View file

@ -2,10 +2,13 @@ package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -13,6 +16,7 @@ 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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@ -24,6 +28,7 @@ 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.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
@ -39,10 +44,15 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.SurfaceDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.LiveTvPreferences
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -66,16 +76,21 @@ fun TvGuideGrid(
modifier: Modifier = Modifier,
viewModel: LiveTvViewModel = hiltViewModel(),
) {
var firstLoad by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) {
viewModel.init(firstLoad)
firstLoad = false
}
val scope = rememberCoroutineScope()
// 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(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
.collectAsState(AppPreferences.getDefaultInstance())
val tvPrefs = preferences.interfacePreferences.liveTvPreferences
var showViewOptions by remember { mutableStateOf(false) }
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
@ -94,16 +109,49 @@ fun TvGuideGrid(
val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending)
var showItemDialog by remember { mutableStateOf<Int?>(null) }
val focusRequester = remember { FocusRequester() }
val buttonFocusRequester = remember { FocusRequester() }
if (requestFocusAfterLoading) {
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
}
Column(modifier = modifier) {
var focusedPosition by rememberPosition(0, 0)
val focusedProgram =
remember(focusedPosition) {
focusedPosition.let {
val channelId = channels.getOrNull(it.row)?.id
programs.programsByChannel[channelId]?.getOrNull(it.column)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
if (channels.isEmpty()) {
ErrorMessage("Live TV is enabled, but no channels were found.", null)
} else {
AnimatedVisibility(tvPrefs.showHeader) {
TvGuideHeader(
program = focusedProgram,
modifier =
Modifier
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
.fillMaxHeight(.30f),
)
}
AnimatedVisibility(focusedPosition.row < 1) {
ExpandableFaButton(
title = R.string.view_options,
iconStringRes = R.string.fa_sliders,
onClick = { showViewOptions = true },
modifier =
Modifier
.padding(start = tvGuideDimensions.channelWidth)
.focusRequester(buttonFocusRequester),
)
}
TvGuideGridContent(
preferences = tvPrefs,
loading = state is LoadingState.Loading,
channels = channels,
programs = programs,
@ -118,6 +166,7 @@ fun TvGuideGrid(
)
},
onFocus = {
focusedPosition = it
onRowPosition.invoke(it.row)
viewModel.onFocusChannel(it)
},
@ -144,6 +193,7 @@ fun TvGuideGrid(
showItemDialog = index
}
},
buttonFocusRequester = buttonFocusRequester,
modifier =
Modifier
.fillMaxSize()
@ -192,10 +242,33 @@ fun TvGuideGrid(
}
}
}
if (showViewOptions) {
LiveTvViewOptionsDialog(
preferences = preferences,
onDismissRequest = { showViewOptions = false },
onViewOptionsChange = { newPrefs ->
scope.launchIO {
viewModel.preferences.updateData {
newPrefs
}
}
},
)
}
}
val tvGuideDimensions =
ProgramGuideDimensions(
timelineHourWidth = 240.dp,
timelineHeight = 32.dp,
channelWidth = 120.dp,
channelHeight = 64.dp,
currentTimeWidth = 2.dp,
)
@Composable
fun TvGuideGridContent(
preferences: LiveTvPreferences,
loading: Boolean,
channels: List<TvChannel>,
programs: FetchedPrograms,
@ -204,6 +277,7 @@ fun TvGuideGridContent(
onClickChannel: (Int, TvChannel) -> Unit,
onClickProgram: (Int, TvProgram) -> Unit,
onFocus: (RowColumn) -> Unit,
buttonFocusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -229,25 +303,19 @@ fun TvGuideGridContent(
state.animateToProgram(focusedProgramIndex, Alignment.Center)
}
}
val dimensions =
ProgramGuideDimensions(
timelineHourWidth = 240.dp,
timelineHeight = 32.dp,
channelWidth = 120.dp,
channelHeight = 64.dp,
currentTimeWidth = 2.dp,
)
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
Box(modifier = modifier) {
ProgramGuide(
state = state,
dimensions = dimensions,
dimensions = tvGuideDimensions,
modifier =
Modifier
.fillMaxSize()
.onFocusChanged {
gridHasFocus = it.hasFocus
}.focusProperties {
up = buttonFocusRequester
}.focusable()
.onPreviewKeyEvent {
if (it.type == KeyEventType.KeyUp) {
@ -291,7 +359,7 @@ fun TvGuideGridContent(
Key.DirectionUp -> {
if (item.row <= 0) {
focusManager.moveFocus(FocusDirection.Up)
// focusManager.moveFocus(FocusDirection.Up)
null
} else {
val newChannelIndex = item.row - 1
@ -516,7 +584,7 @@ fun TvGuideGridContent(
val program = programs.programs[programIndex]
val focused =
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
Program(program, focused, Modifier)
Program(program, focused, preferences.colorCodePrograms, Modifier)
}
channels(

View file

@ -0,0 +1,122 @@
package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.StreamLabel
import com.github.damontecres.wholphin.ui.roundMinutes
import java.time.LocalDateTime
import java.time.ZoneId
import kotlin.time.toKotlinDuration
@Composable
fun TvGuideHeader(
program: TvProgram?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val now = LocalDateTime.now()
val details =
remember(program) {
buildList {
program?.let {
val differentDay = it.start.toLocalDate() != now.toLocalDate()
val time =
DateUtils.formatDateRange(
context,
it.start
.atZone(ZoneId.systemDefault())
.toInstant()
.epochSecond * 1000,
it.end
.atZone(ZoneId.systemDefault())
.toInstant()
.epochSecond * 1000,
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
)
add(time)
}
if (program?.isFake == false) {
program
.duration
.roundMinutes
.toString()
.let(::add)
if (now.isAfter(program.start) && now.isBefore(program.end)) {
java.time.Duration
.between(now, program.end)
.toKotlinDuration()
.roundMinutes
.let { add("$it left") }
}
program.seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let(::add)
program.officialRating?.let(::add)
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.Start,
modifier = modifier,
) {
Text(
text = program?.name ?: program?.id.toString(),
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(.6f),
) {
program?.subtitle?.let {
Text(
text = program.subtitle,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DotSeparatedRow(
texts = details,
communityRating = null,
criticRating = null,
textStyle = MaterialTheme.typography.titleSmall,
modifier = Modifier,
)
if (program?.isRepeat == true) {
StreamLabel(stringResource(R.string.live_tv_repeat))
}
}
OverviewText(
overview = program?.overview ?: "",
maxLines = 3,
onClick = {},
enabled = false,
)
}
}
}

View file

@ -125,6 +125,13 @@ message SubtitlePreferences{
int32 edge_thickness = 12;
}
message LiveTvPreferences {
bool show_header = 1;
bool favorite_channels_at_beginning = 2;
bool sort_by_recently_watched = 3;
bool color_code_programs = 4;
}
message InterfacePreferences {
ThemeSongVolume play_theme_songs = 1;
bool remember_selected_tab = 2;
@ -133,6 +140,7 @@ message InterfacePreferences {
bool nav_drawer_switch_on_focus = 5;
bool show_clock = 6;
SubtitlePreferences subtitles_preferences = 7;
LiveTvPreferences live_tv_preferences = 8;
}
message AdvancedPreferences {

View file

@ -389,6 +389,10 @@
<string name="bit_unit">bit</string>
<string name="sample_rate_unit">Hz</string>
<string name="show_titles">Show titles</string>
<string name="live_tv_repeat">Repeat</string>
<string name="favorite_channels_at_beginning">Show favorite channels first</string>
<string name="sort_channels_recently_watched">Sort channels by recently watched</string>
<string name="color_code_programs">Color-code programs</string>
<string-array name="theme_song_volume">
<item>Disabled</item>