feat(jam-session): enhance guest functionality with track addition and participant moderation options

This commit is contained in:
Kingkor Roy Tirtho 2026-09-12 00:01:34 +06:00
parent ad994f3ca3
commit 4c724a333b
16 changed files with 406 additions and 109 deletions

View File

@ -92,10 +92,14 @@ class JamRoomService(
private var localClientId: String = "" private var localClientId: String = ""
private var localDisplayName: String = "" private var localDisplayName: String = ""
/** Display name of the local participant (stamped on items this device adds). */ /** Display name of the local participant. */
val participantDisplayName: String val participantDisplayName: String
get() = localDisplayName get() = localDisplayName
/** Stable id of the local participant (stamped on items this device adds). */
val participantClientId: String
get() = localClientId
private var hostBroadcastJob: Job? = null private var hostBroadcastJob: Job? = null
/** Guest side: last queue snapshot applied to the local player. */ /** Guest side: last queue snapshot applied to the local player. */
@ -257,7 +261,7 @@ class JamRoomService(
jamClient.publishCommand( jamClient.publishCommand(
JamMessage.SuggestTrack( JamMessage.SuggestTrack(
mediaItem = JamMediaItem.fromTrack(track), mediaItem = JamMediaItem.fromTrack(track),
addedBy = localDisplayName, addedBy = localClientId,
) )
) )
} }
@ -267,7 +271,7 @@ class JamRoomService(
jamClient.publishCommand( jamClient.publishCommand(
JamMessage.SuggestPlaylist( JamMessage.SuggestPlaylist(
tracks = tracks.map(JamMediaItem::fromTrack), tracks = tracks.map(JamMediaItem::fromTrack),
addedBy = localDisplayName, addedBy = localClientId,
) )
) )
} }
@ -396,11 +400,15 @@ class JamRoomService(
} }
is JamMessage.SuggestTrack -> { is JamMessage.SuggestTrack -> {
if (_role.value == JamRole.Host) acceptSuggestion(listOf(message.mediaItem)) if (_role.value == JamRole.Host) {
acceptSuggestion(listOf(message.mediaItem.copy(addedBy = message.addedBy)))
}
} }
is JamMessage.SuggestPlaylist -> { is JamMessage.SuggestPlaylist -> {
if (_role.value == JamRole.Host) acceptSuggestion(message.tracks) if (_role.value == JamRole.Host) {
acceptSuggestion(message.tracks.map { it.copy(addedBy = message.addedBy) })
}
} }
is JamMessage.Kick -> { is JamMessage.Kick -> {

View File

@ -176,7 +176,7 @@ class RemotePlaybackController(
try { try {
when (jamRoomService.role.value) { when (jamRoomService.role.value) {
JamRole.Host -> audioPlayerQueue.addToQueue( JamRole.Host -> audioPlayerQueue.addToQueue(
QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantDisplayName) QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantClientId)
) )
JamRole.Guest -> jamRoomService.suggestTrack(track) JamRole.Guest -> jamRoomService.suggestTrack(track)
@ -203,7 +203,7 @@ class RemotePlaybackController(
QueueEntry.StreamingTrack( QueueEntry.StreamingTrack(
track = track, track = track,
url = "", url = "",
addedBy = jamRoomService.participantDisplayName, addedBy = jamRoomService.participantClientId,
) )
} }
) )

View File

@ -27,6 +27,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
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.fillMaxSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
@ -48,6 +49,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember 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.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
@ -71,6 +73,8 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.resources.iconsax.User
private val BadgeShape = RoundedCornerShape(11.dp) private val BadgeShape = RoundedCornerShape(11.dp)
private const val DisabledContentAlpha = 0.38f
private val ButtonMinHeight = 40.dp private val ButtonMinHeight = 40.dp
private val SquareButtonSize = 40.dp private val SquareButtonSize = 40.dp
@ -178,6 +182,7 @@ fun OutlineButton(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Row( Row(
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
content = { content = {
@ -226,6 +231,7 @@ fun PrimaryButton(
) { ) {
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
Row( Row(
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
content = content, content = content,
@ -271,6 +277,7 @@ fun SecondaryButton(
) { ) {
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
Row( Row(
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
content = content, content = content,
@ -488,7 +495,14 @@ fun GroupIconButton(
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
content() Box(
modifier = Modifier
.fillMaxSize()
.alpha(if (enabled) 1f else DisabledContentAlpha),
contentAlignment = Alignment.Center,
) {
content()
}
} }
} }

View File

@ -78,6 +78,9 @@ fun CollectionDetails(
onShufflePlay: () -> Unit, onShufflePlay: () -> Unit,
onAddToQueue: () -> Unit, onAddToQueue: () -> Unit,
isPlaying: Boolean = false, isPlaying: Boolean = false,
/** Guest in a jam session: play/shuffle are replaced by Add to Jam. */
isJamGuest: Boolean = false,
onAddToJam: (() -> Unit)? = null,
isFollowing: Boolean = false, isFollowing: Boolean = false,
onFollowClick: () -> Unit = { }, onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true, showFollowButton: Boolean = true,
@ -91,39 +94,61 @@ fun CollectionDetails(
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
val playPauseButton = @Composable { val playPauseButton = @Composable {
if (isJamGuest) {
Row( if (onAddToJam != null) {
modifier = Modifier.fillMaxWidth(), Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth(),
) { horizontalArrangement = Arrangement.spacedBy(6.dp),
val modifier = if (isCompact) { ) {
Modifier.weight(1f) PrimaryButton(
} else { modifier = if (isCompact) Modifier.weight(1f) else Modifier,
Modifier onClick = onAddToJam!!,
) {
Icon(
imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add to Jam",
)
TextWithShimmer(
text = "Add to Jam",
modifier = Modifier.padding(start = 6.dp),
)
}
}
} }
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
val modifier = if (isCompact) {
Modifier.weight(1f)
} else {
Modifier
}
PrimaryButton( PrimaryButton(
modifier = modifier, modifier = modifier,
onClick = onPlay, onClick = onPlay,
) { ) {
Icon( Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play", contentDescription = if (isPlaying) "Pause" else "Play",
) )
TextWithShimmer( TextWithShimmer(
text = if (isPlaying) "Pause" else "Play", text = if (isPlaying) "Pause" else "Play",
modifier = Modifier.padding(start = 6.dp), modifier = Modifier.padding(start = 6.dp),
) )
} }
OutlineButton( OutlineButton(
modifier = modifier, modifier = modifier,
onClick = onShufflePlay onClick = onShufflePlay
) { ) {
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
TextWithShimmer( TextWithShimmer(
text = "Shuffle", text = "Shuffle",
modifier = Modifier.padding(start = 6.dp), modifier = Modifier.padding(start = 6.dp),
) )
}
} }
} }
} }

View File

@ -89,6 +89,8 @@ fun CollectionView(
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {}, onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {}, onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
isInJam: Boolean = false, isInJam: Boolean = false,
isJamGuest: Boolean = false,
onAddToJam: (List<MetadataTrack>) -> Unit = {},
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
footerContent: (@Composable () -> Unit)? = null, footerContent: (@Composable () -> Unit)? = null,
trailingContent: @Composable () -> Unit = {}, trailingContent: @Composable () -> Unit = {},
@ -116,7 +118,7 @@ fun CollectionView(
} else { } else {
{} {}
}, },
actions = if (isCollapsed) { actions = if (isCollapsed && !isJamGuest) {
{ {
IconButton(onClick = onPlay) { IconButton(onClick = onPlay) {
Icon( Icon(
@ -161,6 +163,8 @@ fun CollectionView(
showFollowButton = showFollowButton, showFollowButton = showFollowButton,
onEdit = onEdit, onEdit = onEdit,
sharedElementKey = sharedElementKey, sharedElementKey = sharedElementKey,
isJamGuest = isJamGuest,
onAddToJam = { onAddToJam(tracks) },
) )
} }
} else { } else {
@ -181,6 +185,8 @@ fun CollectionView(
showFollowButton = showFollowButton, showFollowButton = showFollowButton,
onEdit = onEdit, onEdit = onEdit,
sharedElementKey = sharedElementKey, sharedElementKey = sharedElementKey,
isJamGuest = isJamGuest,
onAddToJam = { onAddToJam(tracks) },
) )
} }
}, },
@ -204,6 +210,7 @@ fun CollectionView(
onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam, onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest,
trackOptionsState = trackOptionsState, trackOptionsState = trackOptionsState,
) )
} }

View File

@ -145,6 +145,7 @@ fun TrackList(
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {}, onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {}, onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
isInJam: Boolean = false, isInJam: Boolean = false,
isJamGuest: Boolean = false,
currentTrackId: String? = null, currentTrackId: String? = null,
isCurrentTrackPlaying: Boolean = false, isCurrentTrackPlaying: Boolean = false,
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
@ -249,6 +250,8 @@ fun TrackList(
} else { } else {
selectedTrackIds + track.id selectedTrackIds + track.id
} }
} else if (isJamGuest) {
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
} else { } else {
onTrackClick(track) onTrackClick(track)
} }
@ -277,6 +280,7 @@ fun TrackList(
}, },
trackOptionsState = trackOptionsState(track), trackOptionsState = trackOptionsState(track),
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest,
onShowOptionsClick = { selectedTrackForOptions = track }, onShowOptionsClick = { selectedTrackForOptions = track },
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
@ -424,28 +428,38 @@ fun TrackList(
val isAll = val isAll =
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
AdaptiveDropdownBottomSheet( AdaptiveDropdownBottomSheet(
items = listOf( items = buildList {
AdaptiveMenuItem( add(
icon = Iconsax.IconsaxDirectboxReceive, AdaptiveMenuItem(
label = if (isAll) "Download All" else "Download $trackCount", icon = Iconsax.IconsaxDirectboxReceive,
onClick = { onBulkDownload(targetTracks) }, label = if (isAll) "Download All" else "Download $trackCount",
), onClick = { onBulkDownload(targetTracks) },
AdaptiveMenuItem( ),
icon = Iconsax.IconsaxAddSquare, )
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", if (!isJamGuest) {
onClick = { onBulkAddToQueue(targetTracks) }, add(
), AdaptiveMenuItem(
AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare,
icon = Iconsax.IconsaxNext, label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
label = if (isAll) "Play All Next" else "Play $trackCount Next", onClick = { onBulkAddToQueue(targetTracks) },
onClick = { onBulkPlayNext(targetTracks) }, ),
), )
AdaptiveMenuItem( add(
icon = Iconsax.IconsaxMusicPlaylist, AdaptiveMenuItem(
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", icon = Iconsax.IconsaxNext,
onClick = { onBulkAddToPlaylist(targetTracks) }, label = if (isAll) "Play All Next" else "Play $trackCount Next",
), onClick = { onBulkPlayNext(targetTracks) },
) + if (isInJam) { ),
)
}
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicPlaylist,
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
onClick = { onBulkAddToPlaylist(targetTracks) },
),
)
} + if (isInJam) {
listOf( listOf(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare, icon = Iconsax.IconsaxAddSquare,
@ -511,6 +525,7 @@ fun TrackList(
}, },
onAlbumClick = { track.album?.let { onAlbumClick(it) } }, onAlbumClick = { track.album?.let { onAlbumClick(it) } },
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest,
) )
} }
} }
@ -570,6 +585,7 @@ private fun TrackListRow(
onTrackOptionsAction: (TrackOptionsAction) -> Unit, onTrackOptionsAction: (TrackOptionsAction) -> Unit,
trackOptionsState: TrackOptionsState, trackOptionsState: TrackOptionsState,
isInJam: Boolean, isInJam: Boolean,
isJamGuest: Boolean,
onShowOptionsClick: () -> Unit, onShowOptionsClick: () -> Unit,
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
@ -766,6 +782,7 @@ private fun TrackListRow(
onAction = onTrackOptionsAction, onAction = onTrackOptionsAction,
onAlbumClick = { track.album?.let { onAlbumClick(it) } }, onAlbumClick = { track.album?.let { onAlbumClick(it) } },
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest,
) )
} else { } else {
GhostIconButton(onClick = onShowOptionsClick) { GhostIconButton(onClick = onShowOptionsClick) {
@ -860,6 +877,7 @@ private fun ShimmerTrackListRow(
onTrackOptionsAction = {}, onTrackOptionsAction = {},
trackOptionsState = TrackOptionsState(), trackOptionsState = TrackOptionsState(),
isInJam = false, isInJam = false,
isJamGuest = false,
onShowOptionsClick = {}, onShowOptionsClick = {},
onArtistClick = {}, onArtistClick = {},
onAlbumClick = {}, onAlbumClick = {},

View File

@ -100,6 +100,7 @@ fun TrackOptions(
onAlbumClick: () -> Unit, onAlbumClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isInJam: Boolean = false, isInJam: Boolean = false,
isJamGuest: Boolean = false,
) { ) {
AdaptiveDropdownBottomSheet( AdaptiveDropdownBottomSheet(
items = buildTrackMenuItems( items = buildTrackMenuItems(
@ -108,6 +109,7 @@ fun TrackOptions(
onAction = onAction, onAction = onAction,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest,
), ),
trigger = { onClick -> trigger = { onClick ->
GhostIconButton(onClick = onClick) { GhostIconButton(onClick = onClick) {
@ -133,6 +135,7 @@ fun TrackOptionsBottomSheet(
onAction: (TrackOptionsAction) -> Unit, onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit, onAlbumClick: () -> Unit,
isInJam: Boolean = false, isInJam: Boolean = false,
isJamGuest: Boolean = false,
) { ) {
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
@ -156,6 +159,7 @@ fun TrackOptionsBottomSheet(
onDismiss() onDismiss()
}, },
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest,
).forEach { item -> ).forEach { item ->
Row( Row(
modifier = Modifier modifier = Modifier
@ -247,6 +251,7 @@ private fun buildTrackMenuItems(
onAction: (TrackOptionsAction) -> Unit, onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit, onAlbumClick: () -> Unit,
isInJam: Boolean = false, isInJam: Boolean = false,
isJamGuest: Boolean = false,
): List<AdaptiveMenuItem> = buildList { ): List<AdaptiveMenuItem> = buildList {
if (isInJam) { if (isInJam) {
add( add(
@ -266,40 +271,44 @@ private fun buildTrackMenuItems(
), ),
) )
if (!state.isInQueue && !state.isCurrentlyPlaying) { // A guest's queue is the shared jam queue — mutating it locally is not
add( // allowed, so queue actions are replaced by "Add to Jam".
AdaptiveMenuItem( if (!isJamGuest) {
icon = Iconsax.IconsaxNext, if (!state.isInQueue && !state.isCurrentlyPlaying) {
label = "Play next", add(
onClick = { onAction(TrackOptionsAction.PlayNext) }, AdaptiveMenuItem(
), icon = Iconsax.IconsaxNext,
) label = "Play next",
} else if (state.isInQueue && !state.isCurrentlyPlaying) { onClick = { onAction(TrackOptionsAction.PlayNext) },
add( ),
AdaptiveMenuItem( )
icon = Iconsax.IconsaxNext, } else if (state.isInQueue && !state.isCurrentlyPlaying) {
label = "Move to next", add(
onClick = { onAction(TrackOptionsAction.PlayNext) }, AdaptiveMenuItem(
), icon = Iconsax.IconsaxNext,
) label = "Move to next",
} onClick = { onAction(TrackOptionsAction.PlayNext) },
),
)
}
if (!state.isInQueue) { if (!state.isInQueue) {
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare, icon = Iconsax.IconsaxAddSquare,
label = "Add to queue", label = "Add to queue",
onClick = { onAction(TrackOptionsAction.AddToQueue) }, onClick = { onAction(TrackOptionsAction.AddToQueue) },
), ),
) )
} else { } else {
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicSquareRemove, icon = Iconsax.IconsaxMusicSquareRemove,
label = "Remove from queue", label = "Remove from queue",
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }, onClick = { onAction(TrackOptionsAction.RemoveFromQueue) },
), ),
) )
}
} }
add( add(

View File

@ -25,6 +25,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.jam.JamRoomService
import org.koin.compose.koinInject import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
@ -43,6 +44,8 @@ fun AlbumScreen(
val jamRoomService: JamRoomService = koinInject() val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null } val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle() val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
@ -100,6 +103,8 @@ fun AlbumScreen(
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam, onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive, isInJam = jamActive,
isJamGuest = isJamGuest,
onAddToJam = viewModel::addTracksToJam,
trailingContent = { trailingContent = {
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,

View File

@ -63,6 +63,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
@ -100,6 +101,8 @@ fun ArtistScreen(
val jamRoomService: JamRoomService = koinInject() val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null } val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
@ -186,6 +189,7 @@ fun ArtistScreen(
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam, onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive, isInJam = jamActive,
isJamGuest = isJamGuest,
) )
} }

View File

@ -36,6 +36,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.jam.JamRoomService
import org.koin.compose.koinInject import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
@ -59,6 +60,8 @@ fun PlaylistScreen(
val jamRoomService: JamRoomService = koinInject() val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null } val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle() val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
@ -135,6 +138,8 @@ fun PlaylistScreen(
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam, onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive, isInJam = jamActive,
isJamGuest = isJamGuest,
onAddToJam = viewModel::addTracksToJam,
footerContent = footerContent, footerContent = footerContent,
trailingContent = { trailingContent = {
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist

View File

@ -26,6 +26,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.jam.JamRoomService
import org.koin.compose.koinInject import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
@ -45,6 +46,8 @@ fun SavedTracksScreen(
val jamRoomService: JamRoomService = koinInject() val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null } val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
@ -93,6 +96,8 @@ fun SavedTracksScreen(
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam, onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive, isInJam = jamActive,
isJamGuest = isJamGuest,
onAddToJam = viewModel::addTracksToJam,
trailingContent = { trailingContent = {
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,

View File

@ -86,6 +86,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.jam.JamRoomService
import org.koin.compose.koinInject import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.remote.RemotePlaybackController
@ -133,6 +134,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val jamRoomService: JamRoomService = koinInject() val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null } val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val selectedType = state.selectedSearchType val selectedType = state.selectedSearchType
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
@ -341,6 +344,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
}, },
onBulkAddToJam = ::bulkAddToJam, onBulkAddToJam = ::bulkAddToJam,
isInJam = jamActive, isInJam = jamActive,
isJamGuest = isJamGuest,
onArtistClick = { artist -> onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id)) navigationCommands.navigateTo(Routes.Artist(artist.id))
}, },
@ -372,6 +376,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
}, },
onBulkAddToJam = ::bulkAddToJam, onBulkAddToJam = ::bulkAddToJam,
isInJam = jamActive, isInJam = jamActive,
isJamGuest = isJamGuest,
onArtistClick = { artist -> onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id)) navigationCommands.navigateTo(Routes.Artist(artist.id))
}, },
@ -655,6 +660,7 @@ private fun SearchAllTab(
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit, onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> Unit, onBulkAddToJam: (List<MetadataTrack>) -> Unit,
isInJam: Boolean, isInJam: Boolean,
isJamGuest: Boolean,
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit,
@ -714,6 +720,7 @@ private fun SearchAllTab(
onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam, onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick, onArtistsOverflowClick = onArtistsOverflowClick,
@ -803,6 +810,7 @@ private fun SearchTracksTab(
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit, onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> Unit, onBulkAddToJam: (List<MetadataTrack>) -> Unit,
isInJam: Boolean, isInJam: Boolean,
isJamGuest: Boolean,
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit,
@ -834,6 +842,7 @@ private fun SearchTracksTab(
onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam, onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick, onArtistsOverflowClick = onArtistsOverflowClick,

View File

@ -133,13 +133,6 @@ fun AppShell(
PlayDestinationPickerHost() PlayDestinationPickerHost()
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 96.dp),
)
val useSidebar = viewModel.useSidebar() val useSidebar = viewModel.useSidebar()
val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar) val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar)
@ -281,6 +274,15 @@ fun AppShell(
} }
} }
} }
// Drawn last so it floats above the players/sheets, just above the
// bottom overlay (large player or compact player + bottombar).
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = bottomOverlayInset + 12.dp),
)
} }
} }

