diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 83974283..1727774a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -135,6 +135,9 @@ fun CollectionFolderLiveTv( 0 -> { TvGuideGrid( true, + onRowPosition = { + showHeader = it <= 0 + }, Modifier .fillMaxSize() .focusRequester(focusRequester), 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 new file mode 100644 index 00000000..7011975f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt @@ -0,0 +1,129 @@ +package com.github.damontecres.wholphin.ui.detail.livetv + +import androidx.compose.foundation.background +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.contentColorFor +import androidx.tv.material3.surfaceColorAtElevation +import coil3.compose.AsyncImage + +@Composable +fun Program( + program: TvProgram, + focused: Boolean, + modifier: Modifier = Modifier, +) { + val background = + if (focused) { + MaterialTheme.colorScheme.inverseSurface + } else { + program.category?.color + ?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) + } + val textColor = MaterialTheme.colorScheme.contentColorFor(background) + Box( + modifier = + modifier + .padding(2.dp) + .fillMaxSize() + .background( + background, + shape = RoundedCornerShape(4.dp), + ), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier + .fillMaxSize() + .padding(4.dp), + ) { + Text( + text = program.name ?: program.id.toString(), + color = textColor, + fontSize = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + listOfNotNull( + program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, + program.subtitle, + ).joinToString(" - ") + .ifBlank { null } + ?.let { + Text( + text = it, + color = textColor, + fontSize = 14.sp, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + } + } + RecordingMarker( + isRecording = program.isRecording, + isSeriesRecording = program.isSeriesRecording, + modifier = Modifier.align(Alignment.BottomEnd), + ) + } +} + +@Composable +fun Channel( + channel: TvChannel, + channelIndex: Int, + focused: Boolean, + modifier: Modifier = Modifier, +) { + val background = + if (focused) { + MaterialTheme.colorScheme.inverseSurface + } else { + MaterialTheme.colorScheme.surface + } + val textColor = MaterialTheme.colorScheme.contentColorFor(background) + Box( + modifier = + modifier + .fillMaxSize() + .background( + background, + shape = RoundedCornerShape(4.dp), + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(4.dp) + .fillMaxSize(), + ) { + Text( + text = channel.number ?: channel.name ?: channelIndex.toString(), + color = textColor, + modifier = Modifier, + ) + AsyncImage( + model = channel.imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxHeight(.66f), + ) + } + } +} 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 6e4be864..199c6ce4 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 @@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.services.NavigationManager import com.github.damontecres.wholphin.ui.AppColors @@ -56,6 +57,7 @@ class LiveTvViewModel @param:ApplicationContext private val context: Context, val api: ApiClient, val navigationManager: NavigationManager, + private val serverRepository: ServerRepository, ) : ViewModel() { val loading = MutableLiveData(LoadingState.Pending) @@ -88,6 +90,9 @@ class LiveTvViewModel val channelData by api.liveTvApi.getLiveTvChannels( GetLiveTvChannelsRequest( startIndex = 0, + userId = serverRepository.currentUser.value?.id, + enableFavoriteSorting = true, + addCurrentProgram = false, ), ) val channels = @@ -103,8 +108,8 @@ class LiveTvViewModel Timber.d("Got ${channels.size} channels") channelsIdToIndex = channels.withIndex().associateBy({ it.value.id }, { it.index }) - // Initially, quickly load the first 10 channels (only ~6 are visible immediately), then below will load more - // This makes the guide appear faster, and load more useable data in the background + // Initially, quickly load the first 10 channels (only some are visible immediately), then below will load more + // This makes the guide appear faster, and load more usable data in the background val initial = 10 fetchPrograms(channels, 0.. 0) { - if (channelId == UUID.fromString("638232b7-7ef7-7e98-903d-2249ee3fd2cd")) { - Timber.v("Found ") - } var previous = listing.last() while (previous.endHours < p.startHours) { // Fill gaps between programs diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt index c8f39df7..9f1d5215 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close @@ -20,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -35,6 +37,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.ErrorMessage +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 @@ -141,86 +144,10 @@ fun ProgramDialog( .padding(top = 8.dp) .fillMaxWidth(), ) { - if (canRecord) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (dto.isSeries ?: false) { - Button( - onClick = { - if (isSeriesRecording) { - onCancelRecord.invoke(true) - } else { - onRecord.invoke(true) - } - }, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (isSeriesRecording) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - tint = Color.Red, - ) - } - Text( - text = - if (isSeriesRecording) { - stringResource( - R.string.cancel_series_recording, - ) - } else { - stringResource(R.string.record_series) - }, - ) - } - } - } - if (dto.endDate?.isAfter(LocalDateTime.now()) ?: true) { - // Only show program specific recording button if it hasn't finished yet - Button( - onClick = { - if (isRecording) { - onCancelRecord.invoke(false) - } else { - onRecord.invoke(false) - } - }, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (isRecording) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - tint = Color.Red, - ) - } - Text( - text = - if (isRecording) { - stringResource( - R.string.cancel_recording, - ) - } else { - stringResource( - R.string.record_program, - ) - }, - ) - } - } - } - } - } if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) { Button( onClick = onWatch, + modifier = Modifier, ) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -237,6 +164,111 @@ fun ProgramDialog( } } } + if (canRecord) { + val recordFocusRequester = remember { FocusRequester() } + LazyRow( + horizontalArrangement = + Arrangement.spacedBy( + 16.dp, + Alignment.CenterHorizontally, + ), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(recordFocusRequester), + ) { + if (dto.isSeries ?: false) { + item { + Button( + onClick = { + if (isSeriesRecording) { + onCancelRecord.invoke(true) + } else { + onRecord.invoke(true) + } + }, + modifier = + Modifier.focusRequester(recordFocusRequester), + ) { + Row( + horizontalArrangement = + Arrangement.spacedBy( + 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSeriesRecording) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = Color.Red, + ) + } + Text( + text = + if (isSeriesRecording) { + stringResource( + R.string.cancel_series_recording, + ) + } else { + stringResource(R.string.record_series) + }, + ) + } + } + } + } + if (dto.endDate?.isAfter(LocalDateTime.now()) ?: true) { + // Only show program specific recording button if it hasn't finished yet + item { + Button( + onClick = { + if (isRecording) { + onCancelRecord.invoke(false) + } else { + onRecord.invoke(false) + } + }, + modifier = + Modifier.ifElse( + !(dto.isSeries ?: false), + Modifier.focusRequester( + recordFocusRequester, + ), + ), + ) { + Row( + horizontalArrangement = + Arrangement.spacedBy( + 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isRecording) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = Color.Red, + ) + } + Text( + text = + if (isRecording) { + stringResource( + R.string.cancel_recording, + ) + } else { + stringResource( + R.string.record_program, + ) + }, + ) + } + } + } + } + } + } } } } 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 7db259d3..f4b91430 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 @@ -4,16 +4,12 @@ import android.text.format.DateUtils import android.widget.Toast 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.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -42,14 +38,10 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text -import androidx.tv.material3.contentColorFor -import androidx.tv.material3.surfaceColorAtElevation -import coil3.compose.AsyncImage import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -69,6 +61,7 @@ import kotlin.math.abs @Composable fun TvGuideGrid( requestFocusAfterLoading: Boolean, + onRowPosition: (Int) -> Unit, modifier: Modifier = Modifier, viewModel: LiveTvViewModel = hiltViewModel(), ) { @@ -101,7 +94,7 @@ fun TvGuideGrid( } } Column(modifier = modifier) { - TvGuideGrid( + TvGuideGridContent( loading = state is LoadingState.Loading, channels = channels, programs = programs, @@ -115,7 +108,10 @@ fun TvGuideGrid( ), ) }, - onFocus = viewModel::onFocusChannel, + onFocus = { + onRowPosition.invoke(it.row) + viewModel.onFocusChannel(it) + }, onClickProgram = { index, program -> if (program.isFake) { val now = LocalDateTime.now() @@ -186,7 +182,7 @@ fun TvGuideGrid( } @Composable -fun TvGuideGrid( +fun TvGuideGridContent( loading: Boolean, channels: List, programs: FetchedPrograms, @@ -207,7 +203,7 @@ fun TvGuideGrid( val focusedProgramIndex = remember(programs.range, focusedItem) { focusedItem.let { focus -> - (programs.range.start.. 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 @@ -496,56 +494,7 @@ fun TvGuideGrid( val program = programs.programs[programIndex] val focused = gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex - val background = - if (focused) { - MaterialTheme.colorScheme.inverseSurface - } else { - program.category?.color - ?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) - } - val textColor = MaterialTheme.colorScheme.contentColorFor(background) - Box( - modifier = - Modifier - .padding(2.dp) - .fillMaxSize() - .background( - background, - shape = RoundedCornerShape(4.dp), - ), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier - .fillMaxSize() - .padding(4.dp), - ) { - Text( - text = program.name ?: program.id.toString(), - color = textColor, - maxLines = 1, - modifier = Modifier.enableMarquee(focused), - ) - listOfNotNull( - program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, - program.subtitle, - ).joinToString(" - ") - .ifBlank { null } - ?.let { - Text( - text = it, - color = textColor, - modifier = Modifier, - ) - } - } - RecordingMarker( - isRecording = program.isRecording, - isSeriesRecording = program.isSeriesRecording, - modifier = Modifier.align(Alignment.BottomEnd), - ) - } + Program(program, focused, Modifier) } channels( @@ -557,42 +506,12 @@ fun TvGuideGrid( val channel = channels[channelIndex] val focused = gridHasFocus && channelColumnFocused && focusedChannelIndex == channelIndex - val background = - if (focused) { - MaterialTheme.colorScheme.inverseSurface - } else { - MaterialTheme.colorScheme.surface - } - val textColor = MaterialTheme.colorScheme.contentColorFor(background) - Box( - modifier = - Modifier - .fillMaxSize() - .background( - background, - shape = RoundedCornerShape(4.dp), - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .padding(4.dp) - .fillMaxSize(), - ) { - Text( - text = channel.number ?: channel.name ?: channelIndex.toString(), - color = textColor, - modifier = Modifier, - ) - AsyncImage( - model = channel.imageUrl, - contentDescription = null, - modifier = Modifier.fillMaxHeight(.66f), - ) - } - } + Channel( + channel = channel, + channelIndex = channelIndex, + focused = focused, + modifier = Modifier, + ) } topCorner {