From 6edcfb1067db1c7ce620f0db2536423207bc7bc1 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:05:01 -0500 Subject: [PATCH] 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 --- .../wholphin/preferences/AppPreference.kt | 53 ++++++++ .../preferences/AppPreferencesSerializer.kt | 18 +++ .../wholphin/services/AppUpgradeHandler.kt | 14 ++ .../wholphin/ui/detail/livetv/Components.kt | 5 +- .../ui/detail/livetv/LiveTvViewModel.kt | 59 ++++++++- .../detail/livetv/LiveTvViewOptionsDialog.kt | 94 ++++++++++++++ .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 102 ++++++++++++--- .../ui/detail/livetv/TvGuideHeader.kt | 122 ++++++++++++++++++ app/src/main/proto/WholphinDataStore.proto | 8 ++ app/src/main/res/values/strings.xml | 4 + 10 files changed, 456 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 9a196d6b..19810b5c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -784,6 +784,51 @@ sealed interface AppPreference { } }, ) + + val LiveTvShowHeader = + AppSwitchPreference( + 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( + 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( + 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( + 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( @get:StringRes override val title: Int, override val defaultValue: Boolean, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index e3223663..0529e644 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -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() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index eda96fa3..67b55a1b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -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 + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt index 7011975f..41b7b114 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 0d86729d..b9c74ce6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -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, private val serverRepository: ServerRepository, ) : ViewModel() { val loading = MutableLiveData(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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt new file mode 100644 index 00000000..a0971f8e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt @@ -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 + 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, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index d0a811fc..8fee8995 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -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(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, 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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt new file mode 100644 index 00000000..73b9b3c2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index fe601b0b..a003bbcc 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -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 { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d28905b..84ca35c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -389,6 +389,10 @@ bit Hz Show titles + Repeat + Show favorite channels first + Sort channels by recently watched + Color-code programs Disabled