mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 06:34:00 +00:00
feat(jam-session): enhance guest functionality with track addition and participant moderation options
This commit is contained in:
parent
ad994f3ca3
commit
4c724a333b
@ -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 -> {
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 = {},
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user