Add my requests page

This commit is contained in:
Damontecres 2026-01-06 15:02:32 -05:00
parent 921ffadc1c
commit ee640f82db
No known key found for this signature in database
11 changed files with 364 additions and 13 deletions

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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 <T : CardGridItem> 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

View file

@ -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)
}
}
}
}

View file

@ -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

View file

@ -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<List<RequestGridItem>>,
) {
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<List<RequestGridItem>> -> {
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(),
)
}
}
}
}
}

View file

@ -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,
)

View file

@ -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,

View file

@ -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<out T> {
data object Pending : DataLoadingState<Nothing>
data object Loading : DataLoadingState<Nothing>
data class Success<T>(
val data: T,
) : DataLoadingState<T>
data class Error(
val message: String? = null,
val exception: Throwable? = null,
) : DataLoadingState<Nothing> {
constructor(exception: Throwable) : this(null, exception)
val localizedMessage: String =
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
}
}

View file

@ -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