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 data: BaseItemDto,
val useSeriesForPrimary: Boolean, val useSeriesForPrimary: Boolean,
) : CardGridItem { ) : CardGridItem {
override val id get() = data.id val id get() = data.id
override val gridId get() = id.toString()
override val playable: Boolean override val playable: Boolean
get() = type.playable 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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults 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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.setup.rememberIdColor
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import timber.log.Timber
import java.util.UUID import java.util.UUID
@Composable @Composable

View file

@ -173,11 +173,12 @@ class GenreViewModel
} }
data class Genre( data class Genre(
override val id: UUID, val id: UUID,
val name: String, val name: String,
val imageUrl: String?, val imageUrl: String?,
val color: Color, val color: Color,
) : CardGridItem { ) : CardGridItem {
override val gridId: String get() = id.toString()
override val playable: Boolean = false override val playable: Boolean = false
override val sortName: String get() = name override val sortName: String get() = name
} }

View file

@ -73,12 +73,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.util.UUID
private const val DEBUG = false private const val DEBUG = false
interface CardGridItem { interface CardGridItem {
val id: UUID val gridId: String
val playable: Boolean val playable: Boolean
val sortName: String val sortName: String
} }
@ -244,7 +243,7 @@ fun <T : CardGridItem> CardGrid(
} else if (isPlayKeyUp(it)) { } else if (isPlayKeyUp(it)) {
val item = pager.getOrNull(focusedIndex) val item = pager.getOrNull(focusedIndex)
if (item?.playable == true) { if (item?.playable == true) {
Timber.v("Clicked play on ${item.id}") Timber.v("Clicked play on ${item.gridId}")
onClickPlay.invoke(focusedIndex, item) onClickPlay.invoke(focusedIndex, item)
} }
return@onKeyEvent true 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.Arrangement
import androidx.compose.foundation.layout.Column 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.movie.MovieDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview 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.HomePage
import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.main.SearchPage
import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage 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 com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
@ -253,7 +253,7 @@ fun DestinationContent(
} }
Destination.Discover -> { Destination.Discover -> {
SeerrDiscoverPage( DiscoverPage(
preferences = preferences, preferences = preferences,
modifier = modifier, modifier = modifier,
) )

View file

@ -479,6 +479,11 @@ fun PreferencesContent(
} else { } else {
val currentUser by seerrVm.currentUser.observeAsState() val currentUser by seerrVm.currentUser.observeAsState()
val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
LaunchedEffect(status) {
if (status == LoadingState.Success) {
showSeerrServerDialog = false
}
}
AddSeerServerDialog( AddSeerServerDialog(
currentUsername = currentUser?.name, currentUsername = currentUser?.name,
status = status, status = status,

View file

@ -4,6 +4,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem
/** /**
* Generic state for loading something from the API * Generic state for loading something from the API
*
* @see DataLoadingState
*/ */
sealed interface LoadingState { sealed interface LoadingState {
data object Pending : LoadingState data object Pending : LoadingState
@ -68,3 +70,28 @@ sealed interface HomeRowLoadingState {
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") 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 type: integer
rootFolder: rootFolder:
type: string type: string
type:
type: string
required: required:
- id - id
- status - status
@ -1269,10 +1271,10 @@ components:
id: id:
type: integer type: integer
readOnly: true readOnly: true
tmdbid: tmdbId:
type: integer type: integer
readOnly: true readOnly: true
tvdbid: tvdbId:
type: integer type: integer
readOnly: true readOnly: true
nullable: true nullable: true