View File

@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
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.size import androidx.compose.foundation.layout.size
@ -33,8 +34,12 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -52,6 +57,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.jam.JamParticipant
import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.IconButton
@ -59,12 +65,15 @@ import dev.krtirtho.spotube.core.ui.base.ListRowTile
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.core.ui.base.copyShape
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
@ -79,6 +88,7 @@ fun PlayerQueueContent(
val filterQuery = state.filterQuery val filterQuery = state.filterQuery
val isFiltered = state.isFiltered val isFiltered = state.isFiltered
val isReadOnly = state.isReadOnly val isReadOnly = state.isReadOnly
var selectedParticipant by remember { mutableStateOf<JamParticipant?>(null) }
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState( val reorderableLazyListState = rememberReorderableLazyListState(
@ -153,16 +163,135 @@ fun PlayerQueueContent(
onDragStarted = { viewModel.onDragStart() }, onDragStarted = { viewModel.onDragStart() },
onDragStopped = { viewModel.onDragStop() }, onDragStopped = { viewModel.onDragStop() },
showOptions = !isReadOnly, showOptions = !isReadOnly,
enabled = !isReadOnly,
onParticipantClick = { selectedParticipant = it },
) )
} }
} }
} }
} }
} }
selectedParticipant?.let { participant ->
ParticipantDialog(
participant = participant,
isJamHost = state.isJamHost,
onDismiss = { selectedParticipant = null },
onKick = { viewModel.kickParticipant(participant.id) },
onBan = { viewModel.banParticipant(participant.id) },
onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) },
)
}
} }
} }
} }
@Composable
private fun ParticipantDialog(
participant: JamParticipant,
isJamHost: Boolean,
onDismiss: () -> Unit,
onKick: () -> Unit,
onBan: () -> Unit,
onRemoveSuggestions: () -> Unit,
) {
AdaptiveDialogBottomSheet(
onDismiss = onDismiss,
title = { Text(participant.displayName, style = MaterialTheme.typography.titleLarge) },
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(vertical = 8.dp),
) {
ParticipantAvatar(participant, size = 40)
Text(
text = participant.displayName,
style = MaterialTheme.typography.bodyLarge,
)
if (participant.isHost) {
Text(
text = "Host",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
if (isJamHost && !participant.isHost) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
ListRowTile(
onClick = {
onKick()
onDismiss()
},
leading = {
Icon(
imageVector = Iconsax.IconsaxCloseSquare,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
title = { Text("Kick") },
subtitle = { Text("Remove them from the session") },
)
ListRowTile(
onClick = {
onBan()
onDismiss()
},
leading = {
Icon(
imageVector = Iconsax.IconsaxUserRemove,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
title = { Text("Ban") },
subtitle = { Text("Kick and prevent them from rejoining") },
)
ListRowTile(
onClick = {
onRemoveSuggestions()
onDismiss()
},
leading = {
Icon(
imageVector = Iconsax.IconsaxMusicSquareRemove,
contentDescription = null,
)
},
title = { Text("Remove suggestions") },
subtitle = { Text("Remove every track they added to the queue") },
)
}
}
}
}
@Composable
private fun ParticipantAvatar(participant: JamParticipant, size: Int) {
Box(
modifier = Modifier
.size(size.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
@Composable @Composable
private fun QueueItemRow( private fun QueueItemRow(
item: QueueItemUi, item: QueueItemUi,
@ -172,11 +301,14 @@ private fun QueueItemRow(
onDragStarted: () -> Unit, onDragStarted: () -> Unit,
onDragStopped: () -> Unit, onDragStopped: () -> Unit,
showOptions: Boolean = true, showOptions: Boolean = true,
enabled: Boolean = true,
onParticipantClick: (JamParticipant) -> Unit = {},
) { ) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
ListRowTile( ListRowTile(
onClick = onPlayClick, onClick = onPlayClick,
enabled = enabled,
selected = item.isCurrent, selected = item.isCurrent,
modifier = Modifier, modifier = Modifier,
leading = { leading = {
@ -260,6 +392,24 @@ private fun QueueItemRow(
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
item.addedByParticipant?.let { participant ->
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { onParticipantClick(participant) },
contentAlignment = Alignment.Center,
) {
Text(
text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(4.dp))
}
if (showOptions) { if (showOptions) {
Box { Box {
GhostIconButton( GhostIconButton(

View File

@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.jam.JamParticipant
import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.jam.JamRoomService
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -40,6 +41,8 @@ data class QueueItemUi(
val isCurrent: Boolean, val isCurrent: Boolean,
val imageUrl: String?, val imageUrl: String?,
val originalIndex: Int, val originalIndex: Int,
/** Participant who added this item to the jam queue, if any. */
val addedByParticipant: JamParticipant? = null,
) )
data class QueueContentUiState( data class QueueContentUiState(
@ -48,6 +51,8 @@ data class QueueContentUiState(
val isFiltered: Boolean = false, val isFiltered: Boolean = false,
/** Guests cannot reorder/remove/clear the shared jam queue. */ /** Guests cannot reorder/remove/clear the shared jam queue. */
val isReadOnly: Boolean = false, val isReadOnly: Boolean = false,
val isJamHost: Boolean = false,
val participants: List<JamParticipant> = emptyList(),
) )
class PlayerQueueContentViewModel( class PlayerQueueContentViewModel(
@ -65,7 +70,8 @@ class PlayerQueueContentViewModel(
private val computedItems: StateFlow<List<QueueItemUi>> = combine( private val computedItems: StateFlow<List<QueueItemUi>> = combine(
audioPlayerQueue.queueFlow, audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow, audioPlayerQueue.currentQueueEntryFlow,
) { queue, currentEntry -> jamRoomService.participants,
) { queue, currentEntry, participants ->
val currentIndex = if (currentEntry != null) { val currentIndex = if (currentEntry != null) {
queue.indexOfFirst { it.matchesCurrent(currentEntry) } queue.indexOfFirst { it.matchesCurrent(currentEntry) }
} else { } else {
@ -94,9 +100,9 @@ class PlayerQueueContentViewModel(
} }
} }
val addedBy = entry.addedBy val addedByParticipant = participants.firstOrNull { it.id == entry.addedBy }
if (addedBy.isNotBlank()) { if (addedByParticipant != null) {
subtitle = "$subtitle • Added by $addedBy" subtitle = "$subtitle • Added by ${addedByParticipant.displayName}"
} }
QueueItemUi( QueueItemUi(
@ -107,6 +113,7 @@ class PlayerQueueContentViewModel(
isCurrent = index == currentIndex, isCurrent = index == currentIndex,
imageUrl = imageUrl, imageUrl = imageUrl,
originalIndex = index, originalIndex = index,
addedByParticipant = addedByParticipant,
) )
} }
}.stateIn( }.stateIn(
@ -120,7 +127,8 @@ class PlayerQueueContentViewModel(
reorderBuffer, reorderBuffer,
queueFilterFlow, queueFilterFlow,
jamRoomService.role, jamRoomService.role,
) { items, buffer, filterQuery, role -> jamRoomService.participants,
) { items, buffer, filterQuery, role, participants ->
val normalizedFilter = filterQuery.trim().lowercase() val normalizedFilter = filterQuery.trim().lowercase()
val isFiltered = normalizedFilter.isNotBlank() val isFiltered = normalizedFilter.isNotBlank()
val filtered = if (isFiltered) { val filtered = if (isFiltered) {
@ -136,6 +144,8 @@ class PlayerQueueContentViewModel(
displayItems = buffer ?: filtered, displayItems = buffer ?: filtered,
isFiltered = isFiltered, isFiltered = isFiltered,
isReadOnly = role == JamRole.Guest, isReadOnly = role == JamRole.Guest,
isJamHost = role == JamRole.Host,
participants = participants,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -156,7 +166,7 @@ class PlayerQueueContentViewModel(
} }
fun playQueueItem(index: Int) { fun playQueueItem(index: Int) {
if (index < 0) return if (index < 0 || queueContentUiState.value.isReadOnly) return
viewModelScope.launch { viewModelScope.launch {
audioPlayerQueue.jumpTo(index) audioPlayerQueue.jumpTo(index)
} }
@ -187,6 +197,27 @@ class PlayerQueueContentViewModel(
} }
} }
// ---------- Jam participant moderation (host only) ----------
fun kickParticipant(participantId: String) {
if (!queueContentUiState.value.isJamHost) return
viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
}
fun banParticipant(participantId: String) {
if (!queueContentUiState.value.isJamHost) return
viewModelScope.launch { jamRoomService.banParticipant(participantId) }
}
/** Removes every queue item that the given participant suggested. */
fun removeParticipantTracks(participantId: String) {
if (!queueContentUiState.value.isJamHost) return
viewModelScope.launch {
val entries = audioPlayerQueue.queueFlow.value.filter { it.addedBy == participantId }
entries.forEach { audioPlayerQueue.removeFromQueue(it) }
}
}
fun onDragStart() { fun onDragStart() {
if (reorderBuffer.value != null) return if (reorderBuffer.value != null) return
if (queueContentUiState.value.isReadOnly) return if (queueContentUiState.value.isReadOnly) return

View File

@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
private val SlidingSheetBreakpoint = 840.dp private val SlidingSheetBreakpoint = 840.dp
@ -68,7 +69,11 @@ private fun SlidingQueueSheet(
visible = isVisible, visible = isVisible,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(top = 12.dp, end = 12.dp, bottom = 12.dp), .padding(
top = 12.dp,
end = 12.dp,
bottom = 12.dp + LocalAppShellBottomInset.current,
),
enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(), enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(),
exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(), exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(),
) { ) {