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 localClientId: String = ""
|
||||||
private var localDisplayName: String = ""
|
private var localDisplayName: String = ""
|
||||||
|
|
||||||
/** Display name of the local participant (stamped on items this device adds). */
|
/** Display name of the local participant. */
|
||||||
val participantDisplayName: String
|
val participantDisplayName: String
|
||||||
get() = localDisplayName
|
get() = localDisplayName
|
||||||
|
|
||||||
|
/** Stable id of the local participant (stamped on items this device adds). */
|
||||||
|
val participantClientId: String
|
||||||
|
get() = localClientId
|
||||||
|
|
||||||
private var hostBroadcastJob: Job? = null
|
private var hostBroadcastJob: Job? = null
|
||||||
|
|
||||||
/** Guest side: last queue snapshot applied to the local player. */
|
/** Guest side: last queue snapshot applied to the local player. */
|
||||||
@ -257,7 +261,7 @@ class JamRoomService(
|
|||||||
jamClient.publishCommand(
|
jamClient.publishCommand(
|
||||||
JamMessage.SuggestTrack(
|
JamMessage.SuggestTrack(
|
||||||
mediaItem = JamMediaItem.fromTrack(track),
|
mediaItem = JamMediaItem.fromTrack(track),
|
||||||
addedBy = localDisplayName,
|
addedBy = localClientId,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -267,7 +271,7 @@ class JamRoomService(
|
|||||||
jamClient.publishCommand(
|
jamClient.publishCommand(
|
||||||
JamMessage.SuggestPlaylist(
|
JamMessage.SuggestPlaylist(
|
||||||
tracks = tracks.map(JamMediaItem::fromTrack),
|
tracks = tracks.map(JamMediaItem::fromTrack),
|
||||||
addedBy = localDisplayName,
|
addedBy = localClientId,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -396,11 +400,15 @@ class JamRoomService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is JamMessage.SuggestTrack -> {
|
is JamMessage.SuggestTrack -> {
|
||||||
if (_role.value == JamRole.Host) acceptSuggestion(listOf(message.mediaItem))
|
if (_role.value == JamRole.Host) {
|
||||||
|
acceptSuggestion(listOf(message.mediaItem.copy(addedBy = message.addedBy)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is JamMessage.SuggestPlaylist -> {
|
is JamMessage.SuggestPlaylist -> {
|
||||||
if (_role.value == JamRole.Host) acceptSuggestion(message.tracks)
|
if (_role.value == JamRole.Host) {
|
||||||
|
acceptSuggestion(message.tracks.map { it.copy(addedBy = message.addedBy) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is JamMessage.Kick -> {
|
is JamMessage.Kick -> {
|
||||||
|
|||||||
@ -176,7 +176,7 @@ class RemotePlaybackController(
|
|||||||
try {
|
try {
|
||||||
when (jamRoomService.role.value) {
|
when (jamRoomService.role.value) {
|
||||||
JamRole.Host -> audioPlayerQueue.addToQueue(
|
JamRole.Host -> audioPlayerQueue.addToQueue(
|
||||||
QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantDisplayName)
|
QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantClientId)
|
||||||
)
|
)
|
||||||
|
|
||||||
JamRole.Guest -> jamRoomService.suggestTrack(track)
|
JamRole.Guest -> jamRoomService.suggestTrack(track)
|
||||||
@ -203,7 +203,7 @@ class RemotePlaybackController(
|
|||||||
QueueEntry.StreamingTrack(
|
QueueEntry.StreamingTrack(
|
||||||
track = track,
|
track = track,
|
||||||
url = "",
|
url = "",
|
||||||
addedBy = jamRoomService.participantDisplayName,
|
addedBy = jamRoomService.participantClientId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
@ -48,6 +49,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
@ -71,6 +73,8 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.User
|
import dev.krtirtho.spotube.resources.iconsax.User
|
||||||
|
|
||||||
private val BadgeShape = RoundedCornerShape(11.dp)
|
private val BadgeShape = RoundedCornerShape(11.dp)
|
||||||
|
private const val DisabledContentAlpha = 0.38f
|
||||||
|
|
||||||
private val ButtonMinHeight = 40.dp
|
private val ButtonMinHeight = 40.dp
|
||||||
private val SquareButtonSize = 40.dp
|
private val SquareButtonSize = 40.dp
|
||||||
|
|
||||||
@ -178,6 +182,7 @@ fun OutlineButton(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
content = {
|
content = {
|
||||||
@ -226,6 +231,7 @@ fun PrimaryButton(
|
|||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
content = content,
|
content = content,
|
||||||
@ -271,6 +277,7 @@ fun SecondaryButton(
|
|||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
content = content,
|
content = content,
|
||||||
@ -487,9 +494,16 @@ fun GroupIconButton(
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@ -78,6 +78,9 @@ fun CollectionDetails(
|
|||||||
onShufflePlay: () -> Unit,
|
onShufflePlay: () -> Unit,
|
||||||
onAddToQueue: () -> Unit,
|
onAddToQueue: () -> Unit,
|
||||||
isPlaying: Boolean = false,
|
isPlaying: Boolean = false,
|
||||||
|
/** Guest in a jam session: play/shuffle are replaced by Add to Jam. */
|
||||||
|
isJamGuest: Boolean = false,
|
||||||
|
onAddToJam: (() -> Unit)? = null,
|
||||||
isFollowing: Boolean = false,
|
isFollowing: Boolean = false,
|
||||||
onFollowClick: () -> Unit = { },
|
onFollowClick: () -> Unit = { },
|
||||||
showFollowButton: Boolean = true,
|
showFollowButton: Boolean = true,
|
||||||
@ -91,7 +94,28 @@ fun CollectionDetails(
|
|||||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||||
|
|
||||||
val playPauseButton = @Composable {
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
@ -127,6 +151,7 @@ fun CollectionDetails(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val actions = @Composable {
|
val actions = @Composable {
|
||||||
ButtonGroup {
|
ButtonGroup {
|
||||||
|
|||||||
@ -89,6 +89,8 @@ fun CollectionView(
|
|||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
||||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
|
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||||
isInJam: Boolean = false,
|
isInJam: Boolean = false,
|
||||||
|
isJamGuest: Boolean = false,
|
||||||
|
onAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||||
footerContent: (@Composable () -> Unit)? = null,
|
footerContent: (@Composable () -> Unit)? = null,
|
||||||
trailingContent: @Composable () -> Unit = {},
|
trailingContent: @Composable () -> Unit = {},
|
||||||
@ -116,7 +118,7 @@ fun CollectionView(
|
|||||||
} else {
|
} else {
|
||||||
{}
|
{}
|
||||||
},
|
},
|
||||||
actions = if (isCollapsed) {
|
actions = if (isCollapsed && !isJamGuest) {
|
||||||
{
|
{
|
||||||
IconButton(onClick = onPlay) {
|
IconButton(onClick = onPlay) {
|
||||||
Icon(
|
Icon(
|
||||||
@ -161,6 +163,8 @@ fun CollectionView(
|
|||||||
showFollowButton = showFollowButton,
|
showFollowButton = showFollowButton,
|
||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
sharedElementKey = sharedElementKey,
|
sharedElementKey = sharedElementKey,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
|
onAddToJam = { onAddToJam(tracks) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -181,6 +185,8 @@ fun CollectionView(
|
|||||||
showFollowButton = showFollowButton,
|
showFollowButton = showFollowButton,
|
||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
sharedElementKey = sharedElementKey,
|
sharedElementKey = sharedElementKey,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
|
onAddToJam = { onAddToJam(tracks) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -204,6 +210,7 @@ fun CollectionView(
|
|||||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||||
onBulkAddToJam = onBulkAddToJam,
|
onBulkAddToJam = onBulkAddToJam,
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
trackOptionsState = trackOptionsState,
|
trackOptionsState = trackOptionsState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -145,6 +145,7 @@ fun TrackList(
|
|||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
||||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
|
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||||
isInJam: Boolean = false,
|
isInJam: Boolean = false,
|
||||||
|
isJamGuest: Boolean = false,
|
||||||
currentTrackId: String? = null,
|
currentTrackId: String? = null,
|
||||||
isCurrentTrackPlaying: Boolean = false,
|
isCurrentTrackPlaying: Boolean = false,
|
||||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||||
@ -249,6 +250,8 @@ fun TrackList(
|
|||||||
} else {
|
} else {
|
||||||
selectedTrackIds + track.id
|
selectedTrackIds + track.id
|
||||||
}
|
}
|
||||||
|
} else if (isJamGuest) {
|
||||||
|
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
|
||||||
} else {
|
} else {
|
||||||
onTrackClick(track)
|
onTrackClick(track)
|
||||||
}
|
}
|
||||||
@ -277,6 +280,7 @@ fun TrackList(
|
|||||||
},
|
},
|
||||||
trackOptionsState = trackOptionsState(track),
|
trackOptionsState = trackOptionsState(track),
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
@ -424,28 +428,38 @@ fun TrackList(
|
|||||||
val isAll =
|
val isAll =
|
||||||
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
|
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
|
||||||
AdaptiveDropdownBottomSheet(
|
AdaptiveDropdownBottomSheet(
|
||||||
items = listOf(
|
items = buildList {
|
||||||
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxDirectboxReceive,
|
icon = Iconsax.IconsaxDirectboxReceive,
|
||||||
label = if (isAll) "Download All" else "Download $trackCount",
|
label = if (isAll) "Download All" else "Download $trackCount",
|
||||||
onClick = { onBulkDownload(targetTracks) },
|
onClick = { onBulkDownload(targetTracks) },
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
if (!isJamGuest) {
|
||||||
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
||||||
onClick = { onBulkAddToQueue(targetTracks) },
|
onClick = { onBulkAddToQueue(targetTracks) },
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxNext,
|
icon = Iconsax.IconsaxNext,
|
||||||
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
||||||
onClick = { onBulkPlayNext(targetTracks) },
|
onClick = { onBulkPlayNext(targetTracks) },
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicPlaylist,
|
icon = Iconsax.IconsaxMusicPlaylist,
|
||||||
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
||||||
onClick = { onBulkAddToPlaylist(targetTracks) },
|
onClick = { onBulkAddToPlaylist(targetTracks) },
|
||||||
),
|
),
|
||||||
) + if (isInJam) {
|
)
|
||||||
|
} + if (isInJam) {
|
||||||
listOf(
|
listOf(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
@ -511,6 +525,7 @@ fun TrackList(
|
|||||||
},
|
},
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -570,6 +585,7 @@ private fun TrackListRow(
|
|||||||
onTrackOptionsAction: (TrackOptionsAction) -> Unit,
|
onTrackOptionsAction: (TrackOptionsAction) -> Unit,
|
||||||
trackOptionsState: TrackOptionsState,
|
trackOptionsState: TrackOptionsState,
|
||||||
isInJam: Boolean,
|
isInJam: Boolean,
|
||||||
|
isJamGuest: Boolean,
|
||||||
onShowOptionsClick: () -> Unit,
|
onShowOptionsClick: () -> Unit,
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||||
@ -766,6 +782,7 @@ private fun TrackListRow(
|
|||||||
onAction = onTrackOptionsAction,
|
onAction = onTrackOptionsAction,
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
GhostIconButton(onClick = onShowOptionsClick) {
|
GhostIconButton(onClick = onShowOptionsClick) {
|
||||||
@ -860,6 +877,7 @@ private fun ShimmerTrackListRow(
|
|||||||
onTrackOptionsAction = {},
|
onTrackOptionsAction = {},
|
||||||
trackOptionsState = TrackOptionsState(),
|
trackOptionsState = TrackOptionsState(),
|
||||||
isInJam = false,
|
isInJam = false,
|
||||||
|
isJamGuest = false,
|
||||||
onShowOptionsClick = {},
|
onShowOptionsClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
|
|||||||
@ -100,6 +100,7 @@ fun TrackOptions(
|
|||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isInJam: Boolean = false,
|
isInJam: Boolean = false,
|
||||||
|
isJamGuest: Boolean = false,
|
||||||
) {
|
) {
|
||||||
AdaptiveDropdownBottomSheet(
|
AdaptiveDropdownBottomSheet(
|
||||||
items = buildTrackMenuItems(
|
items = buildTrackMenuItems(
|
||||||
@ -108,6 +109,7 @@ fun TrackOptions(
|
|||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
),
|
),
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
GhostIconButton(onClick = onClick) {
|
GhostIconButton(onClick = onClick) {
|
||||||
@ -133,6 +135,7 @@ fun TrackOptionsBottomSheet(
|
|||||||
onAction: (TrackOptionsAction) -> Unit,
|
onAction: (TrackOptionsAction) -> Unit,
|
||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
isInJam: Boolean = false,
|
isInJam: Boolean = false,
|
||||||
|
isJamGuest: Boolean = false,
|
||||||
) {
|
) {
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
@ -156,6 +159,7 @@ fun TrackOptionsBottomSheet(
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
).forEach { item ->
|
).forEach { item ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -247,6 +251,7 @@ private fun buildTrackMenuItems(
|
|||||||
onAction: (TrackOptionsAction) -> Unit,
|
onAction: (TrackOptionsAction) -> Unit,
|
||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
isInJam: Boolean = false,
|
isInJam: Boolean = false,
|
||||||
|
isJamGuest: Boolean = false,
|
||||||
): List<AdaptiveMenuItem> = buildList {
|
): List<AdaptiveMenuItem> = buildList {
|
||||||
if (isInJam) {
|
if (isInJam) {
|
||||||
add(
|
add(
|
||||||
@ -266,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) {
|
if (!state.isInQueue && !state.isCurrentlyPlaying) {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
@ -301,6 +309,7 @@ private fun buildTrackMenuItems(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
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.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
@ -43,6 +44,8 @@ fun AlbumScreen(
|
|||||||
val jamRoomService: JamRoomService = koinInject()
|
val jamRoomService: JamRoomService = koinInject()
|
||||||
val jamActive by jamRoomService.role.map { it != null }
|
val jamActive by jamRoomService.role.map { it != null }
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
|
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
|
||||||
@ -100,6 +103,8 @@ fun AlbumScreen(
|
|||||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
onBulkAddToJam = viewModel::addTracksToJam,
|
onBulkAddToJam = viewModel::addTracksToJam,
|
||||||
isInJam = jamActive,
|
isInJam = jamActive,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
|
onAddToJam = viewModel::addTracksToJam,
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
AddToPlaylistPicker(
|
AddToPlaylistPicker(
|
||||||
visible = showAddToPlaylistPicker,
|
visible = showAddToPlaylistPicker,
|
||||||
|
|||||||
@ -63,6 +63,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
@ -100,6 +101,8 @@ fun ArtistScreen(
|
|||||||
val jamRoomService: JamRoomService = koinInject()
|
val jamRoomService: JamRoomService = koinInject()
|
||||||
val jamActive by jamRoomService.role.map { it != null }
|
val jamActive by jamRoomService.role.map { it != null }
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
|
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
|
||||||
@ -186,6 +189,7 @@ fun ArtistScreen(
|
|||||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
onBulkAddToJam = viewModel::addTracksToJam,
|
onBulkAddToJam = viewModel::addTracksToJam,
|
||||||
isInJam = jamActive,
|
isInJam = jamActive,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
@ -59,6 +60,8 @@ fun PlaylistScreen(
|
|||||||
val jamRoomService: JamRoomService = koinInject()
|
val jamRoomService: JamRoomService = koinInject()
|
||||||
val jamActive by jamRoomService.role.map { it != null }
|
val jamActive by jamRoomService.role.map { it != null }
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
|
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
|
||||||
@ -135,6 +138,8 @@ fun PlaylistScreen(
|
|||||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
onBulkAddToJam = viewModel::addTracksToJam,
|
onBulkAddToJam = viewModel::addTracksToJam,
|
||||||
isInJam = jamActive,
|
isInJam = jamActive,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
|
onAddToJam = viewModel::addTracksToJam,
|
||||||
footerContent = footerContent,
|
footerContent = footerContent,
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
|
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
@ -45,6 +46,8 @@ fun SavedTracksScreen(
|
|||||||
val jamRoomService: JamRoomService = koinInject()
|
val jamRoomService: JamRoomService = koinInject()
|
||||||
val jamActive by jamRoomService.role.map { it != null }
|
val jamActive by jamRoomService.role.map { it != null }
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||||
@ -93,6 +96,8 @@ fun SavedTracksScreen(
|
|||||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
onBulkAddToJam = viewModel::addTracksToJam,
|
onBulkAddToJam = viewModel::addTracksToJam,
|
||||||
isInJam = jamActive,
|
isInJam = jamActive,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
|
onAddToJam = viewModel::addTracksToJam,
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
AddToPlaylistPicker(
|
AddToPlaylistPicker(
|
||||||
visible = showAddToPlaylistPicker,
|
visible = showAddToPlaylistPicker,
|
||||||
|
|||||||
@ -86,6 +86,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||||
@ -133,6 +134,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
val jamRoomService: JamRoomService = koinInject()
|
val jamRoomService: JamRoomService = koinInject()
|
||||||
val jamActive by jamRoomService.role.map { it != null }
|
val jamActive by jamRoomService.role.map { it != null }
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val selectedType = state.selectedSearchType
|
val selectedType = state.selectedSearchType
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||||
@ -341,6 +344,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
},
|
},
|
||||||
onBulkAddToJam = ::bulkAddToJam,
|
onBulkAddToJam = ::bulkAddToJam,
|
||||||
isInJam = jamActive,
|
isInJam = jamActive,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||||
},
|
},
|
||||||
@ -372,6 +376,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
},
|
},
|
||||||
onBulkAddToJam = ::bulkAddToJam,
|
onBulkAddToJam = ::bulkAddToJam,
|
||||||
isInJam = jamActive,
|
isInJam = jamActive,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||||
},
|
},
|
||||||
@ -655,6 +660,7 @@ private fun SearchAllTab(
|
|||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
|
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
|
||||||
isInJam: Boolean,
|
isInJam: Boolean,
|
||||||
|
isJamGuest: Boolean,
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||||
@ -714,6 +720,7 @@ private fun SearchAllTab(
|
|||||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||||
onBulkAddToJam = onBulkAddToJam,
|
onBulkAddToJam = onBulkAddToJam,
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||||
@ -803,6 +810,7 @@ private fun SearchTracksTab(
|
|||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
|
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
|
||||||
isInJam: Boolean,
|
isInJam: Boolean,
|
||||||
|
isJamGuest: Boolean,
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||||
@ -834,6 +842,7 @@ private fun SearchTracksTab(
|
|||||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||||
onBulkAddToJam = onBulkAddToJam,
|
onBulkAddToJam = onBulkAddToJam,
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||||
|
|||||||
@ -133,13 +133,6 @@ fun AppShell(
|
|||||||
PlayDestinationPickerHost()
|
PlayDestinationPickerHost()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
SnackbarHost(
|
|
||||||
hostState = snackbarHostState,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(bottom = 96.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
val useSidebar = viewModel.useSidebar()
|
val useSidebar = viewModel.useSidebar()
|
||||||
val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar)
|
val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar)
|
||||||
|
|
||||||
@ -281,6 +274,15 @@ fun AppShell(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drawn last so it floats above the players/sheets, just above the
|
||||||
|
// bottom overlay (large player or compact player + bottombar).
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = bottomOverlayInset + 12.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@ -33,8 +34,12 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@ -52,6 +57,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamParticipant
|
||||||
import dev.krtirtho.spotube.core.ui.base.Card
|
import dev.krtirtho.spotube.core.ui.base.Card
|
||||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||||
@ -59,12 +65,15 @@ import dev.krtirtho.spotube.core.ui.base.ListRowTile
|
|||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||||
import dev.krtirtho.spotube.core.ui.base.copyShape
|
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import sh.calvin.reorderable.ReorderableItem
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
@ -79,6 +88,7 @@ fun PlayerQueueContent(
|
|||||||
val filterQuery = state.filterQuery
|
val filterQuery = state.filterQuery
|
||||||
val isFiltered = state.isFiltered
|
val isFiltered = state.isFiltered
|
||||||
val isReadOnly = state.isReadOnly
|
val isReadOnly = state.isReadOnly
|
||||||
|
var selectedParticipant by remember { mutableStateOf<JamParticipant?>(null) }
|
||||||
|
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
val reorderableLazyListState = rememberReorderableLazyListState(
|
val reorderableLazyListState = rememberReorderableLazyListState(
|
||||||
@ -153,14 +163,133 @@ fun PlayerQueueContent(
|
|||||||
onDragStarted = { viewModel.onDragStart() },
|
onDragStarted = { viewModel.onDragStart() },
|
||||||
onDragStopped = { viewModel.onDragStop() },
|
onDragStopped = { viewModel.onDragStop() },
|
||||||
showOptions = !isReadOnly,
|
showOptions = !isReadOnly,
|
||||||
|
enabled = !isReadOnly,
|
||||||
|
onParticipantClick = { selectedParticipant = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedParticipant?.let { participant ->
|
||||||
|
ParticipantDialog(
|
||||||
|
participant = participant,
|
||||||
|
isJamHost = state.isJamHost,
|
||||||
|
onDismiss = { selectedParticipant = null },
|
||||||
|
onKick = { viewModel.kickParticipant(participant.id) },
|
||||||
|
onBan = { viewModel.banParticipant(participant.id) },
|
||||||
|
onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ParticipantDialog(
|
||||||
|
participant: JamParticipant,
|
||||||
|
isJamHost: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onKick: () -> Unit,
|
||||||
|
onBan: () -> Unit,
|
||||||
|
onRemoveSuggestions: () -> Unit,
|
||||||
|
) {
|
||||||
|
AdaptiveDialogBottomSheet(
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
title = { Text(participant.displayName, style = MaterialTheme.typography.titleLarge) },
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
ParticipantAvatar(participant, size = 40)
|
||||||
|
Text(
|
||||||
|
text = participant.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
if (participant.isHost) {
|
||||||
|
Text(
|
||||||
|
text = "Host",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJamHost && !participant.isHost) {
|
||||||
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
ListRowTile(
|
||||||
|
onClick = {
|
||||||
|
onKick()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxCloseSquare,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text("Kick") },
|
||||||
|
subtitle = { Text("Remove them from the session") },
|
||||||
|
)
|
||||||
|
ListRowTile(
|
||||||
|
onClick = {
|
||||||
|
onBan()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxUserRemove,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text("Ban") },
|
||||||
|
subtitle = { Text("Kick and prevent them from rejoining") },
|
||||||
|
)
|
||||||
|
ListRowTile(
|
||||||
|
onClick = {
|
||||||
|
onRemoveSuggestions()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxMusicSquareRemove,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text("Remove suggestions") },
|
||||||
|
subtitle = { Text("Remove every track they added to the queue") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ParticipantAvatar(participant: JamParticipant, size: Int) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(size.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -172,11 +301,14 @@ private fun QueueItemRow(
|
|||||||
onDragStarted: () -> Unit,
|
onDragStarted: () -> Unit,
|
||||||
onDragStopped: () -> Unit,
|
onDragStopped: () -> Unit,
|
||||||
showOptions: Boolean = true,
|
showOptions: Boolean = true,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onParticipantClick: (JamParticipant) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
ListRowTile(
|
ListRowTile(
|
||||||
onClick = onPlayClick,
|
onClick = onPlayClick,
|
||||||
|
enabled = enabled,
|
||||||
selected = item.isCurrent,
|
selected = item.isCurrent,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
leading = {
|
leading = {
|
||||||
@ -260,6 +392,24 @@ private fun QueueItemRow(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
|
item.addedByParticipant?.let { participant ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(28.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable { onParticipantClick(participant) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
if (showOptions) {
|
if (showOptions) {
|
||||||
Box {
|
Box {
|
||||||
GhostIconButton(
|
GhostIconButton(
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamParticipant
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@ -40,6 +41,8 @@ data class QueueItemUi(
|
|||||||
val isCurrent: Boolean,
|
val isCurrent: Boolean,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val originalIndex: Int,
|
val originalIndex: Int,
|
||||||
|
/** Participant who added this item to the jam queue, if any. */
|
||||||
|
val addedByParticipant: JamParticipant? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class QueueContentUiState(
|
data class QueueContentUiState(
|
||||||
@ -48,6 +51,8 @@ data class QueueContentUiState(
|
|||||||
val isFiltered: Boolean = false,
|
val isFiltered: Boolean = false,
|
||||||
/** Guests cannot reorder/remove/clear the shared jam queue. */
|
/** Guests cannot reorder/remove/clear the shared jam queue. */
|
||||||
val isReadOnly: Boolean = false,
|
val isReadOnly: Boolean = false,
|
||||||
|
val isJamHost: Boolean = false,
|
||||||
|
val participants: List<JamParticipant> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
class PlayerQueueContentViewModel(
|
class PlayerQueueContentViewModel(
|
||||||
@ -65,7 +70,8 @@ class PlayerQueueContentViewModel(
|
|||||||
private val computedItems: StateFlow<List<QueueItemUi>> = combine(
|
private val computedItems: StateFlow<List<QueueItemUi>> = combine(
|
||||||
audioPlayerQueue.queueFlow,
|
audioPlayerQueue.queueFlow,
|
||||||
audioPlayerQueue.currentQueueEntryFlow,
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
) { queue, currentEntry ->
|
jamRoomService.participants,
|
||||||
|
) { queue, currentEntry, participants ->
|
||||||
val currentIndex = if (currentEntry != null) {
|
val currentIndex = if (currentEntry != null) {
|
||||||
queue.indexOfFirst { it.matchesCurrent(currentEntry) }
|
queue.indexOfFirst { it.matchesCurrent(currentEntry) }
|
||||||
} else {
|
} else {
|
||||||
@ -94,9 +100,9 @@ class PlayerQueueContentViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val addedBy = entry.addedBy
|
val addedByParticipant = participants.firstOrNull { it.id == entry.addedBy }
|
||||||
if (addedBy.isNotBlank()) {
|
if (addedByParticipant != null) {
|
||||||
subtitle = "$subtitle • Added by $addedBy"
|
subtitle = "$subtitle • Added by ${addedByParticipant.displayName}"
|
||||||
}
|
}
|
||||||
|
|
||||||
QueueItemUi(
|
QueueItemUi(
|
||||||
@ -107,6 +113,7 @@ class PlayerQueueContentViewModel(
|
|||||||
isCurrent = index == currentIndex,
|
isCurrent = index == currentIndex,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
originalIndex = index,
|
originalIndex = index,
|
||||||
|
addedByParticipant = addedByParticipant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
@ -120,7 +127,8 @@ class PlayerQueueContentViewModel(
|
|||||||
reorderBuffer,
|
reorderBuffer,
|
||||||
queueFilterFlow,
|
queueFilterFlow,
|
||||||
jamRoomService.role,
|
jamRoomService.role,
|
||||||
) { items, buffer, filterQuery, role ->
|
jamRoomService.participants,
|
||||||
|
) { items, buffer, filterQuery, role, participants ->
|
||||||
val normalizedFilter = filterQuery.trim().lowercase()
|
val normalizedFilter = filterQuery.trim().lowercase()
|
||||||
val isFiltered = normalizedFilter.isNotBlank()
|
val isFiltered = normalizedFilter.isNotBlank()
|
||||||
val filtered = if (isFiltered) {
|
val filtered = if (isFiltered) {
|
||||||
@ -136,6 +144,8 @@ class PlayerQueueContentViewModel(
|
|||||||
displayItems = buffer ?: filtered,
|
displayItems = buffer ?: filtered,
|
||||||
isFiltered = isFiltered,
|
isFiltered = isFiltered,
|
||||||
isReadOnly = role == JamRole.Guest,
|
isReadOnly = role == JamRole.Guest,
|
||||||
|
isJamHost = role == JamRole.Host,
|
||||||
|
participants = participants,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@ -156,7 +166,7 @@ class PlayerQueueContentViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun playQueueItem(index: Int) {
|
fun playQueueItem(index: Int) {
|
||||||
if (index < 0) return
|
if (index < 0 || queueContentUiState.value.isReadOnly) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
audioPlayerQueue.jumpTo(index)
|
audioPlayerQueue.jumpTo(index)
|
||||||
}
|
}
|
||||||
@ -187,6 +197,27 @@ class PlayerQueueContentViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Jam participant moderation (host only) ----------
|
||||||
|
|
||||||
|
fun kickParticipant(participantId: String) {
|
||||||
|
if (!queueContentUiState.value.isJamHost) return
|
||||||
|
viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun banParticipant(participantId: String) {
|
||||||
|
if (!queueContentUiState.value.isJamHost) return
|
||||||
|
viewModelScope.launch { jamRoomService.banParticipant(participantId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes every queue item that the given participant suggested. */
|
||||||
|
fun removeParticipantTracks(participantId: String) {
|
||||||
|
if (!queueContentUiState.value.isJamHost) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val entries = audioPlayerQueue.queueFlow.value.filter { it.addedBy == participantId }
|
||||||
|
entries.forEach { audioPlayerQueue.removeFromQueue(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onDragStart() {
|
fun onDragStart() {
|
||||||
if (reorderBuffer.value != null) return
|
if (reorderBuffer.value != null) return
|
||||||
if (queueContentUiState.value.isReadOnly) return
|
if (queueContentUiState.value.isReadOnly) return
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
|
|
||||||
private val SlidingSheetBreakpoint = 840.dp
|
private val SlidingSheetBreakpoint = 840.dp
|
||||||
|
|
||||||
@ -68,7 +69,11 @@ private fun SlidingQueueSheet(
|
|||||||
visible = isVisible,
|
visible = isVisible,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(top = 12.dp, end = 12.dp, bottom = 12.dp),
|
.padding(
|
||||||
|
top = 12.dp,
|
||||||
|
end = 12.dp,
|
||||||
|
bottom = 12.dp + LocalAppShellBottomInset.current,
|
||||||
|
),
|
||||||
enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(),
|
enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(),
|
||||||
exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(),
|
exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user