Updates & fixes to discover pages (#1836)

## Description
Fixes several issues on the discover pages:
- Fixes sorting by release date for tv shows
- Fixes automatic go-to for movie & tv show grid
- Fixes tv show discover page's go-to using a movie detail page
- Updates the alignment & padding for the headers on different discover
pages to match the reset of the app
- Use media's logo image if available and the setting is enabled

### Related issues
N/A

### Testing
Emulator mostly

## Screenshots
N/A, headers and logos are unchanged, just moved slightly to match
regular content

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-08-16 10:29:03 -04:00 • committed by GitHub
parent 4e53cbaa7f
commit f89d752d2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 182 additions and 75 deletions

View file

@ -103,7 +103,7 @@ Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x
The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue! The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue!
Seerr integration is tested with `v3.3.0`. Older versions may not work. Seerr integration is generally tested with the latest stable version. Older versions may not work as expected. The Seerr server should configured with integration to your Jellyfin server for full compatibility.
## Contributions ## Contributions

View file

@ -17,10 +17,11 @@ private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-M-d")
enum class DiscoverSort( enum class DiscoverSort(
val key: String, val key: String,
@StringRes val stringRes: Int, @param:StringRes val stringRes: Int,
val tvKey: String = key,
) { ) {
POPULARITY("popularity", R.string.popularity), POPULARITY("popularity", R.string.popularity),
RELEASE_DATE("release_date", R.string.sort_by_date_released), RELEASE_DATE("release_date", R.string.sort_by_date_released, "first_air_date"),
TMDB_VOTE("vote_average", R.string.community_rating), TMDB_VOTE("vote_average", R.string.community_rating),
ALPHABETICAL("original_title", R.string.sort_by_name), ALPHABETICAL("original_title", R.string.sort_by_name),
} }
@ -36,7 +37,7 @@ data class DiscoverSortAndDirection(
val sort: DiscoverSort = DiscoverSort.POPULARITY, val sort: DiscoverSort = DiscoverSort.POPULARITY,
val direction: SortOrder = SortOrder.DESCENDING, val direction: SortOrder = SortOrder.DESCENDING,
) { ) {
val key = "${sort.key}.${direction.key}" fun key(isTv: Boolean) = if (isTv) "${sort.tvKey}.${direction.key}" else "${sort.key}.${direction.key}"
fun flip() = copy(direction = direction.flip()) fun flip() = copy(direction = direction.flip())
} }
@ -103,7 +104,7 @@ data class DiscoverFilter(
network = null, network = null,
keywords = keywordIds?.joinToString(",") { it.toString() }, keywords = keywordIds?.joinToString(",") { it.toString() },
excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() }, excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() },
sortBy = sortBy?.key, sortBy = sortBy?.key(true),
// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) }, // firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) },
// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) }, // firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) },
withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(), withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(),
@ -135,7 +136,7 @@ data class DiscoverFilter(
// network = null, // network = null,
keywords = keywordIds?.joinToString(",") { it.toString() }, keywords = keywordIds?.joinToString(",") { it.toString() },
excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() }, excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() },
sortBy = sortBy?.key, sortBy = sortBy?.key(false),
// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) }, // firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) },
// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) }, // firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) },
withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(), withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(),

View file

@ -103,6 +103,7 @@ data class DiscoverItem(
@Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?, @Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?,
val posterUrl: String?, val posterUrl: String?,
val backDropUrl: String?, val backDropUrl: String?,
val logoUrl: String?,
val jellyfinItemId: UUID?, val jellyfinItemId: UUID?,
) : CardGridItem { ) : CardGridItem {
override val gridId: String get() = id.toString() override val gridId: String get() = id.toString()

View file

@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -169,14 +170,7 @@ class SeerrService
backdropWidth: Int = 1920, backdropWidth: Int = 1920,
): String? { ): String? {
if (mediaInfo != null) { if (mediaInfo != null) {
val itemId = val itemId = mediaInfo.jellyfinId
if (mediaInfo.jellyfinMediaId.isNotNullOrBlank()) {
mediaInfo.jellyfinMediaId.toUUIDOrNull()
} else if (mediaInfo.jellyfinMediaId4k.isNotNullOrBlank()) {
mediaInfo.jellyfinMediaId4k.toUUIDOrNull()
} else {
null
}
if (itemId != null) { if (itemId != null) {
return imageUrlService.getItemImageUrl( return imageUrlService.getItemImageUrl(
itemId = itemId, itemId = itemId,
@ -348,7 +342,8 @@ class SeerrService
releaseDate = toLocalDate(movie.releaseDate), releaseDate = toLocalDate(movie.releaseDate),
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo),
jellyfinItemId = movie.mediaInfo?.jellyfinId,
) )
suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem = suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem =
@ -364,7 +359,8 @@ class SeerrService
releaseDate = toLocalDate(movie.releaseDate), releaseDate = toLocalDate(movie.releaseDate),
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo),
jellyfinItemId = movie.mediaInfo?.jellyfinId,
) )
suspend fun createDiscoverItem(tv: TvResult): DiscoverItem = suspend fun createDiscoverItem(tv: TvResult): DiscoverItem =
@ -380,7 +376,8 @@ class SeerrService
releaseDate = toLocalDate(tv.firstAirDate), releaseDate = toLocalDate(tv.firstAirDate),
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo),
jellyfinItemId = tv.mediaInfo?.jellyfinId,
) )
suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem = suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem =
@ -396,7 +393,8 @@ class SeerrService
releaseDate = toLocalDate(tv.firstAirDate), releaseDate = toLocalDate(tv.firstAirDate),
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo),
jellyfinItemId = tv.mediaInfo?.jellyfinId,
) )
suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem = suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem =
@ -412,7 +410,8 @@ class SeerrService
releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate), releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate),
posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo), posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo),
jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), logoUrl = createImageUrl(ImageType.LOGO, null, search.mediaInfo),
jellyfinItemId = search.mediaInfo?.jellyfinId,
) )
suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem = suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem =
@ -438,7 +437,8 @@ class SeerrService
credit.backdropPath, credit.backdropPath,
credit.mediaInfo, credit.mediaInfo,
), ),
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo),
jellyfinItemId = credit.mediaInfo?.jellyfinId,
) )
suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem = suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem =
@ -464,6 +464,13 @@ class SeerrService
credit.backdropPath, credit.backdropPath,
credit.mediaInfo, credit.mediaInfo,
), ),
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo),
jellyfinItemId = credit.mediaInfo?.jellyfinId,
) )
} }
val MediaInfo.jellyfinId: UUID?
get() = jellyfinMediaId4k?.toUUIDOrNull() ?: jellyfinMediaId?.toUUIDOrNull()
val MediaInfo.jellyfinIdAsString: String?
get() = jellyfinMediaId4k ?: jellyfinMediaId

View file

