Add integration with Jellyseerr/Seerr (#558)

## Description
This PR add the ability to integrate with a [Jellyseerr/Seerr
server](https://github.com/seerr-team/seerr).

### Features
- Login to the Seerr server using API Key, Jellyfin credentials, or
local Seerr credentials
    - Separate Seerr logins per user profile
- Search Seerr from the search page
- Discover media from Seerr on the nav drawer `Discover` page
    - Also from movie, series, or person pages
- Seamless integration with existing media, i.e. selecting media found
from Seerr that already exists on the Jellyfin server, will go directly
to the regular media page
- Mostly consistent UI between Jellyfin media & Seerr media
- Submit requests to Seerr
- Cancel submitted requests

### Screenshots

![discover](https://github.com/user-attachments/assets/f830b48f-e2f1-43f4-9680-9c6d4e071765)

### Related issues
Closes #54

## Acknowledgements

The `app/src/main/seerr/seerr-api.yml` file is copied & modified from
https://github.com/seerr-team/seerr/blob/develop/seerr-api.yml. I hope
to try to upstream some of the changes in the future.
This commit is contained in:
Ray 2026-01-12 16:35:27 -05:00 committed by GitHub
parent f4e1b0e171
commit 4c7c465c67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 13369 additions and 137 deletions

View file

@ -4,9 +4,11 @@ import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.format.FormatStyle
import java.util.Locale
@ -23,6 +25,16 @@ fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateT
fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime)
fun toLocalDate(date: String?): LocalDate? =
date?.let {
try {
LocalDate.parse(it)
} catch (_: DateTimeParseException) {
Timber.w("Could not parse date: %s", date)
null
}
}
/**
* If the item has season & episode info, format as `S# E#`
*/

View file

@ -21,18 +21,23 @@ val LocalImageUrlService =
/**
* Colors not associated with the theme
*/
sealed class AppColors private constructor() {
companion object {
val TransparentBlack25 = Color(0x40000000)
val TransparentBlack50 = Color(0x80000000)
val TransparentBlack75 = Color(0xBF000000)
object AppColors {
val TransparentBlack25 = Color(0x40000000)
val TransparentBlack50 = Color(0x80000000)
val TransparentBlack75 = Color(0xBF000000)
val DarkGreen = Color(0xFF114000)
val DarkRed = Color(0xFF400000)
val DarkCyan = Color(0xFF21556E)
val DarkPurple = Color(0xFF261370)
val DarkGreen = Color(0xFF114000)
val DarkRed = Color(0xFF400000)
val DarkCyan = Color(0xFF21556E)
val DarkPurple = Color(0xFF261370)
val GoldenYellow = Color(0xFFDAB440)
val GoldenYellow = Color(0xFFDAB440)
object Discover {
val Blue = Color(37, 99, 235)
val Purple = Color(147, 51, 234)
val Green = Color(74, 222, 128)
val Yellow = Color(234, 179, 8)
}
}

View file

@ -0,0 +1,294 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.layout.ContentScale
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import kotlinx.coroutines.delay
@Composable
@NonRestartableComposable
fun DiscoverItemCard(
item: DiscoverItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
showOverlay: Boolean,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
val width = Cards.height2x3 * AspectRatios.TALL
val height = Dp.Unspecified * (1f / AspectRatios.TALL)
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height),
) {
Card(
modifier =
Modifier
.size(Dp.Unspecified, Cards.height2x3)
.aspectRatio(AspectRatios.TALL),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
Box(
modifier =
Modifier
.fillMaxSize(),
) {
ItemCardImage(
imageUrl = item?.posterUrl,
name = item?.title,
showOverlay = false,
favorite = false,
watched = false,
unwatchedCount = 0,
watchedPercent = null,
numberOfVersions = -1,
useFallbackText = false,
contentScale = ContentScale.FillBounds,
modifier =
Modifier
.fillMaxSize(),
)
when (item?.availability) {
SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING,
-> {
PendingIndicator(Modifier.align(Alignment.TopEnd))
}
SeerrAvailability.PARTIALLY_AVAILABLE -> {
PartiallyAvailableIndicator(Modifier.align(Alignment.TopEnd))
}
SeerrAvailability.AVAILABLE,
-> {
AvailableIndicator(Modifier.align(Alignment.TopEnd))
}
else -> {}
}
if (showOverlay) {
val color =
remember(item?.type) {
when (item?.type) {
SeerrItemType.MOVIE -> AppColors.Discover.Blue
SeerrItemType.TV -> AppColors.Discover.Purple
SeerrItemType.PERSON -> AppColors.Discover.Green
SeerrItemType.UNKNOWN -> Color.Black
null -> Color.Black
}.copy(alpha = .8f)
}
val text =
remember(item?.type) {
when (item?.type) {
SeerrItemType.MOVIE -> R.plurals.movies
SeerrItemType.TV -> R.plurals.tv_shows
SeerrItemType.PERSON -> R.plurals.people
SeerrItemType.UNKNOWN -> null
null -> null
}
}
text?.let {
Text(
text = pluralStringResource(it, 1),
style = MaterialTheme.typography.bodySmall,
fontSize = 10.sp,
modifier =
Modifier
.align(Alignment.TopStart)
.padding(4.dp)
.background(
color = color,
shape = CircleShape,
).padding(4.dp),
)
}
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
Text(
text = item?.releaseDate?.year?.toString() ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}
@Composable
fun PendingIndicator(modifier: Modifier = Modifier) {
Box(
modifier =
modifier
.padding(4.dp)
.border(
width = .5.dp,
color = AppColors.Discover.Yellow,
shape = CircleShape,
).background(
color = Color.White.copy(alpha = .85f),
shape = CircleShape,
).size(16.dp),
) {
Text(
text = stringResource(R.string.fa_bell),
fontFamily = FontAwesome,
fontSize = 10.sp,
color = AppColors.Discover.Yellow,
modifier = Modifier.align(Alignment.Center),
)
}
}
@Composable
fun AvailableIndicator(modifier: Modifier = Modifier) {
Box(
modifier =
modifier
.padding(4.dp)
.border(
width = .5.dp,
color = Color.White,
shape = CircleShape,
).background(
color = AppColors.Discover.Green.copy(alpha = .85f),
shape = CircleShape,
).size(16.dp),
) {
Text(
text = stringResource(R.string.fa_check),
fontFamily = FontAwesome,
fontSize = 10.sp,
color = Color.White,
modifier = Modifier.align(Alignment.Center),
)
}
}
@Composable
fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) {
Box(
modifier =
modifier
.padding(4.dp)
.border(
width = .5.dp,
color = Color.White,
shape = CircleShape,
).background(
color = AppColors.Discover.Green.copy(alpha = .85f),
shape = CircleShape,
).size(16.dp),
) {
Box(
modifier =
Modifier
.align(Alignment.Center)
.size(width = 10.dp, height = 2.dp)
.background(
color = Color.White,
shape = CircleShape,
),
)
}
}
@PreviewTvSpec
@Composable
private fun Preview() {
WholphinTheme {
Column {
PendingIndicator()
AvailableIndicator()
PartiallyAvailableIndicator()
}
}
}

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

@ -179,10 +179,12 @@ fun ExpandablePlayButton(
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
mirrorIcon: Boolean = false,
enabled: Boolean = true,
) {
val isFocused = interactionSource.collectIsFocusedAsState().value
Button(
onClick = { onClick.invoke(resume) },
enabled = enabled,
modifier =
modifier.requiredSizeIn(
minWidth = MinButtonSize,
@ -234,6 +236,7 @@ fun ExpandableFaButton(
val isFocused = interactionSource.collectIsFocusedAsState().value
Button(
onClick = onClick,
enabled = enabled,
modifier =
modifier.requiredSizeIn(
minWidth = MinButtonSize,
@ -242,7 +245,6 @@ fun ExpandableFaButton(
),
contentPadding = DefaultButtonPadding,
interactionSource = interactionSource,
enabled = enabled,
) {
Box(
modifier =

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
@ -375,7 +374,7 @@ fun <T : CardGridItem> CardGrid(
pager
.getOrNull(focusedIndex)
?.sortName
?.first()
?.firstOrNull()
?.uppercaseChar()
?.let {
if (it >= '0' && it <= '9') {

View file

@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
@ -25,7 +24,6 @@ import java.util.UUID
fun CollectionFolderBoxSet(
preferences: UserPreferences,
itemId: UUID,
item: BaseItem?,
recursive: Boolean,
modifier: Modifier = Modifier,
filter: CollectionFolderFilter = CollectionFolderFilter(),

View file

@ -62,7 +62,7 @@ fun CollectionFolderMovie(
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
val firstTabFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) {
logTab("movie", selectedTabIndex)

View file

@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
@ -22,7 +21,6 @@ import java.util.UUID
fun CollectionFolderPlaylist(
preferences: UserPreferences,
itemId: UUID,
item: BaseItem?,
recursive: Boolean,
modifier: Modifier = Modifier,
filter: CollectionFolderFilter = CollectionFolderFilter(),

View file

@ -78,7 +78,6 @@ fun buildMoreDialogItems(
Destination.Playback(
item.id,
item.resumeMs ?: 0L,
item,
),
)
},
@ -166,6 +165,7 @@ fun buildMoreDialogItems(
Destination.MediaItem(
seriesId,
BaseItemKind.SERIES,
null,
),
)
},
@ -200,7 +200,6 @@ fun buildMoreDialogItems(
Destination.Playback(
item.id,
item.resumeMs ?: 0L,
item,
forceTranscoding = true,
),
)
@ -309,6 +308,7 @@ fun buildMoreDialogItemsForHome(
Destination.MediaItem(
it,
BaseItemKind.SERIES,
null,
),
)
},
@ -328,7 +328,7 @@ fun buildMoreDialogItemsForPerson(
context.getString(R.string.go_to),
Icons.Default.ArrowForward,
) {
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON))
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null))
},
)
add(

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@ -40,9 +41,11 @@ import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@ -57,6 +60,8 @@ import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.formatDate
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -67,11 +72,14 @@ import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.RowLoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
@ -90,10 +98,12 @@ class PersonViewModel
api: ApiClient,
val navigationManager: NavigationManager,
private val favoriteWatchManager: FavoriteWatchManager,
private val seerrService: SeerrService,
) : LoadingItemViewModel(api) {
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
fun init(itemId: UUID) {
viewModelScope.launchIO(
@ -119,6 +129,10 @@ class PersonViewModel
} else {
episodes.setValueOnMain(RowLoadingState.Success(listOf()))
}
viewModelScope.launchIO {
val results = seerrService.similar(person).orEmpty()
discovered.update { results }
}
}
}
}
@ -181,6 +195,7 @@ fun PersonPage(
val movies by viewModel.movies.observeAsState(RowLoadingState.Pending)
val series by viewModel.series.observeAsState(RowLoadingState.Pending)
val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending)
val discovered by viewModel.discovered.collectAsState()
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) {
@ -219,6 +234,10 @@ fun PersonPage(
favoriteOnClick = {
viewModel.setFavorite(!person.favorite)
},
discovered = discovered,
onClickDiscover = { index, item ->
viewModel.navigationManager.navigateTo(item.destination)
},
modifier = modifier,
)
AnimatedVisibility(showOverviewDialog) {
@ -243,6 +262,7 @@ private const val HEADER_ROW = 0
private const val MOVIE_ROW = 1
private const val SERIES_ROW = 2
private const val EPISODE_ROW = 3
private const val DISCOVER_ROW = EPISODE_ROW + 1
@Composable
fun PersonPageContent(
@ -257,9 +277,11 @@ fun PersonPageContent(
movies: RowLoadingState,
series: RowLoadingState,
episodes: RowLoadingState,
discovered: List<DiscoverItem>,
onClickItem: (Int, BaseItem) -> Unit,
overviewOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
onClickDiscover: (Int, DiscoverItem) -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
@ -359,6 +381,24 @@ fun PersonPageContent(
},
)
}
if (discovered.isNotEmpty()) {
item {
DiscoverRow(
row =
DiscoverRowData(
stringResource(R.string.discover),
DataLoadingState.Success(discovered),
),
onClickItem = { index: Int, item: DiscoverItem ->
position = RowColumn(DISCOVER_ROW, index)
onClickDiscover.invoke(index, item)
},
onLongClickItem = { _, _ -> },
onCardFocus = {},
focusRequester = focusRequester,
)
}
}
}
}

View file

@ -0,0 +1,458 @@
package com.github.damontecres.wholphin.ui.detail.discover
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.focus.focusRestorer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@Composable
fun DiscoverMovieDetails(
preferences: UserPreferences,
destination: Destination.DiscoveredItem,
modifier: Modifier = Modifier,
viewModel: DiscoverMovieViewModel =
hiltViewModel<DiscoverMovieViewModel, DiscoverMovieViewModel.Factory>(
creationCallback = { it.create(destination.item) },
),
) {
val context = LocalContext.current
LifecycleResumeEffect(Unit) {
viewModel.init()
onPauseOrDispose { }
}
val item by viewModel.movie.observeAsState()
val rating by viewModel.rating.observeAsState(null)
val people by viewModel.people.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
val recommended by viewModel.similar.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val userConfig by viewModel.userConfig.collectAsState(null)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
val moreActions =
MoreDialogActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched -> },
onClickFavorite = { itemId, favorite -> },
onClickAddPlaylist = { itemId -> },
)
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
}
LoadingState.Success -> {
item?.let { movie ->
DiscoverMovieDetailsContent(
preferences = preferences,
movie = movie,
userConfig = userConfig,
rating = rating,
people = people,
trailers = trailers,
similar = similar,
recommended = recommended,
requestOnClick = {
movie.id?.let { viewModel.request(it) }
},
cancelOnClick = {
movie.id?.let { viewModel.cancelRequest(it) }
},
onClickItem = { index, item ->
viewModel.navigateTo(Destination.DiscoveredItem(item))
},
onClickPerson = { item ->
viewModel.navigateTo(Destination.DiscoveredItem(item))
},
overviewOnClick = {
overviewDialog =
ItemDetailsDialogInfo(
title = movie.title ?: context.getString(R.string.unknown),
overview = movie.overview,
genres = movie.genres?.mapNotNull { it.name }.orEmpty(),
files = listOf(),
)
},
goToOnClick = {
movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
viewModel.navigateTo(
Destination.MediaItem(
itemId = it,
type = BaseItemKind.MOVIE,
),
)
}
},
moreOnClick = {
moreDialog =
DialogParams(
fromLongClick = false,
title = movie.title + " (${movie.releaseDate ?: ""})",
items = listOf(),
)
},
onLongClickPerson = { index, person -> },
onLongClickSimilar = { index, similar ->
},
trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo)
},
modifier = modifier,
)
}
}
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
showFilePath = false,
onDismissRequest = { overviewDialog = null },
)
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
}
private const val HEADER_ROW = 0
private const val PEOPLE_ROW = HEADER_ROW + 1
private const val CHAPTER_ROW = PEOPLE_ROW + 1
private const val EXTRAS_ROW = CHAPTER_ROW + 1
private const val SIMILAR_ROW = EXTRAS_ROW + 1
private const val RECOMMENDED_ROW = SIMILAR_ROW + 1
@Composable
fun DiscoverMovieDetailsContent(
preferences: UserPreferences,
userConfig: SeerrUserConfig?,
movie: MovieDetails,
rating: DiscoverRating?,
people: List<DiscoverItem>,
trailers: List<Trailer>,
similar: List<DiscoverItem>,
recommended: List<DiscoverItem>,
requestOnClick: () -> Unit,
cancelOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit,
overviewOnClick: () -> Unit,
goToOnClick: () -> Unit,
moreOnClick: () -> Unit,
onClickItem: (Int, DiscoverItem) -> Unit,
onClickPerson: (DiscoverItem) -> Unit,
onLongClickPerson: (Int, DiscoverItem) -> Unit,
onLongClickSimilar: (Int, DiscoverItem) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var position by rememberInt(0)
val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
LaunchedEffect(Unit) {
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
Box(modifier = modifier) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
modifier = Modifier.fillMaxSize(),
) {
item {
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.fillMaxWidth()
.bringIntoViewRequester(bringIntoViewRequester),
) {
DiscoverMovieDetailsHeader(
preferences = preferences,
movie = movie,
rating = rating,
bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = overviewOnClick,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 32.dp, bottom = 16.dp),
)
val canCancel =
remember(movie, userConfig) {
(
// User requested this
userConfig.hasPermission(SeerrPermission.REQUEST) &&
movie.mediaInfo?.requests?.any { it.requestedBy?.id == userConfig?.id } == true
) ||
userConfig.hasPermission(SeerrPermission.MANAGE_REQUESTS)
}
ExpandableDiscoverButtons(
availability =
SeerrAvailability.from(movie.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
requestOnClick = requestOnClick,
cancelOnClick = cancelOnClick,
moreOnClick = moreOnClick,
goToOnClick = goToOnClick,
buttonOnFocusChanged = {
if (it.isFocused) {
position = HEADER_ROW
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
canCancel = canCancel,
trailers = trailers,
trailerOnClick = trailerOnClick,
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.focusRequester(focusRequesters[HEADER_ROW]),
)
}
}
if (people.isNotEmpty()) {
item {
DiscoverRow(
row =
DiscoverRowData(
stringResource(R.string.people),
DataLoadingState.Success(people),
),
onClickItem = { index: Int, item: DiscoverItem ->
position = PEOPLE_ROW
onClickPerson.invoke(item)
},
onLongClickItem = { index, person ->
position = PEOPLE_ROW
onLongClickPerson.invoke(index, person)
},
onCardFocus = {},
focusRequester = focusRequesters[PEOPLE_ROW],
)
}
}
if (similar.isNotEmpty()) {
item {
ItemRow(
title = stringResource(R.string.more_like_this),
items = similar,
onClickItem = { index, item ->
position = SIMILAR_ROW
onClickItem.invoke(index, item)
},
onLongClickItem = { index, similar ->
position = SIMILAR_ROW
onLongClickSimilar.invoke(index, similar)
},
cardContent = { index, item, mod, onClick, onLongClick ->
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = false,
modifier = mod,
)
},
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[SIMILAR_ROW]),
)
}
}
if (recommended.isNotEmpty()) {
item {
ItemRow(
title = stringResource(R.string.recommended),
items = similar,
onClickItem = { index, item ->
position = RECOMMENDED_ROW
onClickItem.invoke(index, item)
},
onLongClickItem = { index, similar ->
position = RECOMMENDED_ROW
onLongClickSimilar.invoke(index, similar)
},
cardContent = { index, item, mod, onClick, onLongClick ->
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = true,
modifier = mod,
)
},
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[RECOMMENDED_ROW]),
)
}
}
}
}
}
@Composable
fun TrailerRow(
trailers: List<Trailer>,
onClickTrailer: (Trailer) -> Unit,
modifier: Modifier = Modifier,
) {
val state = rememberLazyListState()
val firstFocus = remember { FocusRequester() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = stringResource(R.string.trailers),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(firstFocus),
) {
itemsIndexed(trailers) { index, item ->
val cardModifier =
if (index == 0) {
Modifier.focusRequester(firstFocus)
} else {
Modifier
}
when (item) {
is LocalTrailer -> {
SeasonCard(
item = item.baseItem,
onClick = { onClickTrailer.invoke(item) },
onLongClick = {},
imageHeight = Cards.height2x3,
imageWidth = Dp.Unspecified,
showImageOverlay = false,
modifier = cardModifier,
)
}
is RemoteTrailer -> {
val subtitle =
when (item.url.toUri().host) {
"youtube.com", "www.youtube.com" -> "YouTube"
else -> null
}
SeasonCard(
title = item.name,
subtitle = subtitle,
name = item.name,
imageUrl = null,
isFavorite = false,
isPlayed = false,
unplayedItemCount = 0,
playedPercentage = 0.0,
numberOfVersions = -1,
onClick = { onClickTrailer.invoke(item) },
onLongClick = {},
modifier = cardModifier,
showImageOverlay = false,
imageHeight = Cards.height2x3,
imageWidth = Dp.Unspecified,
)
}
}
}
}
}
}

View file

@ -0,0 +1,150 @@
package com.github.damontecres.wholphin.ui.detail.discover
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
import java.util.Locale
import kotlin.time.Duration.Companion.minutes
@Composable
fun DiscoverMovieDetailsHeader(
preferences: UserPreferences,
movie: MovieDetails,
rating: DiscoverRating?,
bringIntoViewRequester: BringIntoViewRequester,
overviewOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
// Title
Text(
text = movie.title ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displaySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f),
) {
val padding = 4.dp
val details =
remember(movie) {
buildList {
movie.releaseDate?.let(::add)
movie.runtime
?.toDouble()
?.minutes
?.roundMinutes
?.toString()
?.let(::add)
val release =
movie.releases
?.results
?.firstOrNull { it.iso31661 == Locale.getDefault().country }
?: movie.releases
?.results
?.firstOrNull { it.iso31661 == Locale.US.country }
?: movie.releases
?.results
?.firstOrNull()
release
?.releaseDates
?.firstOrNull()
?.certification
?.let(::add)
}
}
DotSeparatedRow(
texts = details,
communityRating = rating?.audienceRating,
criticRating = rating?.criticRating?.toFloat(),
textStyle = MaterialTheme.typography.titleSmall,
modifier = Modifier,
)
movie.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(bottom = padding))
}
movie.tagline?.let { tagline ->
Text(
text = tagline,
style = MaterialTheme.typography.bodyLarge,
fontStyle = FontStyle.Italic,
modifier = Modifier,
)
}
// Description
movie.overview?.let { overview ->
OverviewText(
overview = overview,
maxLines = 3,
onClick = overviewOnClick,
textBoxHeight = Dp.Unspecified,
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
}
val directorName =
remember(movie.credits?.crew) {
movie.credits
?.crew
?.filter { it.job == "Directing" }
?.joinToString(", ") { it.name!! }
}
directorName
?.let {
Text(
text = stringResource(R.string.directed_by, it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}

View file

@ -0,0 +1,171 @@
package com.github.damontecres.wholphin.ui.detail.discover
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer
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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class)
class DiscoverMovieViewModel
@AssistedInject
constructor(
private val api: ApiClient,
@param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager,
private val backdropService: BackdropService,
val serverRepository: ServerRepository,
val seerrService: SeerrService,
private val seerrServerRepository: SeerrServerRepository,
@Assisted val item: DiscoverItem,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(item: DiscoverItem): DiscoverMovieViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val movie = MutableLiveData<MovieDetails?>(null)
val rating = MutableLiveData<DiscoverRating?>(null)
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>(listOf())
val recommended = MutableLiveData<List<DiscoverItem>>(listOf())
val userConfig = seerrServerRepository.current.map { it?.config }
init {
init()
}
private fun fetchAndSetItem(): Deferred<MovieDetails> =
viewModelScope.async(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
this@DiscoverMovieViewModel.movie.setValueOnMain(movie)
movie
}
fun init(): Job =
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
val movie = fetchAndSetItem().await()
val discoveredItem = DiscoverItem(movie)
backdropService.submit(discoveredItem)
withContext(Dispatchers.Main) {
loading.value = LoadingState.Success
}
viewModelScope.launchIO {
val result = seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id)
rating.setValueOnMain(DiscoverRating(result))
}
if (!similar.isInitialized) {
viewModelScope.launchIO {
val result =
seerrService.api.moviesApi
.movieMovieIdSimilarGet(movieId = item.id, page = 2)
.results
?.map(::DiscoverItem)
.orEmpty()
similar.setValueOnMain(result)
}
viewModelScope.launchIO {
val result =
seerrService.api.moviesApi
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2)
.results
?.map(::DiscoverItem)
.orEmpty()
similar.setValueOnMain(result)
}
}
val people =
movie.credits
?.cast
?.map(::DiscoverItem)
.orEmpty() +
movie.credits
?.crew
?.map(::DiscoverItem)
.orEmpty()
this@DiscoverMovieViewModel.people.setValueOnMain(people)
val trailers =
movie.relatedVideos
?.filter { it.type == RelatedVideo.Type.TRAILER }
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
?.map {
RemoteTrailer(it.name!!, it.url!!, null)
}.orEmpty()
this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers)
}
fun navigateTo(destination: Destination) {
navigationManager.navigateTo(destination)
}
fun request(id: Int) {
viewModelScope.launchIO {
val request =
seerrService.api.requestApi.requestPost(
RequestPostRequest(
is4k = false,
mediaId = id,
mediaType = RequestPostRequest.MediaType.MOVIE,
),
)
fetchAndSetItem().await()
}
}
fun cancelRequest(id: Int) {
viewModelScope.launchIO {
movie.value?.mediaInfo?.requests?.firstOrNull()?.let {
// TODO handle multiple requests? Or just delete self's request?
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
fetchAndSetItem().await()
}
}
}
}

