mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Add my requests page
This commit is contained in:
parent
921ffadc1c
commit
ee640f82db
11 changed files with 364 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(" - ")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue