mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-05 18:30:59 +01:00
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:
parent
5219b22fc7
commit
de30c2ccac
4 changed files with 137 additions and 139 deletions
|
|
@ -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 { " " } }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue