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>( data class AppSwitchPreference<Pref>(
@get:StringRes override val title: Int, @get:StringRes override val title: Int,
override val defaultValue: Boolean, override val defaultValue: Boolean,

View file

@ -98,6 +98,19 @@ class AppPreferencesSerializer
.apply { .apply {
resetSubtitles() resetSubtitles()
}.build() }.build()
liveTvPreferences =
LiveTvPreferences
.newBuilder()
.apply {
showHeader = AppPreference.LiveTvShowHeader.defaultValue
favoriteChannelsAtBeginning =
AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
sortByRecentlyWatched =
AppPreference.LiveTvChannelSortByWatched.defaultValue
colorCodePrograms =
AppPreference.LiveTvColorCodePrograms.defaultValue
}.build()
}.build() }.build()
advancedPreferences = advancedPreferences =
@ -155,6 +168,11 @@ inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.B
subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build() 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 = inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.Builder.() -> Unit): AppPreferences =
update { update {
advancedPreferences = advancedPreferences.toBuilder().apply(block).build() 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.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences 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.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences 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( fun Program(
program: TvProgram, program: TvProgram,
focused: Boolean, focused: Boolean,
colorCode: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val background = val background =
if (focused) { if (focused) {
MaterialTheme.colorScheme.inverseSurface MaterialTheme.colorScheme.inverseSurface
} else { } else if (colorCode) {
program.category?.color program.category?.color
?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) ?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
} }
val textColor = MaterialTheme.colorScheme.contentColorFor(background) val textColor = MaterialTheme.colorScheme.contentColorFor(background)
Box( Box(

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.livetv
import android.content.Context import android.content.Context
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.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.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
@ -23,7 +25,13 @@ 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.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job 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.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock 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.BaseItemDto
import org.jellyfin.sdk.model.api.GetProgramsDto import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy 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.TimerInfoDto
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -45,11 +55,13 @@ import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.min import kotlin.math.min
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
const val MAX_HOURS = 48L const val MAX_HOURS = 48L
@OptIn(FlowPreview::class)
@HiltViewModel @HiltViewModel
class LiveTvViewModel class LiveTvViewModel
@Inject @Inject
@ -57,6 +69,7 @@ class LiveTvViewModel
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
val api: ApiClient, val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val preferences: DataStore<AppPreferences>,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
) : ViewModel() { ) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
@ -75,11 +88,25 @@ class LiveTvViewModel
private val range = 100 private val range = 100
fun init(firstLoad: Boolean) { init {
guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) viewModelScope.launchIO {
if (!firstLoad) { preferences.data
loading.value = LoadingState.Loading .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( viewModelScope.launch(
Dispatchers.IO + Dispatchers.IO +
LoadingExceptionHandler( LoadingExceptionHandler(
@ -87,11 +114,26 @@ class LiveTvViewModel
"Could not fetch channels", "Could not fetch channels",
), ),
) { ) {
val prefs =
(preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance())
.interfacePreferences.liveTvPreferences
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 = true, enableFavoriteSorting = prefs.favoriteChannelsAtBeginning,
sortBy =
if (prefs.sortByRecentlyWatched) {
listOf(ItemSortBy.DATE_PLAYED)
} else {
null
},
sortOrder =
if (prefs.sortByRecentlyWatched) {
SortOrder.DESCENDING
} else {
null
},
addCurrentProgram = false, addCurrentProgram = false,
), ),
) )
@ -148,6 +190,7 @@ class LiveTvViewModel
channelIds = channelsToFetch.map { it.id }, channelIds = channelsToFetch.map { it.id },
sortBy = listOf(ItemSortBy.START_DATE), sortBy = listOf(ItemSortBy.START_DATE),
userId = serverRepository.currentUser.value?.id, userId = serverRepository.currentUser.value?.id,
fields = listOf(ItemFields.OVERVIEW),
) )
val fetchedPrograms = val fetchedPrograms =
api.liveTvApi api.liveTvApi
@ -173,6 +216,7 @@ class LiveTvViewModel
} else { } else {
null null
} }
val p = val p =
TvProgram( TvProgram(
id = dto.id, id = dto.id,
@ -188,6 +232,8 @@ class LiveTvViewModel
duration = dto.runTimeTicks!!.ticks, duration = dto.runTimeTicks!!.ticks,
name = dto.seriesName ?: dto.name, name = dto.seriesName ?: dto.name,
subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false }, subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false },
overview = dto.overview,
officialRating = dto.officialRating,
seasonEpisode = seasonEpisode =
if (dto.indexNumber != null && dto.parentIndexNumber != null) { if (dto.indexNumber != null && dto.parentIndexNumber != null) {
SeasonEpisode( SeasonEpisode(
@ -474,11 +520,13 @@ data class TvProgram(
val duration: Duration, val duration: Duration,
val name: String?, val name: String?,
val subtitle: String?, val subtitle: String?,
val overview: String? = null,
val seasonEpisode: SeasonEpisode?, val seasonEpisode: SeasonEpisode?,
val isRecording: Boolean, val isRecording: Boolean,
val isSeriesRecording: Boolean, val isSeriesRecording: Boolean,
val isRepeat: Boolean, val isRepeat: Boolean,
val category: ProgramCategory?, val category: ProgramCategory?,
val officialRating: String? = null,
) { ) {
val isFake = category == ProgramCategory.FAKE val isFake = category == ProgramCategory.FAKE
@ -500,6 +548,7 @@ data class TvProgram(
duration = ((endHours - startHours) * 60).toInt().minutes, duration = ((endHours - startHours) * 60).toInt().minutes,
name = NO_DATA, name = NO_DATA,
subtitle = null, subtitle = null,
overview = null,
seasonEpisode = null, seasonEpisode = null,
isRecording = false, isRecording = false,
isSeriesRecording = 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.text.format.DateUtils
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
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.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.layout.size 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.foundation.shape.RoundedCornerShape
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.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -24,6 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.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.input.key.Key
@ -39,10 +44,15 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.SurfaceDefaults
import androidx.tv.material3.Text 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.CircularProgress
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.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.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
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -66,16 +76,21 @@ fun TvGuideGrid(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: LiveTvViewModel = hiltViewModel(), viewModel: LiveTvViewModel = hiltViewModel(),
) { ) {
var firstLoad by rememberSaveable { mutableStateOf(true) } val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { // var firstLoad by rememberSaveable { mutableStateOf(true) }
viewModel.init(firstLoad) // LaunchedEffect(Unit) {
firstLoad = false // viewModel.init(firstLoad)
} // firstLoad = false
// }
val loading by viewModel.loading.observeAsState(LoadingState.Pending) val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val channels by viewModel.channels.observeAsState(listOf()) val channels by viewModel.channels.observeAsState(listOf())
val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf())) val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf()))
// val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf()) // val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
// val fetchedRange by viewModel.fetchedRange.observeAsState(0..0) // 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) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(state, modifier)
@ -94,16 +109,49 @@ fun TvGuideGrid(
val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending) val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending)
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() }
if (requestFocusAfterLoading) { if (requestFocusAfterLoading) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.tryRequestFocus() 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()) { if (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) {
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( TvGuideGridContent(
preferences = tvPrefs,
loading = state is LoadingState.Loading, loading = state is LoadingState.Loading,
channels = channels, channels = channels,
programs = programs, programs = programs,
@ -118,6 +166,7 @@ fun TvGuideGrid(
) )
}, },
onFocus = { onFocus = {
focusedPosition = it
onRowPosition.invoke(it.row) onRowPosition.invoke(it.row)
viewModel.onFocusChannel(it) viewModel.onFocusChannel(it)
}, },
@ -144,6 +193,7 @@ fun TvGuideGrid(
showItemDialog = index showItemDialog = index
} }
}, },
buttonFocusRequester = buttonFocusRequester,
modifier = modifier =
Modifier Modifier
.fillMaxSize() .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 @Composable
fun TvGuideGridContent( fun TvGuideGridContent(
preferences: LiveTvPreferences,
loading: Boolean, loading: Boolean,
channels: List<TvChannel>, channels: List<TvChannel>,
programs: FetchedPrograms, programs: FetchedPrograms,
@ -204,6 +277,7 @@ 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
@ -229,25 +303,19 @@ fun TvGuideGridContent(
state.animateToProgram(focusedProgramIndex, Alignment.Center) 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 gridHasFocus by rememberSaveable { mutableStateOf(false) }
var channelColumnFocused by rememberSaveable { mutableStateOf(false) } var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
Box(modifier = modifier) { Box(modifier = modifier) {
ProgramGuide( ProgramGuide(
state = state, state = state,
dimensions = dimensions, dimensions = tvGuideDimensions,
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.onFocusChanged { .onFocusChanged {
gridHasFocus = it.hasFocus gridHasFocus = it.hasFocus
}.focusProperties {
up = buttonFocusRequester
}.focusable() }.focusable()
.onPreviewKeyEvent { .onPreviewKeyEvent {
if (it.type == KeyEventType.KeyUp) { if (it.type == KeyEventType.KeyUp) {
@ -291,7 +359,7 @@ fun TvGuideGridContent(
Key.DirectionUp -> { Key.DirectionUp -> {
if (item.row <= 0) { if (item.row <= 0) {
focusManager.moveFocus(FocusDirection.Up) // focusManager.moveFocus(FocusDirection.Up)
null null
} else { } else {
val newChannelIndex = item.row - 1 val newChannelIndex = item.row - 1
@ -516,7 +584,7 @@ fun TvGuideGridContent(
val program = programs.programs[programIndex] val program = programs.programs[programIndex]
val focused = val focused =
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
Program(program, focused, Modifier) Program(program, focused, preferences.colorCodePrograms, Modifier)
} }
channels( 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; 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 { message InterfacePreferences {
ThemeSongVolume play_theme_songs = 1; ThemeSongVolume play_theme_songs = 1;
bool remember_selected_tab = 2; bool remember_selected_tab = 2;
@ -133,6 +140,7 @@ message InterfacePreferences {
bool nav_drawer_switch_on_focus = 5; bool nav_drawer_switch_on_focus = 5;
bool show_clock = 6; bool show_clock = 6;
SubtitlePreferences subtitles_preferences = 7; SubtitlePreferences subtitles_preferences = 7;
LiveTvPreferences live_tv_preferences = 8;
} }
message AdvancedPreferences { message AdvancedPreferences {

View file

@ -389,6 +389,10 @@
<string name="bit_unit">bit</string> <string name="bit_unit">bit</string>
<string name="sample_rate_unit">Hz</string> <string name="sample_rate_unit">Hz</string>
<string name="show_titles">Show titles</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"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>