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