From bbfbc13532a1c95fe6494d89e2a4a363f0880cfa Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:18:57 -0500 Subject: [PATCH] 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 --- app/build.gradle.kts | 1 + .../wholphin/preferences/AppPreference.kt | 14 + .../preferences/AppPreferencesSerializer.kt | 1 + .../wholphin/services/BackdropService.kt | 239 ++++++++++++++++++ .../ui/components/CollectionFolderGrid.kt | 22 +- .../ui/components/DetailsBackdropImage.kt | 154 ----------- .../ui/components/RecommendedContent.kt | 9 + .../ui/components/RecommendedMovie.kt | 4 +- .../ui/components/RecommendedTvShow.kt | 4 +- .../wholphin/ui/detail/PlaylistDetails.kt | 16 +- .../ui/detail/episode/EpisodeDetails.kt | 2 - .../ui/detail/episode/EpisodeViewModel.kt | 3 + .../wholphin/ui/detail/movie/MovieDetails.kt | 2 - .../ui/detail/movie/MovieViewModel.kt | 3 + .../ui/detail/series/SeriesDetails.kt | 3 - .../ui/detail/series/SeriesOverviewContent.kt | 2 - .../ui/detail/series/SeriesViewModel.kt | 5 + .../damontecres/wholphin/ui/main/HomePage.kt | 11 +- .../wholphin/ui/main/HomeViewModel.kt | 9 + .../wholphin/ui/nav/ApplicationContent.kt | 225 ++++++++++++++--- .../wholphin/ui/nav/DestinationContent.kt | 16 ++ .../damontecres/wholphin/ui/nav/NavDrawer.kt | 10 +- .../damontecres/wholphin/ui/theme/Theme.kt | 22 +- app/src/main/proto/WholphinDataStore.proto | 7 + app/src/main/res/values/strings.xml | 7 + gradle/libs.versions.toml | 2 + 26 files changed, 569 insertions(+), 224 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d80026c..55ba0024 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 19810b5c..4d6154d6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -659,6 +659,19 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) + val BackdropStylePref = + AppChoicePreference( + 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( 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, ), ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 0529e644..c39b9012 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -91,6 +91,7 @@ class AppPreferencesSerializer navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue showClock = AppPreference.ShowClock.defaultValue + backdropStyle = AppPreference.BackdropStylePref.defaultValue subtitlesPreferences = SubtitlePreferences diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt new file mode 100644 index 00000000..cdbb7a2a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt @@ -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, + ) { + private val extractedColorCache = LruCache(50) + + private val _backdropFlow = MutableStateFlow(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) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 35b5d1c3..f22e87a2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -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.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(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt deleted file mode 100644 index eea7fa90..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt +++ /dev/null @@ -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(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, - ), - ) - }, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 30bb369c..869a454d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -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, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index 82eafee9..1272faf5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index 80e8aa72..cb179860 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 402ee077..d27edfff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -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.Pending) val items = MutableLiveData>(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 = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index fdda6e4a..bca08e7b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -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), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index ad113a9d..584d5510 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -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) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index d22774e5..24d2f98e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -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), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 4634aafa..be62b4aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index cf75ecc6..0ab81b7e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 3d2aa4c7..ecb06efb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -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 = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 35837c05..5dbe62f8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -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) } } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 12bd8ff6..67b4200b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 834b0102..38c1ff4f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -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.Pending) val refreshState = MutableLiveData(LoadingState.Pending) @@ -316,6 +319,12 @@ class HomeViewModel init(preferences) } } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } } val supportedLatestCollectionTypes = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 8e546fdb..2d3200ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -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) + } + } + }, + ) + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 47d62e22..2f6feb7a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 62a37ca6..9a7d3559 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -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? = null val moreLibraries = MutableLiveData>(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(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt index 112d8817..53c10bd2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt @@ -15,22 +15,24 @@ import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors val LocalTheme = compositionLocalOf { 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 { diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index a003bbcc..f10b49c5 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -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 { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6532b865..a6647d22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -394,6 +394,7 @@ Sort channels by recently watched Color-code programs Subtitle delay + Backdrop style Disabled @@ -482,4 +483,10 @@ + + Image with dynamic color + Image only + None + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 94708ac2..b899dc14 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ protobuf-javalite = "4.33.1" hilt = "2.57.2" room = "2.8.4" preferenceKtx = "1.2.1" +paletteKtx = "1.0.0" [libraries] 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" } 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-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }