More button & queue management

This commit is contained in:
Damontecres 2026-02-11 11:25:10 -05:00
parent 11e28a605c
commit b48eb9898c
No known key found for this signature in database
6 changed files with 265 additions and 116 deletions

View file

@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.profile.Codec
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@ -75,6 +76,33 @@ class MusicService
setQueue(items, false)
}
/**
* Replace the queue with the given list and starting playing the song as startIndex as soon as its ready
*
* Fetches each item in a blocking way and adds to the queue
*/
suspend fun setQueue(
items: BlockingList<BaseItem?>,
startIndex: Int,
shuffled: Boolean,
) = withContext(Dispatchers.IO) {
Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled)
withContext(Dispatchers.Main) {
player.setMediaItems(emptyList())
player.shuffleModeEnabled = shuffled
player.play()
}
(startIndex..items.lastIndex).forEach {
val item = items.getBlocking(it)
if (item != null && item.type == BaseItemKind.AUDIO) {
val mediaItem = convert(item)
withContext(Dispatchers.Main) {
player.addMediaItem(mediaItem)
}
}
}
}
suspend fun setQueue(
items: List<BaseItem>,
shuffled: Boolean,
@ -136,6 +164,8 @@ data class MusicServiceState(
companion object {
val EMPTY = MusicServiceState(emptyList(), 0, false)
}
val currentItemId: UUID? get() = if (isPlaying) queue.getOrNull(currentIndex)?.id else null
}
/**

View file

@ -1,35 +1,30 @@
package com.github.damontecres.wholphin.ui.detail.music
import android.content.Context
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@ -52,9 +47,9 @@ import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText
@ -85,7 +80,6 @@ import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.util.UUID
import kotlin.time.Duration
@HiltViewModel(assistedFactory = AlbumViewModel.Factory::class)
class AlbumViewModel
@ -93,7 +87,7 @@ class AlbumViewModel
constructor(
private val api: ApiClient,
@param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager,
val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
val mediaReportService: MediaReportService,
private val favoriteWatchManager: FavoriteWatchManager,
@ -111,6 +105,8 @@ class AlbumViewModel
private val _state = MutableStateFlow(AlbumState.EMPTY)
val state: StateFlow<AlbumState> = _state
val currentMusic = musicService.state
init {
viewModelScope.launchIO {
try {
@ -169,8 +165,10 @@ class AlbumViewModel
}
}
fun setFavorite(favorite: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
fun setFavorite(
itemId: UUID,
favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite)
val album =
api.userLibraryApi
@ -187,12 +185,27 @@ class AlbumViewModel
viewModelScope.launchIO {
Timber.v("Playing album %s from %s", itemId, startIndex)
val songs = state.value.songs as ApiRequestPager<*>
val songsToAdd =
(startIndex..songs.lastIndex)
.mapNotNull { idx ->
songs.getBlocking(idx)
musicService.setQueue(songs, startIndex, shuffled)
}
}
fun addToQueue(
itemId: UUID,
index: Int,
) {
viewModelScope.launchIO {
val songs = state.value.songs as ApiRequestPager<*>
if (itemId == this@AlbumViewModel.itemId) {
songs.indices.forEach {
songs.getBlocking(it)?.let {
musicService.addToQueue(it)
}
}
} else {
songs.getBlocking(index)?.let {
musicService.addToQueue(it)
}
}
musicService.setQueue(songsToAdd, shuffled)
}
}
@ -225,8 +238,22 @@ fun AlbumDetailsPage(
creationCallback = { it.create(itemId) },
),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val state by viewModel.state.collectAsState()
val currentMusic by viewModel.currentMusic.collectAsState()
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
val moreDialogActions =
remember {
MusicMoreDialogActions(
onNavigate = { viewModel.navigationManager.navigateTo(it) },
onClickPlay = { index, _ -> viewModel.play(false, index) },
onClickAddToQueue = { index, itemId -> viewModel.addToQueue(itemId, index) },
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
onClickAddPlaylist = {},
)
}
when (val loading = state.loading) {
is LoadingState.Error -> {
@ -271,9 +298,25 @@ fun AlbumDetailsPage(
MusicButtonActions(
onClickPlay = { viewModel.play(it, 0) },
onClickInstantMix = viewModel::startInstantMix,
onClickFavorite = { viewModel.setFavorite(!album.favorite) },
onClickFavorite = {
viewModel.setFavorite(
album.id,
!album.favorite,
)
},
onClickMore = {
// TODO
moreDialog =
DialogParams(
fromLongClick = false,
title = album.name + " (${album.data.productionYear ?: ""})",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = album,
index = 0,
),
)
},
)
},
@ -295,16 +338,41 @@ fun AlbumDetailsPage(
SongListItem(
song = song,
onClick = { viewModel.play(false, index) },
onClickAddToQueue = {},
onClickAddToPlaylist = {},
onLongClick = {
if (song != null) {
moreDialog =
DialogParams(
fromLongClick = true,
title = song.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = song,
index = index,
),
)
}
},
modifier = Modifier.padding(horizontal = 16.dp),
showArtist = false,
isPlaying = song != null && currentMusic.currentItemId == song.id,
)
}
}
}
}
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
}
@Composable
@ -388,57 +456,3 @@ fun AlbumHeader(
}
}
}
@Composable
fun AlbumButtons(
onClickPlay: (Boolean) -> Unit,
onClickAddToPlaylist: () -> Unit,
onClickGoToArtist: () -> Unit,
onClickMore: () -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier,
) {
val firstFocus = remember { FocusRequester() }
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
modifier =
modifier
.focusGroup()
.focusRestorer(firstFocus),
) {
item {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = { onClickPlay.invoke(false) },
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged)
.focusRequester(firstFocus),
)
}
item {
ExpandableFaButton(
title = R.string.shuffle,
iconStringRes = R.string.fa_shuffle,
onClick = { onClickPlay.invoke(true) },
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged),
)
}
item {
ExpandablePlayButton(
title = R.string.go_to_artist,
resume = Duration.ZERO,
icon = Icons.Default.AccountCircle,
onClick = { onClickGoToArtist.invoke() },
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged),
)
}
}
}

View file

@ -113,8 +113,7 @@ fun NowPlayingPage(
onClick = {
player.seekTo(index, 0L)
},
onClickAddToQueue = {},
onClickAddToPlaylist = {},
onLongClick = {},
modifier = Modifier,
)
}

View file

@ -1,22 +1,27 @@
package com.github.damontecres.wholphin.ui.detail.music
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem
import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.roundMinutes
@ -31,8 +36,7 @@ fun SongListDisplay(
songs: List<BaseItem?>,
showArtist: Boolean,
onClick: (Int, BaseItem) -> Unit,
onClickAddToQueue: (Int, BaseItem) -> Unit,
onClickAddToPlaylist: (Int, BaseItem) -> Unit,
onLongClick: (Int, BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
@ -43,8 +47,7 @@ fun SongListDisplay(
SongListItem(
song = song,
onClick = { song?.let { onClick.invoke(index, song) } },
onClickAddToQueue = { song?.let { onClick.invoke(index, song) } },
onClickAddToPlaylist = { song?.let { onClick.invoke(index, song) } },
onLongClick = { song?.let { onLongClick.invoke(index, song) } },
showArtist = showArtist,
modifier = Modifier,
)
@ -56,10 +59,10 @@ fun SongListDisplay(
fun SongListItem(
song: BaseItem?,
onClick: () -> Unit,
onClickAddToQueue: () -> Unit,
onClickAddToPlaylist: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
showArtist: Boolean = false,
isPlaying: Boolean = false,
) = SongListItem(
title = song?.title,
artist = if (showArtist) song?.data?.albumArtist else null,
@ -71,10 +74,10 @@ fun SongListItem(
?.ticks
?.roundMinutes,
onClick = onClick,
onClickAddToQueue = onClickAddToQueue,
onClickAddToPlaylist = onClickAddToPlaylist,
onLongClick = onLongClick,
modifier = modifier,
showArtist = showArtist,
isPlaying = isPlaying,
)
@Composable
@ -84,27 +87,42 @@ fun SongListItem(
indexNumber: Int?,
runtime: Duration?,
onClick: () -> Unit,
onClickAddToQueue: () -> Unit,
onClickAddToPlaylist: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
showArtist: Boolean = false,
isPlaying: Boolean = false,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
val focused by interactionSource.collectIsFocusedAsState()
ListItem(
selected = isPlaying,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
leadingContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = indexNumber?.toString() ?: "",
)
if (isPlaying) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
)
}
}
},
headlineContent = {
Text(
text = title ?: "",
maxLines = 1,
modifier = Modifier.enableMarquee(focused),
)
},
supportingContent =
@ -125,14 +143,6 @@ fun SongListItem(
scale = ListItemDefaults.scale(1f, 1f, .95f),
modifier = Modifier.weight(1f),
)
Button(
onClick = onClickAddToQueue,
shape = ButtonDefaults.shape(shape = RoundedCornerShape(8.dp)),
) {
Text(
text = "Add to queue",
)
}
}
}
@ -149,21 +159,20 @@ fun SongListItemPreview() {
indexNumber = 1,
runtime = 2.minutes + 30.seconds,
onClick = {},
onClickAddToQueue = { },
onClickAddToPlaylist = {},
onLongClick = { },
modifier = Modifier,
showArtist = false,
)
SongListItem(
title = "Song title",
artist = "Artists",
indexNumber = 1,
indexNumber = 2,
runtime = 2.minutes + 30.seconds,
onClick = {},
onClickAddToQueue = { },
onClickAddToPlaylist = {},
onLongClick = { },
modifier = Modifier,
showArtist = true,
isPlaying = true,
)
}
}

View file

@ -0,0 +1,95 @@
package com.github.damontecres.wholphin.ui.detail.music
import android.content.Context
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.ui.graphics.Color
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.nav.Destination
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
data class MusicMoreDialogActions(
val onNavigate: (Destination) -> Unit,
val onClickPlay: (Int, UUID) -> Unit,
val onClickAddToQueue: (Int, UUID) -> Unit,
val onClickFavorite: (UUID, Boolean) -> Unit,
val onClickAddPlaylist: (UUID) -> Unit,
val onClickGoToAlbum: (UUID) -> Unit = {
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
},
val onClickGoToArtist: (UUID) -> Unit = {
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
},
)
fun buildMoreDialogForMusic(
context: Context,
actions: MusicMoreDialogActions,
item: BaseItem,
index: Int,
): List<DialogItem> =
buildList {
add(
DialogItem(
context.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
actions.onClickPlay(index, item.id)
},
)
add(
DialogItem(
context.getString(R.string.add_to_queue),
Icons.Default.Add,
) {
actions.onClickAddToQueue(index, item.id)
},
)
add(
DialogItem(
text = R.string.add_to_playlist,
iconStringRes = R.string.fa_list_ul,
) {
actions.onClickAddPlaylist.invoke(item.id)
},
)
add(
DialogItem(
text = if (item.favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
iconColor = if (item.favorite) Color.Red else Color.Unspecified,
) {
actions.onClickFavorite.invoke(item.id, !item.favorite)
},
)
if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) {
add(
DialogItem(
context.getString(R.string.go_to_album),
Icons.Default.ArrowForward,
) {
actions.onClickGoToAlbum.invoke(item.data.albumId!!)
},
)
}
if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) {
add(
DialogItem(
context.getString(R.string.go_to_artist),
Icons.Default.ArrowForward,
) {
actions.onClickGoToArtist.invoke(
item.data.artistItems!!
.first()
.id,
)
},
)
}
}

View file

@ -498,6 +498,8 @@
<string name="go_to_artist">Go to artist</string>
<string name="now_playing">Now playing</string>
<string name="instant_mix">Instant mix</string>
<string name="go_to_album">Go to album</string>
<string name="add_to_queue">Add to queue</string>
<string-array name="theme_song_volume">
<item>Disabled</item>