mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Request individual seasons from Jellyseerr (#1060)
## Description Adds a dialog to select individual seasons for a TV show when requesting it via Discover/Jellyseerr If a request is already pending, it can be updated. If there's more than 7 seasons, a second submit button is added at the bottom of the dialog for easier access. Also, adds a button from Jellyfin series details to its Discover equivalent and added a request button for partially available series to request more seasons. ### Related issues Closes #961 ### Testing Emulator & Jellyseerr 2.7.3 ## Screenshots  ## AI or LLM usage None
This commit is contained in:
parent
49a6cd8d2f
commit
afc47f254b
12 changed files with 512 additions and 46 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.CreditCast
|
import com.github.damontecres.wholphin.api.seerr.model.CreditCast
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
|
import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||||
|
|
@ -74,6 +75,7 @@ enum class SeerrAvailability(
|
||||||
/**
|
/**
|
||||||
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well.
|
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well.
|
||||||
*/
|
*/
|
||||||
|
@Stable
|
||||||
@Serializable
|
@Serializable
|
||||||
data class DiscoverItem(
|
data class DiscoverItem(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ class SeerrServerRepository
|
||||||
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
|
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
|
||||||
val currentUser: Flow<SeerrUser?> =
|
val currentUser: Flow<SeerrUser?> =
|
||||||
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
|
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
|
||||||
|
val currentUserId: Flow<Int?> = current.map { it?.config?.id }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Seerr integration is currently active of not
|
* Whether Seerr integration is currently active of not
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
|
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
@ -125,4 +126,16 @@ class SeerrService
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getTvSeries(item: BaseItem): TvDetails? =
|
||||||
|
if (active.first()) {
|
||||||
|
item.data.providerIds
|
||||||
|
?.get("Tmdb")
|
||||||
|
?.toIntOrNull()
|
||||||
|
?.let { tvId ->
|
||||||
|
api.tvApi.tvTvIdGet(tvId = tvId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.Season
|
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
|
@ -99,6 +98,7 @@ fun DiscoverSeriesDetails(
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
var showRequestSeasonDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val requestStr = stringResource(R.string.request)
|
val requestStr = stringResource(R.string.request)
|
||||||
val request4kStr = stringResource(R.string.request_4k)
|
val request4kStr = stringResource(R.string.request_4k)
|
||||||
|
|
@ -159,26 +159,7 @@ fun DiscoverSeriesDetails(
|
||||||
trailers = trailers,
|
trailers = trailers,
|
||||||
requestOnClick = {
|
requestOnClick = {
|
||||||
item.id?.let { id ->
|
item.id?.let { id ->
|
||||||
if (request4kEnabled) {
|
showRequestSeasonDialog = true
|
||||||
moreDialog =
|
|
||||||
DialogParams(
|
|
||||||
fromLongClick = false,
|
|
||||||
title = item.name ?: "",
|
|
||||||
items =
|
|
||||||
listOf(
|
|
||||||
DialogItem(
|
|
||||||
text = requestStr,
|
|
||||||
onClick = { viewModel.request(id, false) },
|
|
||||||
),
|
|
||||||
DialogItem(
|
|
||||||
text = request4kStr,
|
|
||||||
onClick = { viewModel.request(id, true) },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
viewModel.request(id, false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cancelOnClick = {
|
cancelOnClick = {
|
||||||
|
|
@ -218,6 +199,18 @@ fun DiscoverSeriesDetails(
|
||||||
waitToLoad = params.fromLongClick,
|
waitToLoad = params.fromLongClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showRequestSeasonDialog) {
|
||||||
|
RequestSeasonsDialog(
|
||||||
|
title = item?.name ?: "",
|
||||||
|
seasons = seasons,
|
||||||
|
request4kEnabled = request4kEnabled,
|
||||||
|
onSubmit = { seasons, is4k ->
|
||||||
|
item?.id?.let { viewModel.request(it, seasons, is4k) }
|
||||||
|
showRequestSeasonDialog = false
|
||||||
|
},
|
||||||
|
onDismissRequest = { showRequestSeasonDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val HEADER_ROW = 0
|
private const val HEADER_ROW = 0
|
||||||
|
|
@ -234,7 +227,7 @@ fun DiscoverSeriesDetailsContent(
|
||||||
series: TvDetails,
|
series: TvDetails,
|
||||||
rating: DiscoverRating?,
|
rating: DiscoverRating?,
|
||||||
canCancel: Boolean,
|
canCancel: Boolean,
|
||||||
seasons: List<Season>,
|
seasons: List<RequestSeason>,
|
||||||
similar: List<DiscoverItem>,
|
similar: List<DiscoverItem>,
|
||||||
recommended: List<DiscoverItem>,
|
recommended: List<DiscoverItem>,
|
||||||
trailers: List<Trailer>,
|
trailers: List<Trailer>,
|
||||||
|
|
@ -299,6 +292,7 @@ fun DiscoverSeriesDetailsContent(
|
||||||
SeerrAvailability.from(series.mediaInfo?.status)
|
SeerrAvailability.from(series.mediaInfo?.status)
|
||||||
?: SeerrAvailability.UNKNOWN,
|
?: SeerrAvailability.UNKNOWN,
|
||||||
requestOnClick = requestOnClick,
|
requestOnClick = requestOnClick,
|
||||||
|
pendingOnClick = requestOnClick,
|
||||||
cancelOnClick = cancelOnClick,
|
cancelOnClick = cancelOnClick,
|
||||||
moreOnClick = moreOnClick,
|
moreOnClick = moreOnClick,
|
||||||
goToOnClick = goToOnClick,
|
goToOnClick = goToOnClick,
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,13 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
|
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
|
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.Season
|
import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutRequest
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||||
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||||
import com.github.damontecres.wholphin.data.model.Trailer
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
|
@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
@ -63,7 +65,7 @@ class DiscoverSeriesViewModel
|
||||||
val tvSeries = MutableLiveData<TvDetails?>(null)
|
val tvSeries = MutableLiveData<TvDetails?>(null)
|
||||||
val rating = MutableLiveData<DiscoverRating?>(null)
|
val rating = MutableLiveData<DiscoverRating?>(null)
|
||||||
|
|
||||||
val seasons = MutableLiveData<List<Season>>(listOf())
|
val seasons = MutableLiveData<List<RequestSeason>>(listOf())
|
||||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||||
val people = MutableLiveData<List<DiscoverItem>>(listOf())
|
val people = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||||
val similar = MutableLiveData<List<DiscoverItem>>()
|
val similar = MutableLiveData<List<DiscoverItem>>()
|
||||||
|
|
@ -103,6 +105,7 @@ class DiscoverSeriesViewModel
|
||||||
val discoveredItem = DiscoverItem(tv)
|
val discoveredItem = DiscoverItem(tv)
|
||||||
backdropService.submit(discoveredItem)
|
backdropService.submit(discoveredItem)
|
||||||
|
|
||||||
|
updateSeasonStatus()
|
||||||
updateCanCancel()
|
updateCanCancel()
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|
@ -157,6 +160,43 @@ class DiscoverSeriesViewModel
|
||||||
navigationManager.navigateTo(destination)
|
navigationManager.navigateTo(destination)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun updateSeasonStatus() {
|
||||||
|
tvSeries.value?.let { tv ->
|
||||||
|
val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
|
||||||
|
tv.seasons?.forEach {
|
||||||
|
it.seasonNumber?.let {
|
||||||
|
seasonStatus[it] = SeerrAvailability.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val tvStatus =
|
||||||
|
SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN
|
||||||
|
tv.mediaInfo
|
||||||
|
?.requests
|
||||||
|
?.forEach {
|
||||||
|
it.seasons?.mapNotNull { season ->
|
||||||
|
season.seasonNumber?.let {
|
||||||
|
val current = seasonStatus[season.seasonNumber]
|
||||||
|
val new =
|
||||||
|
SeerrAvailability
|
||||||
|
.from(season.status)
|
||||||
|
?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus
|
||||||
|
if (current == null || new.status > current.status) {
|
||||||
|
seasonStatus[season.seasonNumber] = new
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Timber.v("seasonStatus=%s", seasonStatus)
|
||||||
|
val requestSeasons =
|
||||||
|
seasonStatus.mapNotNull { (seasonNumber, availability) ->
|
||||||
|
tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let {
|
||||||
|
RequestSeason(it, availability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seasons.setValueOnMain(requestSeasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun updateCanCancel() {
|
private suspend fun updateCanCancel() {
|
||||||
val user = userConfig.firstOrNull()
|
val user = userConfig.firstOrNull()
|
||||||
val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests)
|
val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests)
|
||||||
|
|
@ -165,20 +205,43 @@ class DiscoverSeriesViewModel
|
||||||
|
|
||||||
fun request(
|
fun request(
|
||||||
id: Int,
|
id: Int,
|
||||||
|
seasons: Set<Int>,
|
||||||
is4k: Boolean,
|
is4k: Boolean,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val request =
|
tvSeries.value?.let { tv ->
|
||||||
seerrService.api.requestApi.requestPost(
|
val currentRequest =
|
||||||
RequestPostRequest(
|
tv.mediaInfo?.requests?.firstOrNull {
|
||||||
is4k = is4k,
|
it.requestedBy?.id ==
|
||||||
mediaId = id,
|
seerrServerRepository.currentUserId.first()
|
||||||
mediaType = RequestPostRequest.MediaType.TV,
|
}
|
||||||
seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons
|
if (currentRequest != null) {
|
||||||
),
|
Timber.v("User has pending request, will update")
|
||||||
)
|
seerrService.api.requestApi.requestRequestIdPut(
|
||||||
fetchAndSetItem().await()
|
requestId = currentRequest.id.toString(),
|
||||||
updateCanCancel()
|
requestRequestIdPutRequest =
|
||||||
|
RequestRequestIdPutRequest(
|
||||||
|
is4k = is4k,
|
||||||
|
mediaType = RequestRequestIdPutRequest.MediaType.TV,
|
||||||
|
seasons = seasons.toList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Timber.v("New request for %s seasons", seasons.size)
|
||||||
|
seerrService.api.requestApi.requestPost(
|
||||||
|
RequestPostRequest(
|
||||||
|
is4k = is4k,
|
||||||
|
mediaId = id,
|
||||||
|
mediaType = RequestPostRequest.MediaType.TV,
|
||||||
|
seasons = seasons.toList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAndSetItem().await()
|
||||||
|
updateSeasonStatus()
|
||||||
|
updateCanCancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ fun ExpandableDiscoverButtons(
|
||||||
trailerOnClick: (Trailer) -> Unit,
|
trailerOnClick: (Trailer) -> Unit,
|
||||||
buttonOnFocusChanged: (FocusState) -> Unit,
|
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
pendingOnClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val firstFocus = remember { FocusRequester() }
|
val firstFocus = remember { FocusRequester() }
|
||||||
LazyRow(
|
LazyRow(
|
||||||
|
|
@ -89,7 +90,7 @@ fun ExpandableDiscoverButtons(
|
||||||
SeerrAvailability.PENDING,
|
SeerrAvailability.PENDING,
|
||||||
SeerrAvailability.PROCESSING,
|
SeerrAvailability.PROCESSING,
|
||||||
-> {
|
-> {
|
||||||
// TODO?
|
pendingOnClick.invoke()
|
||||||
}
|
}
|
||||||
|
|
||||||
SeerrAvailability.PARTIALLY_AVAILABLE,
|
SeerrAvailability.PARTIALLY_AVAILABLE,
|
||||||
|
|
@ -109,6 +110,21 @@ fun ExpandableDiscoverButtons(
|
||||||
.onFocusChanged(buttonOnFocusChanged),
|
.onFocusChanged(buttonOnFocusChanged),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (availability == SeerrAvailability.PARTIALLY_AVAILABLE) {
|
||||||
|
item("request_partial") {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.request,
|
||||||
|
iconStringRes = R.string.fa_download,
|
||||||
|
onClick = {
|
||||||
|
requestOnClick.invoke()
|
||||||
|
},
|
||||||
|
enabled = availability == SeerrAvailability.PARTIALLY_AVAILABLE,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (canCancel) {
|
if (canCancel) {
|
||||||
item("cancel") {
|
item("cancel") {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,323 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.discover
|
||||||
|
|
||||||
|
import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateSetOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
|
import androidx.tv.material3.ListItem
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
|
import androidx.tv.material3.Switch
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.contentColorFor
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.Season
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.AvailableIndicator
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.PendingIndicator
|
||||||
|
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
|
||||||
|
data class RequestSeason(
|
||||||
|
val season: Season,
|
||||||
|
val availability: SeerrAvailability,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RequestSeasons(
|
||||||
|
title: String,
|
||||||
|
seasons: List<RequestSeason>,
|
||||||
|
onSubmit: (Set<Int>, Boolean) -> Unit,
|
||||||
|
request4kEnabled: Boolean,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() }
|
||||||
|
val selected =
|
||||||
|
remember {
|
||||||
|
mutableStateSetOf<Int>(
|
||||||
|
*seasons
|
||||||
|
.mapNotNull {
|
||||||
|
if (it.availability > SeerrAvailability.UNKNOWN) {
|
||||||
|
it.season.seasonNumber
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.toTypedArray(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var is4k by remember { mutableStateOf(false) }
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
LazyColumn {
|
||||||
|
item {
|
||||||
|
val isSelected = selected.containsAll(allSeasonNumbers)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
ClickSwitch(
|
||||||
|
label = stringResource(R.string.select_all),
|
||||||
|
checked = isSelected,
|
||||||
|
onClick = {
|
||||||
|
if (isSelected) {
|
||||||
|
selected.removeAll(allSeasonNumbers)
|
||||||
|
} else {
|
||||||
|
selected.addAll(allSeasonNumbers)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { onSubmit.invoke(selected, is4k) },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.submit),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (request4kEnabled) {
|
||||||
|
item {
|
||||||
|
ClickSwitch(
|
||||||
|
label = stringResource(R.string.request_4k),
|
||||||
|
checked = is4k,
|
||||||
|
onClick = { is4k = !is4k },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemsIndexed(seasons) { index, season ->
|
||||||
|
val seasonNumber = season.season.seasonNumber
|
||||||
|
val isSelected = seasonNumber in selected
|
||||||
|
SeasonListItem(
|
||||||
|
season = season,
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
if (isSelected) {
|
||||||
|
selected.remove(seasonNumber)
|
||||||
|
} else {
|
||||||
|
seasonNumber?.let { selected.add(it) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (seasons.size > 7) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { onSubmit.invoke(selected, is4k) },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.submit),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SeasonListItem(
|
||||||
|
season: RequestSeason,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
selected = false,
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
season.season.name
|
||||||
|
?: (stringResource(R.string.tv_season) + " ${season.season.seasonNumber}"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
season.season.episodeCount?.let {
|
||||||
|
Text(
|
||||||
|
// TODO should use plurals string
|
||||||
|
text = "${season.season.episodeCount} " + stringResource(R.string.episodes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
when (season.availability) {
|
||||||
|
SeerrAvailability.UNKNOWN -> {}
|
||||||
|
|
||||||
|
SeerrAvailability.DELETED -> {}
|
||||||
|
|
||||||
|
SeerrAvailability.PENDING,
|
||||||
|
SeerrAvailability.PROCESSING,
|
||||||
|
-> {
|
||||||
|
PendingIndicator()
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrAvailability.PARTIALLY_AVAILABLE -> {
|
||||||
|
PartiallyAvailableIndicator()
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrAvailability.AVAILABLE -> {
|
||||||
|
AvailableIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Row {
|
||||||
|
Switch(
|
||||||
|
checked = selected,
|
||||||
|
onCheckedChange = {
|
||||||
|
onClick.invoke()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ClickSurface(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable BoxScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
colors =
|
||||||
|
ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.inverseSurface,
|
||||||
|
focusedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface),
|
||||||
|
pressedContainerColor = MaterialTheme.colorScheme.inverseSurface,
|
||||||
|
pressedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface),
|
||||||
|
),
|
||||||
|
onClick = onClick,
|
||||||
|
content = content,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ClickSwitch(
|
||||||
|
label: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
ClickSurface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.height(54.dp),
|
||||||
|
) {
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = {},
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RequestSeasonsDialog(
|
||||||
|
title: String,
|
||||||
|
seasons: List<RequestSeason>,
|
||||||
|
request4kEnabled: Boolean,
|
||||||
|
onSubmit: (Set<Int>, Boolean) -> Unit,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
|
BasicDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
) {
|
||||||
|
RequestSeasons(
|
||||||
|
title = title,
|
||||||
|
seasons = seasons,
|
||||||
|
request4kEnabled = request4kEnabled,
|
||||||
|
onSubmit = onSubmit,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(
|
||||||
|
device = "spec:parent=tv_1080p",
|
||||||
|
backgroundColor = 0xFF383535,
|
||||||
|
uiMode = UI_MODE_TYPE_TELEVISION,
|
||||||
|
heightDp = 800,
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun RequestSeasonsPreview() {
|
||||||
|
val seasons =
|
||||||
|
List(10) {
|
||||||
|
RequestSeason(
|
||||||
|
season =
|
||||||
|
Season(
|
||||||
|
seasonNumber = it + 1,
|
||||||
|
episodeCount = 10 + it,
|
||||||
|
),
|
||||||
|
availability =
|
||||||
|
if (it < 3) {
|
||||||
|
SeerrAvailability.AVAILABLE
|
||||||
|
} else {
|
||||||
|
SeerrAvailability.UNKNOWN
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
WholphinTheme {
|
||||||
|
RequestSeasons(
|
||||||
|
title = "Series title",
|
||||||
|
seasons = seasons,
|
||||||
|
request4kEnabled = true,
|
||||||
|
onSubmit = { _, _ -> },
|
||||||
|
modifier = Modifier.width(400.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.series
|
package com.github.damontecres.wholphin.ui.detail.series
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -120,6 +123,7 @@ fun SeriesDetails(
|
||||||
val people by viewModel.people.observeAsState(listOf())
|
val people by viewModel.people.observeAsState(listOf())
|
||||||
val similar by viewModel.similar.observeAsState(listOf())
|
val similar by viewModel.similar.observeAsState(listOf())
|
||||||
val discovered by viewModel.discovered.collectAsState()
|
val discovered by viewModel.discovered.collectAsState()
|
||||||
|
val discoverSeries by viewModel.discoverSeries.collectAsState()
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
|
|
@ -230,6 +234,12 @@ fun SeriesDetails(
|
||||||
onClickExtra = { _, extra ->
|
onClickExtra = { _, extra ->
|
||||||
viewModel.navigateTo(extra.destination)
|
viewModel.navigateTo(extra.destination)
|
||||||
},
|
},
|
||||||
|
discoverSeries = discoverSeries,
|
||||||
|
onClickDiscoverSeries = {
|
||||||
|
discoverSeries?.let {
|
||||||
|
viewModel.navigateTo(Destination.DiscoveredItem(it))
|
||||||
|
}
|
||||||
|
},
|
||||||
discovered = discovered,
|
discovered = discovered,
|
||||||
onClickDiscover = { index, item ->
|
onClickDiscover = { index, item ->
|
||||||
viewModel.navigateTo(item.destination)
|
viewModel.navigateTo(item.destination)
|
||||||
|
|
@ -350,6 +360,8 @@ fun SeriesDetailsContent(
|
||||||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||||
moreActions: MoreDialogActions,
|
moreActions: MoreDialogActions,
|
||||||
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||||
|
discoverSeries: DiscoverItem?,
|
||||||
|
onClickDiscoverSeries: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -487,6 +499,25 @@ fun SeriesDetailsContent(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = discoverSeries != null,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.discover,
|
||||||
|
iconStringRes = R.string.fa_magnifying_glass_plus,
|
||||||
|
onClick = onClickDiscoverSeries,
|
||||||
|
modifier =
|
||||||
|
Modifier.onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
scope.launch(ExceptionHandler()) {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
@ -123,6 +124,7 @@ class SeriesViewModel
|
||||||
|
|
||||||
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
||||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||||
|
val discoverSeries = MutableStateFlow<DiscoverItem?>(null)
|
||||||
|
|
||||||
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
|
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
|
||||||
|
|
||||||
|
|
@ -232,6 +234,22 @@ class SeriesViewModel
|
||||||
val results = seerrService.similar(item).orEmpty()
|
val results = seerrService.similar(item).orEmpty()
|
||||||
discovered.update { results }
|
discovered.update { results }
|
||||||
}
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
seerrService.active.collectLatest { active ->
|
||||||
|
val tv =
|
||||||
|
if (active) {
|
||||||
|
try {
|
||||||
|
seerrService.getTvSeries(item)?.let { DiscoverItem(it) }
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
discoverSeries.update { tv }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mediaManagementService.deletedItemFlow
|
mediaManagementService.deletedItemFlow
|
||||||
.onEach { deletedItem ->
|
.onEach { deletedItem ->
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ class SwitchSeerrViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
serverConnectionStatus.update { LoadingState.Success }
|
||||||
} else {
|
} else {
|
||||||
val message =
|
val message =
|
||||||
results
|
results
|
||||||
|
|
|
||||||
|
|
@ -719,5 +719,6 @@
|
||||||
<string name="creator">Creator</string>
|
<string name="creator">Creator</string>
|
||||||
<string name="artist">Artist</string>
|
<string name="artist">Artist</string>
|
||||||
<string name="search_for">Search %s</string>
|
<string name="search_for">Search %s</string>
|
||||||
|
<string name="select_all">Select all</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1086,6 +1086,11 @@ components:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/Episode'
|
$ref: '#/components/schemas/Episode'
|
||||||
|
status:
|
||||||
|
type: integer
|
||||||
|
example: 0
|
||||||
|
description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED
|
||||||
|
readOnly: true
|
||||||
TvDetails:
|
TvDetails:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|
@ -1268,6 +1273,10 @@ components:
|
||||||
type: string
|
type: string
|
||||||
type:
|
type:
|
||||||
type: string
|
type: string
|
||||||
|
seasons:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Season'
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- status
|
- status
|
||||||
|
|
@ -6135,16 +6144,10 @@ paths:
|
||||||
type: integer
|
type: integer
|
||||||
example: 123
|
example: 123
|
||||||
seasons:
|
seasons:
|
||||||
# oneOf:
|
type: array
|
||||||
# - type: array
|
items:
|
||||||
# items:
|
type: integer
|
||||||
# type: integer
|
minimum: 0
|
||||||
# minimum: 0
|
|
||||||
# - type: string
|
|
||||||
# enum: [all]
|
|
||||||
# TODO
|
|
||||||
type: string
|
|
||||||
enum: [all]
|
|
||||||
nullable: true
|
nullable: true
|
||||||
is4k:
|
is4k:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue