diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt index d9618e3c..f8c15a2b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt @@ -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 -> { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 2367ae1a..40602ea9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -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, ) } ) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt index edc24ebb..885f6357 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt @@ -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, @@ -488,7 +495,14 @@ fun GroupIconButton( ), contentAlignment = Alignment.Center, ) { - content() + Box( + modifier = Modifier + .fillMaxSize() + .alpha(if (enabled) 1f else DisabledContentAlpha), + contentAlignment = Alignment.Center, + ) { + content() + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt index 7f4335b5..4b9ec5a9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt @@ -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,39 +94,61 @@ fun CollectionDetails( val animatedVisibilityScope = LocalAnimatedVisibilityScope.current val playPauseButton = @Composable { - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - val modifier = if (isCompact) { - Modifier.weight(1f) - } else { - Modifier + 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), + ) { + val modifier = if (isCompact) { + Modifier.weight(1f) + } else { + Modifier + } - PrimaryButton( - modifier = modifier, - onClick = onPlay, - ) { - Icon( - imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, - contentDescription = if (isPlaying) "Pause" else "Play", - ) - TextWithShimmer( - text = if (isPlaying) "Pause" else "Play", - modifier = Modifier.padding(start = 6.dp), - ) - } - OutlineButton( - modifier = modifier, - onClick = onShufflePlay - ) { - Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") - TextWithShimmer( - text = "Shuffle", - modifier = Modifier.padding(start = 6.dp), - ) + PrimaryButton( + modifier = modifier, + onClick = onPlay, + ) { + Icon( + imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, + contentDescription = if (isPlaying) "Pause" else "Play", + ) + TextWithShimmer( + text = if (isPlaying) "Pause" else "Play", + modifier = Modifier.padding(start = 6.dp), + ) + } + OutlineButton( + modifier = modifier, + onClick = onShufflePlay + ) { + Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") + TextWithShimmer( + text = "Shuffle", + modifier = Modifier.padding(start = 6.dp), + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt index 44ef5360..e4c9dd31 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt @@ -89,6 +89,8 @@ fun CollectionView( onBulkAddToPlaylist: (List) -> Unit = {}, onBulkAddToJam: (List) -> Unit = {}, isInJam: Boolean = false, + isJamGuest: Boolean = false, + onAddToJam: (List) -> 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, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index 4873cb30..a985983a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -145,6 +145,7 @@ fun TrackList( onBulkAddToPlaylist: (List) -> Unit = {}, onBulkAddToJam: (List) -> 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( - AdaptiveMenuItem( - icon = Iconsax.IconsaxDirectboxReceive, - 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", - onClick = { onBulkAddToQueue(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = if (isAll) "Play All Next" else "Play $trackCount Next", - onClick = { onBulkPlayNext(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxMusicPlaylist, - label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", - onClick = { onBulkAddToPlaylist(targetTracks) }, - ), - ) + if (isInJam) { + 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) { 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 = {}, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index e0cf1874..bccc4fb2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -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 = buildList { if (isInJam) { add( @@ -266,40 +271,44 @@ private fun buildTrackMenuItems( ), ) - if (!state.isInQueue && !state.isCurrentlyPlaying) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = "Play next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) - } else if (state.isInQueue && !state.isCurrentlyPlaying) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = "Move to next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) - } + // 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( + icon = Iconsax.IconsaxNext, + label = "Play next", + onClick = { onAction(TrackOptionsAction.PlayNext) }, + ), + ) + } else if (state.isInQueue && !state.isCurrentlyPlaying) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = "Move to next", + onClick = { onAction(TrackOptionsAction.PlayNext) }, + ), + ) + } - if (!state.isInQueue) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxAddSquare, - label = "Add to queue", - onClick = { onAction(TrackOptionsAction.AddToQueue) }, - ), - ) - } else { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxMusicSquareRemove, - label = "Remove from queue", - onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }, - ), - ) + if (!state.isInQueue) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = "Add to queue", + onClick = { onAction(TrackOptionsAction.AddToQueue) }, + ), + ) + } else { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicSquareRemove, + label = "Remove from queue", + onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }, + ), + ) + } } add( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index dbd7da14..db76747f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index b0fcdbf7..a38c36e6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -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, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index 09dc458b..b48f1253 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index 4fa10f2a..191923a0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index 35c50407..2b2bb588 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -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) -> Unit, onBulkAddToJam: (List) -> 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) -> Unit, onBulkAddToJam: (List) -> 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, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index b81d2686..b044e3a4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -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), + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 0628e243..0042df8d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -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(null) } val lazyListState = rememberLazyListState() val reorderableLazyListState = rememberReorderableLazyListState( @@ -153,16 +163,135 @@ 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( item: QueueItemUi, @@ -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( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index ced76d8e..a426a839 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -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 = emptyList(), ) class PlayerQueueContentViewModel( @@ -65,7 +70,8 @@ class PlayerQueueContentViewModel( private val computedItems: StateFlow> = 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 diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt index a0e8220c..47206bab 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt @@ -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(), ) {