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 0d9bd338..906040fa 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 @@ -142,7 +142,6 @@ fun CollectionFolderLiveTv( when (selectedTabIndex) { 0 -> { TvGuideGrid( - true, onRowPosition = { showHeader = it <= 0 }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt index c27cde4e..41d53fd5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt @@ -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,26 +103,28 @@ fun Program( ) { Text( text = title, - color = textColor, + color = LocalContentColor.current, 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, - ) + val subtitle = + remember(program) { + listOfNotNull( + program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, + program.subtitle, + ).joinToString(" - ").ifBlank { null } } + subtitle?.let { + Text( + text = it, + color = LocalContentColor.current, + fontSize = 14.sp, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + } } } RecordingMarker( @@ -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(), ) { - Text( - text = channel.number ?: channel.name ?: channelIndex.toString(), - color = textColor, - modifier = Modifier, - ) - AsyncImage( - model = channel.imageUrl, - contentDescription = null, - modifier = Modifier.fillMaxHeight(.66f), - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + Text( + 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 = 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), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt index f9758d5f..d1d4fac6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt @@ -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) } 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 ce3fbd5b..173c6cf2 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 @@ -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, private val serverRepository: ServerRepository, + private val imageUrlService: ImageUrlService, + private val favoriteWatchManager: FavoriteWatchManager, ) : ViewModel() { - val loading = MutableLiveData(LoadingState.Pending) - private lateinit var channelsIdToIndex: Map private val mutex = Mutex() - val guideTimes = MutableLiveData>(buildGuideTimes()) - val channels = MutableLiveData>() - val channelProgramCount = mutableMapOf() - val programs = MutableLiveData() + private val _state = MutableStateFlow(LiveTvState()) + val state: StateFlow = _state - val fetchingItem = MutableLiveData(LoadingState.Pending) - val fetchedItem = MutableLiveData(null) + private val _programDialogState = MutableStateFlow(ProgramDialogState()) + val programDialogState: StateFlow = _programDialogState private val range = 100 init { - viewModelScope.launchIO { + viewModelScope.launchDefault { preferences.data .map { it.interfacePreferences.liveTvPreferences.let { @@ -105,69 +103,74 @@ 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", - ), - ) { - val prefs = - (preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()) - .interfacePreferences.liveTvPreferences - val channelData by api.liveTvApi.getLiveTvChannels( - GetLiveTvChannelsRequest( - startIndex = 0, - userId = serverRepository.currentUser.value?.id, - enableFavoriteSorting = prefs.favoriteChannelsAtBeginning, - sortBy = - if (prefs.sortByRecentlyWatched) { - listOf(ItemSortBy.DATE_PLAYED) - } else { - null - }, - sortOrder = - if (prefs.sortByRecentlyWatched) { - SortOrder.DESCENDING - } else { - null - }, - addCurrentProgram = false, - ), - ) - val channels = - channelData.items - .map { - TvChannel( - it.id, - it.channelNumber, - it.channelName, - api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY), - ) - } - Timber.d("Got ${channels.size} channels") - channelsIdToIndex = - channels.withIndex().associateBy({ it.value.id }, { it.index }) - // 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(guideStart, channels, 0.. initial) { - fetchPrograms(guideStart, channels, 0.. initial) { + fetchPrograms(guideStart, channels, 0.., range: IntRange, ) { - loading.setValueOnMain(LoadingState.Loading) - val guideStart = guideTimes.value!!.first() - fetchPrograms(guideStart, channels, range) - loading.setValueOnMain(LoadingState.Success) + _state.update { it.copy(refreshing = true) } + val guideStart = _state.value.guideTimes.first() + try { + fetchPrograms(guideStart, channels, range) + _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")) { - 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 = + fun fetchProgramForDialog(programId: UUID) { + _programDialogState.update { it.copy(loading = DataLoadingState.Loading) } + viewModelScope.launchDefault { + try { + val result = api.liveTvApi - .getPrograms(GetProgramsDto(seriesTimerId = result.data.seriesTimerId)) - .content.items - Timber.v("items=$items") + .getProgram(programId.toServerString()) + .content + .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,43 +508,60 @@ 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 quarter = range / 4 - var rangeStart = fetchedRange.start + quarter - var rangeEnd = fetchedRange.last - quarter + 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 - if (rangeEnd - rangeStart < range) { - if (position.row < range / 2) { - // Close to beginning - rangeStart = 0 - } else if (position.row > (channels.size - range / 2)) { - // Close to the end - rangeEnd = channels.size - } + if (rangeEnd - rangeStart < range) { + if (position.row < range / 2) { + // Close to beginning + rangeStart = 0 + } else if (position.row > (channels.size - range / 2)) { + // Close to the end + rangeEnd = channels.size } - val testRange = rangeStart.. = emptyList(), + val channels: List = emptyList(), + val channelProgramCount: Map = emptyMap(), + val programs: FetchedPrograms = FetchedPrograms(), +) + +data class ProgramDialogState( + val loading: DataLoadingState = 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, - val programsByChannel: Map>, + val range: IntRange = 0..0, + val programs: List = emptyList(), + val programsByChannel: Map> = emptyMap(), ) fun LocalDateTime.roundDownToHalfHour(): LocalDateTime { 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 8b42e9e1..53dabb47 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 @@ -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, 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,185 +88,184 @@ fun ProgramDialog( ) } - LoadingState.Success -> { - item?.let { item -> - val now = LocalDateTime.now() - val dto = item.data - val isRecording = dto.timerId.isNotNullOrBlank() - val isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank() - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Text( - text = item.name ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleLarge, + is DataLoadingState.Success -> { + val item = st.data + val now = LocalDateTime.now() + val dto = item.data + val isRecording = dto.timerId.isNotNullOrBlank() + val isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank() + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Text( + text = item.name ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge, + ) + if (dto.isSeries ?: false) { + listOfNotNull(dto.seasonEpisode, dto.episodeTitle) + .joinToString(" - ") + .ifBlank { null } + ?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, + ) + } + } + val time = + DateUtils.formatDateRange( + context, + dto.startDate!! + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + dto.endDate!! + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_TIME, ) - if (dto.isSeries ?: false) { - listOfNotNull(dto.seasonEpisode, dto.episodeTitle) - .joinToString(" - ") - .ifBlank { null } - ?.let { + Text( + text = time, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall, + ) + dto.overview?.let { overview -> + Text( + text = overview, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + maxLines = 3, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .padding(top = 8.dp) + .fillMaxWidth(), + ) { + if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) { + TextButton( + onClick = { onWatch.invoke(item) }, + modifier = Modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = stringResource(R.string.delete), + tint = Color.Green.copy(alpha = .75f), + ) Text( - text = it, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleMedium, + text = stringResource(R.string.watch_live), ) } - } - val time = - DateUtils.formatDateRange( - context, - dto.startDate!! - .atZone(ZoneId.systemDefault()) - .toInstant() - .epochSecond * 1000, - dto.endDate!! - .atZone(ZoneId.systemDefault()) - .toInstant() - .epochSecond * 1000, - DateUtils.FORMAT_SHOW_TIME, - ) - Text( - text = time, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleSmall, - ) - dto.overview?.let { overview -> - Text( - text = overview, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium, - overflow = TextOverflow.Ellipsis, - maxLines = 3, - ) - } - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier - .padding(top = 8.dp) - .fillMaxWidth(), - ) { - if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) { - TextButton( - onClick = onWatch, - modifier = Modifier, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = stringResource(R.string.delete), - tint = Color.Green.copy(alpha = .75f), - ) - Text( - text = stringResource(R.string.watch_live), - ) - } - } } - if (canRecord) { - val recordFocusRequester = remember { FocusRequester() } - LazyRow( - horizontalArrangement = - Arrangement.spacedBy( - 16.dp, - Alignment.CenterHorizontally, - ), - modifier = - Modifier - .fillMaxWidth() - .focusRestorer(recordFocusRequester), - ) { - if (dto.isSeries ?: false) { - item { - TextButton( - onClick = { - if (isSeriesRecording) { - onCancelRecord.invoke(true) - } else { - onRecord.invoke(true) - } - }, - modifier = - Modifier.focusRequester(recordFocusRequester), + } + if (canRecord) { + val recordFocusRequester = remember { FocusRequester() } + LazyRow( + horizontalArrangement = + Arrangement.spacedBy( + 16.dp, + Alignment.CenterHorizontally, + ), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(recordFocusRequester), + ) { + if (dto.isSeries ?: false) { + item { + TextButton( + onClick = { + if (isSeriesRecording) { + onCancelRecord.invoke(item, true) + } else { + onRecord.invoke(item, true) + } + }, + modifier = + Modifier.focusRequester(recordFocusRequester), + ) { + Row( + horizontalArrangement = + Arrangement.spacedBy( + 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, ) { - 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 (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 { - TextButton( - onClick = { - if (isRecording) { - onCancelRecord.invoke(false) - } else { - onRecord.invoke(false) - } - }, - modifier = - Modifier.ifElse( - !(dto.isSeries ?: false), - Modifier.focusRequester( - recordFocusRequester, - ), + } + if (dto.endDate?.isAfter(LocalDateTime.now()) ?: true) { + // Only show program specific recording button if it hasn't finished yet + item { + TextButton( + onClick = { + if (isRecording) { + onCancelRecord.invoke(item, false) + } else { + onRecord.invoke(item, false) + } + }, + modifier = + Modifier.ifElse( + !(dto.isSeries ?: false), + Modifier.focusRequester( + recordFocusRequester, ), + ), + ) { + Row( + horizontalArrangement = + Arrangement.spacedBy( + 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, ) { - 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 (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 30309304..54e32aa9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -2,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?>(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(null) } val focusRequester = remember { FocusRequester() } val buttonFocusRequester = remember { FocusRequester() } - if (requestFocusAfterLoading) { - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } + 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 { - viewModel.record( - programId = it.id, - series = series, - ) - } + onRecord = { program, series -> + viewModel.record( + programId = program.id, + series = series, + ) onDismissRequest.invoke() }, - onCancelRecord = { series -> - fetchedItem?.data?.let { - viewModel.cancelRecording( - series = series, - timerId = if (series) it.seriesTimerId else it.timerId, - ) - } + onCancelRecord = { program, series -> + viewModel.cancelRecording( + series = series, + 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, programs: FetchedPrograms, channelProgramCount: Map, @@ -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.. 0) { + scope.launch { + focusedProgramIndex = 0 + state.animateToProgram(0, Alignment.Center) + programFocusRequester.tryRequestFocus() + } + } + + BackHandler(focusedItem.column > 0) { + scope.launch { + val programIndex = + (0.. { - 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..= 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..= 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.. 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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt index 1c39ec71..7813cfb3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt @@ -4,66 +4,95 @@ 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, ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.Start, + Row( + horizontalArrangement = Arrangement.SpaceBetween, 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), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start, ) { - program?.subtitle?.let { - Text( - text = program.subtitle, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + 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 { + 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, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + if (program?.isRepeat == true) { + StreamLabel(stringResource(R.string.live_tv_repeat)) + } + } + OverviewText( + overview = program?.overview ?: "", + maxLines = 3, + onClick = {}, + enabled = false, ) } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - program?.quickDetails?.let { QuickDetails(it, null) } - if (program?.isRepeat == true) { - StreamLabel(stringResource(R.string.live_tv_repeat)) - } - } - OverviewText( - overview = program?.overview ?: "", - maxLines = 3, - onClick = {}, - enabled = false, - ) } + + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(program?.imageUrl) + .transitionFactory(CrossFadeFactory(300.milliseconds)) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + ) } }