feat(jam-session): implement track addition to jam session and enhance UI feedback for jam state

This commit is contained in:
Kingkor Roy Tirtho 2026-09-11 22:04:59 +06:00
parent a2b9f4178c
commit ad994f3ca3
20 changed files with 353 additions and 120 deletions

View File

@ -39,6 +39,14 @@ sealed class JamMessage {
val items: List<JamMediaItem>,
val currentIndex: Int,
val shuffleEnabled: Boolean = false,
/**
* Whether guests should follow the host's current index. True when the
* host manually skipped/jumped or loaded a queue; false when the host
* merely auto-advanced because its song ended (guests stay put).
*/
val follow: Boolean = false,
/** Host's live play state — late-joining guests start with it. */
val isPlaying: Boolean = false,
) : JamMessage()
@Serializable

View File

@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlin.random.Random
import kotlin.time.Clock
/**
* A jam session over MQTT (star topology, host-authoritative queue).
@ -54,6 +55,13 @@ import kotlin.random.Random
* - Guests can only add to the queue (suggest); the host applies suggestions.
* - If the host leaves, the participant with the lowest client id takes over.
*/
private data class PlaybackBroadcast(
val queue: List<QueueEntry>,
val current: QueueEntry?,
val shuffle: Boolean,
val playerState: PlayerState,
)
class JamRoomService(
private val jamClient: JamRoomClient,
private val audioPlayer: AudioPlayerInterface,
@ -83,12 +91,28 @@ class JamRoomService(
private var localClientId: String = ""
private var localDisplayName: String = ""
/** Display name of the local participant (stamped on items this device adds). */
val participantDisplayName: String
get() = localDisplayName
private var hostBroadcastJob: Job? = null
/** Guest side: last queue snapshot applied to the local player. */
private var lastAppliedItems: List<JamMediaItem> = emptyList()
private var lastAppliedIndex = -1
/**
* Host side: a song completed, so index changes within this window are
* auto-advance (guests must not follow). The completion event, the player
* state change and the media transition arrive as separate flow emissions,
* so the window covers the whole sequence instead of a single flag.
*/
private var autoAdvanceDeadlineMs = 0L
/** Guest side: playback has started at least once (local control is the guest's own). */
private var hasStartedPlayback = false
/** Host side: client ids banned for this session. */
private val bannedClientIds = mutableSetOf<String>()
@ -110,6 +134,20 @@ class JamRoomService(
jamClient.presence
.onEach { onPresence(it) }
.launchIn(scope)
// A naturally-completed song means the host's next index change is an
// auto-advance — guests must NOT follow those, only manual skips.
audioPlayer.completionFlow
.onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS }
.launchIn(scope)
// Once a guest has played on its own (or was started by the host), its
// play/pause is its own — the host's play state only starts fresh joiners.
audioPlayer.playerStateFlow
.onEach { state ->
if (_role.value == JamRole.Guest && state == PlayerState.PLAYING) {
hasStartedPlayback = true
}
}
.launchIn(scope)
}
// ---------- Session lifecycle ----------
@ -138,6 +176,8 @@ class JamRoomService(
lastAppliedIndex = -1
bannedClientIds.clear()
leaving = false
autoAdvanceDeadlineMs = 0L
hasStartedPlayback = false
startHostBroadcast()
persistLastCode(code)
code
@ -170,6 +210,7 @@ class JamRoomService(
lastAppliedItems = emptyList()
lastAppliedIndex = -1
leaving = false
hasStartedPlayback = false
persistLastCode(normalized)
}
}
@ -187,6 +228,7 @@ class JamRoomService(
lastAppliedItems = emptyList()
lastAppliedIndex = -1
bannedClientIds.clear()
hasStartedPlayback = false
}
// ---------- Controls (called from the UI) ----------
@ -250,20 +292,25 @@ class JamRoomService(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
audioPlayer.shuffleModeFlow,
) { queue, current, shuffle -> Triple(queue, current, shuffle) }
.onEach { (queue, current, shuffle) ->
audioPlayer.playerStateFlow,
) { queue, current, shuffle, playerState ->
PlaybackBroadcast(queue, current, shuffle, playerState)
}
.onEach { broadcast ->
if (_role.value != JamRole.Host) return@onEach
val index = if (current != null) {
queue.indexOfFirst { it.matchesEntry(current) }
val index = if (broadcast.current != null) {
broadcast.queue.indexOfFirst { it.matchesEntry(broadcast.current) }
} else {
-1
}
_shuffleEnabled.value = shuffle
_shuffleEnabled.value = broadcast.shuffle
jamClient.publishState(
JamMessage.QueueState(
items = queue.map(JamMediaItem::fromQueueEntry),
items = broadcast.queue.map(JamMediaItem::fromQueueEntry),
currentIndex = index.coerceAtLeast(0),
shuffleEnabled = shuffle,
shuffleEnabled = broadcast.shuffle,
follow = now() > autoAdvanceDeadlineMs,
isPlaying = broadcast.playerState == PlayerState.PLAYING,
)
)
}
@ -285,25 +332,54 @@ class JamRoomService(
_shuffleEnabled.value = state.shuffleEnabled
runCatching { audioPlayer.shuffle(state.shuffleEnabled) }
// Late joiner: the host is already playing, so start immediately. Once
// this guest has played on its own, the host's play state is ignored.
if (state.isPlaying && !hasStartedPlayback) {
runCatching { audioPlayer.play() }
.onFailure { log.w(it) { "Failed to start playback on host play state" } }
}
val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING
if (items != lastAppliedItems) {
val previous = lastAppliedItems
lastAppliedItems = items
lastAppliedIndex = state.currentIndex
val guestCurrent = audioPlayerQueue.currentQueueEntryFlow.value
val guestIndex = items.indexOfFirst { it.matchesEntry(guestCurrent) }
// The host only appended items (e.g. accepted suggestions): merge
// them in without resetting playback or the guest's position.
if (previous.isNotEmpty() && items.size > previous.size &&
items.take(previous.size) == previous && guestIndex >= 0
) {
lastAppliedIndex = guestIndex
val appended = items.drop(previous.size)
runCatching {
audioPlayerQueue.addAllToQueue(appended.map { it.toQueueEntry() })
}.onFailure { log.w(it) { "Failed to append jam queue items" } }
return
}
// Full re-sync. Keep the guest's current track when it still exists
// in the synced queue; otherwise take the host's position.
val startIndex = if (guestIndex >= 0) guestIndex
else state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0))
lastAppliedIndex = startIndex
runCatching {
audioPlayerQueue.load(
entries = items.map { it.toQueueEntry() },
autoPlay = wasPlaying,
startPosition = state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)),
startPosition = startIndex,
)
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
return
}
if (state.currentIndex != lastAppliedIndex) {
// Same queue content: only follow the host when it moved manually
// (skip/jump). Natural auto-advance keeps everyone where they are.
if (state.follow && state.currentIndex != lastAppliedIndex) {
lastAppliedIndex = state.currentIndex
// Queue moved on: follow it, but keep this device's play/pause state.
runCatching {
audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false)
}.onFailure { log.w(it) { "Failed to follow jam queue index" } }
@ -459,6 +535,17 @@ class JamRoomService(
)
}
private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean {
if (entry == null) return false
return when (entry) {
is QueueEntry.StreamingTrack ->
trackId.isNotBlank() && entry.track.id == trackId
is QueueEntry.LocalTrack ->
url.isNotBlank() && entry.url == url && entry.name == title
}
}
private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean = when {
this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack ->
this.track.id == other.track.id
@ -469,7 +556,10 @@ class JamRoomService(
else -> false
}
private fun now(): Long = Clock.System.now().toEpochMilliseconds()
companion object {
private const val HOST_TAKEOVER_DELAY_MS = 1_500L
private const val AUTO_ADVANCE_WINDOW_MS = 2_000L
}
}

