Smoother now playing backdrop transition

This commit is contained in:
Damontecres 2026-03-15 15:42:33 -04:00
parent 8ddd3fc7eb
commit b70a7bc7bb
No known key found for this signature in database
5 changed files with 75 additions and 7 deletions

View file

@ -114,7 +114,7 @@ class BackdropService
} }
} }
private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors = suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (imageUrl.isNullOrBlank()) { if (imageUrl.isNullOrBlank()) {
return@withContext ExtractedColors.DEFAULT return@withContext ExtractedColors.DEFAULT

View file

@ -54,6 +54,7 @@ import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.BackdropStyle
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.rememberQueue import com.github.damontecres.wholphin.services.rememberQueue
import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.BasicDialog
@ -61,6 +62,7 @@ import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.findActivity import com.github.damontecres.wholphin.ui.findActivity
import com.github.damontecres.wholphin.ui.nav.Backdrop
import com.github.damontecres.wholphin.ui.playback.BottomDialog import com.github.damontecres.wholphin.ui.playback.BottomDialog
import com.github.damontecres.wholphin.ui.playback.BottomDialogItem import com.github.damontecres.wholphin.ui.playback.BottomDialogItem
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
@ -159,6 +161,15 @@ fun NowPlayingPage(
} }
Box(modifier) { Box(modifier) {
Backdrop(
backdrop = state.backdropResult,
drawerIsOpen = false,
backdropStyle = BackdropStyle.BACKDROP_DYNAMIC_COLOR,
modifier = Modifier.fillMaxSize(),
enableTopScrim = false,
useExistingImageAsPlaceholder = true,
crossfadeDuration = 1.5.seconds,
)
Box( Box(
modifier = modifier =
Modifier Modifier

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.detail.music package com.github.damontecres.wholphin.ui.detail.music
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.github.damontecres.wholphin.services.BackdropResult
import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.MusicServiceState
import org.jellyfin.sdk.model.api.LyricDto import org.jellyfin.sdk.model.api.LyricDto
@ -10,6 +11,7 @@ data class NowPlayingState(
val lyrics: LyricDto? = null, val lyrics: LyricDto? = null,
val currentLyricIndex: Int? = null, val currentLyricIndex: Int? = null,
val visualizerPermissions: Boolean = false, val visualizerPermissions: Boolean = false,
val backdropResult: BackdropResult = BackdropResult.NONE,
) { ) {
val hasLyrics: Boolean get() = lyrics != null && lyrics.lyrics.isNotEmpty() val hasLyrics: Boolean get() = lyrics != null && lyrics.lyrics.isNotEmpty()
} }

View file

@ -16,7 +16,9 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.updateMusicPreferences import com.github.damontecres.wholphin.preferences.updateMusicPreferences
import com.github.damontecres.wholphin.services.BackdropResult
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.NowPlayingStatus import com.github.damontecres.wholphin.services.NowPlayingStatus
@ -45,6 +47,7 @@ import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.lyricsApi import org.jellyfin.sdk.api.client.extensions.lyricsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.LyricDto import org.jellyfin.sdk.model.api.LyricDto
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
@ -63,6 +66,7 @@ class NowPlayingViewModel
private val api: ApiClient, private val api: ApiClient,
private val musicService: MusicService, private val musicService: MusicService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val imageUrlService: ImageUrlService,
private val preferencesDataStore: DataStore<AppPreferences>, private val preferencesDataStore: DataStore<AppPreferences>,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val userPreferencesService: UserPreferencesService, val userPreferencesService: UserPreferencesService,
@ -123,6 +127,7 @@ class NowPlayingViewModel
controllerViewState.pulseControls() controllerViewState.pulseControls()
} }
viewModelScope.launchDefault { viewModelScope.launchDefault {
backdropService.clearBackdrop()
updateBackdrop(getCurrent()) updateBackdrop(getCurrent())
} }
playbackLoop() playbackLoop()
@ -230,19 +235,18 @@ class NowPlayingViewModel
val backdropItem = val backdropItem =
getBackdropItemForAlbum(api, BaseItem(it, false)) getBackdropItemForAlbum(api, BaseItem(it, false))
if (backdropItem != null) { if (backdropItem != null) {
backdropService.submit(backdropItem) doUpdateBackdrop(backdropItem)
} else { } else {
backdropService.clearBackdrop() clearBackdrop()
} }
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error fetching backdrop") Timber.e(ex, "Error fetching backdrop")
backdropService.clearBackdrop() clearBackdrop()
} }
} else { } else {
backdropService.clearBackdrop() clearBackdrop()
} }
// TODO needs smoother transition
delay(60.seconds) delay(60.seconds)
try { try {
val index = Random.nextInt(state.value.musicServiceState.queueSize) val index = Random.nextInt(state.value.musicServiceState.queueSize)
@ -257,6 +261,27 @@ class NowPlayingViewModel
} }
} }
private suspend fun doUpdateBackdrop(item: BaseItem) {
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
val (primaryColor, secondaryColor, tertiaryColor) =
backdropService.extractColorsFromBackdrop(
imageUrl,
)
val backdropResult =
BackdropResult(
itemId = item.id.toString(),
imageUrl = imageUrl,
primaryColor = primaryColor,
secondaryColor = secondaryColor,
tertiaryColor = tertiaryColor,
)
state.update { it.copy(backdropResult = backdropResult) }
}
private fun clearBackdrop() {
state.update { it.copy(backdropResult = BackdropResult.NONE) }
}
fun moveQueue( fun moveQueue(
index: Int, index: Int,
direction: MoveDirection, direction: MoveDirection,

View file

@ -1,3 +1,5 @@
@file:OptIn(ExperimentalCoilApi::class)
package com.github.damontecres.wholphin.ui.nav package com.github.damontecres.wholphin.ui.nav
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
@ -24,11 +26,15 @@ import androidx.compose.ui.platform.LocalContext
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.useExistingImageAsPlaceholder
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.transitionFactory import coil3.request.transitionFactory
import com.github.damontecres.wholphin.preferences.BackdropStyle import com.github.damontecres.wholphin.preferences.BackdropStyle
import com.github.damontecres.wholphin.services.BackdropResult
import com.github.damontecres.wholphin.ui.CrossFadeFactory import com.github.damontecres.wholphin.ui.CrossFadeFactory
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
@ -38,8 +44,31 @@ fun Backdrop(
viewModel: ApplicationContentViewModel = hiltViewModel(), viewModel: ApplicationContentViewModel = hiltViewModel(),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enableTopScrim: Boolean = true, enableTopScrim: Boolean = true,
useExistingImageAsPlaceholder: Boolean = false,
crossfadeDuration: Duration = 800.milliseconds,
) { ) {
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
Backdrop(
backdrop = backdrop,
drawerIsOpen = drawerIsOpen,
backdropStyle = backdropStyle,
modifier = modifier,
enableTopScrim = enableTopScrim,
useExistingImageAsPlaceholder = useExistingImageAsPlaceholder,
crossfadeDuration = crossfadeDuration,
)
}
@Composable
fun Backdrop(
backdrop: BackdropResult,
drawerIsOpen: Boolean,
backdropStyle: BackdropStyle,
modifier: Modifier = Modifier,
enableTopScrim: Boolean = true,
useExistingImageAsPlaceholder: Boolean = false,
crossfadeDuration: Duration = 800.milliseconds,
) {
val baseBackgroundColor = MaterialTheme.colorScheme.background val baseBackgroundColor = MaterialTheme.colorScheme.background
if (backdrop.hasColors && if (backdrop.hasColors &&
(backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED) (backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
@ -117,7 +146,8 @@ fun Backdrop(
ImageRequest ImageRequest
.Builder(LocalContext.current) .Builder(LocalContext.current)
.data(backdrop.imageUrl) .data(backdrop.imageUrl)
.transitionFactory(CrossFadeFactory(800.milliseconds)) .useExistingImageAsPlaceholder(useExistingImageAsPlaceholder)
.transitionFactory(CrossFadeFactory(crossfadeDuration))
.build(), .build(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,