Updates to playlists, add more button on album/artist pages

This commit is contained in:
Damontecres 2026-03-06 20:25:41 -05:00
parent 8ddd4bd747
commit 5f5b07e861
No known key found for this signature in database
6 changed files with 182 additions and 28 deletions

View file

@ -186,7 +186,7 @@ class MusicService
updateQueueSize() updateQueueSize()
if (player.mediaItemCount == 1) { if (player.mediaItemCount == 1) {
// Start playing if this was the first time added // Start playing if this was the first time added
player.play() start()
} }
} }
} }
@ -295,7 +295,12 @@ class MusicService
suspend fun playNext(song: BaseItem) { suspend fun playNext(song: BaseItem) {
val mediaItem = convert(song) val mediaItem = convert(song)
onMain { player.addMediaItem(state.value.currentIndex + 1, mediaItem) } onMain {
player.addMediaItem(state.value.currentIndex + 1, mediaItem)
if (player.mediaItemCount == 1) {
start()
}
}
updateQueueSize() updateQueueSize()
} }

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -44,6 +45,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -67,6 +69,7 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ItemCardImage import com.github.damontecres.wholphin.ui.cards.ItemCardImage
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
@ -77,9 +80,12 @@ import com.github.damontecres.wholphin.ui.components.FilterByButton
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.SortByButton import com.github.damontecres.wholphin.ui.components.SortByButton
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
@ -224,17 +230,24 @@ class PlaylistViewModel
} }
} }
/**
* This method tries to determine the [MediaType] of a playlist
*
* In theory, the server will set the type, but sometime it doesn't
*/
private suspend fun determineMediaType() { private suspend fun determineMediaType() {
// Use the type the server says
var mediaType = var mediaType =
super.item.value super.item.value
?.data ?.data
?.mediaType ?: MediaType.UNKNOWN ?.mediaType ?: MediaType.UNKNOWN
mediaType = mediaType =
if (mediaType == MediaType.UNKNOWN) { if (mediaType == MediaType.UNKNOWN) {
// Otherwise, if a most of the list is one type, we can assume that type
val pager = (this@PlaylistViewModel.items.value as? ApiRequestPager<*>) val pager = (this@PlaylistViewModel.items.value as? ApiRequestPager<*>)
if (pager != null && pager.size < 50) { if (pager != null && pager.size <= 50) {
val types = val types =
(0..<50).groupBy { index -> (0..<50.coerceAtMost(pager.size)).groupBy { index ->
val pagerItem = pager.getBlocking(index) val pagerItem = pager.getBlocking(index)
when (pagerItem?.type) { when (pagerItem?.type) {
BaseItemKind.AUDIO -> MediaType.AUDIO BaseItemKind.AUDIO -> MediaType.AUDIO
@ -259,6 +272,7 @@ class PlaylistViewModel
} else { } else {
mediaType mediaType
} }
Timber.d("mediaType=%s", mediaType)
playlistMediaType.update { mediaType } playlistMediaType.update { mediaType }
} }
@ -312,7 +326,7 @@ fun PlaylistDetails(
val playlistMediaType by viewModel.playlistMediaType.collectAsState() val playlistMediaType by viewModel.playlistMediaType.collectAsState()
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) } var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
var showConfirmTypeDialog by remember { mutableStateOf(false) } var showConfirmTypeDialog by remember { mutableStateOf<Pair<Int, Boolean>?>(null) }
val goToString = stringResource(R.string.go_to) val goToString = stringResource(R.string.go_to)
val playFromHereString = stringResource(R.string.play_from_here) val playFromHereString = stringResource(R.string.play_from_here)
@ -340,7 +354,7 @@ fun PlaylistDetails(
} }
else -> { else -> {
showConfirmTypeDialog = true showConfirmTypeDialog = Pair(index, shuffle)
} }
} }
} }
@ -390,6 +404,12 @@ fun PlaylistDetails(
onDismissRequest = { longClickDialog = null }, onDismissRequest = { longClickDialog = null },
) )
} }
showConfirmTypeDialog?.let { (index, shuffle) ->
ConfirmMediaTypeDialog(
onConfirm = { mediaType -> play(index, shuffle, mediaType) },
onCancel = { showConfirmTypeDialog = null },
)
}
} }
@Composable @Composable
@ -513,10 +533,18 @@ fun PlaylistDetailsContent(
onLongClickIndex.invoke(index, item) onLongClickIndex.invoke(index, item)
} }
}, },
isPlaying =
equalsNotNull(
musicState.currentItemId,
item?.id,
),
isQueued = item?.id in musicState.queuedIds,
modifier = modifier =
Modifier Modifier
.height(80.dp)
.ifElse( .ifElse(
item?.type != BaseItemKind.AUDIO,
Modifier.height(80.dp),
).ifElse(
index == savedIndex, index == savedIndex,
Modifier.focusRequester(focus), Modifier.focusRequester(focus),
).onFocusChanged { ).onFocusChanged {
@ -646,6 +674,8 @@ fun PlaylistItem(
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
isPlaying: Boolean = false,
isQueued: Boolean = false,
) { ) {
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
ListItem( ListItem(
@ -658,6 +688,12 @@ fun PlaylistItem(
text = item?.title ?: "", text = item?.title ?: "",
modifier = Modifier.enableMarquee(focused), modifier = Modifier.enableMarquee(focused),
) )
if (item?.type == BaseItemKind.AUDIO) {
MusicQueueMarker(
isPlaying = isPlaying,
isQueued = isQueued,
)
}
}, },
supportingContent = { supportingContent = {
Text( Text(
@ -714,3 +750,47 @@ fun PlaylistItem(
modifier = modifier, modifier = modifier,
) )
} }
@Composable
fun ConfirmMediaTypeDialog(
onConfirm: (MediaType) -> Unit,
onCancel: () -> Unit,
) {
BasicDialog(
onDismissRequest = onCancel,
properties = DialogProperties(),
elevation = 3.dp,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp),
modifier = Modifier.wrapContentSize(),
) {
item {
Text(
text = stringResource(R.string.play_as_type),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillParentMaxWidth(),
)
}
item {
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth(),
) {
TextButton(
stringRes = R.string.audio,
onClick = { onConfirm.invoke(MediaType.AUDIO) },
)
TextButton(
stringRes = R.string.video,
onClick = { onConfirm.invoke(MediaType.VIDEO) },
)
}
}
}
}
}

