Add backdrops to discover pages

This commit is contained in:
Damontecres 2025-12-23 17:15:25 -05:00
parent f27415de0f
commit 22348a5adc
No known key found for this signature in database
5 changed files with 111 additions and 22 deletions

View file

@ -1,11 +1,18 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response 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.MovieResult
import com.github.damontecres.wholphin.api.seerr.model.TvResult import com.github.damontecres.wholphin.api.seerr.model.TvResult
import com.github.damontecres.wholphin.services.SeerrSearchResult import com.github.damontecres.wholphin.services.SeerrSearchResult
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
@Serializable @Serializable
enum class SeerrItemType { enum class SeerrItemType {
@ -59,7 +66,11 @@ data class DiscoverItem(
val releaseDate: String?, val releaseDate: String?,
val posterPath: String?, val posterPath: String?,
val backdropPath: String?, val backdropPath: String?,
val jellyfinItemId: UUID?,
) { ) {
val backDropUrl: String? get() = backdropPath?.let { "https://image.tmdb.org/t/p/w1920_and_h800_multi_faces$it" }
val posterUrl: String? get() = posterPath?.let { "https://image.tmdb.org/t/p/w500$it" }
constructor(movie: MovieResult) : this( constructor(movie: MovieResult) : this(
id = movie.id, id = movie.id,
type = SeerrItemType.MOVIE, type = SeerrItemType.MOVIE,
@ -68,6 +79,18 @@ data class DiscoverItem(
releaseDate = movie.releaseDate, releaseDate = movie.releaseDate,
posterPath = movie.posterPath, posterPath = movie.posterPath,
backdropPath = movie.backdropPath, backdropPath = movie.backdropPath,
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
constructor(movie: MovieDetails) : this(
id = movie.id ?: -1,
type = SeerrItemType.MOVIE,
title = movie.title,
availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN,
releaseDate = movie.releaseDate,
posterPath = movie.posterPath,
backdropPath = movie.backdropPath,
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
) )
constructor(tv: TvResult) : this( constructor(tv: TvResult) : this(
@ -78,6 +101,7 @@ data class DiscoverItem(
releaseDate = tv.firstAirDate, releaseDate = tv.firstAirDate,
posterPath = tv.posterPath, posterPath = tv.posterPath,
backdropPath = tv.backdropPath, backdropPath = tv.backdropPath,
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
) )
constructor(search: SeerrSearchResult) : this( constructor(search: SeerrSearchResult) : this(
@ -90,6 +114,7 @@ data class DiscoverItem(
releaseDate = search.releaseDate ?: search.firstAirDate, releaseDate = search.releaseDate ?: search.firstAirDate,
posterPath = search.posterPath, posterPath = search.posterPath,
backdropPath = search.backdropPath, backdropPath = search.backdropPath,
jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
) )
} }

View file

@ -15,6 +15,7 @@ import coil3.request.SuccessResult
import coil3.request.allowHardware import coil3.request.allowHardware
import coil3.request.bitmapConfig import coil3.request.bitmapConfig
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.BackdropStyle import com.github.damontecres.wholphin.preferences.BackdropStyle
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -27,7 +28,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber import timber.log.Timber
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -47,27 +47,38 @@ class BackdropService
suspend fun submit(item: BaseItem) = suspend fun submit(item: BaseItem) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) { submit(item.id.toString(), imageUrl)
_backdropFlow.update {
it.copy(
itemId = item.id,
imageUrl = null,
)
}
extractColors(item)
}
} }
suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl)
suspend fun submit(
itemId: String,
imageUrl: String?,
) = withContext(Dispatchers.IO) {
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
_backdropFlow.update {
it.copy(
itemId = itemId,
imageUrl = null,
)
}
extractColors(itemId, imageUrl)
}
}
suspend fun clearBackdrop() { suspend fun clearBackdrop() {
_backdropFlow.update { _backdropFlow.update {
BackdropResult.NONE BackdropResult.NONE
} }
} }
private suspend fun extractColors(item: BaseItem) { private suspend fun extractColors(
itemId: String,
imageUrl: String?,
) {
delay(500) delay(500)
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
val backdropStyle = val backdropStyle =
preferences.data preferences.data
.firstOrNull() .firstOrNull()
@ -83,9 +94,9 @@ class BackdropService
ExtractedColors.DEFAULT ExtractedColors.DEFAULT
} }
_backdropFlow.update { _backdropFlow.update {
if (it.itemId == item.id) { if (it.itemId == itemId) {
BackdropResult( BackdropResult(
itemId = item.id, itemId = itemId,
imageUrl = imageUrl, imageUrl = imageUrl,
primaryColor = primaryColor, primaryColor = primaryColor,
secondaryColor = secondaryColor, secondaryColor = secondaryColor,
@ -208,7 +219,7 @@ class BackdropService
} }
data class BackdropResult( data class BackdropResult(
val itemId: UUID?, val itemId: String?,
val imageUrl: String?, val imageUrl: String?,
val primaryColor: Color = Color.Unspecified, val primaryColor: Color = Color.Unspecified,
val secondaryColor: Color = Color.Unspecified, val secondaryColor: Color = Color.Unspecified,

View file

@ -92,7 +92,7 @@ fun DiscoverItemCard(
.fillMaxSize(), .fillMaxSize(),
) { ) {
ItemCardImage( ItemCardImage(
imageUrl = item?.posterPath?.let { "https://image.tmdb.org/t/p/w500$it" }, imageUrl = item?.posterUrl,
name = item?.title, name = item?.title,
showOverlay = false, showOverlay = false,
favorite = false, favorite = false,

View file

@ -89,8 +89,8 @@ class DiscoverMovieViewModel
), ),
) { ) {
val movie = fetchAndSetItem().await() val movie = fetchAndSetItem().await()
// TODO backdrop url = "https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/$it" val discoveredItem = DiscoverItem(movie)
// backdropService.submit(movie) backdropService.submit(discoveredItem)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
loading.value = LoadingState.Success loading.value = LoadingState.Success

View file

@ -10,6 +10,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
@ -23,16 +24,21 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.MediaApi import com.github.damontecres.wholphin.api.seerr.MediaApi
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.preferences.UserPreferences 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.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.data.RowColumn
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.rememberPosition
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
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 javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -42,6 +48,7 @@ class SeerrDiscoverViewModel
private val seerrService: SeerrService, private val seerrService: SeerrService,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val api: ApiClient, private val api: ApiClient,
private val backdropService: BackdropService,
) : ViewModel() { ) : ViewModel() {
val recentlyAdded = MutableLiveData<List<DiscoverItem>>(listOf()) val recentlyAdded = MutableLiveData<List<DiscoverItem>>(listOf())
val discoverMovies = MutableLiveData<List<DiscoverItem>>(listOf()) val discoverMovies = MutableLiveData<List<DiscoverItem>>(listOf())
@ -49,7 +56,7 @@ class SeerrDiscoverViewModel
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
backdropService.clearBackdrop()
val tv = val tv =
seerrService.api.mediaApi seerrService.api.mediaApi
.mediaGet( .mediaGet(
@ -69,6 +76,14 @@ class SeerrDiscoverViewModel
discoverTv.setValueOnMain(tv) discoverTv.setValueOnMain(tv)
} }
} }
fun updateBackdrop(item: DiscoverItem?) {
viewModelScope.launchIO {
if (item != null) {
backdropService.submit("discover_${item.id}", item.backDropUrl)
}
}
}
} }
@Composable @Composable
@ -83,6 +98,16 @@ fun SeerrDiscoverPage(
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(movies) { if (movies.isNotEmpty()) focusRequester.tryRequestFocus() } LaunchedEffect(movies) { if (movies.isNotEmpty()) focusRequester.tryRequestFocus() }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var position by rememberPosition(0, 0)
LaunchedEffect(position) {
position.let {
val item = if (it.row == 0) movies.getOrNull(it.column) else tv.getOrNull(it.column)
Timber.v("Backdrop for $item")
viewModel.updateBackdrop(item)
}
}
Column( Column(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier =
@ -94,7 +119,16 @@ fun SeerrDiscoverPage(
title = stringResource(R.string.movies), title = stringResource(R.string.movies),
items = movies, items = movies,
onClickItem = { index, item -> onClickItem = { index, item ->
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) if (item.jellyfinItemId != null) {
viewModel.navigationManager.navigateTo(
Destination.MediaItem(
itemId = item.jellyfinItemId,
type = BaseItemKind.MOVIE,
),
)
} else {
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
}
}, },
onLongClickItem = { index, item -> }, onLongClickItem = { index, item -> },
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
@ -106,13 +140,27 @@ fun SeerrDiscoverPage(
modifier = mod, modifier = mod,
) )
}, },
cardOnFocus = { isFocused, index ->
if (isFocused) {
position = RowColumn(0, index)
}
},
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
) )
ItemRow( ItemRow(
title = stringResource(R.string.tv_shows), title = stringResource(R.string.tv_shows),
items = tv, items = tv,
onClickItem = { index, item -> onClickItem = { index, item ->
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) if (item.jellyfinItemId != null) {
viewModel.navigationManager.navigateTo(
Destination.MediaItem(
itemId = item.jellyfinItemId,
type = BaseItemKind.SERIES,
),
)
} else {
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
}
}, },
onLongClickItem = { index, item -> }, onLongClickItem = { index, item -> },
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
@ -124,6 +172,11 @@ fun SeerrDiscoverPage(
modifier = mod, modifier = mod,
) )
}, },
cardOnFocus = { isFocused, index ->
if (isFocused) {
position = RowColumn(1, index)
}
},
) )
} }
} }