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() { fun updateBackDrop() {
state.value.album?.let { album -> state.value.album?.let { album ->
viewModelScope.launchDefault { viewModelScope.launchDefault {
if (album.data.imageTags?.contains(ImageType.BACKDROP) == true) { getBackdropItemForAlbum(api, album)?.let {
backdropService.submit(album) backdropService.submit(it)
} 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)) }
} }
} }
} }
@ -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( data class AlbumState(
val album: BaseItem?, val album: BaseItem?,
val isVariousArtists: Boolean, val isVariousArtists: Boolean,

View file

@ -8,11 +8,35 @@ import com.github.damontecres.wholphin.preferences.updateMusicPreferences
fun getMusicPreferences() = fun getMusicPreferences() =
listOf( listOf(
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.bold_font, title = R.string.show_album_cover,
defaultValue = false, defaultValue = false,
getter = { it.musicPreferences.showAlbumArt }, getter = { it.musicPreferences.showAlbumArt },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateMusicPreferences { showAlbumArt = 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.activity.compose.BackHandler
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandHorizontally import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.slideInVertically 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -85,6 +86,7 @@ fun NowPlayingPage(
AppPreferences.getDefaultInstance(), AppPreferences.getDefaultInstance(),
), ),
).value.appPreferences ).value.appPreferences
val musicPrefs = preferences.musicPreferences
val keyHandler = val keyHandler =
remember(preferences) { remember(preferences) {
@ -120,9 +122,7 @@ fun NowPlayingPage(
) )
} }
var showMoreDialog by remember { mutableStateOf(false) } var showViewOptionsDialog by remember { mutableStateOf(false) }
var lyricsActive by remember { mutableStateOf(true) }
var showDebugInfo by remember { mutableStateOf(true) }
var itemMoreDialog by remember { mutableStateOf<DialogParams?>(null) } var itemMoreDialog by remember { mutableStateOf<DialogParams?>(null) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -135,56 +135,26 @@ fun NowPlayingPage(
.onPreviewKeyEvent(keyHandler::onKeyEvent) .onPreviewKeyEvent(keyHandler::onKeyEvent)
.focusRequester(focusRequester) .focusRequester(focusRequester)
.focusable(), .focusable(),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = lyricsActive && state.hasLyrics, musicPrefs.showAlbumArt,
enter = expandHorizontally(expandFrom = Alignment.Start),
exit = shrinkHorizontally(shrinkTowards = Alignment.Start),
modifier = Modifier,
) {
LyricsContent(
synced = true,
lyrics = state.lyrics,
currentLyricPosition = state.currentLyricIndex,
onClick = {},
modifier = modifier =
Modifier Modifier
.padding(vertical = 120.dp, horizontal = 32.dp) .padding(32.dp)
.fillMaxHeight() .fillMaxWidth(.5f)
.width(320.dp), .align(Alignment.CenterStart),
) ) {
}
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(40.dp)
.fillMaxSize()
.weight(1f),
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(.75f),
) { ) {
AsyncImage( AsyncImage(
contentDescription = null, contentDescription = null,
model = current?.imageUrl, model = current?.imageUrl,
modifier = Modifier.fillMaxSize(), modifier =
Modifier
.height(320.dp),
) )
BarVisualizer(
data = viz,
modifier = Modifier.fillMaxSize(),
)
}
current?.title?.let { current?.title?.let {
Text( Text(
text = it, 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 = val showContextForItem =
remember { remember {
@ -243,7 +257,7 @@ fun NowPlayingPage(
queue = queue, queue = queue,
controllerViewState = controllerViewState, controllerViewState = controllerViewState,
onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) }, onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) },
onClickMore = { showMoreDialog = true }, onClickMore = { showViewOptionsDialog = true },
onClickSong = { index, _ -> viewModel.play(index) }, onClickSong = { index, _ -> viewModel.play(index) },
onClickMoreItem = { index, song -> showContextForItem.invoke(false, index, song) }, onClickMoreItem = { index, song -> showContextForItem.invoke(false, index, song) },
onLongClickSong = { index, song -> showContextForItem.invoke(true, index, song) }, onLongClickSong = { index, song -> showContextForItem.invoke(true, index, song) },
@ -277,14 +291,11 @@ fun NowPlayingPage(
waitToLoad = params.fromLongClick, waitToLoad = params.fromLongClick,
) )
} }
if (showMoreDialog) { if (showViewOptionsDialog) {
NowPlayingBottomDialog( MusicViewOptionsDialog(
showDebugInfo = showDebugInfo, appPreferences = preferences,
lyricsActive = lyricsActive, onDismissRequest = { showViewOptionsDialog = false },
songHasLyrics = state.hasLyrics, onViewOptionsChange = { viewModel.updatePreferences(it) },
onDismissRequest = { showMoreDialog = false },
onClickShowDebug = { showDebugInfo = !showDebugInfo },
onClickLyrics = { lyricsActive = !lyricsActive },
) )
} }
} }

View file

@ -2,12 +2,18 @@ package com.github.damontecres.wholphin.ui.detail.music
import android.content.Context import android.content.Context
import android.media.audiofx.Visualizer import android.media.audiofx.Visualizer
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player 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.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.services.BackdropService
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.MusicService 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.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
@ -29,6 +35,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
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.model.api.LyricDto import org.jellyfin.sdk.model.api.LyricDto
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
@ -38,6 +45,7 @@ import kotlin.math.ceil
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@UnstableApi
@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class) @HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class)
class NowPlayingViewModel class NowPlayingViewModel
@AssistedInject @AssistedInject
@ -46,6 +54,9 @@ class NowPlayingViewModel
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val musicService: MusicService, private val musicService: MusicService,
private val imageUrlService: ImageUrlService,
private val backdropService: BackdropService,
private val preferencesDataStore: DataStore<AppPreferences>,
val userPreferencesService: UserPreferencesService, val userPreferencesService: UserPreferencesService,
) : ViewModel(), ) : ViewModel(),
Visualizer.OnDataCaptureListener, Visualizer.OnDataCaptureListener,
@ -103,6 +114,9 @@ class NowPlayingViewModel
}.join() }.join()
controllerViewState.pulseControls() controllerViewState.pulseControls()
} }
viewModelScope.launchDefault {
updateBackdrop(getCurrent())
}
playbackLoop() playbackLoop()
} }
@ -163,9 +177,10 @@ class NowPlayingViewModel
mediaItem: MediaItem?, mediaItem: MediaItem?,
reason: Int, reason: Int,
) { ) {
viewModelScope.launchDefault {
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)
viewModelScope.launchDefault {
state.update { state.update {
it.copy( it.copy(
lyrics = null, 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( fun moveQueue(
index: Int, index: Int,
direction: MoveDirection, direction: MoveDirection,
@ -239,4 +280,22 @@ class NowPlayingViewModel
} }
viz.update { processed } 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_lyrics = 1;
bool show_visualizer = 2; bool show_visualizer = 2;
bool show_album_Art = 3; bool show_album_Art = 3;
bool show_backdrop = 4;
} }
message AppPreferences { message AppPreferences {

View file

@ -738,6 +738,9 @@
<string name="show_lyrics">Show lyrics</string> <string name="show_lyrics">Show lyrics</string>
<string name="song_has_lyrics">Song has lyrics</string> <string name="song_has_lyrics">Song has lyrics</string>
<string name="remove_from_queue">Remove from queue</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> </resources>