View file

@ -455,6 +455,23 @@ fun AlbumDetailsPage(
) )
} }
}, },
onClickMore = {
if (song != null) {
moreDialog =
DialogParams(
fromLongClick = false,
title = song.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = song,
index = index,
canRemove = false,
),
)
}
},
showArtist = state.isVariousArtists, showArtist = state.isVariousArtists,
isPlaying = song != null && currentMusic.currentItemId == song.id, isPlaying = song != null && currentMusic.currentItemId == song.id,
isQueued = song != null && song.id in currentMusic.queuedIds, isQueued = song != null && song.id in currentMusic.queuedIds,

View file

@ -441,6 +441,23 @@ fun ArtistDetailsPage(
) )
} }
}, },
onClickMore = {
if (song != null) {
moreDialog =
DialogParams(
fromLongClick = false,
title = song.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = song,
index = index,
canRemove = false,
),
)
}
},
showArtist = false, showArtist = false,
isPlaying = song != null && currentMusic.currentItemId == song.id, isPlaying = song != null && currentMusic.currentItemId == song.id,
isQueued = song != null && song.id in currentMusic.queuedIds, isQueued = song != null && song.id in currentMusic.queuedIds,

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -18,14 +19,17 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem import androidx.tv.material3.ListItem
import androidx.tv.material3.ListItemDefaults import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.LocalContentColor import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.roundSeconds import com.github.damontecres.wholphin.ui.roundSeconds
@ -40,6 +44,7 @@ fun SongListItem(
song: BaseItem?, song: BaseItem?,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onClickMore: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showArtist: Boolean = false, showArtist: Boolean = false,
isPlaying: Boolean = false, isPlaying: Boolean = false,
@ -62,6 +67,8 @@ fun SongListItem(
showArtist = showArtist, showArtist = showArtist,
isPlaying = isPlaying, isPlaying = isPlaying,
isQueued = isQueued, isQueued = isQueued,
showMoreButton = true,
onClickMore = onClickMore,
) )
@Composable @Composable
@ -76,6 +83,8 @@ fun SongListItem(
showArtist: Boolean = false, showArtist: Boolean = false,
isPlaying: Boolean = false, isPlaying: Boolean = false,
isQueued: Boolean = false, isQueued: Boolean = false,
showMoreButton: Boolean = false,
onClickMore: () -> Unit = {},
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) { ) {
Row( Row(
@ -90,25 +99,10 @@ fun SongListItem(
Text( Text(
text = indexNumber?.toString() ?: "", text = indexNumber?.toString() ?: "",
) )
if (isPlaying) { MusicQueueMarker(
Icon( isPlaying = isPlaying,
imageVector = Icons.Default.PlayArrow, isQueued = isQueued,
contentDescription = null,
) )
} else if (isQueued) {
// Icon(
// imageVector = Icons.Default.Add,
// contentDescription = null,
// )
Box(
modifier =
Modifier
.padding(horizontal = 8.dp)
.clip(CircleShape)
.background(LocalContentColor.current)
.size(8.dp),
)
}
} }
} }
val headlineContent = @Composable { val headlineContent = @Composable {
@ -140,7 +134,7 @@ fun SongListItem(
}, },
trailingContent = trailingContent, trailingContent = trailingContent,
scale = ListItemDefaults.scale(1f, 1f, .95f), scale = ListItemDefaults.scale(1f, 1f, .95f),
modifier = Modifier, modifier = Modifier.weight(1f),
) )
} else { } else {
ListItem( ListItem(
@ -153,14 +147,53 @@ fun SongListItem(
supportingContent = null, supportingContent = null,
trailingContent = trailingContent, trailingContent = trailingContent,
scale = ListItemDefaults.scale(1f, 1f, .95f), scale = ListItemDefaults.scale(1f, 1f, .95f),
modifier = Modifier, modifier = Modifier.weight(1f),
) )
} }
if (showMoreButton) {
Button(
onClick = onClickMore,
enabled = true,
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.more),
)
}
}
} }
} }
val BaseItem.artistsString: String? get() = data.artists?.letNotEmpty { it.joinToString(", ") } val BaseItem.artistsString: String? get() = data.artists?.letNotEmpty { it.joinToString(", ") }
/**
* Add an indicator for if the item is currently playing or queued
*/
@Composable
fun MusicQueueMarker(
isPlaying: Boolean,
isQueued: Boolean,
modifier: Modifier = Modifier,
) {
if (isPlaying) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
modifier = modifier,
)
} else if (isQueued) {
Box(
modifier =
modifier
.padding(horizontal = 8.dp)
.clip(CircleShape)
.background(LocalContentColor.current)
.size(8.dp),
)
}
}
@PreviewTvSpec @PreviewTvSpec
@Composable @Composable
fun SongListItemPreview() { fun SongListItemPreview() {
@ -186,6 +219,7 @@ fun SongListItemPreview() {
modifier = Modifier, modifier = Modifier,
showArtist = false, showArtist = false,
isQueued = true, isQueued = true,
showMoreButton = true,
) )
SongListItem( SongListItem(
title = "Song title", title = "Song title",

View file

@ -741,6 +741,7 @@
<string name="show_album_cover">Show album cover</string> <string name="show_album_cover">Show album cover</string>
<string name="show_visualizer">Show visualizer</string> <string name="show_visualizer">Show visualizer</string>
<string name="show_backdrop">Show backdrop</string> <string name="show_backdrop">Show backdrop</string>
<string name="play_as_type">Play as?</string>
</resources> </resources>