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.preference.ktx)
implementation(libs.androidx.room.testing)
implementation(libs.androidx.palette.ktx)
ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler)

View file

@ -659,6 +659,19 @@ sealed interface AppPreference<Pref, T> {
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 =
AppSwitchPreference<AppPreferences>(
title = R.string.one_click_pause,
@ -909,6 +922,7 @@ val advancedPreferences =
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout,
AppPreference.BackdropStylePref,
),
),
)

View file

@ -91,6 +91,7 @@ class AppPreferencesSerializer
navDrawerSwitchOnFocus =
AppPreference.NavDrawerSwitchOnFocus.defaultValue
showClock = AppPreference.ShowClock.defaultValue
backdropStyle = AppPreference.BackdropStylePref.defaultValue
subtitlesPreferences =
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.LibraryDisplayInfo
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.NavigationManager
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.PlaylistLoadingState
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.nav.Destination
import com.github.damontecres.wholphin.ui.playback.scale
@ -123,6 +125,7 @@ class CollectionFolderViewModel
private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
val navigationManager: NavigationManager,
) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
@ -454,6 +457,12 @@ class CollectionFolderViewModel
favoriteWatchManager.setFavorite(itemId, favorite)
(pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
}
/**
@ -578,6 +587,7 @@ fun CollectionFolderGrid(
viewOptions = viewOptions,
defaultViewOptions = defaultViewOptions,
onSaveViewOptions = { viewModel.saveViewOptions(it) },
onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled,
onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item))
@ -687,6 +697,7 @@ fun CollectionFolderGridContent(
viewOptions: ViewOptions,
onClickPlayAll: (shuffle: Boolean) -> Unit,
onClickPlay: (Int, BaseItem) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
@ -707,14 +718,13 @@ fun CollectionFolderGridContent(
var position by rememberInt(0)
val focusedItem = pager.getOrNull(position)
if (viewOptions.showDetails) {
LaunchedEffect(focusedItem) {
focusedItem?.let(onChangeBackdrop)
}
}
Box(modifier = modifier) {
if (viewOptions.showDetails) {
DelayedDetailsBackdropImage(
item = focusedItem,
modifier = Modifier,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
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.data.model.BaseItem
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.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@ -45,6 +46,7 @@ abstract class RecommendedViewModel(
val context: Context,
val navigationManager: NavigationManager,
val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
) : ViewModel() {
abstract fun init()
@ -86,6 +88,12 @@ abstract class RecommendedViewModel(
}
}
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
abstract fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
@ -152,6 +160,7 @@ fun RecommendedContent(
},
onFocusPosition = onFocusPosition,
showClock = preferences.appPreferences.interfacePreferences.showClock,
onUpdateBackdrop = viewModel::updateBackdrop,
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.AppPreferences
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.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
@ -56,7 +57,8 @@ class RecommendedMovieViewModel
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) {
backdropService: BackdropService,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
@AssistedFactory
interface Factory {
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.AppPreferences
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.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
@ -59,7 +60,8 @@ class RecommendedTvShowViewModel
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) {
backdropService: BackdropService,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedTvShowViewModel

View file

@ -50,10 +50,10 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
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.ui.DefaultItemFields
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.DialogParams
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.enableMarquee
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.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -88,6 +89,7 @@ class PlaylistViewModel
constructor(
api: ApiClient,
val navigationManager: NavigationManager,
private val backdropService: BackdropService,
) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val items = MutableLiveData<List<BaseItem?>>(listOf())
@ -111,6 +113,12 @@ class PlaylistViewModel
}
}
}
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
}
@Composable
@ -146,6 +154,7 @@ fun PlaylistDetails(
playlist = it,
items = items,
focusRequester = focusRequester,
onChangeBackdrop = viewModel::updateBackdrop,
onClickIndex = { index, _ ->
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
@ -212,6 +221,7 @@ fun PlaylistDetailsContent(
onClickIndex: (Int, BaseItem) -> Unit,
onLongClickIndex: (Int, BaseItem) -> Unit,
onClickPlay: (shuffle: Boolean) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
@ -219,13 +229,15 @@ fun PlaylistDetailsContent(
var focusedIndex by remember { mutableIntStateOf(savedIndex) }
val focus = remember { FocusRequester() }
val focusedItem = items.getOrNull(focusedIndex)
LaunchedEffect(focusedItem) {
focusedItem?.let(onChangeBackdrop)
}
val playButtonFocusRequester = remember { FocusRequester() }
Box(
modifier = modifier,
) {
DelayedDetailsBackdropImage(focusedItem)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
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.model.BaseItem
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.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -296,7 +295,6 @@ fun EpisodeDetailsContent(
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
Box(modifier = modifier) {
DetailsBackdropImage(ep)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.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.ItemPlayback
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.NavigationManager
import com.github.damontecres.wholphin.services.StreamChoiceService
@ -50,6 +51,7 @@ class EpisodeViewModel
private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
@ -96,6 +98,7 @@ class EpisodeViewModel
this@EpisodeViewModel.item.value = item
chosenStreams.value = result
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.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -396,7 +395,6 @@ fun MovieDetailsContent(
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
Box(modifier = modifier) {
DetailsBackdropImage(movie)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.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.Trailer
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.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
@ -63,6 +64,7 @@ class MovieViewModel
private val trailerService: TrailerService,
private val extrasService: ExtrasService,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
@ -118,6 +120,7 @@ class MovieViewModel
this@MovieViewModel.item.value = item
chosenStreams.value = result
loading.value = LoadingState.Success
backdropService.submit(item)
}
viewModelScope.launchIO {
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.SeasonCard
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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
@ -320,8 +319,6 @@ fun SeriesDetailsContent(
Box(
modifier = modifier,
) {
DetailsBackdropImage(series)
Column(
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.cards.BannerCard
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.LoadingPage
import com.github.damontecres.wholphin.ui.components.SeriesName
@ -112,7 +111,6 @@ fun SeriesOverviewContent(
modifier
.fillMaxWidth(),
) {
DetailsBackdropImage(backdropImageUrl)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
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.preferences.ThemeSongVolume
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.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
@ -74,6 +75,7 @@ class SeriesViewModel
private val extrasService: ExtrasService,
val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
) : ItemViewModel(api) {
private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences
@ -103,6 +105,7 @@ class SeriesViewModel
) + Dispatchers.IO,
) {
val item = fetchItem(seriesId)
backdropService.submit(item)
val seasons = getSeasons(item)
// If a particular season was requested, fetch those episodes, otherwise get the first season
@ -332,6 +335,8 @@ class SeriesViewModel
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.ItemRow
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.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails
@ -167,6 +166,7 @@ fun HomePage(
},
loadingState = refreshing,
showClock = preferences.appPreferences.interfacePreferences.showClock,
onUpdateBackdrop = viewModel::updateBackdrop,
modifier = modifier,
)
dialog?.let { params ->
@ -203,6 +203,7 @@ fun HomePageContent(
onLongClickItem: (RowColumn, BaseItem) -> Unit,
onClickPlay: (RowColumn, BaseItem) -> Unit,
showClock: Boolean,
onUpdateBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null,
@ -249,12 +250,10 @@ fun HomePageContent(
LaunchedEffect(position) {
listState.animateScrollToItem(position.row)
}
LaunchedEffect(focusedItem) {
focusedItem?.let(onUpdateBackdrop)
}
Box(modifier = modifier) {
DelayedDetailsBackdropImage(
item = focusedItem,
modifier = Modifier,
)
Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader(
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.model.BaseItem
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.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
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.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -58,6 +60,7 @@ class HomeViewModel
val navDrawerItemRepository: NavDrawerItemRepository,
private val favoriteWatchManager: FavoriteWatchManager,
private val datePlayedService: DatePlayedService,
private val backdropService: BackdropService,
) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
@ -316,6 +319,12 @@ class HomeViewModel
init(preferences)
}
}
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
}
val supportedLatestCollectionTypes =

View file

@ -1,17 +1,59 @@
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.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
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.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
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.JellyfinUser
import com.github.damontecres.wholphin.preferences.BackdropStyle
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.ui.CrossFadeFactory
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
@ -25,37 +67,158 @@ fun ApplicationContent(
navigationManager: NavigationManager,
preferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: ApplicationContentViewModel = hiltViewModel(),
) {
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,
modifier = modifier.fillMaxSize(),
)
} else if (user != null && server != null) {
NavDrawer(
destination = key,
preferences = preferences,
user = user,
server = server,
modifier = modifier,
)
} else {
ErrorMessage("Trying to go to $key without a user logged in", null)
}
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
Box(
modifier = modifier,
) {
val baseBackgroundColor = MaterialTheme.colorScheme.background
if (backdrop.hasColors &&
(backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
) {
val animPrimary by animateColorAsState(
backdrop.primaryColor,
animationSpec = tween(1250),
label = "dynamic_backdrop_primary",
)
val animSecondary by animateColorAsState(
backdrop.secondaryColor,
animationSpec = tween(1250),
label = "dynamic_backdrop_secondary",
)
val animTertiary by animateColorAsState(
backdrop.tertiaryColor,
animationSpec = tween(1250),
label = "dynamic_backdrop_tertiary",
)
Box(
modifier =
Modifier
.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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
@ -41,8 +42,12 @@ import timber.log.Timber
fun DestinationContent(
destination: Destination,
preferences: UserPreferences,
onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier,
) {
if (destination.fullScreen) {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
}
when (destination) {
is Destination.Home -> {
HomePage(
@ -122,6 +127,7 @@ fun DestinationContent(
}
BaseItemKind.BOX_SET -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderBoxSet(
preferences = preferences,
itemId = destination.itemId,
@ -133,6 +139,7 @@ fun DestinationContent(
}
BaseItemKind.PLAYLIST -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
PlaylistDetails(
destination = destination,
modifier = modifier,
@ -140,6 +147,7 @@ fun DestinationContent(
}
BaseItemKind.COLLECTION_FOLDER -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolder(
preferences = preferences,
destination = destination,
@ -151,6 +159,7 @@ fun DestinationContent(
}
BaseItemKind.FOLDER -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolder(
preferences = preferences,
destination = destination,
@ -162,6 +171,7 @@ fun DestinationContent(
}
BaseItemKind.USER_VIEW -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolder(
preferences = preferences,
destination = destination,
@ -173,6 +183,7 @@ fun DestinationContent(
}
BaseItemKind.PERSON -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
PersonPage(
preferences,
destination,
@ -188,6 +199,7 @@ fun DestinationContent(
}
is Destination.FilteredCollection -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderGeneric(
preferences = preferences,
itemId = destination.itemId,
@ -201,6 +213,7 @@ fun DestinationContent(
}
is Destination.Recordings -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderRecordings(
preferences,
destination.itemId,
@ -210,6 +223,7 @@ fun DestinationContent(
}
is Destination.ItemGrid -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
ItemGrid(
destination,
modifier,
@ -217,6 +231,7 @@ fun DestinationContent(
}
Destination.Favorites -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
FavoritesPage(
preferences = preferences,
modifier = modifier,
@ -232,6 +247,7 @@ fun DestinationContent(
}
Destination.Search -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
SearchPage(
userPreferences = preferences,
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.preferences.AppThemeColors
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.TimeDisplay
@ -105,6 +106,7 @@ class NavDrawerViewModel
constructor(
private val navDrawerItemRepository: NavDrawerItemRepository,
val navigationManager: NavigationManager,
val backdropService: BackdropService,
) : ViewModel() {
private var all: List<NavDrawerItem>? = null
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
@ -210,6 +212,7 @@ fun NavDrawer(
preferences: UserPreferences,
user: JellyfinUser,
server: JellyfinServer,
onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier,
viewModel: NavDrawerViewModel =
hiltViewModel(
@ -300,11 +303,9 @@ fun NavDrawer(
val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp)
val drawerBackground by animateColorAsState(
if (drawerState.isOpen) {
MaterialTheme.colorScheme.surfaceColorAtElevation(
1.dp,
)
} else {
MaterialTheme.colorScheme.surface
} else {
Color.Transparent
},
)
val spacedBy = 4.dp
@ -528,6 +529,7 @@ fun NavDrawer(
DestinationContent(
destination = destination,
preferences = preferences,
onClearBackdrop = onClearBackdrop,
modifier =
Modifier
.fillMaxSize(),

View file

@ -15,22 +15,24 @@ import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors
val LocalTheme =
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
fun WholphinTheme(
darkTheme: Boolean = true,
appThemeColors: AppThemeColors = AppThemeColors.PURPLE,
content: @Composable () -> Unit,
) {
val 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
}
val themeColors = getThemeColors(appThemeColors)
val colorScheme =
when {

View file

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

View file

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