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

@ -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)
}
}