View file

@ -0,0 +1,161 @@
package com.github.damontecres.wholphin.ui.detail.discover
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
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.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.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@HiltViewModel(assistedFactory = DiscoverPersonViewModel.Factory::class)
class DiscoverPersonViewModel
@AssistedInject
constructor(
val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
val seerrService: SeerrService,
private val backdropService: BackdropService,
@Assisted val item: DiscoverItem,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(item: DiscoverItem): DiscoverPersonViewModel
}
val credits = MutableStateFlow<DataLoadingState<List<DiscoverItem>>>(DataLoadingState.Pending)
init {
viewModelScope.launchIO {
backdropService.clearBackdrop()
val credits =
seerrService.api.personApi
.personPersonIdCombinedCreditsGet(personId = item.id)
.let { credits ->
val cast =
credits.cast
?.map(::DiscoverItem)
.orEmpty()
val crew =
credits.crew
?.map(::DiscoverItem)
.orEmpty()
cast + crew
}
this@DiscoverPersonViewModel.credits.update {
DataLoadingState.Success(credits)
}
}
}
}
@Composable
fun DiscoverPersonPage(
person: DiscoverItem,
modifier: Modifier = Modifier,
viewModel: DiscoverPersonViewModel =
hiltViewModel<DiscoverPersonViewModel, DiscoverPersonViewModel.Factory>(
creationCallback = { it.create(person) },
),
) {
val credits by viewModel.credits.collectAsState()
when (val state = credits) {
is DataLoadingState.Error -> {
ErrorMessage(state.message, state.exception, modifier.focusable())
}
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage(modifier.focusable())
}
is DataLoadingState.Success<List<DiscoverItem>> -> {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (state.data.isNotEmpty()) {
focusRequester.tryRequestFocus()
}
}
Column(modifier = modifier) {
Text(
text = stringResource(R.string.discover) + (person.title?.let { ": $it" } ?: ""),
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: DiscoverItem ->
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
},
onLongClickItem = { index: Int, item: DiscoverItem ->
},
onClickPlay = { _, item ->
},
letterPosition = { c: Char -> 0 },
gridFocusRequester = focusRequester,
showJumpButtons = false,
showLetterButtons = false,
spacing = 16.dp,
cardContent = @Composable { item, onClick, onLongClick, mod ->
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = true,
modifier = mod,
)
},
columns = 6,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
}

View file

@ -0,0 +1,38 @@
package com.github.damontecres.wholphin.ui.detail.discover
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import timber.log.Timber
@Composable
fun DiscoverQuickDetails(
item: DiscoverItem?,
rating: DiscoverRating?,
modifier: Modifier = Modifier,
) {
Timber.v("id=${item?.id}, rating=$rating")
val context = LocalContext.current
val details =
remember(item) {
buildList {
item
?.releaseDate
?.year
?.toString()
?.let(::add)
}
}
DotSeparatedRow(
texts = details,
communityRating = rating?.audienceRating,
criticRating = rating?.criticRating?.toFloat(),
textStyle = MaterialTheme.typography.titleSmall,
modifier = modifier,
)
}

View file

@ -0,0 +1,527 @@
package com.github.damontecres.wholphin.ui.detail.discover
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
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.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import kotlin.time.Duration.Companion.minutes
@Composable
fun DiscoverSeriesDetails(
preferences: UserPreferences,
destination: Destination.DiscoveredItem,
modifier: Modifier = Modifier,
viewModel: DiscoverSeriesViewModel =
hiltViewModel<DiscoverSeriesViewModel, DiscoverSeriesViewModel.Factory>(
creationCallback = { it.create(destination.item) },
),
) {
val context = LocalContext.current
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.tvSeries.observeAsState()
val seasons by viewModel.seasons.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
val recommended by viewModel.recommended.observeAsState(listOf())
val userConfig by viewModel.userConfig.collectAsState(null)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
}
LoadingState.Success -> {
item?.let { item ->
val rating by viewModel.rating.observeAsState(null)
DiscoverSeriesDetailsContent(
preferences = preferences,
series = item,
userConfig = userConfig,
rating = rating,
seasons = seasons,
people = people,
similar = similar,
recommended = recommended,
modifier = modifier,
onClickItem = { index, item ->
viewModel.navigateTo(Destination.DiscoveredItem(item))
},
onClickPerson = {
viewModel.navigateTo(Destination.DiscoveredItem(it))
},
goToOnClick = {
item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
viewModel.navigateTo(
Destination.MediaItem(
itemId = it,
type = BaseItemKind.MOVIE,
),
)
}
},
overviewOnClick = {
overviewDialog =
ItemDetailsDialogInfo(
title = item.name ?: context.getString(R.string.unknown),
overview = item.overview,
genres = item.genres?.mapNotNull { it.name }.orEmpty(),
files = listOf(),
)
},
trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo)
},
trailers = listOf(),
requestOnClick = {
item.id?.let { viewModel.request(it) }
},
cancelOnClick = {
item.id?.let { viewModel.cancelRequest(it) }
},
moreOnClick = {
},
onLongClickPerson = { _, _ -> },
onLongClickSimilar = { _, _ -> },
)
}
}
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
showFilePath = false,
onDismissRequest = { overviewDialog = null },
)
}
seasonDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
waitToLoad = params.fromLongClick,
onDismissRequest = { seasonDialog = null },
)
}
}
private const val HEADER_ROW = 0
private const val SEASONS_ROW = HEADER_ROW + 1
private const val PEOPLE_ROW = SEASONS_ROW + 1
private const val EXTRAS_ROW = PEOPLE_ROW + 1
private const val SIMILAR_ROW = EXTRAS_ROW + 1
private const val RECOMMENDED_ROW = SIMILAR_ROW + 1
@Composable
fun DiscoverSeriesDetailsContent(
preferences: UserPreferences,
userConfig: SeerrUserConfig?,
series: TvDetails,
rating: DiscoverRating?,
seasons: List<Season>,
similar: List<DiscoverItem>,
recommended: List<DiscoverItem>,
trailers: List<Trailer>,
people: List<DiscoverItem>,
requestOnClick: () -> Unit,
cancelOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit,
overviewOnClick: () -> Unit,
goToOnClick: () -> Unit,
moreOnClick: () -> Unit,
onClickItem: (Int, DiscoverItem) -> Unit,
onClickPerson: (DiscoverItem) -> Unit,
onLongClickPerson: (Int, DiscoverItem) -> Unit,
onLongClickSimilar: (Int, DiscoverItem) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val bringIntoViewRequester = remember { BringIntoViewRequester() }
var position by rememberInt()
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
Box(
modifier = modifier,
) {
Column(
modifier =
Modifier
.padding(16.dp)
.fillMaxSize(),
) {
LazyColumn(
contentPadding = PaddingValues(bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier,
) {
item {
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.fillMaxWidth()
.bringIntoViewRequester(bringIntoViewRequester),
) {
DiscoverSeriesDetailsHeader(
series = series,
rating = rating,
overviewOnClick = overviewOnClick,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 32.dp, bottom = 16.dp),
)
val canCancel =
remember(series, userConfig) {
(
// User requested this
userConfig.hasPermission(SeerrPermission.REQUEST) &&
series.mediaInfo?.requests?.any { it.requestedBy?.id == userConfig?.id } == true
) ||
userConfig.hasPermission(SeerrPermission.MANAGE_REQUESTS)
}
ExpandableDiscoverButtons(
availability =
SeerrAvailability.from(series.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
requestOnClick = requestOnClick,
cancelOnClick = cancelOnClick,
moreOnClick = moreOnClick,
goToOnClick = goToOnClick,
buttonOnFocusChanged = {
if (it.isFocused) {
position = HEADER_ROW
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
canCancel = canCancel,
trailers = trailers,
trailerOnClick = trailerOnClick,
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.focusRequester(focusRequesters[HEADER_ROW]),
)
}
}
// item {
// ItemRow(
// title = stringResource(R.string.tv_seasons) + " (${seasons.size})",
// items = seasons,
// onClickItem = { index, item ->
// position = SEASONS_ROW
// // onClickItem.invoke(index, item)
// },
// onLongClickItem = { index, item ->
// position = SEASONS_ROW
// },
// modifier =
// Modifier
// .fillMaxWidth()
// .focusRequester(focusRequesters[SEASONS_ROW]),
// cardContent = @Composable { index, item, mod, onClick, onLongClick ->
// SeasonCard(
// item = item,
// onClick = onClick,
// onLongClick = onLongClick,
// imageHeight = Cards.height2x3,
// imageWidth = Dp.Unspecified,
// showImageOverlay = true,
// modifier = mod,
// )
// },
// )
// }
if (people.isNotEmpty()) {
item {
DiscoverRow(
row =
DiscoverRowData(
stringResource(R.string.people),
DataLoadingState.Success(people),
),
onClickItem = { index: Int, item: DiscoverItem ->
position = PEOPLE_ROW
onClickPerson.invoke(item)
},
onLongClickItem = { index, person ->
position = PEOPLE_ROW
onLongClickPerson.invoke(index, person)
},
onCardFocus = {},
focusRequester = focusRequesters[PEOPLE_ROW],
)
}
}
if (similar.isNotEmpty()) {
item {
ItemRow(
title = stringResource(R.string.more_like_this),
items = similar,
onClickItem = { index, item ->
position = SIMILAR_ROW
onClickItem.invoke(index, item)
},
onLongClickItem = { index, similar ->
position = SIMILAR_ROW
onLongClickSimilar.invoke(index, similar)
},
cardContent = { index, item, mod, onClick, onLongClick ->
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = false,
modifier = mod,
)
},
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[SIMILAR_ROW]),
)
}
}
if (recommended.isNotEmpty()) {
item {
ItemRow(
title = stringResource(R.string.recommended),
items = similar,
onClickItem = { index, item ->
position = RECOMMENDED_ROW
onClickItem.invoke(index, item)
},
onLongClickItem = { index, similar ->
position = RECOMMENDED_ROW
onLongClickSimilar.invoke(index, similar)
},
cardContent = { index, item, mod, onClick, onLongClick ->
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = true,
modifier = mod,
)
},
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[RECOMMENDED_ROW]),
)
}
}
}
}
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
}
@Composable
fun DiscoverSeriesDetailsHeader(
series: TvDetails,
rating: DiscoverRating?,
overviewOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = series.name ?: stringResource(R.string.unknown),
style = MaterialTheme.typography.displaySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f),
) {
val padding = 4.dp
val details =
remember(series) {
buildList {
series.firstAirDate?.let(::add)
series.episodeRunTime
?.average()
?.takeIf { !it.isNaN() && it > 0 }
?.minutes
?.roundMinutes
?.toString()
?.let(::add)
// TODO
}
}
DotSeparatedRow(
texts = details,
communityRating = rating?.audienceRating,
criticRating = rating?.criticRating?.toFloat(),
textStyle = MaterialTheme.typography.titleSmall,
modifier = Modifier,
)
series.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(bottom = padding))
}
series.overview?.let { overview ->
OverviewText(
overview = overview,
maxLines = 3,
onClick = overviewOnClick,
textBoxHeight = Dp.Unspecified,
)
}
}
}
}
fun buildDialogForSeason(
context: Context,
s: BaseItem,
onClickItem: (BaseItem) -> Unit,
markPlayed: (Boolean) -> Unit,
onClickPlay: (Boolean) -> Unit,
): DialogParams {
val items =
buildList {
add(
DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) {
onClickItem.invoke(s)
},
)
if (s.data.userData?.played == true) {
add(
DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) {
markPlayed.invoke(false)
},
)
} else {
add(
DialogItem(context.getString(R.string.mark_watched), R.string.fa_eye_slash) {
markPlayed.invoke(true)
},
)
}
add(
DialogItem(
context.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
onClickPlay.invoke(false)
},
)
add(
DialogItem(
context.getString(R.string.shuffle),
R.string.fa_shuffle,
) {
onClickPlay.invoke(true)
},
)
}
return DialogParams(
title = s.name ?: context.getString(R.string.tv_season),
fromLongClick = true,
items = items,
)
}

View file

@ -0,0 +1,163 @@
package com.github.damontecres.wholphin.ui.detail.discover
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.TvDetails
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.Trailer
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.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class)
class DiscoverSeriesViewModel
@AssistedInject
constructor(
private val api: ApiClient,
@param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager,
private val backdropService: BackdropService,
val serverRepository: ServerRepository,
val seerrService: SeerrService,
private val seerrServerRepository: SeerrServerRepository,
@Assisted val item: DiscoverItem,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(item: DiscoverItem): DiscoverSeriesViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val tvSeries = MutableLiveData<TvDetails?>(null)
val rating = MutableLiveData<DiscoverRating?>(null)
val seasons = MutableLiveData<List<Season>>(listOf())
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>(listOf())
val recommended = MutableLiveData<List<DiscoverItem>>(listOf())
val userConfig = seerrServerRepository.current.map { it?.config }
init {
init()
}
private fun fetchAndSetItem(): Deferred<TvDetails> =
viewModelScope.async(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
this@DiscoverSeriesViewModel.tvSeries.setValueOnMain(tv)
tv
}
fun init(): Job =
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
val tv = fetchAndSetItem().await()
val discoveredItem = DiscoverItem(tv)
backdropService.submit(discoveredItem)
withContext(Dispatchers.Main) {
loading.value = LoadingState.Success
}
viewModelScope.launchIO {
val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)
rating.setValueOnMain(DiscoverRating(result))
}
if (!similar.isInitialized) {
viewModelScope.launchIO {
val result =
seerrService.api.moviesApi
.movieMovieIdSimilarGet(movieId = item.id, page = 2)
.results
?.map(::DiscoverItem)
.orEmpty()
similar.setValueOnMain(result)
}
viewModelScope.launchIO {
val result =
seerrService.api.moviesApi
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2)
.results
?.map(::DiscoverItem)
.orEmpty()
similar.setValueOnMain(result)
}
}
val people =
tv.credits
?.cast
?.map(::DiscoverItem)
.orEmpty() +
tv.credits
?.crew
?.map(::DiscoverItem)
.orEmpty()
this@DiscoverSeriesViewModel.people.setValueOnMain(people)
}
fun navigateTo(destination: Destination) {
navigationManager.navigateTo(destination)
}
fun request(id: Int) {
viewModelScope.launchIO {
val request =
seerrService.api.requestApi.requestPost(
RequestPostRequest(
is4k = false,
mediaId = id,
mediaType = RequestPostRequest.MediaType.TV,
seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons
),
)
fetchAndSetItem().await()
}
}
fun cancelRequest(id: Int) {
viewModelScope.launchIO {
tvSeries.value?.mediaInfo?.requests?.firstOrNull()?.let {
// TODO handle multiple requests? Or just delete self's request?
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
fetchAndSetItem().await()
}
}
}
}

