From 05f381114979ba42c03f2c652c0473351ab5e4c4 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 23 Oct 2025 22:46:16 -0400 Subject: [PATCH] Add Live TV & DVR Support (#49) Closes #1 Adds support for Live TV & DVR functionality. TODO: - [x] EPG / TV Guide - [x] View DVR schedule - [x] Play live TV channel - [x] Record program - [x] Record Series - [x] Cancel recordings - [x] Access previously recorded content --- app/build.gradle.kts | 1 + .../damontecres/wholphin/ui/UiConstants.kt | 5 + .../ui/detail/CollectionFolderLiveTv.kt | 222 +++++++ .../wholphin/ui/detail/livetv/DvrSchedule.kt | 320 ++++++++++ .../ui/detail/livetv/LiveTvViewModel.kt | 350 ++++++++++ .../ui/detail/livetv/ProgramDialog.kt | 228 +++++++ .../ui/detail/livetv/RecordingMarker.kt | 64 ++ .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 599 ++++++++++++++++++ .../wholphin/ui/main/HomeViewModel.kt | 31 +- .../wholphin/ui/nav/DestinationContent.kt | 24 +- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 25 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 12 +- .../wholphin/util/ApiRequestPager.kt | 21 + .../damontecres/wholphin/util/Constants.kt | 7 + .../damontecres/wholphin/util/LazyList.kt | 11 + .../util/TrackActivityPlaybackListener.kt | 3 + app/src/main/res/drawable/gf_dvr.xml | 5 + gradle/libs.versions.toml | 2 + 18 files changed, 1915 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/RecordingMarker.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/LazyList.kt create mode 100644 app/src/main/res/drawable/gf_dvr.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7070291e..df0c5200 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -230,6 +230,7 @@ dependencies { implementation(libs.aboutlibraries.compose.m3) implementation(libs.multiplatform.markdown.renderer) implementation(libs.multiplatform.markdown.renderer.m3) + implementation(libs.programguide) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index b03d5950..fe0cfe27 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -22,6 +22,11 @@ sealed class AppColors private constructor() { val TransparentBlack25 = Color(0x40000000) val TransparentBlack50 = Color(0x80000000) val TransparentBlack75 = Color(0xBF000000) + + val DarkGreen = Color(0xFF114000) + val DarkRed = Color(0xFF400000) + val DarkCyan = Color(0xFF21556E) + val DarkPurple = Color(0xFF261370) } } 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 new file mode 100644 index 00000000..368df5ee --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -0,0 +1,222 @@ +package com.github.damontecres.wholphin.ui.detail + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.focus.onFocusChanged +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Tab +import androidx.tv.material3.TabDefaults +import androidx.tv.material3.TabRow +import androidx.tv.material3.TabRowDefaults +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule +import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid +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.nav.NavigationManager +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.RememberTabManager +import dagger.hilt.android.lifecycle.HiltViewModel +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class LiveTvCollectionViewModel + @Inject + constructor( + val api: ApiClient, + val serverRepository: ServerRepository, + val navigationManager: NavigationManager, + val rememberTabManager: RememberTabManager, + ) : ViewModel(), + RememberTabManager by rememberTabManager { + val recordingFolders = MutableLiveData>() + + init { + viewModelScope.launchIO { + val folders = + api.liveTvApi + .getRecordingFolders(userId = serverRepository.currentUser?.id) + .content.items + .map { TabId(it.name ?: "Recordings", it.id) } + this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders) + } + } + } + +data class TabId( + val title: String, + val id: UUID, +) + +@Composable +fun CollectionFolderLiveTv( + preferences: UserPreferences, + destination: Destination.MediaItem, + modifier: Modifier = Modifier, + viewModel: LiveTvCollectionViewModel = hiltViewModel(), +) { + val rememberedTabIndex = + remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } + val folders by viewModel.recordingFolders.observeAsState(listOf()) + + val tabs = + listOf( + TabId("Guide", UUID.randomUUID()), + TabId("DVR Schedule", UUID.randomUUID()), + ) + folders + + var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } + var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } + val focusRequester = remember { FocusRequester() } + + val firstTabFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } + + LaunchedEffect(selectedTabIndex) { + viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) + } + val onClickItem = { item: BaseItem -> + viewModel.navigationManager.navigateTo(item.destination()) + } + + var showHeader by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column( + modifier = modifier, + ) { + AnimatedVisibility( + showHeader, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + TabRow( + selectedTabIndex = focusTabIndex, + modifier = + Modifier + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .focusRestorer(firstTabFocusRequester) + .onFocusChanged { + if (!it.isFocused) { + focusTabIndex = selectedTabIndex + } + }, + indicator = + @Composable { tabPositions, doesTabRowHaveFocus -> + tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition -> +// TabRowDefaults.PillIndicator( +// currentTabPosition = currentTabPosition, +// doesTabRowHaveFocus = doesTabRowHaveFocus, +// ) + TabRowDefaults.UnderlinedIndicator( + currentTabPosition = currentTabPosition, + doesTabRowHaveFocus = doesTabRowHaveFocus, + activeColor = MaterialTheme.colorScheme.border, + ) + } + }, + tabs = { + tabs.forEachIndexed { index, tabId -> + Tab( + selected = focusTabIndex == index, + onClick = { selectedTabIndex = index }, + onFocus = { focusTabIndex = index }, + colors = + TabDefaults.pillIndicatorTabColors( + focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Text( + text = tabId.title, + style = MaterialTheme.typography.titleMedium, + modifier = + Modifier + .padding(8.dp) + .ifElse( + index == selectedTabIndex, + Modifier.focusRequester(firstTabFocusRequester), + ), + ) + } + } + }, + ) + } + when (selectedTabIndex) { + 0 -> { + TvGuideGrid( + true, + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + 1 -> { + DvrSchedule( + true, + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + + else -> { + val folderIndex = selectedTabIndex - 2 + if (folderIndex in folders.indices) { + CollectionFolderGrid( + preferences = preferences, + onClickItem = onClickItem, + itemId = folders[folderIndex].id, + item = null, + initialFilter = GetItemsFilter(), + showTitle = showHeader, + recursive = false, + modifier = + Modifier + .padding(start = 16.dp), + positionCallback = { columns, position -> + showHeader = position < columns + }, + ) + } else { + ErrorMessage("Invalid tab index $selectedTabIndex", null) + } + } + } + } +} 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 new file mode 100644 index 00000000..ad43c16f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt @@ -0,0 +1,320 @@ +package com.github.damontecres.wholphin.ui.detail.livetv + +import android.text.format.DateUtils +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.nav.NavigationManager +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.seasonEpisode +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import java.time.LocalDate +import java.time.OffsetDateTime +import java.time.ZoneId +import javax.inject.Inject + +@HiltViewModel +class DvrScheduleViewModel + @Inject + constructor( + private val api: ApiClient, + val navigationManager: NavigationManager, + ) : ViewModel() { + val loading = MutableLiveData(LoadingState.Loading) + val active = MutableLiveData>() + val scheduled = MutableLiveData>>() + + fun init() { +// loading.value = LoadingState.Loading + viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching DVR Schedule")) { + val active = + api.liveTvApi + .getRecordings( + isInProgress = true, + fields = SlimItemFields, + limit = 100, + ).content.items + .map { BaseItem.from(it, api, true) } + val scheduled = + api.liveTvApi + .getTimers( + isActive = false, + isScheduled = true, + ).content.items + .map { BaseItem.from(it.programInfo!!, api, true) } // TODO this probably breaks for time based recordings + .groupBy { + it.data.startDate!!.toLocalDate() + } + + withContext(Dispatchers.Main) { + this@DvrScheduleViewModel.active.value = active + this@DvrScheduleViewModel.scheduled.value = scheduled + loading.value = LoadingState.Success + } + } + } + + fun cancelRecording( + timerId: String, + series: Boolean, + ) { + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + if (series) { + api.liveTvApi.cancelSeriesTimer(timerId) + } else { + api.liveTvApi.cancelTimer(timerId) + } + init() + } + } + } + +@Composable +fun DvrSchedule( + requestFocusAfterLoading: Boolean, + modifier: Modifier = Modifier, + viewModel: DvrScheduleViewModel = hiltViewModel(), +) { + LaunchedEffect(Unit) { + viewModel.init() + } + val loading by viewModel.loading.observeAsState(LoadingState.Pending) + val active by viewModel.active.observeAsState(listOf()) + val recordings by viewModel.scheduled.observeAsState(mapOf()) + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state, modifier) + + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage(modifier) + + LoadingState.Success -> { + var showDialog by remember { mutableStateOf(null) } + val focusRequester = remember { FocusRequester() } + if (requestFocusAfterLoading) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } + DvrScheduleContent( + activeRecordings = active, + scheduledRecordings = recordings, + onClickItem = { + showDialog = it + }, + modifier = + modifier + .focusRequester(focusRequester), + ) + showDialog?.let { item -> + ProgramDialog( + item = item, + canRecord = true, + loading = LoadingState.Success, + onDismissRequest = { + showDialog = null + }, + onWatch = { + item.data.channelId?.let { + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = it, + positionMs = 0L, + ), + ) + } + }, + onRecord = { + // no-op + }, + onCancelRecord = { series -> + showDialog = null + val timerId = if (series) item.data.seriesTimerId else item.data.timerId + if (timerId != null) { + viewModel.cancelRecording(timerId, series) + } + }, + ) + } + } + } +} + +@Composable +fun DvrScheduleContent( + activeRecordings: List, + scheduledRecordings: Map>, + onClickItem: (BaseItem) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + Box( + contentAlignment = Alignment.TopCenter, + modifier = modifier, + ) { + LazyColumn( + contentPadding = PaddingValues(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth(.6f) + .background(MaterialTheme.colorScheme.surface), + ) { + if (activeRecordings.isEmpty() && scheduledRecordings.isEmpty()) { + item { + Text( + text = "No scheduled recordings", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + if (activeRecordings.isNotEmpty()) { + item { + Text( + text = "Active", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) + } + items(activeRecordings) { item -> + Recording( + item = item, + onClick = { onClickItem.invoke(item) }, + modifier = Modifier, + ) + } + } + scheduledRecordings.keys.sorted().forEach { date -> + val formattedDate = + DateUtils.formatDateTime( + context, + date + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_WEEKDAY or DateUtils.FORMAT_SHOW_DATE, + ) + item { + Text( + text = formattedDate, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) + } + val programs = scheduledRecordings[date].orEmpty() + items(programs) { item -> + Recording( + item = item, + onClick = { onClickItem.invoke(item) }, + modifier = Modifier, + ) + } + } + } + } +} + +@Composable +fun Recording( + item: BaseItem, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + ListItem( + selected = false, + onClick = onClick, + modifier = modifier, + colors = + ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + ), + leadingContent = { + // TODO +// AsyncImage( +// model = item.imageUrl, +// contentDescription = null, +// ) + }, + headlineContent = { + Text( + text = item.title ?: "", + ) + }, + supportingContent = { + if (item.data.isSeries ?: false) { + listOfNotNull( + item.data.seasonEpisode, + item.data.episodeTitle, + ).joinToString(" - ") + .ifBlank { null } + ?.let { + Text( + text = it, + modifier = Modifier, + ) + } + } + }, + trailingContent = { + val time = + DateUtils.formatDateRange( + context, + item.data.startDate!! + .toInstant(OffsetDateTime.now().offset) + .epochSecond * 1000, + item.data.endDate!! + .toInstant(OffsetDateTime.now().offset) + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_TIME, + ) + Text( + text = time, + ) + }, + ) +} 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 new file mode 100644 index 00000000..e26dd5da --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -0,0 +1,350 @@ +package com.github.damontecres.wholphin.ui.detail.livetv + +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.NavigationManager +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.toServerString +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +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.GetProgramsDto +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.TimerInfoDto +import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest +import org.jellyfin.sdk.model.api.request.GetLiveTvProgramsRequest +import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit +import java.util.UUID +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +const val MAX_HOURS = 48L + +@HiltViewModel +class LiveTvViewModel + @Inject + constructor( + val api: ApiClient, + val navigationManager: NavigationManager, + ) : ViewModel() { + val loading = MutableLiveData(LoadingState.Pending) + + lateinit var start: LocalDateTime + private set + private lateinit var channelsIdToIndex: Map + private val mutex = Mutex() + + val channels = MutableLiveData>() + val programs = MutableLiveData>() + val programsByChannel = MutableLiveData>>(mapOf()) + + val fetchingItem = MutableLiveData(LoadingState.Pending) + val fetchedItem = MutableLiveData(null) + + fun init(firstLoad: Boolean) { + start = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) + if (!firstLoad) { + loading.value = LoadingState.Loading + } + viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Could not fetch channels")) { + val channelData by api.liveTvApi.getLiveTvChannels( + GetLiveTvChannelsRequest( + startIndex = 0, + ), + ) + 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 }) + fetchPrograms(channels) + + withContext(Dispatchers.Main) { + this@LiveTvViewModel.channels.value = channels + loading.value = LoadingState.Success + } + } + } + + private suspend fun fetchProgramsWithLoading(channels: List) { + loading.setValueOnMain(LoadingState.Loading) + fetchPrograms(channels) + loading.setValueOnMain(LoadingState.Success) + } + + private suspend fun fetchPrograms(channels: List) = + mutex.withLock { + val maxStartDate = start.plusHours(MAX_HOURS).minusMinutes(1) + val minEndDate = start.plusMinutes(1L) + + val request = + GetLiveTvProgramsRequest( + maxStartDate = maxStartDate, + minEndDate = minEndDate, + channelIds = channels.map { it.id }, + sortBy = listOf(ItemSortBy.START_DATE), + ) + val programs = + api.liveTvApi + .getLiveTvPrograms(request) + .content.items + .map { dto -> + val category = + if (dto.isKids ?: false) { + ProgramCategory.KIDS + } else if (dto.isMovie ?: false) { + ProgramCategory.MOVIE + } else if (dto.isNews ?: false) { + ProgramCategory.NEWS + } else if (dto.isSports ?: false) { + ProgramCategory.SPORTS + } else { + null + } + TvProgram( + id = dto.id, + channelId = dto.channelId!!, + start = dto.startDate!!, + end = dto.endDate!!, + startHours = hoursBetween(start, dto.startDate!!).coerceAtLeast(0f), + endHours = hoursBetween(start, dto.endDate!!), + duration = dto.runTimeTicks!!.ticks, + name = dto.seriesName ?: dto.name, + subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false }, + seasonEpisode = + if (dto.indexNumber != null && dto.parentIndexNumber != null) { + SeasonEpisode(dto.parentIndexNumber!!, dto.indexNumber!!) + } else { + null + }, + isRecording = dto.timerId.isNotNullOrBlank(), + isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank(), + isRepeat = dto.isRepeat ?: false, + category = category, + ) + } + + val programsByChannel = programs.groupBy { it.channelId } + val emptyChannels = channels.filter { programsByChannel[it.id].orEmpty().isEmpty() } + val fake = mutableListOf() + val finalProgramsByChannel = + programsByChannel.toMutableMap().apply { + emptyChannels.forEach { channel -> + val fakePrograms = + (0.. Unit, + onWatch: () -> Unit, + onRecord: (series: Boolean) -> Unit, + onCancelRecord: (series: Boolean) -> Unit, +) { + val context = LocalContext.current + Dialog( + onDismissRequest = onDismissRequest, + ) { + val focusRequester = remember { FocusRequester() } + Box( + modifier = + Modifier + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp), + shape = RoundedCornerShape(16.dp), + ).focusRequester(focusRequester), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(16.dp), + ) { + when (val st = loading) { + is LoadingState.Error -> ErrorMessage(st) + LoadingState.Loading, + LoadingState.Pending, + -> + CircularProgress( + Modifier + .padding(8.dp) + .size(48.dp), + ) + + 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, + ) + 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, + ) + 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 (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) "Cancel Series Recording" else "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) "Cancel Recording" else "Record Program", + ) + } + } + } + } + } + if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) { + Button( + onClick = onWatch, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Delete", + tint = Color.Green.copy(alpha = .75f), + ) + Text( + text = "Watch live", + ) + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/RecordingMarker.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/RecordingMarker.kt new file mode 100644 index 00000000..4d12e88a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/RecordingMarker.kt @@ -0,0 +1,64 @@ +package com.github.damontecres.wholphin.ui.detail.livetv + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.theme.WholphinTheme + +// .align(Alignment.BottomEnd) +@Composable +fun RecordingMarker( + isRecording: Boolean, + isSeriesRecording: Boolean, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + if (isSeriesRecording) { + val color = if (isRecording) Color.Red else Color.Gray + Box( + modifier = + Modifier + .padding(4.dp) + .size(16.dp) + .background(color, shape = CircleShape), + ) + Box( + modifier = + Modifier + .padding(start = 4.dp, top = 4.dp, bottom = 4.dp, end = 10.dp) + .offset(6.dp) + .size(16.dp) + .background(color.copy(alpha = .5f), shape = CircleShape), + ) + } else if (isRecording) { + Box( + modifier = + Modifier + .padding(4.dp) + .size(16.dp) + .background(Color.Red, shape = CircleShape), + ) + } + } +} + +@PreviewTvSpec +@Composable +private fun RecordingMarkerPreview() { + WholphinTheme { + Column { + RecordingMarker(true, false) + RecordingMarker(true, true) + RecordingMarker(false, true) + } + } +} 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 new file mode 100644 index 00000000..40e26b17 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -0,0 +1,599 @@ +package com.github.damontecres.wholphin.ui.detail.livetv + +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 +import androidx.compose.runtime.livedata.observeAsState +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.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.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +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.enableMarquee +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import com.google.common.cache.CacheBuilder +import com.google.common.cache.CacheLoader +import eu.wewox.programguide.ProgramGuide +import eu.wewox.programguide.ProgramGuideDimensions +import eu.wewox.programguide.ProgramGuideItem +import eu.wewox.programguide.rememberSaveableProgramGuideState +import kotlinx.coroutines.launch +import timber.log.Timber +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.util.UUID + +@Composable +fun TvGuideGrid( + requestFocusAfterLoading: Boolean, + modifier: Modifier = Modifier, + viewModel: LiveTvViewModel = hiltViewModel(), +) { + 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(listOf()) + val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf()) + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state, modifier) + LoadingState.Pending, + -> LoadingPage(modifier) + + LoadingState.Loading, + LoadingState.Success, + -> { + val context = LocalContext.current + val fetchedItem by viewModel.fetchedItem.observeAsState(null) + val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending) + var showItemDialog by remember { mutableStateOf(null) } + val focusRequester = remember { FocusRequester() } + if (requestFocusAfterLoading) { + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + } + Column(modifier = modifier) { + TvGuideGrid( + loading = state is LoadingState.Loading, + channels = channels, + programList = programs, + programs = programsByChannel, + start = viewModel.start, + onClickChannel = { index, channel -> + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = channel.id, + positionMs = 0L, + ), + ) + }, + onClickProgram = { index, program -> + if (program.isFake) { + val now = LocalDateTime.now() + if (now.isAfter(program.start) && now.isBefore(program.end)) { + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = program.channelId, + positionMs = 0L, + ), + ) + } else { + Toast + .makeText(context, "No guide data found!", Toast.LENGTH_SHORT) + .show() + } + } else { + viewModel.getItem(program.id) + showItemDialog = index + } + }, + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .focusRequester(focusRequester), + ) + } + if (showItemDialog != null) { + val onDismissRequest = { showItemDialog = null } + ProgramDialog( + item = fetchedItem, + canRecord = true, + loading = loadingItem, + onDismissRequest = onDismissRequest, + onWatch = { + onDismissRequest.invoke() + fetchedItem?.data?.channelId?.let { + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = it, + positionMs = 0L, + ), + ) + } + }, + onRecord = { series -> + fetchedItem?.let { + viewModel.record( + programIndex = showItemDialog!!, + programId = it.id, + series = series, + ) + } + onDismissRequest.invoke() + }, + onCancelRecord = { series -> + fetchedItem?.data?.let { + viewModel.cancelRecording( + programIndex = showItemDialog!!, + programId = it.id, + series = series, + timerId = if (series) it.seriesTimerId else it.timerId, + ) + } + onDismissRequest.invoke() + }, + ) + } + } + } +} + +const val CHANNEL_COLUMN = -1 + +@Composable +fun TvGuideGrid( + loading: Boolean, + channels: List, + programList: List, + programs: Map>, + start: LocalDateTime, + onClickChannel: (Int, TvChannel) -> Unit, + onClickProgram: (Int, TvProgram) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val state = rememberSaveableProgramGuideState() + val scope = rememberCoroutineScope() + var focusedProgramIndex by rememberInt(0) + var focusedChannelIndex by rememberInt(0) + + val dimensions = + ProgramGuideDimensions( + timelineHourWidth = 240.dp, + timelineHeight = 32.dp, + channelWidth = 120.dp, + channelHeight = 80.dp, + currentTimeWidth = 2.dp, + ) + + val programsBeforeChannel = + remember { + CacheBuilder + .newBuilder() + .maximumSize(200) + .build( + object : CacheLoader() { + override fun load(key: Int): Int = + channels + .subList(0, key) + .map { it.id } + .sumOf { programs[it]?.size ?: 0 } + }, + ) + } + + var gridHasFocus by rememberSaveable { mutableStateOf(false) } + var channelColumnFocused by rememberSaveable { mutableStateOf(false) } + Box(modifier = modifier) { + ProgramGuide( + state = state, + dimensions = dimensions, + modifier = + Modifier + .fillMaxSize() + .onFocusChanged { + gridHasFocus = it.hasFocus + }.focusable() + .onPreviewKeyEvent { + if (it.type == KeyEventType.KeyUp) { + return@onPreviewKeyEvent false + } + val newIndex = + when (it.key) { + Key.Back -> { + val pos = programsBeforeChannel.get(focusedChannelIndex) + if (focusedProgramIndex - pos > 0) { + // Not at beginning of row, so move to beginning + pos + } else { + // At beginning, so allow normal back button behavior + return@onPreviewKeyEvent false + } + } + + Key.DirectionRight -> { + if (channelColumnFocused) { + channelColumnFocused = false + focusedProgramIndex + } else { + val nextProgramIndex = focusedProgramIndex + 1 + val programsBefore = + programsBeforeChannel.get(focusedChannelIndex) + val relativePosition = nextProgramIndex - programsBefore + val channelPrograms = + programs[channels[focusedChannelIndex].id].orEmpty() + if (relativePosition >= channelPrograms.size) { + focusManager.moveFocus(FocusDirection.Right) + null + } else { + nextProgramIndex + } + } + } + + Key.DirectionLeft -> { + if (channelColumnFocused) { + focusManager.moveFocus(FocusDirection.Left) + null + } else { + val nextProgramIndex = focusedProgramIndex - 1 + val programsBefore = + programsBeforeChannel.get(focusedChannelIndex) + val relativePosition = nextProgramIndex - programsBefore +// val channelPrograms = +// programs[channels[focusedChannel].id].orEmpty() + if (relativePosition >= 0) { + nextProgramIndex + } else if (relativePosition == CHANNEL_COLUMN) { + channelColumnFocused = true + focusedProgramIndex + } else { + Timber.w("Move left to relativePosition=$relativePosition should not occur") + focusManager.moveFocus(FocusDirection.Left) + null + } + } + } + + Key.DirectionUp -> { + val newFocusedChannelIndex = (focusedChannelIndex - 1) +// Timber.v("newFocusedChannelIndex=$newFocusedChannelIndex") + if (newFocusedChannelIndex < 0) { + focusManager.moveFocus(FocusDirection.Up) + null + } else if (channelColumnFocused) { + focusedChannelIndex = newFocusedChannelIndex + programsBeforeChannel.get(focusedChannelIndex) + } else { + val start = programList[focusedProgramIndex].startHours + focusedChannelIndex = + newFocusedChannelIndex.coerceAtLeast(0) + val channelId = channels[focusedChannelIndex].id + val pro = programs[channelId].orEmpty() + val pIndex = + pro.indexOfFirst { start in (it.startHours..= 0) { + programsBeforeChannel.get(focusedChannelIndex) + pIndex + } else { + val pIndex = pro.indexOfFirst { it.startHours >= start } + if (pIndex >= 0) { + programsBeforeChannel.get(focusedChannelIndex) + pIndex + } else { + programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size + } + } + } + } + + Key.DirectionDown -> { + // Move channel focus down + val newFocusedChannelIndex = (focusedChannelIndex + 1) + if (newFocusedChannelIndex >= channels.size) { + // If trying to move below the final channel, then move focus out of the grid + focusManager.moveFocus(FocusDirection.Down) + null + } else if (channelColumnFocused) { + // If focused on the channel column, move down a channel + focusedChannelIndex = + newFocusedChannelIndex.coerceAtMost(channels.size - 1) + programsBeforeChannel.get(focusedChannelIndex) + } else { + // Otherwise, moving to a new row + focusedChannelIndex = + newFocusedChannelIndex.coerceAtMost(channels.size - 1) + // Get the new row/channel's programs + val channelId = channels[focusedChannelIndex].id + val pro = programs[channelId].orEmpty() + // When the currently focused program starts + val start = programList[focusedProgramIndex].startHours + // Find the first program where the start time (of the previously focused program) is in the middle of a program + val pIndex = + pro.indexOfFirst { start in (it.startHours..= 0) { + // Found one, so focus on it + // Get the sum of all of the previous channels' program sizes, plus the index found to convert a relative program index into absolute + programsBeforeChannel.get(focusedChannelIndex) + 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 = pro.indexOfFirst { it.startHours >= start } + if (pIndex >= 0) { + // Found one, so focus on it + programsBeforeChannel.get(focusedChannelIndex) + pIndex + } else { + // Did not find one, so focus on the final program in the list + programsBeforeChannel.get(focusedChannelIndex) + programs[channelId]!!.size + } + } + } + } + + Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { + if (channelColumnFocused) { + val channel = channels[focusedChannelIndex] + Timber.v("Clicked on %s", channel) + onClickChannel.invoke(focusedChannelIndex, channel) + } else { + val program = programList[focusedProgramIndex] + Timber.v("Clicked on %s", program) + onClickProgram.invoke(focusedProgramIndex, program) + } + null + } + + else -> { + null + } + } + if (newIndex != null) { +// Timber.v("newIndex=$newIndex") + if (newIndex >= 0) { + val before = programsBeforeChannel.get(focusedChannelIndex) + val max = + before + channels[focusedChannelIndex].let { programs[it.id]!!.size } - 1 + val index = newIndex.coerceIn(before, max) + scope.launch(ExceptionHandler()) { + focusedProgramIndex = index + state.animateToProgram(index, Alignment.Center) + } + return@onPreviewKeyEvent true + } else { + Timber.w("newIndex is <0: $newIndex") + return@onPreviewKeyEvent true + } + } + return@onPreviewKeyEvent false + }, + ) { + guideStartHour = 0f + currentTime( + layoutInfo = { + ProgramGuideItem.CurrentTime( + hoursBetween(start, LocalDateTime.now()), + ) + }, + ) { + Surface( + colors = SurfaceDefaults.colors(MaterialTheme.colorScheme.tertiary.copy(alpha = .25f)), + modifier = Modifier, + ) { + // Empty + } + } + timeline( + count = MAX_HOURS.toInt(), + layoutInfo = { index -> + ProgramGuideItem.Timeline( + startHour = index.toFloat(), + endHour = index + 1f, + ) + }, + ) { index -> + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { + val differentDay = + start.toLocalDate() != + start + .plusHours(index.toLong()) + .toLocalDate() + val time = + DateUtils.formatDateTime( + context, + start + .plusHours(index.toLong()) + .toInstant(OffsetDateTime.now().offset) + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, + ) + Text( + text = time.toString(), + modifier = Modifier.background(MaterialTheme.colorScheme.background), + ) + } + } + + programs( + count = programList.size, + layoutInfo = { programIndex -> + val program = programList[programIndex] + val channelIndex = channels.indexOfFirst { it.id == program.channelId } + ProgramGuideItem.Program(channelIndex, program.startHours, program.endHours) + }, + ) { programIndex -> + val program = programList[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 +// .scale(if (focused) 1.1f else 1f) + .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), + ) + } + } + + channels( + count = channels.size, + layoutInfo = { channelIndex -> + ProgramGuideItem.Channel(channelIndex) + }, + ) { channelIndex -> + 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), + ) + } + } + } + + topCorner { + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) + } + } + if (loading) { + CircularProgress( + Modifier + .background( + MaterialTheme.colorScheme.background.copy(alpha = .5f), + shape = CircleShape, + ).size(64.dp) + .padding(16.dp) + .align(Alignment.BottomEnd), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index d8f2d2f1..930df7ef 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -13,7 +13,6 @@ import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.supportItemKinds -import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -21,10 +20,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest @@ -169,15 +170,30 @@ class HomeViewModel val rows = latestMediaIncludes .mapNotNull { viewId -> views.items.firstOrNull { it.id == viewId } } - .filter { it.collectionType in supportedCollectionTypes } + .filter { it.collectionType in supportedLatestCollectionTypes } .map { view -> val title = - view.name?.let { "Recently Added in $it" } + if (view.collectionType == CollectionType.LIVETV) { + "Recently Recorded" + } else { + view.name?.let { "Recently Added in $it" } + } + val viewId = + if (view.collectionType == CollectionType.LIVETV) { + api.liveTvApi + .getRecordingFolders( + userId = user.id, + ).content.items + .firstOrNull() + ?.id + } else { + view.id + } val request = GetLatestMediaRequest( fields = SlimItemFields, imageTypeLimit = 1, - parentId = view.id, + parentId = viewId, groupItems = true, limit = limit, isPlayed = null, // Server will handle user's preference @@ -196,3 +212,10 @@ class HomeViewModel return rows } } + +val supportedLatestCollectionTypes = + setOf( + CollectionType.MOVIES, + CollectionType.TVSHOWS, + CollectionType.LIVETV, + ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index d6ce940a..2093ff14 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -6,6 +6,7 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.LicenseInfo import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric +import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv import com.github.damontecres.wholphin.ui.detail.DebugPage @@ -24,6 +25,7 @@ import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.DeviceProfile +import timber.log.Timber /** * Chose the page for the [Destination] @@ -144,15 +146,35 @@ fun DestinationContent( ) BaseItemKind.USER_VIEW -> + when (destination.item?.data?.collectionType) { + CollectionType.LIVETV -> + CollectionFolderLiveTv( + preferences, + destination, + modifier, + ) + + else -> + CollectionFolderGeneric( + preferences, + destination.itemId, + destination.item, + true, + modifier, + ) + } + + BaseItemKind.FOLDER -> CollectionFolderGeneric( preferences, destination.itemId, destination.item, - true, + false, modifier, ) else -> { + Timber.w("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}") } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 5ade979d..a9ad45a6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -403,6 +404,7 @@ fun NavigationDrawerScope.NavItem( containerColor: Color = Color.Unspecified, ) { val context = LocalContext.current + val useFont = library !is ServerNavDrawerItem || library.type != CollectionType.LIVETV val icon = when (library) { NavDrawerItem.Favorites -> R.string.fa_heart @@ -413,7 +415,7 @@ fun NavigationDrawerScope.NavItem( CollectionType.MOVIES -> R.string.fa_film CollectionType.TVSHOWS -> R.string.fa_tv CollectionType.HOMEVIDEOS -> R.string.fa_video - CollectionType.LIVETV -> R.string.fa_tv + CollectionType.LIVETV -> R.drawable.gf_dvr CollectionType.MUSIC -> R.string.fa_music CollectionType.BOXSETS -> R.string.fa_open_folder CollectionType.PLAYLISTS -> R.string.fa_list_ul @@ -437,13 +439,20 @@ fun NavigationDrawerScope.NavItem( ), leadingContent = { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = stringResource(icon), - textAlign = TextAlign.Center, - fontSize = 16.sp, - fontFamily = FontAwesome, - color = color, - ) + if (useFont) { + Text( + text = stringResource(icon), + textAlign = TextAlign.Center, + fontSize = 16.sp, + fontFamily = FontAwesome, + color = color, + ) + } else { + Icon( + painter = painterResource(icon), + contentDescription = null, + ) + } } }, trailingContent = { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 10c51e2f..e6905b32 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.EqualityMutableLiveData @@ -224,6 +225,7 @@ class PlaybackViewModel ) return@withContext false } + val isLiveTv = base.type == BaseItemKind.TV_CHANNEL dto = base val title = @@ -324,7 +326,7 @@ class PlaybackViewModel rowId = -1, userId = -1, itemId = base.id, - sourceId = mediaSource.id?.toUUIDOrNull(), + sourceId = if (!isLiveTv) mediaSource.id?.toUUIDOrNull() else null, audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED, ) @@ -394,12 +396,16 @@ class PlaybackViewModel subtitleStreamIndex = subtitleIndex, allowVideoStreamCopy = true, allowAudioStreamCopy = true, - autoOpenLiveStream = false, + autoOpenLiveStream = true, mediaSourceId = currentItemPlayback.sourceId?.toServerString(), alwaysBurnInSubtitleWhenTranscoding = null, maxStreamingBitrate = maxBitrate.toInt(), ), ) + if (response.errorCode != null) { + loading.setValueOnMain(LoadingState.Error(response.errorCode?.serialName)) + return@withContext + } val source = response.mediaSources.firstOrNull() source?.let { source -> val mediaUrl = @@ -462,6 +468,7 @@ class PlaybackViewModel listOf(), playMethod = transcodeType, playSessionId = response.playSessionId, + liveStreamId = source.liveStreamId, ) val itemPlayback = currentItemPlayback.copy( @@ -780,6 +787,7 @@ data class CurrentPlayback( val tracks: List, val playMethod: PlayMethod, val playSessionId: String?, + val liveStreamId: String?, ) val Format.idAsInt: Int? diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index 3cf7668c..bde82618 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -17,10 +17,12 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.GetProgramsDto import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest @@ -317,3 +319,22 @@ val GetGenresRequestHandler = request: GetGenresRequest, ): Response = api.genresApi.getGenres(request) } + +val GetProgramsDtoHandler = + object : RequestHandler { + override fun prepare( + request: GetProgramsDto, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetProgramsDto = + request.copy( + startIndex = startIndex, + limit = limit, + ) + + override suspend fun execute( + api: ApiClient, + request: GetProgramsDto, + ): Response = api.liveTvApi.getPrograms(request) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt index a216f31a..60304936 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt @@ -19,8 +19,14 @@ val supportItemKinds = BaseItemKind.VIDEO, BaseItemKind.SEASON, BaseItemKind.COLLECTION_FOLDER, + BaseItemKind.FOLDER, BaseItemKind.USER_VIEW, BaseItemKind.TRAILER, + BaseItemKind.TV_CHANNEL, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_CHANNEL, + BaseItemKind.LIVE_TV_PROGRAM, + BaseItemKind.RECORDING, ) val supportedCollectionTypes = @@ -30,4 +36,5 @@ val supportedCollectionTypes = CollectionType.HOMEVIDEOS, CollectionType.PLAYLISTS, CollectionType.BOXSETS, + CollectionType.LIVETV, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LazyList.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LazyList.kt new file mode 100644 index 00000000..e6eb2a98 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LazyList.kt @@ -0,0 +1,11 @@ +package com.github.damontecres.wholphin.util + +class LazyList( + private val source: List, + private val transform: (Source) -> Transform, +) : AbstractList() { + override fun get(index: Int): Transform = transform.invoke(source[index]) + + override val size: Int + get() = source.size +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index 48bc464d..41ee665e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -49,6 +49,7 @@ class TrackActivityPlaybackListener( isMuted = false, audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, + liveStreamId = playback.liveStreamId, ), ) } @@ -78,6 +79,7 @@ class TrackActivityPlaybackListener( positionTicks = position, failed = false, playSessionId = playback.playSessionId, + liveStreamId = playback.liveStreamId, ), ) } @@ -114,6 +116,7 @@ class TrackActivityPlaybackListener( playSessionId = playback.playSessionId, audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, + liveStreamId = playback.liveStreamId, ), ) } diff --git a/app/src/main/res/drawable/gf_dvr.xml b/app/src/main/res/drawable/gf_dvr.xml new file mode 100644 index 00000000..abc6b00b --- /dev/null +++ b/app/src/main/res/drawable/gf_dvr.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09177416..5fca3a60 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ appcompat = "1.7.1" composeBom = "2025.10.00" compose-runtime = "1.9.3" multiplatformMarkdownRenderer = "0.37.0" +programguide = "1.6.0" timber = "5.0.1" tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" @@ -59,6 +60,7 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } +programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }