Customize now playing page

This commit is contained in:
Damontecres 2026-03-06 13:40:21 -05:00
parent 29269faa63
commit 7233ffa0d9
No known key found for this signature in database
7 changed files with 278 additions and 74 deletions

View file

@ -207,21 +207,8 @@ class AlbumViewModel
fun updateBackDrop() {
state.value.album?.let { album ->
viewModelScope.launchDefault {
if (album.data.imageTags?.contains(ImageType.BACKDROP) == true) {
backdropService.submit(album)
} else {
val artistIds =
album.data.albumArtists
?.shuffled()
?.take(50)
?.map { it.id }
api.itemsApi
.getItems(
ids = artistIds,
imageTypes = listOf(ImageType.BACKDROP),
).content.items
.firstOrNull()
?.let { backdropService.submit(BaseItem(it, false)) }
getBackdropItemForAlbum(api, album)?.let {
backdropService.submit(it)
}
}
}
@ -249,6 +236,28 @@ class AlbumViewModel
}
}
suspend fun getBackdropItemForAlbum(
api: ApiClient,
album: BaseItem,
): BaseItem? {
if (album.data.backdropImageTags?.isNotEmpty() == true) {
return album
} else {
val artistIds =
album.data.albumArtists
?.shuffled()
?.take(50)
?.map { it.id }
return api.itemsApi
.getItems(
ids = artistIds,
imageTypes = listOf(ImageType.BACKDROP),
).content.items
.firstOrNull()
?.let { BaseItem(it, false) }
}
}
data class AlbumState(
val album: BaseItem?,
val isVariousArtists: Boolean,

View file

@ -8,11 +8,35 @@ import com.github.damontecres.wholphin.preferences.updateMusicPreferences
fun getMusicPreferences() =
listOf(
AppSwitchPreference<AppPreferences>(
title = R.string.bold_font,
title = R.string.show_album_cover,
defaultValue = false,
getter = { it.musicPreferences.showAlbumArt },
setter = { prefs, value ->
prefs.updateMusicPreferences { showAlbumArt = value }
},
),
AppSwitchPreference<AppPreferences>(
title = R.string.show_visualizer,
defaultValue = false,
getter = { it.musicPreferences.showVisualizer },
setter = { prefs, value ->
prefs.updateMusicPreferences { showVisualizer = value }
},
),
AppSwitchPreference<AppPreferences>(
title = R.string.show_backdrop,
defaultValue = false,
getter = { it.musicPreferences.showBackdrop },
setter = { prefs, value ->
prefs.updateMusicPreferences { showBackdrop = value }
},
),
AppSwitchPreference<AppPreferences>(
title = R.string.show_lyrics,
defaultValue = false,
getter = { it.musicPreferences.showLyrics },
setter = { prefs, value ->
prefs.updateMusicPreferences { showLyrics = value }
},
),
)

View file

@ -0,0 +1,97 @@
package com.github.damontecres.wholphin.ui.detail.music
import android.view.Gravity
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun MusicViewOptionsDialog(
appPreferences: AppPreferences,
onDismissRequest: () -> Unit,
onViewOptionsChange: (AppPreferences) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
val columnState = rememberLazyListState()
val items = remember { getMusicPreferences() }
Dialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.let { window ->
window.setGravity(Gravity.END)
window.setDimAmount(0f)
}
Column {
Text(
text = stringResource(R.string.view_options),
style = MaterialTheme.typography.titleMedium,
)
}
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.width(256.dp)
.heightIn(max = 380.dp)
.focusRequester(focusRequester)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
shape = RoundedCornerShape(8.dp),
),
) {
items(items) { pref ->
pref as AppPreference<AppPreferences, Any>
val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(appPreferences)
ComposablePreference(
preference = pref,
value = value,
onNavigate = {},
onValueChange = { newValue ->
onViewOptionsChange.invoke(pref.setter(appPreferences, newValue))
},
interactionSource = interactionSource,
modifier = Modifier,
onClickPreference = { pref ->
},
)
}
}
}
}

View file

@ -4,6 +4,7 @@ import android.view.Gravity
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.slideInVertically
@ -12,10 +13,10 @@ import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
@ -85,6 +86,7 @@ fun NowPlayingPage(
AppPreferences.getDefaultInstance(),
),
).value.appPreferences
val musicPrefs = preferences.musicPreferences
val keyHandler =
remember(preferences) {
@ -120,9 +122,7 @@ fun NowPlayingPage(
)
}
var showMoreDialog by remember { mutableStateOf(false) }
var lyricsActive by remember { mutableStateOf(true) }
var showDebugInfo by remember { mutableStateOf(true) }
var showViewOptionsDialog by remember { mutableStateOf(false) }
var itemMoreDialog by remember { mutableStateOf<DialogParams?>(null) }
val focusRequester = remember { FocusRequester() }
@ -136,55 +136,25 @@ fun NowPlayingPage(
.focusRequester(focusRequester)
.focusable(),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
AnimatedVisibility(
musicPrefs.showAlbumArt,
modifier =
Modifier
.padding(32.dp)
.fillMaxWidth(.5f)
.align(Alignment.CenterStart),
) {
AnimatedVisibility(
visible = lyricsActive && state.hasLyrics,
enter = expandHorizontally(expandFrom = Alignment.Start),
exit = shrinkHorizontally(shrinkTowards = Alignment.Start),
modifier = Modifier,
) {
LyricsContent(
synced = true,
lyrics = state.lyrics,
currentLyricPosition = state.currentLyricIndex,
onClick = {},
modifier =
Modifier
.padding(vertical = 120.dp, horizontal = 32.dp)
.fillMaxHeight()
.width(320.dp),
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.padding(40.dp)
.fillMaxSize()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
contentAlignment = Alignment.Center,
AsyncImage(
contentDescription = null,
model = current?.imageUrl,
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(.75f),
) {
AsyncImage(
contentDescription = null,
model = current?.imageUrl,
modifier = Modifier.fillMaxSize(),
)
BarVisualizer(
data = viz,
modifier = Modifier.fillMaxSize(),
)
}
.height(320.dp),
)
current?.title?.let {
Text(
text = it,
@ -205,6 +175,50 @@ fun NowPlayingPage(
}
}
}
AnimatedVisibility(
musicPrefs.showVisualizer,
modifier =
Modifier
.padding(horizontal = 32.dp)
.fillMaxHeight(.75f)
.align(Alignment.CenterStart),
) {
val visualizerWidth by animateFloatAsState(if (musicPrefs.showLyrics && state.hasLyrics) .5f else 1f)
BarVisualizer(
data = viz,
modifier =
Modifier
.fillMaxHeight()
.fillMaxWidth(visualizerWidth)
.align(Alignment.CenterStart),
)
}
AnimatedVisibility(
visible = musicPrefs.showLyrics && state.hasLyrics,
enter = expandHorizontally(expandFrom = Alignment.End),
exit = shrinkHorizontally(shrinkTowards = Alignment.End),
modifier = Modifier.align(Alignment.CenterEnd),
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.fillMaxWidth(.5f)
.fillMaxHeight(),
) {
LyricsContent(
synced = true,
lyrics = state.lyrics,
currentLyricPosition = state.currentLyricIndex,
onClick = {},
modifier =
Modifier
.padding(vertical = 120.dp)
.fillMaxHeight()
.width(320.dp),
)
}
}
}
val showContextForItem =
remember {
@ -243,7 +257,7 @@ fun NowPlayingPage(
queue = queue,
controllerViewState = controllerViewState,
onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) },
onClickMore = { showMoreDialog = true },
onClickMore = { showViewOptionsDialog = true },
onClickSong = { index, _ -> viewModel.play(index) },
onClickMoreItem = { index, song -> showContextForItem.invoke(false, index, song) },
onLongClickSong = { index, song -> showContextForItem.invoke(true, index, song) },
@ -277,14 +291,11 @@ fun NowPlayingPage(
waitToLoad = params.fromLongClick,
)
}
if (showMoreDialog) {
NowPlayingBottomDialog(
showDebugInfo = showDebugInfo,
lyricsActive = lyricsActive,
songHasLyrics = state.hasLyrics,
onDismissRequest = { showMoreDialog = false },
onClickShowDebug = { showDebugInfo = !showDebugInfo },
onClickLyrics = { lyricsActive = !lyricsActive },
if (showViewOptionsDialog) {
MusicViewOptionsDialog(
appPreferences = preferences,
onDismissRequest = { showViewOptionsDialog = false },
onViewOptionsChange = { viewModel.updatePreferences(it) },
)
}
}

View file

@ -2,12 +2,18 @@ package com.github.damontecres.wholphin.ui.detail.music
import android.content.Context
import android.media.audiofx.Visualizer
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.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.UserPreferencesService
@ -29,6 +35,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
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.LyricDto
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
@ -38,6 +45,7 @@ import kotlin.math.ceil
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@UnstableApi
@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class)
class NowPlayingViewModel
@AssistedInject
@ -46,6 +54,9 @@ class NowPlayingViewModel
@param:ApplicationContext private val context: Context,
val navigationManager: NavigationManager,
private val musicService: MusicService,
private val imageUrlService: ImageUrlService,
private val backdropService: BackdropService,
private val preferencesDataStore: DataStore<AppPreferences>,
val userPreferencesService: UserPreferencesService,
) : ViewModel(),
Visualizer.OnDataCaptureListener,
@ -103,6 +114,9 @@ class NowPlayingViewModel
}.join()
controllerViewState.pulseControls()
}
viewModelScope.launchDefault {
updateBackdrop(getCurrent())
}
playbackLoop()
}
@ -163,9 +177,10 @@ class NowPlayingViewModel
mediaItem: MediaItem?,
reason: Int,
) {
val audio = mediaItem?.localConfiguration?.tag as? AudioItem
Timber.v("onMediaItemTransition to %s", audio?.id)
updateBackdrop(audio)
viewModelScope.launchDefault {
val audio = mediaItem?.localConfiguration?.tag as? AudioItem
Timber.v("onMediaItemTransition to %s", audio?.id)
state.update {
it.copy(
lyrics = null,
@ -190,6 +205,32 @@ class NowPlayingViewModel
}
}
private fun updateBackdrop(audio: AudioItem?) {
viewModelScope.launchDefault {
val showBackdrop =
userPreferencesService
.getCurrent()
.appPreferences.musicPreferences.showBackdrop
if (audio?.albumId != null && showBackdrop) {
try {
api.userLibraryApi.getItem(audio.albumId).content.let {
val backdropItem = getBackdropItemForAlbum(api, BaseItem(it, false))
if (backdropItem != null) {
backdropService.submit(backdropItem)
} else {
backdropService.clearBackdrop()
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching backdrop")
backdropService.clearBackdrop()
}
} else {
backdropService.clearBackdrop()
}
}
}
fun moveQueue(
index: Int,
direction: MoveDirection,
@ -239,4 +280,22 @@ class NowPlayingViewModel
}
viz.update { processed }
}
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 {
backdropService.clearBackdrop()
}
}
}
}
}

View file

@ -182,6 +182,7 @@ message MusicPreferences {
bool show_lyrics = 1;
bool show_visualizer = 2;
bool show_album_Art = 3;
bool show_backdrop = 4;
}
message AppPreferences {

View file

@ -738,6 +738,9 @@
<string name="show_lyrics">Show lyrics</string>
<string name="song_has_lyrics">Song has lyrics</string>
<string name="remove_from_queue">Remove from queue</string>
<string name="show_album_cover">Show album cover</string>
<string name="show_visualizer">Show visualizer</string>
<string name="show_backdrop">Show backdrop</string>
</resources>