From ee640f82dbf2860cc07a8c9e218932b875a597ff Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 6 Jan 2026 15:02:32 -0500 Subject: [PATCH] Add my requests page --- .../wholphin/data/model/BaseItem.kt | 4 +- .../wholphin/ui/cards/GenreCard.kt | 3 - .../wholphin/ui/components/GenreCardGrid.kt | 3 +- .../wholphin/ui/detail/CardGrid.kt | 5 +- .../wholphin/ui/discover/DiscoverPage.kt | 102 +++++++++ .../{seerr => discover}/SeerrDiscoverPage.kt | 2 +- .../wholphin/ui/discover/SeerrRequestsPage.kt | 216 ++++++++++++++++++ .../wholphin/ui/nav/DestinationContent.kt | 4 +- .../ui/preferences/PreferencesContent.kt | 5 + .../damontecres/wholphin/util/LoadingState.kt | 27 +++ app/src/main/seerr/seerr-api.yml | 6 +- 11 files changed, 364 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt rename app/src/main/java/com/github/damontecres/wholphin/ui/{seerr => discover}/SeerrDiscoverPage.kt (99%) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 7e131763..b5ed3259 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -23,7 +23,9 @@ data class BaseItem( val data: BaseItemDto, val useSeriesForPrimary: Boolean, ) : CardGridItem { - override val id get() = data.id + val id get() = data.id + + override val gridId get() = id.toString() override val playable: Boolean get() = type.playable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt index 4340466c..73cf5ba3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -16,11 +16,9 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults @@ -35,7 +33,6 @@ import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import timber.log.Timber import java.util.UUID @Composable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index ec8feca0..ec2f4453 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -173,11 +173,12 @@ class GenreViewModel } data class Genre( - override val id: UUID, + val id: UUID, val name: String, val imageUrl: String?, val color: Color, ) : CardGridItem { + override val gridId: String get() = id.toString() override val playable: Boolean = false override val sortName: String get() = name } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 53b6b9b8..c9af784f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -73,12 +73,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -import java.util.UUID private const val DEBUG = false interface CardGridItem { - val id: UUID + val gridId: String val playable: Boolean val sortName: String } @@ -244,7 +243,7 @@ fun CardGrid( } else if (isPlayKeyUp(it)) { val item = pager.getOrNull(focusedIndex) if (item?.playable == true) { - Timber.v("Clicked play on ${item.id}") + Timber.v("Clicked play on ${item.gridId}") onClickPlay.invoke(focusedIndex, item) } return@onKeyEvent true diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt new file mode 100644 index 00000000..117e4c43 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt @@ -0,0 +1,102 @@ +package com.github.damontecres.wholphin.ui.discover + +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.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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.logTab +import com.github.damontecres.wholphin.ui.nav.NavDrawerItem +import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun DiscoverPage( + preferences: UserPreferences, + modifier: Modifier = Modifier, + preferencesViewModel: PreferencesViewModel = hiltViewModel(), +) { + val rememberedTabIndex = + remember { preferencesViewModel.getRememberedTab(preferences, NavDrawerItem.Discover.id, 0) } + + val tabs = + listOf( + stringResource(R.string.discover), + stringResource(R.string.request), + ) + var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } + val focusRequester = remember { FocusRequester() } + + val firstTabFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } + + LaunchedEffect(selectedTabIndex) { + logTab("discover", selectedTabIndex) + preferencesViewModel.saveRememberedTab(preferences, NavDrawerItem.Discover.id, selectedTabIndex) + preferencesViewModel.backdropService.clearBackdrop() + } + + var showHeader by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column( + modifier = modifier, + ) { + AnimatedVisibility( + showHeader, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = + Modifier + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .focusRequester(firstTabFocusRequester), + tabs = tabs, + onClick = { selectedTabIndex = it }, + ) + } + when (selectedTabIndex) { + // Discover + 0 -> { + SeerrDiscoverPage( + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) + } + + // Requests + 1 -> { + SeerrRequestsPage( + modifier = Modifier.fillMaxSize(), + ) + } + + else -> { + ErrorMessage("Invalid tab index $selectedTabIndex", null) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/seerr/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt similarity index 99% rename from app/src/main/java/com/github/damontecres/wholphin/ui/seerr/SeerrDiscoverPage.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 9f81c866..a1bfe126 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/seerr/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.seerr +package com.github.damontecres.wholphin.ui.discover import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt new file mode 100644 index 00000000..d6bc41f2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt @@ -0,0 +1,216 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MediaRequest +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SeerrRequestsViewModel + @Inject + constructor( + private val seerrServerRepository: SeerrServerRepository, + private val seerrService: SeerrService, + val navigationManager: NavigationManager, + private val backdropService: BackdropService, + ) : ViewModel() { + val state = MutableStateFlow(SeerrRequestsState.EMPTY) + + init { + viewModelScope.launchIO { + backdropService.clearBackdrop() + } + seerrServerRepository.current + .onEach { user -> + state.update { it.copy(requests = DataLoadingState.Loading) } + if (user != null) { + val semaphore = Semaphore(3) + val mediaRequests = + seerrService.api.requestApi + .requestGet() + .results + .orEmpty() + val requests = + mediaRequests.mapNotNull { request -> + if (request.media?.tmdbId != null) { + viewModelScope.async(Dispatchers.IO) { + semaphore.withPermit { + val type = SeerrItemType.fromString(request.type) + when (type) { + SeerrItemType.MOVIE -> { + seerrService.api.moviesApi + .movieMovieIdGet( + movieId = request.media.tmdbId, + ).let { DiscoverItem(it) } + } + + SeerrItemType.TV -> { + seerrService.api.tvApi + .tvTvIdGet(tvId = request.media.tmdbId) + .let { DiscoverItem(it) } + } + + SeerrItemType.PERSON -> { + null + } + + SeerrItemType.UNKNOWN -> { + null + } + }?.let { RequestGridItem(request, it) } + } + } + } else { + Timber.v("No TMDB ID for request %s", request.id) + null + } + } + val results = requests.awaitAll().filterNotNull() + + state.update { it.copy(requests = DataLoadingState.Success(results)) } + } + }.launchIn(viewModelScope) + } + + fun updateBackdrop(item: DiscoverItem?) { + viewModelScope.launchIO { + if (item != null) { + backdropService.submit("discover_${item.id}", item.backDropUrl) + } + } + } + } + +data class SeerrRequestsState( + val requests: DataLoadingState>, +) { + companion object { + val EMPTY = SeerrRequestsState(DataLoadingState.Pending) + } +} + +data class RequestGridItem( + val request: MediaRequest, + val item: DiscoverItem, +) : CardGridItem { + override val gridId: String = request.id.toString() + override val playable: Boolean = false + override val sortName: String = request.updatedAt ?: "0000" +} + +@Composable +fun SeerrRequestsPage( + modifier: Modifier = Modifier, + viewModel: SeerrRequestsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState(SeerrRequestsState.EMPTY) + + when (val state = state.requests) { + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception, modifier.focusable()) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier.focusable()) + } + + is DataLoadingState.Success> -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (state.data.isNotEmpty()) { + focusRequester.tryRequestFocus() + } + } + Column(modifier = modifier) { +// Text( +// text = stringResource(R.string.request), +// style = MaterialTheme.typography.displaySmall, +// color = MaterialTheme.colorScheme.onBackground, +// textAlign = TextAlign.Center, +// modifier = Modifier.fillMaxWidth(), +// ) + if (state.data.isEmpty()) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxSize(), + ) + } else { + CardGrid( + pager = state.data, + onClickItem = { index: Int, item: RequestGridItem -> + viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item.item)) + }, + onLongClickItem = { index: Int, item: RequestGridItem -> + }, + onClickPlay = { _, item -> + }, + letterPosition = { c: Char -> 0 }, + gridFocusRequester = focusRequester, + showJumpButtons = false, + showLetterButtons = false, + spacing = 24.dp, + cardContent = @Composable { item, onClick, onLongClick, mod -> + DiscoverItemCard( + item = item?.item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + columns = 6, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} 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 52ce96fb..8ad3b46a 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 @@ -28,11 +28,11 @@ import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview +import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage -import com.github.damontecres.wholphin.ui.seerr.SeerrDiscoverPage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType @@ -253,7 +253,7 @@ fun DestinationContent( } Destination.Discover -> { - SeerrDiscoverPage( + DiscoverPage( preferences = preferences, modifier = modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index d63f4a37..1c10e565 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -479,6 +479,11 @@ fun PreferencesContent( } else { val currentUser by seerrVm.currentUser.observeAsState() val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) + LaunchedEffect(status) { + if (status == LoadingState.Success) { + showSeerrServerDialog = false + } + } AddSeerServerDialog( currentUsername = currentUser?.name, status = status, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 9bf07d62..c35e9512 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -4,6 +4,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem /** * Generic state for loading something from the API + * + * @see DataLoadingState */ sealed interface LoadingState { data object Pending : LoadingState @@ -68,3 +70,28 @@ sealed interface HomeRowLoadingState { listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") } } + +/** + * Generic state for loading something from the API + * + * @see LoadingState + */ +sealed interface DataLoadingState { + data object Pending : DataLoadingState + + data object Loading : DataLoadingState + + data class Success( + val data: T, + ) : DataLoadingState + + data class Error( + val message: String? = null, + val exception: Throwable? = null, + ) : DataLoadingState { + constructor(exception: Throwable) : this(null, exception) + + val localizedMessage: String = + listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") + } +} diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 6ef6ac0f..cc4dc2a0 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -1260,6 +1260,8 @@ components: type: integer rootFolder: type: string + type: + type: string required: - id - status @@ -1269,10 +1271,10 @@ components: id: type: integer readOnly: true - tmdbid: + tmdbId: type: integer readOnly: true - tvdbid: + tvdbId: type: integer readOnly: true nullable: true