Improvements to Now Playing page, including artist backdrops (#1782)

## Description
This PR has updates the Now Playing page.

Now only the song's backdrops and the song's artists' backdrops will
show. First, if the song has any backdrops (probably pretty uncommon),
they will be shuffled and shown, then if the song's artists have
backdrop, those are shuffled and shown. The backdrop changes every 2
minutes unless there's <15s left in the song to prevent rapid changes.
Note: album backdrops and album artists not directly linked to the
specific song are not included.

If a song has a lyrics and the user is scrolling through them, if the
user doesn't scroll for 5 seconds, the focus leaves the lyrics and they
scroll to the current line if available.

Dev note: Both backdrop & lyric updates are refactored to use `Flow`s to
trigger lookups

### Related issues
Fixes #1170

### Testing
Emulator, I need to test it longer term

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-08-11 13:35:20 -04:00 • committed by GitHub
parent 5219b22fc7
commit de30c2ccac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 137 additions and 139 deletions

View file

@ -121,7 +121,10 @@ fun LyricsContent(
), ),
modifier = modifier =
Modifier Modifier
.focusRequester(focusRequesters[index]), .focusRequester(focusRequesters[index])
.onFocusChanged {
if (it.isFocused) onFocusLyrics.invoke(true)
},
) { ) {
val text = val text =
remember(lyric.text) { lyric.text.ifBlank { " " } } remember(lyric.text) { lyric.text.ifBlank { " " } }

View file

@ -74,7 +74,9 @@ import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import java.util.Date
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@ -140,19 +142,22 @@ fun NowPlayingPage(
var showViewOptionsDialog by remember { mutableStateOf(false) } var showViewOptionsDialog by remember { mutableStateOf(false) }
var showContextMenu by remember { mutableStateOf<ContextMenu.ForQueue?>(null) } var showContextMenu by remember { mutableStateOf<ContextMenu.ForQueue?>(null) }
var lyricsHaveFocus by remember { mutableStateOf(false) } var lyricsFocused by remember { mutableStateOf<Date?>(null) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
val lyricsFocusRequester = remember { FocusRequester() } val lyricsFocusRequester = remember { FocusRequester() }
val hasLyrics = musicPrefs.showLyrics && state.hasLyrics val hasLyrics = musicPrefs.showLyrics && state.hasLyrics
LaunchedEffect(lyricsHaveFocus) { LaunchedEffect(lyricsFocused) {
if (lyricsHaveFocus) { if (lyricsFocused != null) {
controllerViewState.hideControls() controllerViewState.hideControls()
} }
delay(5.seconds)
lyricsFocused = null
focusRequester.tryRequestFocus()
} }
BackHandler(lyricsHaveFocus) { BackHandler(lyricsFocused != null) {
focusRequester.tryRequestFocus() focusRequester.tryRequestFocus()
} }
@ -290,8 +295,10 @@ fun NowPlayingPage(
LyricsContent( LyricsContent(
lyrics = state.lyrics, lyrics = state.lyrics,
currentLyricPosition = state.currentLyricIndex, currentLyricPosition = state.currentLyricIndex,
lyricsHaveFocus = lyricsHaveFocus, lyricsHaveFocus = lyricsFocused != null,
onFocusLyrics = { lyricsHaveFocus = it }, onFocusLyrics = {
lyricsFocused = Date()
},
onClick = { onClick = {
it.start it.start
?.ticks ?.ticks

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.data.model.AudioItem
import com.github.damontecres.wholphin.services.BackdropResult 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
@ -8,6 +9,7 @@ import org.jellyfin.sdk.model.api.LyricDto
@Stable @Stable
data class NowPlayingState( data class NowPlayingState(
val musicServiceState: MusicServiceState, val musicServiceState: MusicServiceState,
val currentMediaItem: AudioItem? = null,
val lyrics: LyricDto? = null, val lyrics: LyricDto? = null,
val currentLyricIndex: Int? = null, val currentLyricIndex: Int? = null,
val visualizerPermissions: Boolean = false, val visualizerPermissions: Boolean = false,

View file

@ -12,7 +12,6 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.data.model.AudioItem
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
@ -23,40 +22,43 @@ 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
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.combinePair
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.playback.ControllerViewState import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.mayakapps.kache.InMemoryKache import com.mayakapps.kache.InMemoryKache
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock 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.itemsApi
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.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.LyricDto import org.jellyfin.sdk.model.api.LyricDto
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.minutes
@UnstableApi @UnstableApi
@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class) @HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class)
@ -99,6 +101,9 @@ class NowPlayingViewModel
} }
init { init {
state.update {
it.copy(currentMediaItem = player.currentMediaItem?.localConfiguration?.tag as? AudioItem)
}
player.addListener(this) player.addListener(this)
addCloseable { addCloseable {
player.removeListener(this) player.removeListener(this)
@ -126,62 +131,39 @@ class NowPlayingViewModel
}.join() }.join()
controllerViewState.pulseControls() controllerViewState.pulseControls()
} }
viewModelScope.launchDefault { viewModelScope.launchDefault { backdropService.clearBackdrop() }
backdropService.clearBackdrop() listenForBackdrop()
updateBackdrop(getCurrent()) listenForLyrics()
}
playbackLoop()
} }
fun reportInteraction() { fun reportInteraction() {
controllerViewState.pulseControls() controllerViewState.pulseControls()
} }
private suspend fun getCurrent(): AudioItem? { private fun listenForLyrics() {
val mediaItem =
onMain {
player.currentMediaItemIndex
.takeIf { it in 0..<player.mediaItemCount }
?.let { player.getMediaItemAt(it) }
}
return mediaItem?.localConfiguration?.tag as? AudioItem
}
private fun playbackLoop() {
viewModelScope.launchDefault { viewModelScope.launchDefault {
while (isActive) { state.map { it.currentMediaItem }.distinctUntilChanged().collectLatest { audio ->
val position = onMain { player.currentPosition }.milliseconds if (audio != null && audio.hasLyrics) {
// Timber.v("playbackLoop: %s", position) val lyrics = fetchAndUpdateLyrics(audio)
getCurrent()?.let { audio -> Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null)
// Timber.v("Got current %s", audio.id) while (lyrics != null && isActive) {
if (audio.hasLyrics) { val position = onMain { player.currentPosition }.milliseconds
val lyrics = val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO
lyricCache.getOrPut(audio.id) { val lyricPosition = offset + position
// TODO remote lyrics?
api.lyricsApi.getLyrics(audio.id).content
}
val lyricIndex = val lyricIndex =
if (lyrics != null) { lyrics.lyrics
val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO .indexOfLast {
val lyricPosition = offset + position it.start?.ticks?.let { lyricPosition >= it } == true
lyrics.lyrics }.takeIf { it >= 0 }
.indexOfLast {
it.start?.ticks?.let { lyricPosition >= it } == true
}.takeIf { it >= 0 }
} else {
null
}
// Timber.v("lyricIndex=$lyricIndex")
state.update { state.update {
it.copy( it.copy(
lyrics = lyrics, lyrics = lyrics,
currentLyricIndex = lyricIndex, currentLyricIndex = lyricIndex,
) )
} }
delay(150.milliseconds)
} }
} }
delay(150)
} }
} }
} }
@ -192,100 +174,114 @@ class NowPlayingViewModel
) { ) {
val audio = mediaItem?.localConfiguration?.tag as? AudioItem val audio = mediaItem?.localConfiguration?.tag as? AudioItem
Timber.v("onMediaItemTransition to %s", audio?.id) Timber.v("onMediaItemTransition to %s", audio?.id)
updateBackdrop(audio) state.update {
viewModelScope.launchDefault { it.copy(
state.update { currentMediaItem = audio,
it.copy( lyrics = null,
lyrics = null, currentLyricIndex = null,
currentLyricIndex = null, )
)
}
audio?.let { audio ->
if (audio.hasLyrics) {
val lyrics =
lyricCache.getOrPut(audio.id) {
// TODO remote lyrics?
api.lyricsApi.getLyrics(audio.id).content
}
Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null)
state.update {
it.copy(
lyrics = lyrics,
)
}
}
}
} }
} }
private var backDropJob: Job? = null private suspend fun fetchAndUpdateLyrics(audio: AudioItem?): LyricDto? =
audio?.let { audio ->
private fun updateBackdrop(audio: AudioItem?) { if (audio.hasLyrics) {
backDropJob?.cancel() lyricCache.getOrPut(audio.id) {
backDropJob = // TODO remote lyrics?
viewModelScope.launchDefault {
val showBackdrop =
userPreferencesService
.getCurrent()
.appPreferences.musicPreferences.showBackdrop
if (showBackdrop) {
var backdropItem: BaseItem? = null
try { try {
if (audio?.artistId != null) { api.lyricsApi.getLyrics(audio.id).content
api.userLibraryApi.getItem(audio.artistId).content.let { } catch (ex: CancellationException) {
if (it.backdropImageTags?.isNotEmpty() == true) { throw ex
backdropItem = BaseItem(it, false)
}
}
}
if (backdropItem == null && audio?.albumId != null) {
api.userLibraryApi.getItem(audio.albumId).content.let {
backdropItem =
getBackdropItemForAlbum(api, BaseItem(it, false))
}
}
if (backdropItem != null) {
doUpdateBackdrop(backdropItem)
} else {
doUpdateBackdropRandom()
}
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error fetching backdrop") Timber.e(ex, "Error fetching lyrics for %s", audio.id)
doUpdateBackdropRandom() null
} }
delay(60.seconds)
doUpdateBackdropRandom()
} }
} else {
null
} }
} }
private suspend fun doUpdateBackdropRandom() { private fun listenForBackdrop() {
val randomArtist = viewModelScope.launchDefault {
api.itemsApi userPreferencesService.flow
.getItems( .map { it.appPreferences.musicPreferences.showBackdrop }
recursive = true, .combinePair(state.map { it.currentMediaItem })
imageTypes = listOf(ImageType.BACKDROP), .distinctUntilChanged()
includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST), .collectLatest { (showBackdrop, audio) ->
sortBy = listOf(ItemSortBy.RANDOM), if (showBackdrop && audio != null) {
limit = 1, try {
).content.items val song =
.firstOrNull() api.userLibraryApi
if (randomArtist != null) { .getItem(audio.id)
doUpdateBackdrop(BaseItem(randomArtist)) .content
} else { val songBackdrops = song.backdropImageUrls()
clearBackdrop()
val artistIds = song.artistItems.orEmpty().map { it.id }
val artists =
GetItemsRequestHandler
.execute(
api,
GetItemsRequest(
ids = artistIds,
imageTypes = listOf(ImageType.BACKDROP),
limit = 50,
enableImages = true,
enableImageTypes = listOf(ImageType.BACKDROP),
),
).content.items
val artistBackdrops =
artists.flatMap { artist -> artist.backdropImageUrls() }
val list = songBackdrops.shuffled() + artistBackdrops.shuffled()
Timber.v("Got %s backdrops for %s", list.size, song.id)
if (list.isNotEmpty()) {
val urls = generateSequence { list }.flatten()
urls.forEach {
val (position, duration) = onMain { player.currentPosition to player.duration }
if (duration - position >= 15_000 || state.value.backdropResult == BackdropResult.NONE) {
// Only change if there's backdrop currently, or at 15 seconds left to prevent quick changes
doUpdateBackdrop(audio.id, it)
delay(2.minutes)
}
}
} else {
clearBackdrop()
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error fetching backdrops for %s", audio.id)
clearBackdrop()
}
} else {
clearBackdrop()
}
}
} }
} }
private suspend fun doUpdateBackdrop(item: BaseItem) { private fun BaseItemDto.backdropImageUrls() =
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) backdropImageTags?.sorted().orEmpty().mapIndexedNotNull { index, tag ->
imageUrlService.getItemImageUrl(
itemId = id,
imageType = ImageType.BACKDROP,
tag = tag,
imageIndex = index,
)
}
private suspend fun doUpdateBackdrop(
itemId: UUID,
imageUrl: String,
) {
val (primaryColor, secondaryColor, tertiaryColor) = val (primaryColor, secondaryColor, tertiaryColor) =
backdropService.extractColorsFromBackdrop( backdropService.extractColorsFromBackdrop(
imageUrl, imageUrl,
) )
val backdropResult = val backdropResult =
BackdropResult( BackdropResult(
itemId = item.id.toString(), itemId = itemId.toString(),
imageUrl = imageUrl, imageUrl = imageUrl,
primaryColor = primaryColor, primaryColor = primaryColor,
secondaryColor = secondaryColor, secondaryColor = secondaryColor,
@ -343,19 +339,9 @@ class NowPlayingViewModel
fun updatePreferences(prefs: AppPreferences) { fun updatePreferences(prefs: AppPreferences) {
viewModelScope.launchDefault { viewModelScope.launchDefault {
var backdropChanged = false
preferencesDataStore.updateData { preferencesDataStore.updateData {
backdropChanged =
it.musicPreferences.showBackdrop != prefs.musicPreferences.showBackdrop
prefs prefs
} }
if (backdropChanged) {
if (prefs.musicPreferences.showBackdrop) {
updateBackdrop(getCurrent())
} else {
clearBackdrop()
}
}
} }
} }