Backdrop improvements (#476)

## Description

The backdrop image shown on most pages is now configurable in advanced
settings:
* Image w/ dynamic color - Shows the backdrop with dynamic color filling
the entire space (new default)
* Image only - Shows only the backdrop image (current Wholphin behavior)
* None - Don't show any backdrops

Additionally, the backdrop is retained between page loads for the same
items.

## Acknowledgements

Big thanks to @YogiBear12 from which the idea and implementation for the
dynamic color was adapted from!

Code adapted from
https://github.com/YogiBear12/Halfin/blob/main/COLOR_EXTRACTION_AND_BACKDROP.md#backdrop-fading-and-transitions

## Issues

Closes #221
Closes #461
Closes #442



https://github.com/user-attachments/assets/1acbc487-c697-44d7-89ed-e7e4c9169360
This commit is contained in:
Ray 2025-12-17 11:18:57 -05:00 committed by GitHub
parent 8224f83678
commit bbfbc13532
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 569 additions and 224 deletions

View file

@ -223,6 +223,7 @@ dependencies {
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.preference.ktx) implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.room.testing) implementation(libs.androidx.room.testing)
implementation(libs.androidx.palette.ktx)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)

View file

@ -659,6 +659,19 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val BackdropStylePref =
AppChoicePreference<AppPreferences, BackdropStyle>(
title = R.string.backdrop_display,
defaultValue = BackdropStyle.BACKDROP_DYNAMIC_COLOR,
getter = { it.interfacePreferences.backdropStyle },
setter = { prefs, value ->
prefs.updateInterfacePreferences { backdropStyle = value }
},
displayValues = R.array.backdrop_style_options,
indexToValue = { BackdropStyle.forNumber(it) },
valueToIndex = { it.number },
)
val OneClickPause = val OneClickPause =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.one_click_pause, title = R.string.one_click_pause,
@ -909,6 +922,7 @@ val advancedPreferences =
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus, // AppPreference.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout, AppPreference.ControllerTimeout,
AppPreference.BackdropStylePref,
), ),
), ),
) )

View file

@ -91,6 +91,7 @@ class AppPreferencesSerializer
navDrawerSwitchOnFocus = navDrawerSwitchOnFocus =
AppPreference.NavDrawerSwitchOnFocus.defaultValue AppPreference.NavDrawerSwitchOnFocus.defaultValue
showClock = AppPreference.ShowClock.defaultValue showClock = AppPreference.ShowClock.defaultValue
backdropStyle = AppPreference.BackdropStylePref.defaultValue
subtitlesPreferences = subtitlesPreferences =
SubtitlePreferences SubtitlePreferences

View file