View File

@ -164,52 +164,56 @@ class RemotePlaybackController(
_pendingRequest.value = null
}
// ---------- Jam actions ----------
/**
* Routes the pending request into the active jam session. On the host the jam
* queue IS the local queue, so the action runs locally; on a guest the content
* is suggested to the host, which accepts it into the shared queue.
* Adds a single track to the active jam queue. The host applies it to the
* local (shared) queue directly; a guest suggests it to the host over MQTT.
*/
fun playOnJam() {
val request = _pendingRequest.value ?: return
_pendingRequest.value = null
fun addTrackToJam(track: MetadataTrack) {
if (jamRoomService.role.value == null) return
scope.launch {
try {
when (jamRoomService.role.value) {
JamRole.Host -> executeLocally(request)
JamRole.Guest -> suggestToJam(request)
JamRole.Host -> audioPlayerQueue.addToQueue(
QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantDisplayName)
)
JamRole.Guest -> jamRoomService.suggestTrack(track)
null -> return@launch
}
_events.emit(confirmationMessage(request))
_events.emit("Added to the jam queue")
} catch (e: Exception) {
logger.e(e) { "Failed to send content to jam session" }
logger.e(e) { "Failed to add track to jam session" }
}
}
}
private fun confirmationMessage(request: PlaybackDestinationRequest): String = when (request.action) {
PlaybackDestinationAction.Play -> "Playing on the jam queue"
PlaybackDestinationAction.AddToQueue -> "Added to the jam queue"
PlaybackDestinationAction.PlayNext -> "Added to play next in the jam queue"
}
/**
* Adds multiple tracks to the active jam queue (host applies locally,
* guest suggests to the host).
*/
fun addTracksToJam(tracks: List<MetadataTrack>) {
if (tracks.isEmpty() || jamRoomService.role.value == null) return
scope.launch {
try {
when (jamRoomService.role.value) {
JamRole.Host -> audioPlayerQueue.addAllToQueue(
tracks.map { track ->
QueueEntry.StreamingTrack(
track = track,
url = "",
addedBy = jamRoomService.participantDisplayName,
)
}
)
private suspend fun suggestToJam(request: PlaybackDestinationRequest) {
when (request) {
is PlaybackDestinationRequest.Collection -> {
val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id)
if (tracks.isNotEmpty()) {
jamRoomService.suggestPlaylist(tracks)
logger.i { "Suggested ${tracks.size} track(s) to the jam session" }
}
}
is PlaybackDestinationRequest.Track -> {
jamRoomService.suggestTrack(request.track)
}
is PlaybackDestinationRequest.Tracks -> {
if (request.tracks.isNotEmpty()) {
jamRoomService.suggestPlaylist(request.tracks)
JamRole.Guest -> jamRoomService.suggestPlaylist(tracks)
null -> return@launch
}
_events.emit("Added ${tracks.size} to the jam queue")
} catch (e: Exception) {
logger.e(e) { "Failed to add tracks to jam session" }
}
}
}
@ -217,9 +221,7 @@ class RemotePlaybackController(
// ---------- Internals ----------
private fun request(request: PlaybackDestinationRequest) {
// The picker offers "This Device", a connected remote device, and an
// active jam session — show it whenever more than one destination exists.
if (isRemoteConnected() || jamRoomService.role.value != null) {
if (isRemoteConnected()) {
_pendingRequest.value = request
} else {
executeLocally(request)

View File

@ -87,6 +87,8 @@ fun CollectionView(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
isInJam: Boolean = false,
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
footerContent: (@Composable () -> Unit)? = null,
trailingContent: @Composable () -> Unit = {},
@ -200,6 +202,8 @@ fun CollectionView(
onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam,
trackOptionsState = trackOptionsState,
)
}

View File

@ -143,6 +143,8 @@ fun TrackList(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
isInJam: Boolean = false,
currentTrackId: String? = null,
isCurrentTrackPlaying: Boolean = false,
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
@ -274,6 +276,7 @@ fun TrackList(
)
},
trackOptionsState = trackOptionsState(track),
isInJam = isInJam,
onShowOptionsClick = { selectedTrackForOptions = track },
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
@ -442,7 +445,17 @@ fun TrackList(
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
onClick = { onBulkAddToPlaylist(targetTracks) },
),
),
) + if (isInJam) {
listOf(
AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare,
label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam",
onClick = { onBulkAddToJam(targetTracks) },
),
)
} else {
emptyList()
},
trigger = { onClick ->
GroupIconButton(
onClick = onClick,
@ -497,6 +510,7 @@ fun TrackList(
selectedTrackForOptions = null
},
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
isInJam = isInJam,
)
}
}
@ -555,6 +569,7 @@ private fun TrackListRow(
onSelectionToggle: (Boolean) -> Unit,
onTrackOptionsAction: (TrackOptionsAction) -> Unit,
trackOptionsState: TrackOptionsState,
isInJam: Boolean,
onShowOptionsClick: () -> Unit,
onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
@ -750,6 +765,7 @@ private fun TrackListRow(
state = trackOptionsState,
onAction = onTrackOptionsAction,
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
isInJam = isInJam,
)
} else {
GhostIconButton(onClick = onShowOptionsClick) {
@ -843,6 +859,7 @@ private fun ShimmerTrackListRow(
onSelectionToggle = {},
onTrackOptionsAction = {},
trackOptionsState = TrackOptionsState(),
isInJam = false,
onShowOptionsClick = {},
onArtistClick = {},
onAlbumClick = {},

View File

@ -56,6 +56,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
sealed interface TrackOptionsAction {
data object AddToJam : TrackOptionsAction
data object StartRadio : TrackOptionsAction
data object PlayNext : TrackOptionsAction
data object AddToQueue : TrackOptionsAction
@ -98,6 +99,7 @@ fun TrackOptions(
onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit,
modifier: Modifier = Modifier,
isInJam: Boolean = false,
) {
AdaptiveDropdownBottomSheet(
items = buildTrackMenuItems(
@ -105,6 +107,7 @@ fun TrackOptions(
state = state,
onAction = onAction,
onAlbumClick = onAlbumClick,
isInJam = isInJam,
),
trigger = { onClick ->
GhostIconButton(onClick = onClick) {
@ -129,6 +132,7 @@ fun TrackOptionsBottomSheet(
onDismiss: () -> Unit,
onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit,
isInJam: Boolean = false,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(modifier = Modifier.fillMaxWidth()) {
@ -151,6 +155,7 @@ fun TrackOptionsBottomSheet(
onAlbumClick()
onDismiss()
},
isInJam = isInJam,
).forEach { item ->
Row(
modifier = Modifier
@ -241,7 +246,18 @@ private fun buildTrackMenuItems(
state: TrackOptionsState,
onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit,
isInJam: Boolean = false,
): List<AdaptiveMenuItem> = buildList {
if (isInJam) {
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare,
label = "Add to Jam",
onClick = { onAction(TrackOptionsAction.AddToJam) },
),
)
}
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicCircle,

View File

@ -20,10 +20,13 @@ package dev.krtirtho.spotube.modules.album
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
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.JamRoomService
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
@ -37,6 +40,9 @@ fun AlbumScreen(
navigationCommands: NavigationCommands
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
@ -92,6 +98,8 @@ fun AlbumScreen(
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
trailingContent = {
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,

View File

@ -273,6 +273,10 @@ class AlbumViewModel(
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
@ -333,6 +337,10 @@ class AlbumViewModel(
tracks.forEach { track -> downloadManager.enqueue(track) }
}
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestTracksAddToQueue(tracks, title)

View File

@ -63,8 +63,10 @@ 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.JamRoomService
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
@ -95,6 +97,9 @@ fun ArtistScreen(
navigationCommands: NavigationCommands
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
@ -179,6 +184,8 @@ fun ArtistScreen(
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
)
}

View File

@ -276,6 +276,10 @@ class ArtistViewModel(
startTrack = track,
)
}
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
remotePlaybackController.requestTracksAddToQueue(tracks, artistName)
@ -306,6 +310,10 @@ class ArtistViewModel(
is TrackOptionsAction.AddToQueue -> {
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->

View File

@ -30,7 +30,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.remote.ConnectionState
import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction
import dev.krtirtho.spotube.core.remote.RemoteControlClient
@ -40,8 +39,6 @@ import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
import kotlinx.coroutines.flow.map
import org.koin.compose.koinInject
/**
@ -53,11 +50,8 @@ import org.koin.compose.koinInject
fun PlayDestinationPickerHost() {
val controller = koinInject<RemotePlaybackController>()
val remoteControlClient = koinInject<RemoteControlClient>()
val jamRoomService = koinInject<JamRoomService>()
val request by controller.pendingRequest.collectAsStateWithLifecycle()
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val pendingRequest = request ?: return
@ -143,33 +137,6 @@ fun PlayDestinationPickerHost() {
},
)
}
if (jamActive) {
ListRowTile(
onClick = controller::playOnJam,
modifier = Modifier.fillMaxWidth(),
leading = {
Icon(
imageVector = Iconsax.IconsaxMusicPlaylist,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
title = {
Text(
text = "Jam Session",
style = MaterialTheme.typography.bodyLarge,
)
},
subtitle = {
Text(
text = "$actionLabel in the shared jam queue",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
}
},
actions = {

View File

@ -31,10 +31,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
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.JamRoomService
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.CollectionView
@ -53,6 +56,9 @@ fun PlaylistScreen(
navigationCommands: NavigationCommands
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
@ -127,6 +133,8 @@ fun PlaylistScreen(
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
footerContent = footerContent,
trailingContent = {
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist

View File

@ -306,6 +306,10 @@ class PlaylistViewModel(
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
@ -378,6 +382,10 @@ class PlaylistViewModel(
}
}
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestTracksAddToQueue(tracks, title)

View File

@ -20,11 +20,14 @@ package dev.krtirtho.spotube.modules.saved_tracks
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
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.QueueCollectionEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.jam.JamRoomService
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
@ -39,6 +42,9 @@ fun SavedTracksScreen(
navigationCommands: NavigationCommands
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
@ -85,6 +91,8 @@ fun SavedTracksScreen(
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
trailingContent = {
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,

View File

@ -248,6 +248,10 @@ class SavedTracksViewModel(
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
@ -312,6 +316,10 @@ class SavedTracksViewModel(
}
}
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks")
}

View File

@ -86,6 +86,8 @@ 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.JamRoomService
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
@ -128,6 +130,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val blacklistRepository: BlacklistRepository = koinInject()
val navigationCommands: NavigationCommands = koinInject()
val state by viewModel.state.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val selectedType = state.selectedSearchType
val scope = rememberCoroutineScope()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
@ -188,6 +193,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
is TrackOptionsAction.AddToQueue -> {
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue()
@ -248,6 +256,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results")
}
fun bulkAddToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun bulkPlayNext(tracks: List<MetadataTrack>) {
remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
}
@ -327,6 +339,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
onBulkAddToJam = ::bulkAddToJam,
isInJam = jamActive,
onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id))
},
@ -356,6 +370,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
onBulkAddToJam = ::bulkAddToJam,
isInJam = jamActive,
onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id))
},
@ -637,6 +653,8 @@ private fun SearchAllTab(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
isInJam: Boolean,
onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit,
@ -694,6 +712,8 @@ private fun SearchAllTab(
onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,
@ -781,6 +801,8 @@ private fun SearchTracksTab(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
isInJam: Boolean,
onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit,
@ -810,6 +832,8 @@ private fun SearchTracksTab(
onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,

View File

@ -82,6 +82,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState
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
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
@ -117,6 +119,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne
import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@ -158,6 +161,10 @@ fun AppExpandedPlayer(
),
) {
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
val jamRoomService: JamRoomService = koinInject()
val isJamGuest by jamRoomService.role
.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val scope = rememberCoroutineScope()
val downloadsViewModel: DownloadsViewModel = koinViewModel()
val navigationCommands: NavigationCommands = koinInject()
@ -198,18 +205,22 @@ fun AppExpandedPlayer(
}
fun onSkipPrevious() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToPrevious() }
}
fun onSkipNext() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToNext() }
}
fun onShuffleToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
}
fun onLoopToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
}
@ -517,7 +528,7 @@ fun AppExpandedPlayer(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
GhostIconButton(onClick = ::onShuffleToggle) {
GhostIconButton(onClick = ::onShuffleToggle, enabled = !isJamGuest) {
Icon(
Iconsax.IconsaxShuffle,
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle",
@ -528,7 +539,7 @@ fun AppExpandedPlayer(
}
)
}
GhostIconButton(onClick = ::onSkipPrevious) {
GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) {
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
}
IconButton(
@ -543,10 +554,10 @@ fun AppExpandedPlayer(
modifier = Modifier.size(30.dp),
)
}
GhostIconButton(onClick = ::onSkipNext) {
GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) {
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
}
GhostIconButton(onClick = ::onLoopToggle) {
GhostIconButton(onClick = ::onLoopToggle, enabled = !isJamGuest) {
Icon(
imageVector = when (playerUiState.loopState) {
LoopState.NONE -> Iconsax.IconsaxRepeateMusic

View File

@ -65,6 +65,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState
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.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.Slider
@ -93,6 +95,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@ -125,6 +128,10 @@ fun AppLargePlayer(
),
) {
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
val jamRoomService: JamRoomService = koinInject()
val isJamGuest by jamRoomService.role
.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val scope = rememberCoroutineScope()
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
var isSeeking by remember { mutableStateOf(false) }
@ -155,18 +162,22 @@ fun AppLargePlayer(
}
fun onSkipPrevious() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToPrevious() }
}
fun onSkipNext() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToNext() }
}
fun onShuffleToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
}
fun onLoopToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
}
@ -294,6 +305,7 @@ fun AppLargePlayer(
) {
VariableIconButton(
onClick = ::onShuffleToggle,
enabled = !isJamGuest,
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
) {
Icon(
@ -306,7 +318,7 @@ fun AppLargePlayer(
}
)
}
GhostIconButton(onClick = ::onSkipPrevious) {
GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) {
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
}
IconButton(
@ -320,11 +332,12 @@ fun AppLargePlayer(
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
)
}
GhostIconButton(onClick = ::onSkipNext) {
GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) {
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
}
VariableIconButton(
onClick = ::onLoopToggle,
enabled = !isJamGuest,
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
) {
Icon(

View File

@ -78,12 +78,13 @@ fun PlayerQueueContent(
val displayItems = state.displayItems
val filterQuery = state.filterQuery
val isFiltered = state.isFiltered
val isReadOnly = state.isReadOnly
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(
lazyListState,
onMove = { from, to ->
if (isFiltered) return@rememberReorderableLazyListState
if (isFiltered || isReadOnly) return@rememberReorderableLazyListState
viewModel.onMove(from.index, to.index)
},
)
@ -118,11 +119,13 @@ fun PlayerQueueContent(
singleLine = true,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = viewModel::clearQueue,
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
) {
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
if (!isReadOnly) {
IconButton(
onClick = viewModel::clearQueue,
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
) {
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
}
}
}
@ -144,11 +147,12 @@ fun PlayerQueueContent(
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
QueueItemRow(
item = item,
reorderScope = if (isFiltered) null else this,
reorderScope = if (isFiltered || isReadOnly) null else this,
onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
onDragStarted = { viewModel.onDragStart() },
onDragStopped = { viewModel.onDragStop() },
showOptions = !isReadOnly,
)
}
}
@ -167,6 +171,7 @@ private fun QueueItemRow(
onRemoveClick: () -> Unit,
onDragStarted: () -> Unit,
onDragStopped: () -> Unit,
showOptions: Boolean = true,
) {
var showMenu by remember { mutableStateOf(false) }
@ -255,31 +260,33 @@ private fun QueueItemRow(
Spacer(modifier = Modifier.width(4.dp))
Box {
GhostIconButton(
onClick = { showMenu = true },
modifier = Modifier.size(36.dp),
) {
Icon(
Iconsax.Iconsax3DotsMore,
contentDescription = "More options",
modifier = Modifier.size(18.dp),
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("Remove from queue") },
onClick = {
onRemoveClick()
showMenu = false
},
leadingIcon = {
Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null)
},
)
if (showOptions) {
Box {
GhostIconButton(
onClick = { showMenu = true },
modifier = Modifier.size(36.dp),
) {
Icon(
Iconsax.Iconsax3DotsMore,
contentDescription = "More options",
modifier = Modifier.size(18.dp),
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("Remove from queue") },
onClick = {
onRemoveClick()
showMenu = false
},
leadingIcon = {
Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null)
},
)
}
}
}
}

View File

@ -21,6 +21,8 @@ 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.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -44,10 +46,13 @@ data class QueueContentUiState(
val filterQuery: String = "",
val displayItems: List<QueueItemUi> = emptyList(),
val isFiltered: Boolean = false,
/** Guests cannot reorder/remove/clear the shared jam queue. */
val isReadOnly: Boolean = false,
)
class PlayerQueueContentViewModel(
private val audioPlayerQueue: AudioPlayerQueue,
private val jamRoomService: JamRoomService,
) : ViewModel() {
private val queueVisibilityFlow = MutableStateFlow(false)
private val queueFilterFlow = MutableStateFlow("")
@ -114,7 +119,8 @@ class PlayerQueueContentViewModel(
computedItems,
reorderBuffer,
queueFilterFlow,
) { items, buffer, filterQuery ->
jamRoomService.role,
) { items, buffer, filterQuery, role ->
val normalizedFilter = filterQuery.trim().lowercase()
val isFiltered = normalizedFilter.isNotBlank()
val filtered = if (isFiltered) {
@ -129,6 +135,7 @@ class PlayerQueueContentViewModel(
filterQuery = filterQuery,
displayItems = buffer ?: filtered,
isFiltered = isFiltered,
isReadOnly = role == JamRole.Guest,
)
}.stateIn(
scope = viewModelScope,
@ -156,7 +163,7 @@ class PlayerQueueContentViewModel(
}
fun removeQueueItem(index: Int) {
if (index < 0) return
if (index < 0 || queueContentUiState.value.isReadOnly) return
viewModelScope.launch {
val currentQueue = audioPlayerQueue.queueFlow.value
if (index < currentQueue.size) {
@ -167,12 +174,14 @@ class PlayerQueueContentViewModel(
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
if (queueContentUiState.value.isReadOnly) return
viewModelScope.launch {
audioPlayerQueue.move(fromIndex, toIndex)
}
}
fun clearQueue() {
if (queueContentUiState.value.isReadOnly) return
viewModelScope.launch {
audioPlayerQueue.clear()
}
@ -180,11 +189,13 @@ class PlayerQueueContentViewModel(
fun onDragStart() {
if (reorderBuffer.value != null) return
if (queueContentUiState.value.isReadOnly) return
val currentItems = queueContentUiState.value.displayItems
reorderBuffer.value = currentItems.toList()
}
fun onMove(from: Int, to: Int) {
if (queueContentUiState.value.isReadOnly) return
val buffer = reorderBuffer.value ?: return
if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return
val item = buffer[from]