View file

@ -0,0 +1,154 @@
package com.github.damontecres.wholphin.ui.detail.discover
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import com.github.damontecres.wholphin.ui.components.TrailerButton
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlin.time.Duration
@Composable
fun ExpandableDiscoverButtons(
canRequest: Boolean,
canCancel: Boolean,
availability: SeerrAvailability,
trailers: List<Trailer>?,
requestOnClick: () -> Unit,
cancelOnClick: () -> Unit,
goToOnClick: () -> Unit,
moreOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier,
) {
val firstFocus = remember { FocusRequester() }
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
modifier =
modifier
.focusGroup()
.focusRestorer(firstFocus),
) {
val text =
when (availability) {
SeerrAvailability.UNKNOWN -> R.string.request
SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING,
-> R.string.pending
SeerrAvailability.PARTIALLY_AVAILABLE,
SeerrAvailability.AVAILABLE,
-> R.string.go_to
SeerrAvailability.DELETED -> R.string.delete // TODO
}
val icon =
when (availability) {
SeerrAvailability.UNKNOWN -> R.string.fa_download
SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING,
-> R.string.fa_clock
SeerrAvailability.PARTIALLY_AVAILABLE,
SeerrAvailability.AVAILABLE,
-> R.string.fa_play
SeerrAvailability.DELETED -> R.string.fa_video // TODO
}
item("first") {
ExpandableFaButton(
title = text,
iconStringRes = icon,
enabled = if (availability == SeerrAvailability.UNKNOWN) canRequest else true,
onClick = {
when (availability) {
SeerrAvailability.UNKNOWN -> {
requestOnClick.invoke()
}
SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING,
-> {
// TODO?
}
SeerrAvailability.PARTIALLY_AVAILABLE,
SeerrAvailability.AVAILABLE,
-> {
goToOnClick.invoke()
}
SeerrAvailability.DELETED -> {
// TODO
}
}
},
modifier =
Modifier
.focusRequester(firstFocus)
.onFocusChanged(buttonOnFocusChanged),
)
}
if (canCancel) {
item("cancel") {
ExpandablePlayButton(
title = R.string.cancel,
icon = Icons.Default.Delete,
onClick = {
firstFocus.tryRequestFocus()
cancelOnClick.invoke()
},
resume = Duration.ZERO,
enabled = canCancel,
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged),
)
}
}
if (trailers != null) {
item("trailers") {
TrailerButton(
trailers = trailers,
trailerOnClick = trailerOnClick,
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
)
}
}
// More button
item("more") {
ExpandablePlayButton(
R.string.more,
Duration.ZERO,
Icons.Default.MoreVert,
{ moreOnClick.invoke() },
Modifier
.onFocusChanged(buttonOnFocusChanged),
)
}
}
}

View file

@ -130,7 +130,6 @@ fun EpisodeDetails(
Destination.Playback(
ep.id,
it.inWholeMilliseconds,
ep,
),
)
},

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@ -31,6 +32,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
@ -61,8 +63,11 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@ -99,6 +104,7 @@ fun MovieDetails(
val similar by viewModel.similar.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
val discovered by viewModel.discovered.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -168,7 +174,6 @@ fun MovieDetails(
Destination.Playback(
movie.id,
it.inWholeMilliseconds,
movie,
),
)
},
@ -300,6 +305,10 @@ fun MovieDetails(
onClickExtra = { index, extra ->
viewModel.navigateTo(extra.destination)
},
discovered = discovered,
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
},
modifier = modifier,
)
}
@ -360,6 +369,7 @@ private const val TRAILER_ROW = PEOPLE_ROW + 1
private const val CHAPTER_ROW = TRAILER_ROW + 1
private const val EXTRAS_ROW = CHAPTER_ROW + 1
private const val SIMILAR_ROW = EXTRAS_ROW + 1
private const val DISCOVER_ROW = SIMILAR_ROW + 1
@Composable
fun MovieDetailsContent(
@ -371,6 +381,7 @@ fun MovieDetailsContent(
trailers: List<Trailer>,
extras: List<ExtrasItem>,
similar: List<BaseItem>,
discovered: List<DiscoverItem>,
playOnClick: (Duration) -> Unit,
trailerOnClick: (Trailer) -> Unit,
overviewOnClick: () -> Unit,
@ -382,12 +393,13 @@ fun MovieDetailsContent(
onLongClickPerson: (Int, Person) -> Unit,
onLongClickSimilar: (Int, BaseItem) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit,
onClickDiscover: (Int, DiscoverItem) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var position by rememberInt(0)
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } }
val dto = movie.data
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
@ -543,6 +555,24 @@ fun MovieDetailsContent(
)
}
}
if (discovered.isNotEmpty()) {
item {
DiscoverRow(
row =
DiscoverRowData(
stringResource(R.string.discover),
DataLoadingState.Success(discovered),
),
onClickItem = { index: Int, item: DiscoverItem ->
position = DISCOVER_ROW
onClickDiscover.invoke(index, item)
},
onLongClickItem = { _, _ -> },
onCardFocus = {},
focusRequester = focusRequesters[DISCOVER_ROW],
)
}
}
}
}
}

View file

@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
@ -19,6 +20,7 @@ import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService
@ -40,6 +42,8 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@ -54,6 +58,7 @@ class MovieViewModel
@AssistedInject
constructor(
private val api: ApiClient,
private val seerrService: SeerrService,
@param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
@ -81,6 +86,7 @@ class MovieViewModel
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
val similar = MutableLiveData<List<BaseItem>>()
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
init {
init()
@ -140,6 +146,10 @@ class MovieViewModel
val extras = extrasService.getExtras(item.id)
this@MovieViewModel.extras.setValueOnMain(extras)
}
viewModelScope.launchIO {
val results = seerrService.similar(item).orEmpty()
discovered.update { results }
}
withContext(Dispatchers.Main) {
chapters.value = Chapter.fromDto(item.data, api)

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@ -40,6 +41,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.UserPreferences
@ -71,9 +73,12 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@ -104,6 +109,7 @@ fun SeriesDetails(
val extras by viewModel.extras.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
val discovered by viewModel.discovered.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showWatchConfirmation by remember { mutableStateOf(false) }
@ -208,6 +214,10 @@ fun SeriesDetails(
onClickExtra = { _, extra ->
viewModel.navigateTo(extra.destination)
},
discovered = discovered,
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
},
moreActions =
MoreDialogActions(
navigateTo = { viewModel.navigateTo(it) },
@ -281,6 +291,7 @@ private const val PEOPLE_ROW = SEASONS_ROW + 1
private const val TRAILER_ROW = PEOPLE_ROW + 1
private const val EXTRAS_ROW = TRAILER_ROW + 1
private const val SIMILAR_ROW = EXTRAS_ROW + 1
private const val DISCOVER_ROW = SIMILAR_ROW + 1
@Composable
fun SeriesDetailsContent(
@ -291,6 +302,7 @@ fun SeriesDetailsContent(
trailers: List<Trailer>,
extras: List<ExtrasItem>,
people: List<Person>,
discovered: List<DiscoverItem>,
played: Boolean,
favorite: Boolean,
onClickItem: (Int, BaseItem) -> Unit,
@ -303,6 +315,7 @@ fun SeriesDetailsContent(
trailerOnClick: (Trailer) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit,
moreActions: MoreDialogActions,
onClickDiscover: (Int, DiscoverItem) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -310,7 +323,7 @@ fun SeriesDetailsContent(
val bringIntoViewRequester = remember { BringIntoViewRequester() }
var position by rememberInt()
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } }
val playFocusRequester = remember { FocusRequester() }
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -546,6 +559,24 @@ fun SeriesDetailsContent(
)
}
}
if (discovered.isNotEmpty()) {
item {
DiscoverRow(
row =
DiscoverRowData(
stringResource(R.string.discover),
DataLoadingState.Success(discovered),
),
onClickItem = { index: Int, item: DiscoverItem ->
position = DISCOVER_ROW
onClickDiscover.invoke(index, item)
},
onLongClickItem = { _, _ -> },
onCardFocus = {},
focusRequester = focusRequesters[DISCOVER_ROW],
)
}
}
}
}
}

View file

@ -312,7 +312,6 @@ fun SeriesOverview(
Destination.Playback(
it.id,
resumePosition.inWholeMilliseconds,
it,
),
)
},
@ -327,7 +326,6 @@ fun SeriesOverview(
Destination.Playback(
it.id,
resume.inWholeMilliseconds,
it,
),
)
}

View file

@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
@ -18,6 +19,7 @@ import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService
@ -84,6 +86,7 @@ class SeriesViewModel
val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
private val seerrService: SeerrService,
@Assisted val seriesId: UUID,
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
@Assisted val seriesPageType: SeriesPageType,
@ -107,6 +110,7 @@ class SeriesViewModel
val similar = MutableLiveData<List<BaseItem>>()
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
@ -210,6 +214,10 @@ class SeriesViewModel
this@SeriesViewModel.similar.setValueOnMain(similar)
}
}
viewModelScope.launchIO {
val results = seerrService.similar(item).orEmpty()
discovered.update { results }
}
}
}
}

View file

@ -0,0 +1,111 @@
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 tabFocusRequesters = remember(tabs) { List(tabs.size) { 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("page") }
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 },
focusRequesters = tabFocusRequesters,
)
}
when (selectedTabIndex) {
// Discover
0 -> {
SeerrDiscoverPage(
preferences = preferences,
modifier =
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
)
}
// Requests
1 -> {
SeerrRequestsPage(
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
modifier =
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
)
}
else -> {
ErrorMessage("Invalid tab index $selectedTabIndex", null)
}
}
}
}

View file

@ -0,0 +1,322 @@
package com.github.damontecres.wholphin.ui.discover
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
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.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverQuickDetails
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import com.google.common.cache.CacheBuilder
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import javax.inject.Inject
@HiltViewModel
class SeerrDiscoverViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val seerrService: SeerrService,
val navigationManager: NavigationManager,
private val api: ApiClient,
private val backdropService: BackdropService,
) : ViewModel() {
val state = MutableStateFlow<DiscoverState>(DiscoverState())
val rating = MutableStateFlow<Map<Int, DiscoverRating>>(mapOf())
init {
viewModelScope.launchIO {
backdropService.clearBackdrop()
}
fetchAndUpdateState(seerrService::discoverMovies) {
this.copy(movies = DiscoverRowData(context.getString(R.string.movies), it))
}
fetchAndUpdateState(seerrService::discoverTv) {
this.copy(tv = DiscoverRowData(context.getString(R.string.tv_shows), it))
}
fetchAndUpdateState(seerrService::trending) {
this.copy(trending = DiscoverRowData(context.getString(R.string.trending), it))
}
fetchAndUpdateState(seerrService::upcomingMovies) {
this.copy(
upcomingMovies =
DiscoverRowData(context.getString(R.string.upcoming_movies), it),
)
}
fetchAndUpdateState(seerrService::upcomingTv) {
this.copy(
upcomingTv =
DiscoverRowData(context.getString(R.string.upcoming_tv), it),
)
}
}
private fun fetchAndUpdateState(
getData: suspend () -> List<DiscoverItem>,
copyFunc: DiscoverState.(DataLoadingState<List<DiscoverItem>>) -> DiscoverState,
) {
viewModelScope.launchIO {
state.update {
copyFunc.invoke(it, DataLoadingState.Loading)
}
try {
val results = getData.invoke()
state.update {
copyFunc.invoke(it, DataLoadingState.Success(results))
}
} catch (ex: Exception) {
state.update {
copyFunc.invoke(it, DataLoadingState.Error(ex))
}
}
}
}
fun updateBackdrop(item: DiscoverItem?) {
viewModelScope.launchIO {
if (item != null) {
backdropService.submit("discover_${item.id}", item.backDropUrl)
fetchRating(item)
}
}
}
private val ratingCache =
CacheBuilder
.newBuilder()
.maximumSize(100)
.build<Int, DiscoverRating>()
// TODO this is not very efficient
fun fetchRating(item: DiscoverItem) {
viewModelScope.launchIO {
val cachedResult = ratingCache.getIfPresent(item.id)
if (cachedResult != null) {
return@launchIO
}
val result =
when (item.type) {
SeerrItemType.MOVIE -> {
DiscoverRating(
seerrService.api.moviesApi.movieMovieIdRatingsGet(
movieId = item.id,
),
)
}
SeerrItemType.TV -> {
DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id))
}
SeerrItemType.PERSON -> {
DiscoverRating(null, null)
}
SeerrItemType.UNKNOWN -> {
DiscoverRating(null, null)
}
}
ratingCache.put(item.id, result)
rating.update {
ratingCache.asMap().toMap()
}
}
}
}
data class DiscoverRowData(
val title: String,
val items: DataLoadingState<List<DiscoverItem>>,
) {
companion object {
val EMPTY = DiscoverRowData("", DataLoadingState.Pending)
}
}
data class DiscoverState(
val movies: DiscoverRowData = DiscoverRowData.EMPTY,
val tv: DiscoverRowData = DiscoverRowData.EMPTY,
val trending: DiscoverRowData = DiscoverRowData.EMPTY,
val upcomingMovies: DiscoverRowData = DiscoverRowData.EMPTY,
val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY,
)
@Composable
fun SeerrDiscoverPage(
preferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: SeerrDiscoverViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val rows =
listOf(state.trending, state.movies, state.tv, state.upcomingMovies, state.upcomingTv)
val ratingMap by viewModel.rating.collectAsState()
val focusRequesters = remember(2) { List(rows.size) { FocusRequester() } }
var position by rememberPosition(0, -1)
val focusedItem =
remember(position) {
position.let {
(rows.getOrNull(it.row)?.items as? DataLoadingState.Success)?.data?.getOrNull(it.column)
}
}
LaunchedEffect(focusedItem) {
viewModel.updateBackdrop(focusedItem)
}
var firstFocused by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(state.trending) {
if (!firstFocused && state.trending.items is DataLoadingState.Success<*>) {
firstFocused = focusRequesters.getOrNull(0)?.tryRequestFocus("discover") == true
}
}
Column(
modifier = modifier,
) {
HomePageHeader(
title = focusedItem?.title,
subtitle = focusedItem?.subtitle,
overview = focusedItem?.overview,
overviewTwoLines = true,
quickDetails = {
DiscoverQuickDetails(
item = focusedItem,
rating = focusedItem?.id?.let { ratingMap[it] },
)
},
modifier =
Modifier
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
.fillMaxHeight(.25f),
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp),
modifier =
Modifier
.focusRestorer()
.fillMaxSize(),
) {
itemsIndexed(rows) { rowIndex, row ->
DiscoverRow(
row = row,
onClickItem = { index, item ->
position = RowColumn(rowIndex, index)
viewModel.navigationManager.navigateTo(item.destination)
},
onLongClickItem = { index, item -> },
onCardFocus = { index -> position = RowColumn(rowIndex, index) },
focusRequester = focusRequesters[rowIndex],
modifier =
Modifier
.fillMaxWidth(),
)
}
}
}
}
@Composable
fun DiscoverRow(
row: DiscoverRowData,
onClickItem: (Int, DiscoverItem) -> Unit,
onLongClickItem: (Int, DiscoverItem) -> Unit,
onCardFocus: (Int) -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
when (val state = row.items) {
is DataLoadingState.Error -> {
ErrorMessage(state.message, state.exception, modifier)
}
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = row.title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = stringResource(R.string.loading),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
is DataLoadingState.Success<List<DiscoverItem>> -> {
ItemRow(
title = row.title,
items = state.data,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = false,
modifier =
mod.onFocusChanged {
if (it.isFocused) {
onCardFocus.invoke(index)
}
},
)
},
modifier = modifier.focusRequester(focusRequester),
)
}
}
}

View file

@ -0,0 +1,219 @@
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(
focusRequesterOnEmpty: FocusRequester?,
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()
} else {
focusRequesterOnEmpty?.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 = 16.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

@ -444,56 +444,75 @@ fun HomePageHeader(
modifier: Modifier = Modifier,
) {
item?.let {
val isEpisode = item.type == BaseItemKind.EPISODE
val dto = item.data
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
item.title?.let {
Text(
text = it,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth(.6f)
.fillMaxHeight(),
) {
val isEpisode = item.type == BaseItemKind.EPISODE
val subtitle = if (isEpisode) dto.name else null
val overview = dto.overview
subtitle?.let {
EpisodeName(it)
}
HomePageHeader(
title = item.title,
subtitle = if (isEpisode) dto.name else null,
overview = dto.overview,
overviewTwoLines = isEpisode,
quickDetails = {
when (item.type) {
BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier)
BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier)
else -> MovieQuickDetails(dto, Modifier)
}
val overviewModifier =
Modifier
.padding(0.dp)
.height(48.dp + if (!isEpisode) 12.dp else 0.dp)
.width(400.dp)
if (overview.isNotNullOrBlank()) {
Text(
text = overview,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (isEpisode) 2 else 3,
overflow = TextOverflow.Ellipsis,
modifier = overviewModifier,
)
} else {
Spacer(overviewModifier)
}
},
modifier = modifier,
)
}
}
@Composable
fun HomePageHeader(
title: String?,
subtitle: String?,
overview: String?,
overviewTwoLines: Boolean,
quickDetails: (@Composable () -> Unit)?,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth(.6f)
.fillMaxHeight(),
) {
subtitle?.let {
EpisodeName(it)
}
quickDetails?.invoke()
val overviewModifier =
Modifier
.padding(0.dp)
.height(48.dp + if (!overviewTwoLines) 12.dp else 0.dp)
.width(400.dp)
if (overview.isNotNullOrBlank()) {
Text(
text = overview,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (overviewTwoLines) 2 else 3,
overflow = TextOverflow.Ellipsis,
modifier = overviewModifier,
)
} else {
Spacer(overviewModifier)
}
}
}

View file

@ -39,10 +39,14 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.EpisodeCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
@ -50,7 +54,10 @@ import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -58,6 +65,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@ -72,11 +80,13 @@ class SearchViewModel
constructor(
val api: ApiClient,
val navigationManager: NavigationManager,
private val seerrService: SeerrService,
) : ViewModel() {
val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
private var currentQuery: String? = null
@ -94,11 +104,13 @@ class SearchViewModel
searchInternal(query, BaseItemKind.SERIES, series)
searchInternal(query, BaseItemKind.EPISODE, episodes)
searchInternal(query, BaseItemKind.BOX_SET, collections)
searchSeerr(query)
} else {
movies.value = SearchResult.NoQuery
series.value = SearchResult.NoQuery
episodes.value = SearchResult.NoQuery
collections.value = SearchResult.NoQuery
seerrResults.value = SearchResult.NoQuery
}
}
@ -132,6 +144,20 @@ class SearchViewModel
}
}
private fun searchSeerr(query: String) {
viewModelScope.launchIO {
if (seerrService.active.first()) {
seerrResults.setValueOnMain(SearchResult.Searching)
val results =
seerrService
.search(query)
.map { DiscoverItem(it) }
.filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV }
seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results))
}
}
}
fun getHints(query: String) {
// TODO
// api.searchApi.getSearchHints()
@ -150,12 +176,17 @@ sealed interface SearchResult {
data class Success(
val items: List<BaseItem?>,
) : SearchResult
data class SuccessSeerr(
val items: List<DiscoverItem>,
) : SearchResult
}
private const val MOVIE_ROW = 0
private const val COLLECTION_ROW = MOVIE_ROW + 1
private const val SERIES_ROW = COLLECTION_ROW + 1
private const val EPISODE_ROW = SERIES_ROW + 1
private const val SEERR_ROW = EPISODE_ROW + 1
@Composable
fun SearchPage(
@ -170,6 +201,7 @@ fun SearchPage(
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
val series by viewModel.series.observeAsState(SearchResult.NoQuery)
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
// val query = rememberTextFieldState()
var query by rememberSaveable { mutableStateOf("") }
@ -288,6 +320,30 @@ fun SearchPage(
)
},
)
searchResultRow(
title = context.getString(R.string.discover),
result = seerrResults,
rowIndex = SEERR_ROW,
position = position,
focusRequester = focusRequester,
onClickItem = { _, _ ->
// no-op
},
onClickDiscover = { _, item ->
val dest =
if (item.jellyfinItemId != null && item.type.baseItemKind != null) {
Destination.MediaItem(
itemId = item.jellyfinItemId,
type = item.type.baseItemKind,
)
} else {
Destination.DiscoveredItem(item)
}
viewModel.navigationManager.navigateTo(dest)
},
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
)
}
}
@ -300,6 +356,7 @@ fun LazyListScope.searchResultRow(
onClickItem: (Int, BaseItem) -> Unit,
onClickPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier,
onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null,
cardContent: @Composable (
index: Int,
item: BaseItem?,
@ -365,6 +422,36 @@ fun LazyListScope.searchResultRow(
)
}
}
is SearchResult.SuccessSeerr -> {
if (r.items.isEmpty()) {
SearchResultPlaceholder(
title = title,
message = stringResource(R.string.no_results),
modifier = modifier,
)
} else {
ItemRow(
title = title,
items = r.items,
onClickItem = { index, item ->
onClickPosition.invoke(RowColumn(rowIndex, index))
onClickDiscover?.invoke(index, item)
},
onLongClickItem = { _, _ -> },
modifier = modifier,
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = true,
modifier = mod,
)
},
)
}
}
}
}
}

View file

@ -6,16 +6,16 @@ import androidx.annotation.StringRes
import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@ -45,7 +45,6 @@ sealed class Destination(
data class SeriesOverview(
val itemId: UUID,
val type: BaseItemKind,
@Transient val item: BaseItem? = null,
val seasonEpisode: SeasonEpisodeIds? = null,
) : Destination() {
override fun toString(): String = "SeriesOverview(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)"
@ -55,11 +54,9 @@ sealed class Destination(
data class MediaItem(
val itemId: UUID,
val type: BaseItemKind,
@Transient val item: BaseItem? = null,
val seasonEpisode: SeasonEpisode? = null,
val collectionType: CollectionType? = null,
) : Destination() {
override fun toString(): String =
"MediaItem(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode, collectionType=${item?.data?.collectionType})"
constructor(item: BaseItem) : this(item.id, item.type, item.data.collectionType)
}
@Serializable
@ -71,13 +68,10 @@ sealed class Destination(
data class Playback(
val itemId: UUID,
val positionMs: Long,
@Transient val item: BaseItem? = null,
val itemPlayback: ItemPlayback? = null,
val forceTranscoding: Boolean = false,
) : Destination(true) {
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"
constructor(item: BaseItem) : this(item.id, item.resumeMs, item)
constructor(item: BaseItem) : this(item.id, item.resumeMs)
}
@Serializable
@ -109,6 +103,14 @@ sealed class Destination(
@Serializable
data object Favorites : Destination(false)
@Serializable
data object Discover : Destination(false)
@Serializable
data class DiscoveredItem(
val item: DiscoverItem,
) : Destination(false)
@Serializable
data object UpdateApp : Destination(true)

View file

@ -3,8 +3,10 @@ package com.github.damontecres.wholphin.ui.nav
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ItemGrid
import com.github.damontecres.wholphin.ui.components.LicenseInfo
@ -20,10 +22,14 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage
import com.github.damontecres.wholphin.ui.detail.FavoritesPage
import com.github.damontecres.wholphin.ui.detail.PersonPage
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
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
@ -121,7 +127,6 @@ fun DestinationContent(
CollectionFolderBoxSet(
preferences = preferences,
itemId = destination.itemId,
item = destination.item,
recursive = false,
playEnabled = true,
modifier = modifier,
@ -141,7 +146,7 @@ fun DestinationContent(
CollectionFolder(
preferences = preferences,
destination = destination,
collectionType = destination.item?.data?.collectionType,
collectionType = destination.collectionType,
usePostersOverride = null,
recursiveOverride = null,
modifier = modifier,
@ -153,7 +158,7 @@ fun DestinationContent(
CollectionFolder(
preferences = preferences,
destination = destination,
collectionType = destination.item?.data?.collectionType,
collectionType = destination.collectionType,
usePostersOverride = true,
recursiveOverride = null,
modifier = modifier,
@ -165,7 +170,7 @@ fun DestinationContent(
CollectionFolder(
preferences = preferences,
destination = destination,
collectionType = destination.item?.data?.collectionType,
collectionType = destination.collectionType,
usePostersOverride = null,
recursiveOverride = true,
modifier = modifier,
@ -247,6 +252,47 @@ fun DestinationContent(
Destination.Debug -> {
DebugPage(preferences, modifier)
}
Destination.Discover -> {
DiscoverPage(
preferences = preferences,
modifier = modifier,
)
}
is Destination.DiscoveredItem -> {
when (destination.item.type) {
SeerrItemType.MOVIE -> {
DiscoverMovieDetails(
preferences = preferences,
destination = destination,
modifier = modifier,
)
}
SeerrItemType.TV -> {
DiscoverSeriesDetails(
preferences = preferences,
destination = destination,
modifier = modifier,
)
}
SeerrItemType.PERSON -> {
DiscoverPersonPage(
person = destination.item,
modifier = modifier,
)
}
SeerrItemType.UNKNOWN -> {
Text(
text = "Unknown discover type",
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
@ -292,7 +338,6 @@ fun CollectionFolder(
CollectionFolderPlaylist(
preferences,
destination.itemId,
destination.item,
true,
modifier,
)

View file

@ -78,6 +78,7 @@ import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.preferences.UserPreferences
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.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.FontAwesome
@ -94,6 +95,8 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.CollectionType
@ -109,17 +112,25 @@ class NavDrawerViewModel
val navigationManager: NavigationManager,
val setupNavigationManager: SetupNavigationManager,
val backdropService: BackdropService,
private val seerrServerRepository: SeerrServerRepository,
) : ViewModel() {
private var all: List<NavDrawerItem>? = null
// private var all: List<NavDrawerItem>? = null
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
val selectedIndex = MutableLiveData(-1)
val showMore = MutableLiveData(false)
init {
seerrServerRepository.active
.onEach {
init()
}.launchIn(viewModelScope)
}
fun init() {
viewModelScope.launchIO {
val all = all ?: navDrawerItemRepository.getNavDrawerItems()
this@NavDrawerViewModel.all = all
val all = navDrawerItemRepository.getNavDrawerItems()
// this@NavDrawerViewModel.all = all
val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
val moreLibraries = all.toMutableList().apply { removeAll(libraries) }
@ -128,11 +139,19 @@ class NavDrawerViewModel
this@NavDrawerViewModel.libraries.value = libraries
}
val asDestinations =
(libraries + listOf(NavDrawerItem.More) + moreLibraries).map {
(
libraries +
listOf(
NavDrawerItem.More,
NavDrawerItem.Discover,
) + moreLibraries
).map {
if (it is ServerNavDrawerItem) {
it.destination
} else if (it is NavDrawerItem.Favorites) {
Destination.Favorites
} else if (it is NavDrawerItem.Discover) {
Destination.Discover
} else {
null
}
@ -155,7 +174,7 @@ class NavDrawerViewModel
null
}
}
// Timber.v("Found $index => $key")
Timber.v("Found $index => $key")
if (index != null) {
selectedIndex.setValueOnMain(index)
break
@ -192,6 +211,13 @@ sealed interface NavDrawerItem {
override fun name(context: Context): String = context.getString(R.string.more)
}
object Discover : NavDrawerItem {
override val id: String
get() = "a_discover"
override fun name(context: Context): String = context.getString(R.string.discover)
}
}
data class ServerNavDrawerItem(
@ -267,6 +293,13 @@ fun NavDrawer(
setShowMore(!showMore)
}
NavDrawerItem.Discover -> {
viewModel.setIndex(index)
viewModel.navigationManager.navigateToFromDrawer(
Destination.Discover,
)
}
is ServerNavDrawerItem -> {
viewModel.setIndex(index)
viewModel.navigationManager.navigateToFromDrawer(item.destination)
@ -606,6 +639,10 @@ fun NavigationDrawerScope.NavItem(
R.string.fa_ellipsis
}
NavDrawerItem.Discover -> {
R.string.fa_magnifying_glass_plus
}
is ServerNavDrawerItem -> {
when (library.type) {
CollectionType.MOVIES -> R.string.fa_film

View file

@ -241,13 +241,7 @@ class PlaybackViewModel
"Error preparing for playback for $itemId",
),
) {
val destItem = (destination as? Destination.Playback)?.item?.data
val queriedItem =
if (destItem?.mediaSources != null) {
destItem
} else {
api.userLibraryApi.getItem(itemId).content
}
val queriedItem = api.userLibraryApi.getItem(itemId).content
val base =
if (queriedItem.type.playable) {
queriedItem

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
@ -42,6 +43,7 @@ import androidx.tv.material3.surfaceColorAtElevation
import coil3.SingletonImageLoader
import coil3.imageLoader
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
@ -50,6 +52,7 @@ import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.uiPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
@ -58,9 +61,12 @@ import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import timber.log.Timber
@ -71,6 +77,7 @@ fun PreferencesContent(
modifier: Modifier = Modifier,
viewModel: PreferencesViewModel = hiltViewModel(),
updateVM: UpdateViewModel = hiltViewModel(),
seerrVm: SwitchSeerrViewModel = hiltViewModel(),
onFocus: (Int, Int) -> Unit = { _, _ -> },
) {
val context = LocalContext.current
@ -84,6 +91,8 @@ fun PreferencesContent(
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
var showSeerrServerDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect {
@ -381,6 +390,22 @@ fun PreferencesContent(
)
}
AppPreference.SeerrIntegration -> {
ClickPreference(
title = stringResource(pref.title),
onClick = { showSeerrServerDialog = true },
modifier = Modifier,
summary =
if (seerrIntegrationEnabled) {
stringResource(R.string.enabled)
} else {
null
},
onLongClick = {},
interactionSource = interactionSource,
)
}
else -> {
val value = pref.getter.invoke(preferences)
ComposablePreference(
@ -440,6 +465,39 @@ fun PreferencesContent(
)
}
}
if (showSeerrServerDialog) {
if (seerrIntegrationEnabled) {
ConfirmDialog(
title = stringResource(R.string.remove_seerr_server),
body = "",
onCancel = { showSeerrServerDialog = false },
onConfirm = {
seerrVm.removeServer()
showSeerrServerDialog = false
},
)
} 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,
onSubmit = { url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod ->
if (method == SeerrAuthMethod.API_KEY) {
seerrVm.submitServer(url, passwordOrApiKey)
} else {
seerrVm.submitServer(url, username ?: "", passwordOrApiKey, method)
}
},
onDismissRequest = { showSeerrServerDialog = false },
)
}
}
}
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerPreferencesDao
@ -17,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
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.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
@ -25,6 +27,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.combine
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
@ -43,6 +46,7 @@ class PreferencesViewModel
private val serverRepository: ServerRepository,
private val navDrawerItemRepository: NavDrawerItemRepository,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
private val deviceInfo: DeviceInfo,
private val clientInfo: ClientInfo,
) : ViewModel(),
@ -52,6 +56,11 @@ class PreferencesViewModel
val currentUser get() = serverRepository.currentUser
val seerrEnabled =
seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser ->
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
}
init {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->

View file

@ -0,0 +1,306 @@
package com.github.damontecres.wholphin.ui.setup.seerr
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
@Composable
fun AddSeerrServerApiKey(
onSubmit: (url: String, apiKey: String) -> Unit,
status: LoadingState,
modifier: Modifier = Modifier,
) {
var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.focusGroup()
.padding(16.dp)
.wrapContentSize(),
) {
var url by remember { mutableStateOf("") }
var apiKey by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Text(
text = "Enter URL & API Key",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = "URL",
modifier = Modifier.padding(end = 8.dp),
)
EditTextBox(
value = url,
onValueChange = {
error = null
url = it
},
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
keyboardActions =
KeyboardActions(
onNext = {
passwordFocusRequester.tryRequestFocus()
},
),
isInputValid = { true },
modifier = Modifier.focusRequester(focusRequester),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = "API Key",
modifier = Modifier.padding(end = 8.dp),
)
EditTextBox(
value = apiKey,
onValueChange = {
error = null
apiKey = it
},
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
keyboardActions =
KeyboardActions(
onGo = { onSubmit.invoke(url, apiKey) },
),
isInputValid = { true },
modifier = Modifier.focusRequester(passwordFocusRequester),
)
}
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleLarge,
)
}
TextButton(
stringRes = R.string.submit,
onClick = { onSubmit.invoke(url, apiKey) },
enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && apiKey.isNotNullOrBlank(),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
@Composable
fun AddSeerrServerUsername(
onSubmit: (url: String, username: String, password: String) -> Unit,
username: String,
status: LoadingState,
modifier: Modifier = Modifier,
) {
var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.focusGroup()
.padding(16.dp)
.wrapContentSize(),
) {
var url by remember { mutableStateOf("") }
var username by remember { mutableStateOf(username) }
var password by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val usernameFocusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Text(
text = stringResource(R.string.username_or_password),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
val labelWidth = 90.dp
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = stringResource(R.string.url),
color = MaterialTheme.colorScheme.onSurface,
modifier =
Modifier
.width(labelWidth)
.padding(end = 8.dp),
)
EditTextBox(
value = url,
onValueChange = {
error = null
url = it
},
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
keyboardActions =
KeyboardActions(
onNext = {
usernameFocusRequester.tryRequestFocus()
},
),
isInputValid = { true },
modifier = Modifier.focusRequester(focusRequester),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = stringResource(R.string.username),
color = MaterialTheme.colorScheme.onSurface,
modifier =
Modifier
.width(labelWidth)
.padding(end = 8.dp),
)
EditTextBox(
value = username,
onValueChange = {
error = null
username = it
},
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
keyboardActions =
KeyboardActions(
onNext = {
passwordFocusRequester.tryRequestFocus()
},
),
isInputValid = { true },
modifier = Modifier.focusRequester(focusRequester),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = stringResource(R.string.password),
color = MaterialTheme.colorScheme.onSurface,
modifier =
Modifier
.width(labelWidth)
.padding(end = 8.dp),
)
EditTextBox(
value = password,
onValueChange = {
error = null
password = it
},
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
keyboardActions =
KeyboardActions(
onGo = { onSubmit.invoke(url, username, password) },
),
isInputValid = { true },
modifier = Modifier.focusRequester(passwordFocusRequester),
)
}
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleLarge,
)
}
TextButton(
stringRes = R.string.submit,
onClick = { onSubmit.invoke(url, username, password) },
enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && password.isNotNullOrBlank(),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
@PreviewTvSpec
@Composable
private fun AddSeerrServerUsernamePreview() {
WholphinTheme {
AddSeerrServerUsername(
onSubmit = { string: String, string1: String, string2: String -> },
username = "test",
status = LoadingState.Pending,
modifier = Modifier,
)
}
}

View file

@ -0,0 +1,100 @@
package com.github.damontecres.wholphin.ui.setup.seerr
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.util.LoadingState
@Composable
fun AddSeerServerDialog(
currentUsername: String?,
status: LoadingState,
onSubmit: (url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit,
onDismissRequest: () -> Unit,
) {
var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) }
LaunchedEffect(status) {
if (status is LoadingState.Success) {
onDismissRequest.invoke()
}
}
when (val auth = authMethod) {
SeerrAuthMethod.LOCAL,
SeerrAuthMethod.JELLYFIN,
-> {
BasicDialog(
onDismissRequest = { authMethod = null },
) {
AddSeerrServerUsername(
onSubmit = { url, username, password ->
onSubmit.invoke(url, username, password, auth)
},
username = currentUsername ?: "",
status = status,
)
}
}
SeerrAuthMethod.API_KEY -> {
BasicDialog(
onDismissRequest = { authMethod = null },
) {
AddSeerrServerApiKey(
onSubmit = { url, apiKey ->
onSubmit.invoke(url, null, apiKey, SeerrAuthMethod.API_KEY)
},
status = status,
)
}
}
null -> {
ChooseSeerrLoginType(
onDismissRequest = onDismissRequest,
onChoose = { authMethod = it },
)
}
}
}
@Composable
fun ChooseSeerrLoginType(
onDismissRequest: () -> Unit,
onChoose: (SeerrAuthMethod) -> Unit,
) {
val params =
remember {
DialogParams(
fromLongClick = false,
title = "Login to Seerr server",
items =
listOf(
DialogItem(
text = "API Key",
onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) },
),
DialogItem(
text = "Jellyfin user",
onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) },
),
DialogItem(
text = "Local user",
onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) },
),
),
)
}
DialogPopup(
params = params,
onDismissRequest = onDismissRequest,
dismissOnClick = false,
)
}

View file

@ -0,0 +1,90 @@
package com.github.damontecres.wholphin.ui.setup.seerr
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import jakarta.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@HiltViewModel
class SwitchSeerrViewModel
@Inject
constructor(
private val seerrServerRepository: SeerrServerRepository,
private val seerrService: SeerrService,
private val serverRepository: ServerRepository,
) : ViewModel() {
val currentUser = serverRepository.currentUser
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
private fun cleanUrl(url: String) =
if (!url.endsWith("/api/v1")) {
url
.toHttpUrlOrNull()
?.newBuilder()
?.apply {
addPathSegment("api")
addPathSegment("v1")
}?.build()
.toString()
} else {
url
}
fun submitServer(
url: String,
apiKey: String,
) {
viewModelScope.launchIO {
val url = cleanUrl(url)
val result =
seerrServerRepository.testConnection(
authMethod = SeerrAuthMethod.API_KEY,
url = url,
username = null,
passwordOrApiKey = apiKey,
)
if (result is LoadingState.Success) {
seerrServerRepository.addAndChangeServer(url, apiKey)
}
serverConnectionStatus.update { result }
}
}
fun submitServer(
url: String,
username: String,
password: String,
authMethod: SeerrAuthMethod,
) {
viewModelScope.launchIO {
val url = cleanUrl(url)
val result =
seerrServerRepository.testConnection(
authMethod = authMethod,
url = url,
username = username,
passwordOrApiKey = password,
)
if (result is LoadingState.Success) {
seerrServerRepository.addAndChangeServer(url, authMethod, username, password)
}
serverConnectionStatus.update { result }
}
}
fun removeServer() {
viewModelScope.launchIO {
seerrServerRepository.removeServer()
}
}
}