@ -0,0 +1,239 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import android.graphics.Bitmap
import android.util.LruCache
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.core.graphics.drawable.toBitmap
import androidx.datastore.core.DataStore
import androidx.palette.graphics.Palette
import coil3.asDrawable
import coil3.imageLoader
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.request.bitmapConfig
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.BackdropStyle
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@OptIn(FlowPreview::class)
class BackdropService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val imageUrlService: ImageUrlService,
private val preferences: DataStore<AppPreferences>,
) {
private val extractedColorCache = LruCache<String, ExtractedColors>(50)
private val _backdropFlow = MutableStateFlow<BackdropResult>(BackdropResult.NONE)
val backdropFlow = _backdropFlow
suspend fun submit(item: BaseItem) =
withContext(Dispatchers.IO) {
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
_backdropFlow.update {
it.copy(
itemId = item.id,
imageUrl = null,
)
}
extractColors(item)
}
}
suspend fun clearBackdrop() {
_backdropFlow.update {
BackdropResult.NONE
}
}
private suspend fun extractColors(item: BaseItem) {
delay(500)
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
val backdropStyle =
preferences.data
.firstOrNull()
?.interfacePreferences
?.backdropStyle
val dynamicEnabled =
backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR ||
backdropStyle == BackdropStyle.UNRECOGNIZED
val (primaryColor, secondaryColor, tertiaryColor) =
if (dynamicEnabled) {
extractColorsFromBackdrop(imageUrl)
} else {
ExtractedColors.DEFAULT
}
_backdropFlow.update {
if (it.itemId == item.id) {
BackdropResult(
itemId = item.id,
imageUrl = imageUrl,
primaryColor = primaryColor,
secondaryColor = secondaryColor,
tertiaryColor = tertiaryColor,
)
} else {
it
}
}
}
private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
withContext(Dispatchers.IO) {
if (imageUrl.isNullOrBlank()) {
return@withContext ExtractedColors.DEFAULT
}
extractedColorCache.get(imageUrl)?.let {
return@withContext it
}
try {
val loader = context.imageLoader
val request =
ImageRequest
.Builder(context)
.data(imageUrl)
.allowHardware(false)
.bitmapConfig(Bitmap.Config.ARGB_8888)
.build()
val result = loader.execute(request)
if (result is SuccessResult) {
val drawable = result.image.asDrawable(context.resources)
val bitmap = drawable.toBitmap(config = Bitmap.Config.ARGB_8888)
extractColorsFromBitmap(bitmap).also {
extractedColorCache.put(imageUrl, it)
}
} else {
ExtractedColors.DEFAULT
}
} catch (e: Exception) {
Timber.e(e, "Error extracting colors from URL: $imageUrl")
ExtractedColors.DEFAULT
}
}
/**
* Helper function to determine if a color is "cool" (blue/purple/green) vs "warm" (red/orange/yellow)
*
* Cool colors have more blue/green than red
*/
private val Palette.Swatch.coolColor: Boolean
get() {
val r = (rgb shr 16) and 0xFF
val g = (rgb shr 8) and 0xFF
val b = rgb and 0xFF
return b > r && (b + g) > (r * 1.5f)
}
private fun toColor(
swatch: Palette.Swatch?,
alpha: Float,
): Color = swatch?.rgb?.let(::Color)?.copy(alpha = alpha) ?: Color.Transparent
/**
* Extracts colors from a bitmap using Android's Palette API.
*
* - Primary (Bottom-Right): darkVibrant -> darkMuted -> default
* - Secondary (Top-Left): Smart selection based on color temperature (prefers cool colors)
* - Tertiary (Top-Right): vibrant -> lightVibrant -> default
*
* @param bitmap The bitmap to extract colors from
* @return ExtractedColors containing primary, secondary, and tertiary colors
*/
private suspend fun extractColorsFromBitmap(bitmap: Bitmap): ExtractedColors =
try {
val palette = Palette.from(bitmap).generate()
val vibrant = palette.vibrantSwatch
val darkVibrant = palette.darkVibrantSwatch
val lightVibrant = palette.lightVibrantSwatch
val muted = palette.mutedSwatch
val darkMuted = palette.darkMutedSwatch
val lightMuted = palette.lightMutedSwatch
val dominant = palette.dominantSwatch
// Primary (Bottom-Right)
val primaryColor = toColor(darkVibrant ?: darkMuted, .4f)
// Secondary (Top-Left): Smart selection based on color properties
// If Vibrant is cool (blue/purple), use it. If Vibrant is warm (yellow/orange) and Muted is cool, use Muted.
// This ensures we get cool tones (blue/purple) for top-left when available
val secondaryColor =
when {
vibrant != null && vibrant.coolColor -> vibrant
muted != null && muted.coolColor -> muted
vibrant != null -> vibrant
muted != null -> muted
else -> null
}.let { toColor(it, .4f) }
// Tertiary (Top-Right under image)
val tertiaryColor = toColor(vibrant ?: lightVibrant, .35f)
Timber.v(
"Colors extracted: primary=%s, secondary=%s, tertiary=%s",
primaryColor,
secondaryColor,
tertiaryColor,
)
ExtractedColors(primaryColor, secondaryColor, tertiaryColor)
} catch (e: Exception) {
Timber.e(e, "Error extracting palette colors")
ExtractedColors.DEFAULT
}
fun clearColorCache() {
extractedColorCache.evictAll()
}
}
data class BackdropResult(
val itemId: UUID?,
val imageUrl: String?,
val primaryColor: Color = Color.Unspecified,
val secondaryColor: Color = Color.Unspecified,
val tertiaryColor: Color = Color.Unspecified,
) {
val hasColors: Boolean =
primaryColor.isSpecified ||
secondaryColor.isSpecified ||
tertiaryColor.isSpecified
companion object {
val NONE =
BackdropResult(
null,
null,
)
}
}
data class ExtractedColors(
val primary: Color,
val secondary: Color,
val tertiary: Color,
) {
companion object {
val DEFAULT = ExtractedColors(Color.Unspecified, Color.Unspecified, Color.Unspecified)
}
}

View file

@ -62,6 +62,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
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.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
@ -76,6 +77,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.playback.scale
@ -123,6 +125,7 @@ class CollectionFolderViewModel
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
@ -454,6 +457,12 @@ class CollectionFolderViewModel
favoriteWatchManager.setFavorite(itemId, favorite) favoriteWatchManager.setFavorite(itemId, favorite)
(pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId) (pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId)
} }
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
} }
/** /**
@ -578,6 +587,7 @@ fun CollectionFolderGrid(
viewOptions = viewOptions, viewOptions = viewOptions,
defaultViewOptions = defaultViewOptions, defaultViewOptions = defaultViewOptions,
onSaveViewOptions = { viewModel.saveViewOptions(it) }, onSaveViewOptions = { viewModel.saveViewOptions(it) },
onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled, playEnabled = playEnabled,
onClickPlay = { _, item -> onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item)) viewModel.navigationManager.navigateTo(Destination.Playback(item))
@ -687,6 +697,7 @@ fun CollectionFolderGridContent(
viewOptions: ViewOptions, viewOptions: ViewOptions,
onClickPlayAll: (shuffle: Boolean) -> Unit, onClickPlayAll: (shuffle: Boolean) -> Unit,
onClickPlay: (Int, BaseItem) -> Unit, onClickPlay: (Int, BaseItem) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
@ -707,14 +718,13 @@ fun CollectionFolderGridContent(
var position by rememberInt(0) var position by rememberInt(0)
val focusedItem = pager.getOrNull(position) val focusedItem = pager.getOrNull(position)
if (viewOptions.showDetails) {
LaunchedEffect(focusedItem) {
focusedItem?.let(onChangeBackdrop)
}
}
Box(modifier = modifier) { Box(modifier = modifier) {
if (viewOptions.showDetails) {
DelayedDetailsBackdropImage(
item = focusedItem,
modifier = Modifier,
)
}
Column( Column(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),

View file

@ -1,154 +0,0 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
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.draw.alpha
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.ImageType
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun BoxScope.DetailsBackdropImage(
item: BaseItem?,
modifier: Modifier = Modifier,
) {
val imageUrlService = LocalImageUrlService.current
val backdropImageUrl =
remember(item) {
if (item != null) {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
} else {
null
}
}
DetailsBackdropImage(backdropImageUrl, modifier)
}
@Composable
fun BoxScope.DetailsBackdropImage(
backdropImageUrl: String?,
modifier: Modifier = Modifier,
) {
if (backdropImageUrl.isNotNullOrBlank()) {
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model = backdropImageUrl,
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.TopEnd,
modifier =
modifier
.align(Alignment.TopEnd)
.fillMaxHeight(.85f)
.alpha(.75f)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = size.height * .5f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(Color.Transparent, gradientColor),
endX = 0f,
startX = size.width * .75f,
),
)
},
)
}
}
@Composable
fun BoxScope.DelayedDetailsBackdropImage(
item: BaseItem?,
modifier: Modifier = Modifier,
) {
val imageUrlService = LocalImageUrlService.current
val backdropImageUrl =
remember(item) {
if (item != null) {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
} else {
null
}
}
DelayedDetailsBackdropImage(backdropImageUrl, modifier)
}
/**
* Shows a backdrop image, but with a crossfade & delay
*
* Used for change backdrops when change items frequently
*/
@Composable
fun BoxScope.DelayedDetailsBackdropImage(
focusedBackdropImageUrl: String?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(focusedBackdropImageUrl) {
backdropImageUrl = null
delay(150)
backdropImageUrl = focusedBackdropImageUrl
}
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model =
ImageRequest
.Builder(context)
.data(backdropImageUrl)
.transitionFactory(CrossFadeFactory(250.milliseconds))
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.TopEnd,
modifier =
modifier
.fillMaxHeight(.7f)
.fillMaxWidth(.7f)
.alpha(.75f)
.align(Alignment.TopEnd)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = size.height * .33f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(gradientColor, Color.Transparent),
startX = 0f,
endX = size.width * .5f,
),
)
},
)
}

View file