@ -21,6 +21,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.logCoilError
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
@ -40,6 +41,10 @@ fun TitleOrLogo(
model = logoImageUrl, model = logoImageUrl,
contentDescription = title, contentDescription = title,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
onError = {
logCoilError(logoImageUrl, it.result)
imageError = true
},
modifier = modifier =
Modifier Modifier
.height(HeaderUtils.logoHeight) .height(HeaderUtils.logoHeight)
@ -113,6 +118,10 @@ fun SimpleTitleOrLogo(
model = logoImageUrl, model = logoImageUrl,
contentDescription = item?.title, contentDescription = item?.title,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
onError = {
logCoilError(logoImageUrl, it.result)
imageError = true
},
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} else { } else {

View file

@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
@ -64,7 +65,6 @@ import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@Composable @Composable
fun DiscoverMovieDetails( fun DiscoverMovieDetails(
@ -138,14 +138,7 @@ fun DiscoverMovieDetails(
) )
}, },
goToOnClick = { goToOnClick = {
movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { viewModel.goTo(movie.mediaInfo, BaseItemKind.MOVIE)
viewModel.navigateTo(
Destination.MediaItem(
itemId = it,
type = BaseItemKind.MOVIE,
),
)
}
}, },
moreOnClick = {}, moreOnClick = {},
onLongClickPerson = { index, person -> }, onLongClickPerson = { index, person -> },
@ -238,10 +231,11 @@ fun DiscoverMovieDetailsContent(
rating = rating, rating = rating,
bringIntoViewRequester = bringIntoViewRequester, bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = overviewOnClick, overviewOnClick = overviewOnClick,
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 32.dp, bottom = 16.dp), .padding(top = HeaderUtils.topPadding, bottom = 16.dp),
) )
ExpandableDiscoverButtons( ExpandableDiscoverButtons(
availability = availability =

View file

@ -14,7 +14,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle 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.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -23,9 +22,13 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.jellyfinId
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetailsText import com.github.damontecres.wholphin.ui.components.QuickDetailsText
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
@ -33,6 +36,7 @@ import com.github.damontecres.wholphin.ui.listToDotString
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.ImageType
import java.util.Locale import java.util.Locale
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@ -43,29 +47,36 @@ fun DiscoverMovieDetailsHeader(
rating: DiscoverRating?, rating: DiscoverRating?,
bringIntoViewRequester: BringIntoViewRequester, bringIntoViewRequester: BringIntoViewRequester,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
showLogo: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val imageUrlService = LocalImageUrlService.current
val logoImageUrl =
remember(movie) {
movie.mediaInfo?.jellyfinId?.let {
imageUrlService.getItemImageUrl(it, ImageType.LOGO)
}
}
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier, modifier = modifier,
) { ) {
// Title TitleOrLogo(
Text( title = movie.title,
text = movie.title ?: "", logoImageUrl = logoImageUrl,
color = MaterialTheme.colorScheme.onSurface, showLogo = showLogo,
style = MaterialTheme.typography.displaySmall, modifier =
maxLines = 2, Modifier
overflow = TextOverflow.Ellipsis, .fillMaxWidth(.75f)
modifier = Modifier.fillMaxWidth(.75f), .padding(start = HeaderUtils.startPadding),
) )
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f), modifier = Modifier.fillMaxWidth(.60f),
) { ) {
val padding = 4.dp
val resources = LocalResources.current val resources = LocalResources.current
val details = val details =
remember(movie, rating, resources) { remember(movie, rating, resources) {
@ -102,9 +113,9 @@ fun DiscoverMovieDetailsHeader(
} }
} }
QuickDetailsText(details) QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding))
movie.genres?.mapNotNull { it.name }?.letNotEmpty { movie.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(bottom = padding)) GenreText(it, Modifier.padding(start = HeaderUtils.startPadding))
} }
val tagline = remember { movie.tagline?.takeIf { it.isNotNullOrBlank() } } val tagline = remember { movie.tagline?.takeIf { it.isNotNullOrBlank() } }
@ -113,7 +124,7 @@ fun DiscoverMovieDetailsHeader(
text = tagline, text = tagline,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
modifier = Modifier, modifier = Modifier.padding(start = HeaderUtils.startPadding),
) )
} }
@ -150,6 +161,7 @@ fun DiscoverMovieDetailsHeader(
text = stringResource(R.string.directed_by, it), text = stringResource(R.string.directed_by, it),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = HeaderUtils.startPadding),
) )
} }
} }

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.discover
import android.content.Context import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.model.MediaInfo
import com.github.damontecres.wholphin.api.seerr.model.MediaRequest import com.github.damontecres.wholphin.api.seerr.model.MediaRequest
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails 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.RelatedVideo
@ -23,6 +24,7 @@ import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
@ -44,6 +46,7 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber import timber.log.Timber
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@ -172,6 +175,15 @@ class DiscoverMovieViewModel
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
fun goTo(
mediaInfo: MediaInfo?,
type: BaseItemKind,
) {
viewModelScope.launchDefault {
goToButtonDiscover(mediaInfo, type, context, navigationManager)
}
}
fun request(request: MovieRequest) { fun request(request: MovieRequest) {
viewModelScope.launchIO { viewModelScope.launchIO {
if (request.movieId == null) { if (request.movieId == null) {

View file

@ -27,12 +27,9 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource 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.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.R
import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
@ -45,6 +42,8 @@ import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.jellyfinId
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
@ -53,9 +52,11 @@ import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetailsText import com.github.damontecres.wholphin.ui.components.QuickDetailsText
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.formatDuration
@ -70,7 +71,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.successValue import com.github.damontecres.wholphin.util.successValue
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.api.ImageType
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@Composable @Composable
@ -125,14 +126,7 @@ fun DiscoverSeriesDetails(
viewModel.navigateTo(Destination.DiscoveredItem(it)) viewModel.navigateTo(Destination.DiscoveredItem(it))
}, },
goToOnClick = { goToOnClick = {
item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { viewModel.goTo(item.mediaInfo, BaseItemKind.SERIES)
viewModel.navigateTo(
Destination.MediaItem(
itemId = it,
type = BaseItemKind.MOVIE,
),
)
}
}, },
overviewOnClick = { overviewOnClick = {
overviewDialog = overviewDialog =
@ -254,7 +248,6 @@ fun DiscoverSeriesDetailsContent(
Column( Column(
modifier = modifier =
Modifier Modifier
.padding(16.dp)
.fillMaxSize(), .fillMaxSize(),
) { ) {
LazyColumn( LazyColumn(
@ -274,10 +267,11 @@ fun DiscoverSeriesDetailsContent(
series = series, series = series,
rating = rating, rating = rating,
overviewOnClick = overviewOnClick, overviewOnClick = overviewOnClick,
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 32.dp, bottom = 16.dp), .padding(top = HeaderUtils.topPadding, bottom = 16.dp),
) )
ExpandableDiscoverButtons( ExpandableDiscoverButtons(
availability = availability =
@ -430,24 +424,33 @@ fun DiscoverSeriesDetailsHeader(
series: TvDetails, series: TvDetails,
rating: DiscoverRating?, rating: DiscoverRating?,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
showLogo: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val imageUrlService = LocalImageUrlService.current
val logoImageUrl =
remember(series) {
series.mediaInfo?.jellyfinId?.let {
imageUrlService.getItemImageUrl(it, ImageType.LOGO)
}
}
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier, modifier = modifier,
) { ) {
Text( TitleOrLogo(
text = series.name ?: stringResource(R.string.unknown), title = series.name,
style = MaterialTheme.typography.displaySmall, logoImageUrl = logoImageUrl,
maxLines = 2, showLogo = showLogo,
overflow = TextOverflow.Ellipsis, modifier =
modifier = Modifier.fillMaxWidth(.75f), Modifier
.fillMaxWidth(.75f)
.padding(start = HeaderUtils.startPadding),
) )
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f), modifier = Modifier.fillMaxWidth(.60f),
) { ) {
val padding = 4.dp
val resources = LocalResources.current val resources = LocalResources.current
val details = val details =
remember(series, rating, resources) { remember(series, rating, resources) {
@ -469,9 +472,9 @@ fun DiscoverSeriesDetailsHeader(
} }
} }
QuickDetailsText(details) QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding))
series.genres?.mapNotNull { it.name }?.letNotEmpty { series.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(bottom = padding)) GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp))
} }
series.overview?.let { overview -> series.overview?.let { overview ->
OverviewText( OverviewText(

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.discover
import android.content.Context import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.model.MediaInfo
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutRequest import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutRequest
@ -20,6 +21,7 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
@ -45,6 +47,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber import timber.log.Timber
@HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class) @HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class)
@ -162,6 +165,15 @@ class DiscoverSeriesViewModel
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
fun goTo(
mediaInfo: MediaInfo?,
type: BaseItemKind,
) {
viewModelScope.launchDefault {
goToButtonDiscover(mediaInfo, type, context, navigationManager)
}
}
private suspend fun updateSeasonStatus(tv: TvDetails) { private suspend fun updateSeasonStatus(tv: TvDetails) {
if (request4kEnabled.first()) { if (request4kEnabled.first()) {
updateSeasonStatus(tv, true) updateSeasonStatus(tv, true)

View file

@ -1,8 +1,17 @@
package com.github.damontecres.wholphin.ui.detail.discover package com.github.damontecres.wholphin.ui.detail.discover
import android.content.Context
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
import com.github.damontecres.wholphin.api.seerr.model.MediaInfo
import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.jellyfinId
import com.github.damontecres.wholphin.services.jellyfinIdAsString
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.showToast
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber import timber.log.Timber
suspend fun getDiscoverRating( suspend fun getDiscoverRating(
@ -24,3 +33,40 @@ suspend fun getDiscoverRating(
Timber.e(ex, "Error fetching rating for %s", id) Timber.e(ex, "Error fetching rating for %s", id)
null null
} }
/**
* Navigates to the Jellyfin media item if the [mediaInfo] has a valid [jellyfinId]
*
* If not, a error toast is shown
*/
suspend fun goToButtonDiscover(
mediaInfo: MediaInfo?,
type: BaseItemKind,
context: Context,
navigationManager: NavigationManager,
) {
val id = mediaInfo?.jellyfinId
if (id != null) {
navigationManager.navigateTo(
Destination.MediaItem(
itemId = id,
type = type,
),
)
} else {
val stringId = mediaInfo?.jellyfinIdAsString
val msg =
if (stringId.isNotNullOrBlank()) {
"Unknown Jellyfin ID: $stringId"
} else {
"No Jellyfin ID found"
}
Timber.w(
"Unknown jellyfinId for tmdb=%s: %s/%s",
mediaInfo?.tmdbId,
mediaInfo?.jellyfinMediaId,
mediaInfo?.jellyfinMediaId4k,
)
showToast(context, msg)
}
}

View file

@ -13,6 +13,7 @@ sealed interface DiscoverFocusedItem {
val subtitle: String? val subtitle: String?
val overview: String? val overview: String?
val backDropUrl: String? val backDropUrl: String?
val logoUrl: String?
data class Item( data class Item(
val item: DiscoverItem, val item: DiscoverItem,
@ -26,6 +27,8 @@ sealed interface DiscoverFocusedItem {
get() = item.overview get() = item.overview
override val backDropUrl: String? override val backDropUrl: String?
get() = item.backDropUrl get() = item.backDropUrl
override val logoUrl: String?
get() = item.logoUrl
} }
data class Genre( data class Genre(
@ -39,6 +42,8 @@ sealed interface DiscoverFocusedItem {
get() = null get() = null
override val overview: String? override val overview: String?
get() = null get() = null
override val logoUrl: String?
get() = null
} }
} }

View file

@ -299,7 +299,7 @@ fun DiscoverRequestGrid(
initialPosition = state.startIndex, initialPosition = state.startIndex,
pager = s.data, pager = s.data,
onClickItem = { index, item -> onClickItem = { index, item ->
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) viewModel.navigationManager.navigateTo(item.destination)
}, },
onLongClickItem = { index, item -> }, onLongClickItem = { index, item -> },
onClickPlay = { _, _ -> }, onClickPlay = { _, _ -> },

View file

@ -6,9 +6,9 @@ import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -40,6 +40,7 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.components.Genre
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.discover.getDiscoverRating import com.github.damontecres.wholphin.ui.detail.discover.getDiscoverRating
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
@ -438,16 +439,20 @@ fun SeerrDiscoverPage(
title = focusedItem?.title?.getString(), title = focusedItem?.title?.getString(),
subtitle = focusedItem?.subtitle, subtitle = focusedItem?.subtitle,
overview = focusedItem?.overview, overview = focusedItem?.overview,
overviewTwoLines = true, overviewTwoLines = false,
quickDetails = details, quickDetails = details,
timeRemaining = null, timeRemaining = null,
endsAt = null, endsAt = null,
showLogo = preferences.appPreferences.interfacePreferences.showLogos, showLogo = preferences.appPreferences.interfacePreferences.showLogos,
logoImageUrl = null, // TODO logoImageUrl = focusedItem?.logoUrl,
modifier = modifier =
Modifier Modifier
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp) .padding(
.fillMaxHeight(.25f), top = HeaderUtils.topPadding,
// Intentionally no bottom padding because of tabs
bottom = 0.dp,
start = HeaderUtils.startPadding,
).height(HeaderUtils.height),
) )
val density = LocalDensity.current val density = LocalDensity.current