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) {
if (imageUrl.isNullOrBlank()) {
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.data.model.AudioItem
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.services.rememberQueue
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.LoadingPage
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.BottomDialogItem
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
@ -159,6 +161,15 @@ fun NowPlayingPage(
}
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(
modifier =
Modifier

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.detail.music
import androidx.compose.runtime.Stable
import com.github.damontecres.wholphin.services.BackdropResult
import com.github.damontecres.wholphin.services.MusicServiceState
import org.jellyfin.sdk.model.api.LyricDto
@ -10,6 +11,7 @@ data class NowPlayingState(
val lyrics: LyricDto? = null,
val currentLyricIndex: Int? = null,
val visualizerPermissions: Boolean = false,
val backdropResult: BackdropResult = BackdropResult.NONE,
) {
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.AppPreferences
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.ImageUrlService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
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.extensions.lyricsApi
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.extensions.ticks
import timber.log.Timber
@ -63,6 +66,7 @@ class NowPlayingViewModel
private val api: ApiClient,
private val musicService: MusicService,
private val backdropService: BackdropService,
private val imageUrlService: ImageUrlService,
private val preferencesDataStore: DataStore<AppPreferences>,
val navigationManager: NavigationManager,
val userPreferencesService: UserPreferencesService,
@ -123,6 +127,7 @@ class NowPlayingViewModel
controllerViewState.pulseControls()
}
viewModelScope.launchDefault {
backdropService.clearBackdrop()
updateBackdrop(getCurrent())
}
playbackLoop()
@ -230,19 +235,18 @@ class NowPlayingViewModel
val backdropItem =
getBackdropItemForAlbum(api, BaseItem(it, false))
if (backdropItem != null) {
backdropService.submit(backdropItem)
doUpdateBackdrop(backdropItem)
} else {
backdropService.clearBackdrop()
clearBackdrop()
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching backdrop")
backdropService.clearBackdrop()
clearBackdrop()
}
} else {
backdropService.clearBackdrop()
clearBackdrop()
}
// TODO needs smoother transition
delay(60.seconds)
try {
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(
index: Int,
direction: MoveDirection,

View file

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