@ -20,6 +20,7 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
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.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@ -45,6 +46,7 @@ abstract class RecommendedViewModel(
val context: Context, val context: Context,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val favoriteWatchManager: FavoriteWatchManager, val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
) : ViewModel() { ) : ViewModel() {
abstract fun init() abstract fun init()
@ -86,6 +88,12 @@ abstract class RecommendedViewModel(
} }
} }
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
abstract fun update( abstract fun update(
@StringRes title: Int, @StringRes title: Int,
row: HomeRowLoadingState, row: HomeRowLoadingState,
@ -152,6 +160,7 @@ fun RecommendedContent(
}, },
onFocusPosition = onFocusPosition, onFocusPosition = onFocusPosition,
showClock = preferences.appPreferences.interfacePreferences.showClock, showClock = preferences.appPreferences.interfacePreferences.showClock,
onUpdateBackdrop = viewModel::updateBackdrop,
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
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.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
@ -56,7 +57,8 @@ class RecommendedMovieViewModel
@Assisted val parentId: UUID, @Assisted val parentId: UUID,
navigationManager: NavigationManager, navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) { backdropService: BackdropService,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(parentId: UUID): RecommendedMovieViewModel fun create(parentId: UUID): RecommendedMovieViewModel

View file

@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
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.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
@ -59,7 +60,8 @@ class RecommendedTvShowViewModel
@Assisted val parentId: UUID, @Assisted val parentId: UUID,
navigationManager: NavigationManager, navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) { backdropService: BackdropService,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(parentId: UUID): RecommendedTvShowViewModel fun create(parentId: UUID): RecommendedTvShowViewModel

View file

@ -50,10 +50,10 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
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.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.cards.ItemCardImage import com.github.damontecres.wholphin.ui.cards.ItemCardImage
import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
@ -64,6 +64,7 @@ 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.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
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.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -88,6 +89,7 @@ class PlaylistViewModel
constructor( constructor(
api: ApiClient, api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val backdropService: BackdropService,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val items = MutableLiveData<List<BaseItem?>>(listOf()) val items = MutableLiveData<List<BaseItem?>>(listOf())
@ -111,6 +113,12 @@ class PlaylistViewModel
} }
} }
} }
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
} }
@Composable @Composable
@ -146,6 +154,7 @@ fun PlaylistDetails(
playlist = it, playlist = it,
items = items, items = items,
focusRequester = focusRequester, focusRequester = focusRequester,
onChangeBackdrop = viewModel::updateBackdrop,
onClickIndex = { index, _ -> onClickIndex = { index, _ ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.PlaybackList( Destination.PlaybackList(
@ -212,6 +221,7 @@ fun PlaylistDetailsContent(
onClickIndex: (Int, BaseItem) -> Unit, onClickIndex: (Int, BaseItem) -> Unit,
onLongClickIndex: (Int, BaseItem) -> Unit, onLongClickIndex: (Int, BaseItem) -> Unit,
onClickPlay: (shuffle: Boolean) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() }, focusRequester: FocusRequester = remember { FocusRequester() },
) { ) {
@ -219,13 +229,15 @@ fun PlaylistDetailsContent(
var focusedIndex by remember { mutableIntStateOf(savedIndex) } var focusedIndex by remember { mutableIntStateOf(savedIndex) }
val focus = remember { FocusRequester() } val focus = remember { FocusRequester() }
val focusedItem = items.getOrNull(focusedIndex) val focusedItem = items.getOrNull(focusedIndex)
LaunchedEffect(focusedItem) {
focusedItem?.let(onChangeBackdrop)
}
val playButtonFocusRequester = remember { FocusRequester() } val playButtonFocusRequester = remember { FocusRequester() }
Box( Box(
modifier = modifier, modifier = modifier,
) { ) {
DelayedDetailsBackdropImage(focusedItem)
Column( Column(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier =

View file

@ -31,7 +31,6 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogParams 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
@ -296,7 +295,6 @@ fun EpisodeDetailsContent(
focusRequesters.getOrNull(position)?.tryRequestFocus() focusRequesters.getOrNull(position)?.tryRequestFocus()
} }
Box(modifier = modifier) { Box(modifier = modifier) {
DetailsBackdropImage(ep)
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),

View file

@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.StreamChoiceService
@ -50,6 +51,7 @@ class EpisodeViewModel
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
@Assisted val itemId: UUID, @Assisted val itemId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -96,6 +98,7 @@ class EpisodeViewModel
this@EpisodeViewModel.item.value = item this@EpisodeViewModel.item.value = item
chosenStreams.value = result chosenStreams.value = result
loading.value = LoadingState.Success loading.value = LoadingState.Success
backdropService.submit(item)
} }
} }

View file

@ -54,7 +54,6 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard 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.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
@ -396,7 +395,6 @@ fun MovieDetailsContent(
focusRequesters.getOrNull(position)?.tryRequestFocus() focusRequesters.getOrNull(position)?.tryRequestFocus()
} }
Box(modifier = modifier) { Box(modifier = modifier) {
DetailsBackdropImage(movie)
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),

View file

@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
@ -63,6 +64,7 @@ class MovieViewModel
private val trailerService: TrailerService, private val trailerService: TrailerService,
private val extrasService: ExtrasService, private val extrasService: ExtrasService,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
@Assisted val itemId: UUID, @Assisted val itemId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -118,6 +120,7 @@ class MovieViewModel
this@MovieViewModel.item.value = item this@MovieViewModel.item.value = item
chosenStreams.value = result chosenStreams.value = result
loading.value = LoadingState.Success loading.value = LoadingState.Success
backdropService.submit(item)
} }
viewModelScope.launchIO { viewModelScope.launchIO {
val trailers = trailerService.getTrailers(item) val trailers = trailerService.getTrailers(item)

View file

@ -51,7 +51,6 @@ import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
@ -320,8 +319,6 @@ fun SeriesDetailsContent(
Box( Box(
modifier = modifier, modifier = modifier,
) { ) {
DetailsBackdropImage(series)
Column( Column(
modifier = modifier =
Modifier Modifier

View file

@ -52,7 +52,6 @@ import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.SeriesName import com.github.damontecres.wholphin.ui.components.SeriesName
@ -112,7 +111,6 @@ fun SeriesOverviewContent(
modifier modifier
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
DetailsBackdropImage(backdropImageUrl)
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier =

View file

@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.ThemeSongVolume
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.ExtrasService import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
@ -74,6 +75,7 @@ class SeriesViewModel
private val extrasService: ExtrasService, private val extrasService: ExtrasService,
val streamChoiceService: StreamChoiceService, val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
private lateinit var seriesId: UUID private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences private lateinit var prefs: UserPreferences
@ -103,6 +105,7 @@ class SeriesViewModel
) + Dispatchers.IO, ) + Dispatchers.IO,
) { ) {
val item = fetchItem(seriesId) val item = fetchItem(seriesId)
backdropService.submit(item)
val seasons = getSeasons(item) val seasons = getSeasons(item)
// If a particular season was requested, fetch those episodes, otherwise get the first season // If a particular season was requested, fetch those episodes, otherwise get the first season
@ -332,6 +335,8 @@ class SeriesViewModel
episodes.value = eps episodes.value = eps
} }
} }
// Kind of hack to ensure the backdrop is reloaded if needed
item.value?.let { backdropService.submit(it) }
} }
/** /**

View file

@ -51,7 +51,6 @@ import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogParams 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.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails
@ -167,6 +166,7 @@ fun HomePage(
}, },
loadingState = refreshing, loadingState = refreshing,
showClock = preferences.appPreferences.interfacePreferences.showClock, showClock = preferences.appPreferences.interfacePreferences.showClock,
onUpdateBackdrop = viewModel::updateBackdrop,
modifier = modifier, modifier = modifier,
) )
dialog?.let { params -> dialog?.let { params ->
@ -203,6 +203,7 @@ fun HomePageContent(
onLongClickItem: (RowColumn, BaseItem) -> Unit, onLongClickItem: (RowColumn, BaseItem) -> Unit,
onClickPlay: (RowColumn, BaseItem) -> Unit, onClickPlay: (RowColumn, BaseItem) -> Unit,
showClock: Boolean, showClock: Boolean,
onUpdateBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onFocusPosition: ((RowColumn) -> Unit)? = null, onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null, loadingState: LoadingState? = null,
@ -249,12 +250,10 @@ fun HomePageContent(
LaunchedEffect(position) { LaunchedEffect(position) {
listState.animateScrollToItem(position.row) listState.animateScrollToItem(position.row)
} }
LaunchedEffect(focusedItem) {
focusedItem?.let(onUpdateBackdrop)
}
Box(modifier = modifier) { Box(modifier = modifier) {
DelayedDetailsBackdropImage(
item = focusedItem,
modifier = Modifier,
)
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader( HomePageHeader(
item = focusedItem, item = focusedItem,

View file

@ -9,10 +9,12 @@ import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
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.DatePlayedService import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -58,6 +60,7 @@ class HomeViewModel
val navDrawerItemRepository: NavDrawerItemRepository, val navDrawerItemRepository: NavDrawerItemRepository,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val datePlayedService: DatePlayedService, private val datePlayedService: DatePlayedService,
private val backdropService: BackdropService,
) : ViewModel() { ) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending) val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending) val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
@ -316,6 +319,12 @@ class HomeViewModel
init(preferences) init(preferences)
} }
} }
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
} }
val supportedLatestCollectionTypes = val supportedLatestCollectionTypes =

View file

@ -1,17 +1,59 @@
package com.github.damontecres.wholphin.ui.nav package com.github.damontecres.wholphin.ui.nav
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.BackdropStyle
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.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.launchIO
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel
class ApplicationContentViewModel
@Inject
constructor(
val backdropService: BackdropService,
) : ViewModel() {
fun clearBackdrop() {
viewModelScope.launchIO { backdropService.clearBackdrop() }
}
}
/** /**
* This is generally the root composable of the of the app * This is generally the root composable of the of the app
@ -25,37 +67,158 @@ fun ApplicationContent(
navigationManager: NavigationManager, navigationManager: NavigationManager,
preferences: UserPreferences, preferences: UserPreferences,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ApplicationContentViewModel = hiltViewModel(),
) { ) {
NavDisplay( val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
backStack = navigationManager.backStack, val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
onBack = { navigationManager.goBack() }, Box(
entryDecorators = modifier = modifier,
listOf( ) {
rememberSaveableStateHolderNavEntryDecorator(), val baseBackgroundColor = MaterialTheme.colorScheme.background
rememberViewModelStoreNavEntryDecorator(), if (backdrop.hasColors &&
), (backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
entryProvider = { key -> ) {
key as Destination val animPrimary by animateColorAsState(
val contentKey = "${key}_${server?.id}_${user?.id}" backdrop.primaryColor,
NavEntry(key, contentKey = contentKey) { animationSpec = tween(1250),
if (key.fullScreen) { label = "dynamic_backdrop_primary",
DestinationContent( )
destination = key, val animSecondary by animateColorAsState(
preferences = preferences, backdrop.secondaryColor,
modifier = modifier.fillMaxSize(), animationSpec = tween(1250),
) label = "dynamic_backdrop_secondary",
} else if (user != null && server != null) { )
NavDrawer( val animTertiary by animateColorAsState(
destination = key, backdrop.tertiaryColor,
preferences = preferences, animationSpec = tween(1250),
user = user, label = "dynamic_backdrop_tertiary",
server = server, )
modifier = modifier, Box(
) modifier =
} else { Modifier
ErrorMessage("Trying to go to $key without a user logged in", null) .fillMaxSize()
} .drawBehind {
drawRect(color = baseBackgroundColor)
// Top Left (Vibrant/Muted)
drawRect(
brush =
Brush.radialGradient(
colors = listOf(animSecondary, Color.Transparent),
center = Offset(0f, 0f),
radius = size.width * 0.8f,
),
)
// Bottom Right (DarkVibrant/DarkMuted)
drawRect(
brush =
Brush.radialGradient(
colors = listOf(animPrimary, Color.Transparent),
center = Offset(size.width, size.height),
radius = size.width * 0.8f,
),
)
// Bottom Left (Dark / Bridge)
drawRect(
brush =
Brush.radialGradient(
colors =
listOf(
baseBackgroundColor,
Color.Transparent,
),
center = Offset(0f, size.height),
radius = size.width * 0.8f,
),
)
// Top Right (Under Image - Vibrant/Bright)
drawRect(
brush =
Brush.radialGradient(
colors = listOf(animTertiary, Color.Transparent),
center = Offset(size.width, 0f),
radius = size.width * 0.8f,
),
)
},
)
}
if (backdropStyle != BackdropStyle.BACKDROP_NONE) {
Box(
modifier = Modifier.fillMaxSize(),
) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(backdrop.imageUrl)
.transitionFactory(CrossFadeFactory(800.milliseconds))
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.TopEnd,
modifier =
Modifier
.align(Alignment.TopEnd)
.fillMaxHeight(.7f)
.fillMaxWidth(.7f)
.alpha(.95f)
.drawWithContent {
drawContent()
drawRect(
brush =
Brush.horizontalGradient(
colors = listOf(Color.Transparent, Color.Black),
startX = 0f,
endX = size.width * 0.6f,
),
blendMode = BlendMode.DstIn,
)
drawRect(
brush =
Brush.verticalGradient(
colors = listOf(Color.Black, Color.Transparent),
startY = 0f,
endY = size.height,
),
blendMode = BlendMode.DstIn,
)
},
)
} }
}, }
) NavDisplay(
backStack = navigationManager.backStack,
onBack = { navigationManager.goBack() },
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = { key ->
key as Destination
val contentKey = "${key}_${server?.id}_${user?.id}"
NavEntry(key, contentKey = contentKey) {
if (key.fullScreen) {
DestinationContent(
destination = key,
preferences = preferences,
onClearBackdrop = viewModel::clearBackdrop,
modifier = Modifier.fillMaxSize(),
)
} else if (user != null && server != null) {
NavDrawer(
destination = key,
preferences = preferences,
user = user,
server = server,
onClearBackdrop = viewModel::clearBackdrop,
modifier = Modifier.fillMaxSize(),
)
} else {
ErrorMessage("Trying to go to $key without a user logged in", null)
}
}
},
)
}
} }

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.nav package com.github.damontecres.wholphin.ui.nav
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
@ -41,8 +42,12 @@ import timber.log.Timber
fun DestinationContent( fun DestinationContent(
destination: Destination, destination: Destination,
preferences: UserPreferences, preferences: UserPreferences,
onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (destination.fullScreen) {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
}
when (destination) { when (destination) {
is Destination.Home -> { is Destination.Home -> {
HomePage( HomePage(
@ -122,6 +127,7 @@ fun DestinationContent(
} }
BaseItemKind.BOX_SET -> { BaseItemKind.BOX_SET -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderBoxSet( CollectionFolderBoxSet(
preferences = preferences, preferences = preferences,
itemId = destination.itemId, itemId = destination.itemId,
@ -133,6 +139,7 @@ fun DestinationContent(
} }
BaseItemKind.PLAYLIST -> { BaseItemKind.PLAYLIST -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
PlaylistDetails( PlaylistDetails(
destination = destination, destination = destination,
modifier = modifier, modifier = modifier,
@ -140,6 +147,7 @@ fun DestinationContent(
} }
BaseItemKind.COLLECTION_FOLDER -> { BaseItemKind.COLLECTION_FOLDER -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolder( CollectionFolder(
preferences = preferences, preferences = preferences,
destination = destination, destination = destination,
@ -151,6 +159,7 @@ fun DestinationContent(
} }
BaseItemKind.FOLDER -> { BaseItemKind.FOLDER -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolder( CollectionFolder(
preferences = preferences, preferences = preferences,
destination = destination, destination = destination,
@ -162,6 +171,7 @@ fun DestinationContent(
} }
BaseItemKind.USER_VIEW -> { BaseItemKind.USER_VIEW -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolder( CollectionFolder(
preferences = preferences, preferences = preferences,
destination = destination, destination = destination,
@ -173,6 +183,7 @@ fun DestinationContent(
} }
BaseItemKind.PERSON -> { BaseItemKind.PERSON -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
PersonPage( PersonPage(
preferences, preferences,
destination, destination,
@ -188,6 +199,7 @@ fun DestinationContent(
} }
is Destination.FilteredCollection -> { is Destination.FilteredCollection -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderGeneric( CollectionFolderGeneric(
preferences = preferences, preferences = preferences,
itemId = destination.itemId, itemId = destination.itemId,
@ -201,6 +213,7 @@ fun DestinationContent(
} }
is Destination.Recordings -> { is Destination.Recordings -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderRecordings( CollectionFolderRecordings(
preferences, preferences,
destination.itemId, destination.itemId,
@ -210,6 +223,7 @@ fun DestinationContent(
} }
is Destination.ItemGrid -> { is Destination.ItemGrid -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
ItemGrid( ItemGrid(
destination, destination,
modifier, modifier,
@ -217,6 +231,7 @@ fun DestinationContent(
} }
Destination.Favorites -> { Destination.Favorites -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
FavoritesPage( FavoritesPage(
preferences = preferences, preferences = preferences,
modifier = modifier, modifier = modifier,
@ -232,6 +247,7 @@ fun DestinationContent(
} }
Destination.Search -> { Destination.Search -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
SearchPage( SearchPage(
userPreferences = preferences, userPreferences = preferences,
modifier = modifier, modifier = modifier,

View file

@ -77,6 +77,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.AppThemeColors
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.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.components.TimeDisplay
@ -105,6 +106,7 @@ class NavDrawerViewModel
constructor( constructor(
private val navDrawerItemRepository: NavDrawerItemRepository, private val navDrawerItemRepository: NavDrawerItemRepository,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val backdropService: BackdropService,
) : ViewModel() { ) : ViewModel() {
private var all: List<NavDrawerItem>? = null private var all: List<NavDrawerItem>? = null
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null) val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
@ -210,6 +212,7 @@ fun NavDrawer(
preferences: UserPreferences, preferences: UserPreferences,
user: JellyfinUser, user: JellyfinUser,
server: JellyfinServer, server: JellyfinServer,
onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: NavDrawerViewModel = viewModel: NavDrawerViewModel =
hiltViewModel( hiltViewModel(
@ -300,11 +303,9 @@ fun NavDrawer(
val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp) val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp)
val drawerBackground by animateColorAsState( val drawerBackground by animateColorAsState(
if (drawerState.isOpen) { if (drawerState.isOpen) {
MaterialTheme.colorScheme.surfaceColorAtElevation(
1.dp,
)
} else {
MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surface
} else {
Color.Transparent
}, },
) )
val spacedBy = 4.dp val spacedBy = 4.dp
@ -528,6 +529,7 @@ fun NavDrawer(
DestinationContent( DestinationContent(
destination = destination, destination = destination,
preferences = preferences, preferences = preferences,
onClearBackdrop = onClearBackdrop,
modifier = modifier =
Modifier Modifier
.fillMaxSize(), .fillMaxSize(),

View file

@ -15,22 +15,24 @@ import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors
val LocalTheme = val LocalTheme =
compositionLocalOf<AppThemeColors> { AppThemeColors.PURPLE } compositionLocalOf<AppThemeColors> { AppThemeColors.PURPLE }
fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors =
when (appThemeColors) {
AppThemeColors.PURPLE -> PurpleThemeColors
AppThemeColors.BLUE -> BlueThemeColors
AppThemeColors.GREEN -> GreenThemeColors
AppThemeColors.ORANGE -> OrangeThemeColors
AppThemeColors.OLED_BLACK -> OledThemeColors
AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors
AppThemeColors.UNRECOGNIZED -> PurpleThemeColors
}
@Composable @Composable
fun WholphinTheme( fun WholphinTheme(
darkTheme: Boolean = true, darkTheme: Boolean = true,
appThemeColors: AppThemeColors = AppThemeColors.PURPLE, appThemeColors: AppThemeColors = AppThemeColors.PURPLE,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val themeColors = val themeColors = getThemeColors(appThemeColors)
when (appThemeColors) {
AppThemeColors.PURPLE -> PurpleThemeColors
AppThemeColors.BLUE -> BlueThemeColors
AppThemeColors.GREEN -> GreenThemeColors
AppThemeColors.ORANGE -> OrangeThemeColors
AppThemeColors.OLED_BLACK -> OledThemeColors
AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors
AppThemeColors.UNRECOGNIZED -> PurpleThemeColors
}
val colorScheme = val colorScheme =
when { when {

View file

@ -132,6 +132,12 @@ message LiveTvPreferences {
bool color_code_programs = 4; bool color_code_programs = 4;
} }
enum BackdropStyle{
BACKDROP_DYNAMIC_COLOR = 0;
BACKDROP_IMAGE_ONLY = 1;
BACKDROP_NONE = 2;
}
message InterfacePreferences { message InterfacePreferences {
ThemeSongVolume play_theme_songs = 1; ThemeSongVolume play_theme_songs = 1;
bool remember_selected_tab = 2; bool remember_selected_tab = 2;
@ -141,6 +147,7 @@ message InterfacePreferences {
bool show_clock = 6; bool show_clock = 6;
SubtitlePreferences subtitles_preferences = 7; SubtitlePreferences subtitles_preferences = 7;
LiveTvPreferences live_tv_preferences = 8; LiveTvPreferences live_tv_preferences = 8;
BackdropStyle backdrop_style = 9;
} }
message AdvancedPreferences { message AdvancedPreferences {

View file

@ -394,6 +394,7 @@
<string name="sort_channels_recently_watched">Sort channels by recently watched</string> <string name="sort_channels_recently_watched">Sort channels by recently watched</string>
<string name="color_code_programs">Color-code programs</string> <string name="color_code_programs">Color-code programs</string>
<string name="subtitle_delay">Subtitle delay</string> <string name="subtitle_delay">Subtitle delay</string>
<string name="backdrop_display">Backdrop style</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>
@ -482,4 +483,10 @@
<!-- <item>Banner</item>--> <!-- <item>Banner</item>-->
</string-array> </string-array>
<string-array name="backdrop_style_options">
<item>Image with dynamic color</item>
<item>Image only</item>
<item>None</item>
</string-array>
</resources> </resources>

View file

@ -32,6 +32,7 @@ protobuf-javalite = "4.33.1"
hilt = "2.57.2" hilt = "2.57.2"
room = "2.8.4" room = "2.8.4"
preferenceKtx = "1.2.1" preferenceKtx = "1.2.1"
paletteKtx = "1.0.0"
[libraries] [libraries]
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" }
@ -100,6 +101,7 @@ slf4j2-timber = { module = "uk.kulikov:slf4j2-timber", version.ref = "slf4j2Timb
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }