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 localDisplayName: String = ""
/** Display name of the local participant (stamped on items this device adds). */
/** Display name of the local participant. */
val participantDisplayName: String
get() = localDisplayName
/** Stable id of the local participant (stamped on items this device adds). */
val participantClientId: String
get() = localClientId
private var hostBroadcastJob: Job? = null
/** Guest side: last queue snapshot applied to the local player. */
@ -257,7 +261,7 @@ class JamRoomService(
jamClient.publishCommand(
JamMessage.SuggestTrack(
mediaItem = JamMediaItem.fromTrack(track),
addedBy = localDisplayName,
addedBy = localClientId,
)
)
}
@ -267,7 +271,7 @@ class JamRoomService(
jamClient.publishCommand(
JamMessage.SuggestPlaylist(
tracks = tracks.map(JamMediaItem::fromTrack),
addedBy = localDisplayName,
addedBy = localClientId,
)
)
}
@ -396,11 +400,15 @@ class JamRoomService(
}
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 -> {
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 -> {

View File

@ -176,7 +176,7 @@ class RemotePlaybackController(
try {
when (jamRoomService.role.value) {
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)
@ -203,7 +203,7 @@ class RemotePlaybackController(
QueueEntry.StreamingTrack(
track = track,
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
@ -48,6 +49,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow
@ -71,6 +73,8 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
import dev.krtirtho.spotube.resources.iconsax.User
private val BadgeShape = RoundedCornerShape(11.dp)
private const val DisabledContentAlpha = 0.38f
private val ButtonMinHeight = 40.dp
private val SquareButtonSize = 40.dp
@ -178,6 +182,7 @@ fun OutlineButton(
contentAlignment = Alignment.Center,
) {
Row(
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = {
@ -226,6 +231,7 @@ fun PrimaryButton(
) {
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
Row(
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = content,
@ -271,6 +277,7 @@ fun SecondaryButton(
) {
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
Row(
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = content,
@ -487,10 +494,17 @@ fun GroupIconButton(
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxSize()
.alpha(if (enabled) 1f else DisabledContentAlpha),
contentAlignment = Alignment.Center,
) {
content()
}
}
}
@Composable
fun ButtonGroup(

View File

@ -78,6 +78,9 @@ fun CollectionDetails(
onShufflePlay: () -> Unit,
onAddToQueue: () -> Unit,
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,
onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true,
@ -91,7 +94,28 @@ fun CollectionDetails(
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
val playPauseButton = @Composable {
if (isJamGuest) {
if (onAddToJam != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
PrimaryButton(
modifier = if (isCompact) Modifier.weight(1f) else 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),
@ -127,6 +151,7 @@ fun CollectionDetails(
}
}
}
}
val actions = @Composable {
ButtonGroup {

View File

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

View File

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

View File

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

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.PlayerState
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.navigation.Routes
@ -43,6 +44,8 @@ fun AlbumScreen(
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
@ -100,6 +103,8 @@ fun AlbumScreen(
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onAddToJam = viewModel::addTracksToJam,
trailingContent = {
AddToPlaylistPicker(
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.PlayerState
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.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
@ -100,6 +101,8 @@ fun ArtistScreen(
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
@ -186,6 +189,7 @@ fun ArtistScreen(
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
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.PlayerState
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.navigation.Routes
@ -59,6 +60,8 @@ fun PlaylistScreen(
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
@ -135,6 +138,8 @@ fun PlaylistScreen(
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onAddToJam = viewModel::addTracksToJam,
footerContent = footerContent,
trailingContent = {
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.QueueCollectionEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.navigation.Routes
@ -45,6 +46,8 @@ fun SavedTracksScreen(
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
@ -93,6 +96,8 @@ fun SavedTracksScreen(
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onAddToJam = viewModel::addTracksToJam,
trailingContent = {
AddToPlaylistPicker(
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.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
@ -133,6 +134,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val selectedType = state.selectedSearchType
val scope = rememberCoroutineScope()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
@ -341,6 +344,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
},
onBulkAddToJam = ::bulkAddToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id))
},
@ -372,6 +376,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
},
onBulkAddToJam = ::bulkAddToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id))
},
@ -655,6 +660,7 @@ private fun SearchAllTab(
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
isInJam: Boolean,
isJamGuest: Boolean,
onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit,
@ -714,6 +720,7 @@ private fun SearchAllTab(
onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam,
isJamGuest = isJamGuest,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,
@ -803,6 +810,7 @@ private fun SearchTracksTab(
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
isInJam: Boolean,
isJamGuest: Boolean,
onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit,
@ -834,6 +842,7 @@ private fun SearchTracksTab(
onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam,
isJamGuest = isJamGuest,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,

View File

@ -133,13 +133,6 @@ fun AppShell(
PlayDestinationPickerHost()
Box(modifier = Modifier.fillMaxSize()) {
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 96.dp),
)
val useSidebar = viewModel.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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -33,8 +34,12 @@ 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.clickable
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.unit.dp
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.GhostIconButton
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.TextField
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.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
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.IconsaxTrash
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
import org.koin.compose.viewmodel.koinViewModel
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@ -79,6 +88,7 @@ fun PlayerQueueContent(
val filterQuery = state.filterQuery
val isFiltered = state.isFiltered
val isReadOnly = state.isReadOnly
var selectedParticipant by remember { mutableStateOf<JamParticipant?>(null) }
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(
@ -153,15 +163,134 @@ fun PlayerQueueContent(
onDragStarted = { viewModel.onDragStart() },
onDragStopped = { viewModel.onDragStop() },
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
private fun QueueItemRow(
@ -172,11 +301,14 @@ private fun QueueItemRow(
onDragStarted: () -> Unit,
onDragStopped: () -> Unit,
showOptions: Boolean = true,
enabled: Boolean = true,
onParticipantClick: (JamParticipant) -> Unit = {},
) {
var showMenu by remember { mutableStateOf(false) }
ListRowTile(
onClick = onPlayClick,
enabled = enabled,
selected = item.isCurrent,
modifier = Modifier,
leading = {
@ -260,6 +392,24 @@ private fun QueueItemRow(
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) {
Box {
GhostIconButton(

View File

@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
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.JamRoomService
import kotlinx.coroutines.flow.MutableStateFlow
@ -40,6 +41,8 @@ data class QueueItemUi(
val isCurrent: Boolean,
val imageUrl: String?,
val originalIndex: Int,
/** Participant who added this item to the jam queue, if any. */
val addedByParticipant: JamParticipant? = null,
)
data class QueueContentUiState(
@ -48,6 +51,8 @@ data class QueueContentUiState(
val isFiltered: Boolean = false,
/** Guests cannot reorder/remove/clear the shared jam queue. */
val isReadOnly: Boolean = false,
val isJamHost: Boolean = false,
val participants: List<JamParticipant> = emptyList(),
)
class PlayerQueueContentViewModel(
@ -65,7 +70,8 @@ class PlayerQueueContentViewModel(
private val computedItems: StateFlow<List<QueueItemUi>> = combine(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
) { queue, currentEntry ->
jamRoomService.participants,
) { queue, currentEntry, participants ->
val currentIndex = if (currentEntry != null) {
queue.indexOfFirst { it.matchesCurrent(currentEntry) }
} else {
@ -94,9 +100,9 @@ class PlayerQueueContentViewModel(
}
}
val addedBy = entry.addedBy
if (addedBy.isNotBlank()) {
subtitle = "$subtitle • Added by $addedBy"
val addedByParticipant = participants.firstOrNull { it.id == entry.addedBy }
if (addedByParticipant != null) {
subtitle = "$subtitle • Added by ${addedByParticipant.displayName}"
}
QueueItemUi(
@ -107,6 +113,7 @@ class PlayerQueueContentViewModel(
isCurrent = index == currentIndex,
imageUrl = imageUrl,
originalIndex = index,
addedByParticipant = addedByParticipant,
)
}
}.stateIn(
@ -120,7 +127,8 @@ class PlayerQueueContentViewModel(
reorderBuffer,
queueFilterFlow,
jamRoomService.role,
) { items, buffer, filterQuery, role ->
jamRoomService.participants,
) { items, buffer, filterQuery, role, participants ->
val normalizedFilter = filterQuery.trim().lowercase()
val isFiltered = normalizedFilter.isNotBlank()
val filtered = if (isFiltered) {
@ -136,6 +144,8 @@ class PlayerQueueContentViewModel(
displayItems = buffer ?: filtered,
isFiltered = isFiltered,
isReadOnly = role == JamRole.Guest,
isJamHost = role == JamRole.Host,
participants = participants,
)
}.stateIn(
scope = viewModelScope,
@ -156,7 +166,7 @@ class PlayerQueueContentViewModel(
}
fun playQueueItem(index: Int) {
if (index < 0) return
if (index < 0 || queueContentUiState.value.isReadOnly) return
viewModelScope.launch {
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() {
if (reorderBuffer.value != null) 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.Modifier
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
private val SlidingSheetBreakpoint = 840.dp
@ -68,7 +69,11 @@ private fun SlidingQueueSheet(
visible = isVisible,
modifier = Modifier
.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(),
exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(),
) {