mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
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:
parent
8224f83678
commit
bbfbc13532
26 changed files with 569 additions and 224 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue