Better discover cards

This commit is contained in:
Damontecres 2025-12-20 15:51:44 -05:00
parent 99d1ccf7e7
commit 76c1fd4b15
No known key found for this signature in database
7 changed files with 214 additions and 35 deletions

View file

@ -3,13 +3,34 @@ package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
import com.github.damontecres.wholphin.api.seerr.model.MovieResult
import com.github.damontecres.wholphin.api.seerr.model.TvResult
import com.github.damontecres.wholphin.services.SeerrSearchResult
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class SeerrItemType {
@SerialName("movie")
MOVIE,
@SerialName("tv")
TV,
@SerialName("person")
PERSON,
@SerialName("unknown")
UNKNOWN,
;
companion object {
fun fromString(str: String?) =
when (str) {
"movie" -> MOVIE
"tv" -> TV
"person" -> PERSON
else -> UNKNOWN
}
}
}
@Serializable
@ -58,6 +79,18 @@ data class DiscoverItem(
posterPath = tv.posterPath,
backdropPath = tv.backdropPath,
)
constructor(search: SeerrSearchResult) : this(
id = search.id,
type = SeerrItemType.fromString(search.mediaType),
title = search.title ?: search.name,
availability =
SeerrAvailability.from(search.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
releaseDate = search.releaseDate ?: search.firstAirDate,
posterPath = search.posterPath,
backdropPath = search.backdropPath,
)
}
data class DiscoverRating(

View file

@ -1,12 +1,43 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
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.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
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.SeerrItemType
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay
@Composable
@NonRestartableComposable
@ -14,20 +45,125 @@ fun DiscoverItemCard(
item: DiscoverItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
showOverlay: Boolean,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = SeasonCard(
title = item?.title,
subtitle = item?.releaseDate,
name = item?.title,
imageUrl = item?.posterPath?.let { "https://image.tmdb.org/t/p/w500$it" }, // TODO
isFavorite = false,
isPlayed = false,
unplayedItemCount = -1,
playedPercentage = -1.0,
onClick = onClick,
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
modifier = modifier,
interactionSource = interactionSource,
)
) {
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?.posterPath?.let { "https://image.tmdb.org/t/p/w500$it" },
name = item?.title,
showOverlay = false,
favorite = false,
watched = false,
unwatchedCount = 0,
watchedPercent = null,
useFallbackText = false,
contentScale = ContentScale.FillBounds,
modifier =
Modifier
.fillMaxSize(),
)
if (showOverlay) {
// TODO better colors
val color =
when (item?.type) {
SeerrItemType.MOVIE -> Color.Blue
SeerrItemType.TV -> Color.Red
SeerrItemType.PERSON -> Color.Green
SeerrItemType.UNKNOWN -> Color.Black
null -> Color.Black
}.copy(alpha = .5f)
when (item?.type) {
SeerrItemType.MOVIE -> R.string.movies
SeerrItemType.TV -> R.string.tv_shows
SeerrItemType.PERSON -> R.string.people
SeerrItemType.UNKNOWN -> null
null -> null
}?.let {
Text(
text = stringResource(it),
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 ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}

View file

@ -49,7 +49,6 @@ import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -227,9 +226,6 @@ fun DiscoverMovieDetailsContent(
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
Box(modifier = modifier) {
movie.backdropPath?.let {
DetailsBackdropImage("https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/$it")
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
@ -326,6 +322,7 @@ fun DiscoverMovieDetailsContent(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = false,
modifier = mod,
)
},
@ -354,6 +351,7 @@ fun DiscoverMovieDetailsContent(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = true,
modifier = mod,
)
},

View file

@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.Person
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.SeerrService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -42,6 +43,7 @@ class DiscoverMovieViewModel
private val api: ApiClient,
@param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager,
private val backdropService: BackdropService,
val serverRepository: ServerRepository,
val seerrService: SeerrService,
@Assisted val item: DiscoverItem,
@ -86,6 +88,9 @@ class DiscoverMovieViewModel
),
) {
val movie = fetchAndSetItem().await()
// TODO backdrop url = "https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/$it"
// backdropService.submit(movie)
withContext(Dispatchers.Main) {
loading.value = LoadingState.Success
}

View file

@ -39,12 +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.SeerrSearchResult
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
@ -144,7 +146,11 @@ class SearchViewModel
if (seerrService.active) {
viewModelScope.launchIO {
seerrResults.setValueOnMain(SearchResult.Searching)
val results = seerrService.search(query)
val results =
seerrService
.search(query)
.map { DiscoverItem(it) }
.filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV }
seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results))
}
}
@ -170,7 +176,7 @@ sealed interface SearchResult {
) : SearchResult
data class SuccessSeerr(
val items: List<SeerrSearchResult>,
val items: List<DiscoverItem>,
) : SearchResult
}
@ -416,19 +422,12 @@ fun LazyListScope.searchResultRow(
onClickItem = { index, item -> },
onLongClickItem = { _, _ -> },
modifier = modifier,
cardContent = { index: Int, item: SeerrSearchResult?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
SeasonCard(
title = item?.title ?: item?.name,
subtitle = null,
name = item?.title ?: item?.name,
imageUrl = item?.posterPath?.let { "https://image.tmdb.org/t/p/w500$it" }, // TODO
isFavorite = false,
isPlayed = false,
unplayedItemCount = -1,
playedPercentage = -1.0,
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
showOverlay = true,
modifier = mod,
)
},

View file

@ -284,6 +284,10 @@ fun DestinationContent(
SeerrItemType.PERSON -> {
TODO()
}
SeerrItemType.UNKNOWN -> {
TODO()
}
}
}
}

View file

@ -9,11 +9,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.MediaApi
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.preferences.UserPreferences
@ -83,7 +85,7 @@ fun SeerrDiscoverPage(
.padding(16.dp),
) {
ItemRow(
title = "Movies",
title = stringResource(R.string.movies),
items = movies,
onClickItem = { index, item ->
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
@ -94,12 +96,13 @@ fun SeerrDiscoverPage(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = false,
modifier = mod,
)
},
)
ItemRow(
title = "TV",
title = stringResource(R.string.tv_shows),
items = tv,
onClickItem = { index, item ->
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
@ -110,6 +113,7 @@ fun SeerrDiscoverPage(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = false,
modifier = mod,
)
},