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
.focusRequester(focusRequesters[index]),
.focusRequester(focusRequesters[index])
.onFocusChanged {
if (it.isFocused) onFocusLyrics.invoke(true)
},
) {
val text =
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.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.extensions.ticks
import java.util.Date
import kotlin.time.Duration.Companion.seconds
@OptIn(UnstableApi::class)
@ -140,19 +142,22 @@ fun NowPlayingPage(
var showViewOptionsDialog by remember { mutableStateOf(false) }
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() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
val lyricsFocusRequester = remember { FocusRequester() }
val hasLyrics = musicPrefs.showLyrics && state.hasLyrics
LaunchedEffect(lyricsHaveFocus) {
if (lyricsHaveFocus) {
LaunchedEffect(lyricsFocused) {
if (lyricsFocused != null) {
controllerViewState.hideControls()
}
delay(5.seconds)
lyricsFocused = null
focusRequester.tryRequestFocus()
}
BackHandler(lyricsHaveFocus) {
BackHandler(lyricsFocused != null) {
focusRequester.tryRequestFocus()
}
@ -290,8 +295,10 @@ fun NowPlayingPage(
LyricsContent(
lyrics = state.lyrics,
currentLyricPosition = state.currentLyricIndex,
lyricsHaveFocus = lyricsHaveFocus,
onFocusLyrics = { lyricsHaveFocus = it },
lyricsHaveFocus = lyricsFocused != null,
onFocusLyrics = {
lyricsFocused = Date()
},
onClick = {
it.start
?.ticks

View file

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

View file

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