mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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:
parent
0d8800863b
commit
6edcfb1067
10 changed files with 456 additions and 23 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue