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 items: List<JamMediaItem>,
val currentIndex: Int, val currentIndex: Int,
val shuffleEnabled: Boolean = false, 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() ) : JamMessage()
@Serializable @Serializable

View File

@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Clock
/** /**
* A jam session over MQTT (star topology, host-authoritative queue). * 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. * - 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. * - 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( class JamRoomService(
private val jamClient: JamRoomClient, private val jamClient: JamRoomClient,
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
@ -83,12 +91,28 @@ 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). */
val participantDisplayName: String
get() = localDisplayName
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. */
private var lastAppliedItems: List<JamMediaItem> = emptyList() private var lastAppliedItems: List<JamMediaItem> = emptyList()
private var lastAppliedIndex = -1 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. */ /** Host side: client ids banned for this session. */
private val bannedClientIds = mutableSetOf<String>() private val bannedClientIds = mutableSetOf<String>()
@ -110,6 +134,20 @@ class JamRoomService(
jamClient.presence jamClient.presence
.onEach { onPresence(it) } .onEach { onPresence(it) }
.launchIn(scope) .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 ---------- // ---------- Session lifecycle ----------
@ -138,6 +176,8 @@ class JamRoomService(
lastAppliedIndex = -1 lastAppliedIndex = -1
bannedClientIds.clear() bannedClientIds.clear()
leaving = false leaving = false
autoAdvanceDeadlineMs = 0L
hasStartedPlayback = false
startHostBroadcast() startHostBroadcast()
persistLastCode(code) persistLastCode(code)
code code
@ -170,6 +210,7 @@ class JamRoomService(
lastAppliedItems = emptyList() lastAppliedItems = emptyList()
lastAppliedIndex = -1 lastAppliedIndex = -1
leaving = false leaving = false
hasStartedPlayback = false
persistLastCode(normalized) persistLastCode(normalized)
} }
} }
@ -187,6 +228,7 @@ class JamRoomService(
lastAppliedItems = emptyList() lastAppliedItems = emptyList()
lastAppliedIndex = -1 lastAppliedIndex = -1
bannedClientIds.clear() bannedClientIds.clear()
hasStartedPlayback = false
} }
// ---------- Controls (called from the UI) ---------- // ---------- Controls (called from the UI) ----------
@ -250,20 +292,25 @@ class JamRoomService(
audioPlayerQueue.queueFlow, audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow, audioPlayerQueue.currentQueueEntryFlow,
audioPlayer.shuffleModeFlow, audioPlayer.shuffleModeFlow,
) { queue, current, shuffle -> Triple(queue, current, shuffle) } audioPlayer.playerStateFlow,
.onEach { (queue, current, shuffle) -> ) { queue, current, shuffle, playerState ->
PlaybackBroadcast(queue, current, shuffle, playerState)
}
.onEach { broadcast ->
if (_role.value != JamRole.Host) return@onEach if (_role.value != JamRole.Host) return@onEach
val index = if (current != null) { val index = if (broadcast.current != null) {
queue.indexOfFirst { it.matchesEntry(current) } broadcast.queue.indexOfFirst { it.matchesEntry(broadcast.current) }
} else { } else {
-1 -1
} }
_shuffleEnabled.value = shuffle _shuffleEnabled.value = broadcast.shuffle
jamClient.publishState( jamClient.publishState(
JamMessage.QueueState( JamMessage.QueueState(
items = queue.map(JamMediaItem::fromQueueEntry), items = broadcast.queue.map(JamMediaItem::fromQueueEntry),
currentIndex = index.coerceAtLeast(0), 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 _shuffleEnabled.value = state.shuffleEnabled
runCatching { audioPlayer.shuffle(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 items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING
if (items != lastAppliedItems) { if (items != lastAppliedItems) {
val previous = lastAppliedItems
lastAppliedItems = items 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 { runCatching {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = items.map { it.toQueueEntry() }, entries = items.map { it.toQueueEntry() },
autoPlay = wasPlaying, autoPlay = wasPlaying,
startPosition = state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)), startPosition = startIndex,
) )
}.onFailure { log.w(it) { "Failed to apply jam queue" } } }.onFailure { log.w(it) { "Failed to apply jam queue" } }
return 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 lastAppliedIndex = state.currentIndex
// Queue moved on: follow it, but keep this device's play/pause state.
runCatching { runCatching {
audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false) audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false)
}.onFailure { log.w(it) { "Failed to follow jam queue index" } } }.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 { private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean = when {
this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack ->
this.track.id == other.track.id this.track.id == other.track.id
@ -469,7 +556,10 @@ class JamRoomService(
else -> false else -> false
} }
private fun now(): Long = Clock.System.now().toEpochMilliseconds()
companion object { companion object {
private const val HOST_TAKEOVER_DELAY_MS = 1_500L 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 _pendingRequest.value = null
} }
// ---------- Jam actions ----------
/** /**
* Routes the pending request into the active jam session. On the host the jam * Adds a single track to the active jam queue. The host applies it to the
* queue IS the local queue, so the action runs locally; on a guest the content * local (shared) queue directly; a guest suggests it to the host over MQTT.
* is suggested to the host, which accepts it into the shared queue.
*/ */
fun playOnJam() { fun addTrackToJam(track: MetadataTrack) {
val request = _pendingRequest.value ?: return if (jamRoomService.role.value == null) return
_pendingRequest.value = null
scope.launch { scope.launch {
try { try {
when (jamRoomService.role.value) { when (jamRoomService.role.value) {
JamRole.Host -> executeLocally(request) JamRole.Host -> audioPlayerQueue.addToQueue(
JamRole.Guest -> suggestToJam(request) QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantDisplayName)
)
JamRole.Guest -> jamRoomService.suggestTrack(track)
null -> return@launch null -> return@launch
} }
_events.emit(confirmationMessage(request)) _events.emit("Added to the jam queue")
} catch (e: Exception) { } 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" * Adds multiple tracks to the active jam queue (host applies locally,
PlaybackDestinationAction.AddToQueue -> "Added to the jam queue" * guest suggests to the host).
PlaybackDestinationAction.PlayNext -> "Added to play next in the jam queue" */
} 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) { JamRole.Guest -> jamRoomService.suggestPlaylist(tracks)
when (request) { null -> return@launch
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)
} }
_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 ---------- // ---------- Internals ----------
private fun request(request: PlaybackDestinationRequest) { private fun request(request: PlaybackDestinationRequest) {
// The picker offers "This Device", a connected remote device, and an if (isRemoteConnected()) {
// active jam session — show it whenever more than one destination exists.
if (isRemoteConnected() || jamRoomService.role.value != null) {
_pendingRequest.value = request _pendingRequest.value = request
} else { } else {
executeLocally(request) executeLocally(request)

View File

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

View File

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

View File

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

View File

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

View File

@ -273,6 +273,10 @@ class AlbumViewModel(
remotePlaybackController.requestTrackAddToQueue(track) remotePlaybackController.requestTrackAddToQueue(track)
} }
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { entry -> queue.find { entry ->
@ -333,6 +337,10 @@ class AlbumViewModel(
tracks.forEach { track -> downloadManager.enqueue(track) } tracks.forEach { track -> downloadManager.enqueue(track) }
} }
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestTracksAddToQueue(tracks, title) 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.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.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
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton import dev.krtirtho.spotube.core.ui.base.SecondaryButton
@ -95,6 +97,9 @@ fun ArtistScreen(
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() 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 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()
@ -179,6 +184,8 @@ fun ArtistScreen(
onBulkAddToQueue = viewModel::addTracksToQueue, onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext, onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
) )
} }

View File

@ -276,6 +276,10 @@ class ArtistViewModel(
startTrack = track, startTrack = track,
) )
} }
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
remotePlaybackController.requestTracksAddToQueue(tracks, artistName) remotePlaybackController.requestTracksAddToQueue(tracks, artistName)
@ -306,6 +310,10 @@ class ArtistViewModel(
is TrackOptionsAction.AddToQueue -> { is TrackOptionsAction.AddToQueue -> {
remotePlaybackController.requestTrackAddToQueue(track) remotePlaybackController.requestTrackAddToQueue(track)
} }
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { entry -> 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.ConnectionState
import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction
import dev.krtirtho.spotube.core.remote.RemoteControlClient 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
import kotlinx.coroutines.flow.map
import org.koin.compose.koinInject import org.koin.compose.koinInject
/** /**
@ -53,11 +50,8 @@ import org.koin.compose.koinInject
fun PlayDestinationPickerHost() { fun PlayDestinationPickerHost() {
val controller = koinInject<RemotePlaybackController>() val controller = koinInject<RemotePlaybackController>()
val remoteControlClient = koinInject<RemoteControlClient>() val remoteControlClient = koinInject<RemoteControlClient>()
val jamRoomService = koinInject<JamRoomService>()
val request by controller.pendingRequest.collectAsStateWithLifecycle() val request by controller.pendingRequest.collectAsStateWithLifecycle()
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val pendingRequest = request ?: return 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 = { actions = {

View File

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

View File

@ -306,6 +306,10 @@ class PlaylistViewModel(
remotePlaybackController.requestTrackAddToQueue(track) remotePlaybackController.requestTrackAddToQueue(track)
} }
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { entry -> queue.find { entry ->
@ -378,6 +382,10 @@ class PlaylistViewModel(
} }
} }
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestTracksAddToQueue(tracks, title) 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface 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.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.JamRoomService
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
@ -39,6 +42,9 @@ fun SavedTracksScreen(
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() 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 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()
@ -85,6 +91,8 @@ fun SavedTracksScreen(
onBulkAddToQueue = viewModel::addTracksToQueue, onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext, onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
trailingContent = { trailingContent = {
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,

View File

@ -248,6 +248,10 @@ class SavedTracksViewModel(
remotePlaybackController.requestTrackAddToQueue(track) remotePlaybackController.requestTrackAddToQueue(track)
} }
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { entry -> queue.find { entry ->
@ -312,6 +316,10 @@ class SavedTracksViewModel(
} }
} }
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks") 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.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.JamRoomService
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
@ -128,6 +130,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val blacklistRepository: BlacklistRepository = koinInject() val blacklistRepository: BlacklistRepository = koinInject()
val navigationCommands: NavigationCommands = koinInject() val navigationCommands: NavigationCommands = koinInject()
val state by viewModel.state.collectAsStateWithLifecycle() 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 selectedType = state.selectedSearchType
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
@ -188,6 +193,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
is TrackOptionsAction.AddToQueue -> { is TrackOptionsAction.AddToQueue -> {
remotePlaybackController.requestTrackAddToQueue(track) remotePlaybackController.requestTrackAddToQueue(track)
} }
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
@ -248,6 +256,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results") remotePlaybackController.requestTracksAddToQueue(tracks, "Search results")
} }
fun bulkAddToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun bulkPlayNext(tracks: List<MetadataTrack>) { fun bulkPlayNext(tracks: List<MetadataTrack>) {
remotePlaybackController.requestTracksPlayNext(tracks, "Search results") remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
} }
@ -327,6 +339,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
tracksToAddToPlaylist = tracks tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true showAddToPlaylistPicker = true
}, },
onBulkAddToJam = ::bulkAddToJam,
isInJam = jamActive,
onArtistClick = { artist -> onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id)) navigationCommands.navigateTo(Routes.Artist(artist.id))
}, },
@ -356,6 +370,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
tracksToAddToPlaylist = tracks tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true showAddToPlaylistPicker = true
}, },
onBulkAddToJam = ::bulkAddToJam,
isInJam = jamActive,
onArtistClick = { artist -> onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id)) navigationCommands.navigateTo(Routes.Artist(artist.id))
}, },
@ -637,6 +653,8 @@ private fun SearchAllTab(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit, onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit, onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit, onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
isInJam: Boolean,
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit,
@ -694,6 +712,8 @@ private fun SearchAllTab(
onBulkAddToQueue = onBulkAddToQueue, onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext, onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick, onArtistsOverflowClick = onArtistsOverflowClick,
@ -781,6 +801,8 @@ private fun SearchTracksTab(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit, onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit, onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit, onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
isInJam: Boolean,
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit,
@ -810,6 +832,8 @@ private fun SearchTracksTab(
onBulkAddToQueue = onBulkAddToQueue, onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext, onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick, 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.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.LoopState
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.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.ui.base.BaseUITheme 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.IconsaxShuffle
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@ -158,6 +161,10 @@ fun AppExpandedPlayer(
), ),
) { ) {
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) 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 scope = rememberCoroutineScope()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val navigationCommands: NavigationCommands = koinInject() val navigationCommands: NavigationCommands = koinInject()
@ -198,18 +205,22 @@ fun AppExpandedPlayer(
} }
fun onSkipPrevious() { fun onSkipPrevious() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToPrevious() } scope.launch { audioPlayer.skipToPrevious() }
} }
fun onSkipNext() { fun onSkipNext() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToNext() } scope.launch { audioPlayer.skipToNext() }
} }
fun onShuffleToggle() { fun onShuffleToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
} }
fun onLoopToggle() { fun onLoopToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
} }
@ -517,7 +528,7 @@ fun AppExpandedPlayer(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
GhostIconButton(onClick = ::onShuffleToggle) { GhostIconButton(onClick = ::onShuffleToggle, enabled = !isJamGuest) {
Icon( Icon(
Iconsax.IconsaxShuffle, Iconsax.IconsaxShuffle,
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", 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") Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
} }
IconButton( IconButton(
@ -543,10 +554,10 @@ fun AppExpandedPlayer(
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
) )
} }
GhostIconButton(onClick = ::onSkipNext) { GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) {
Icon(Iconsax.IconsaxNext, contentDescription = "Next") Icon(Iconsax.IconsaxNext, contentDescription = "Next")
} }
GhostIconButton(onClick = ::onLoopToggle) { GhostIconButton(onClick = ::onLoopToggle, enabled = !isJamGuest) {
Icon( Icon(
imageVector = when (playerUiState.loopState) { imageVector = when (playerUiState.loopState) {
LoopState.NONE -> Iconsax.IconsaxRepeateMusic 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.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.LoopState
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.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
import dev.krtirtho.spotube.core.ui.base.Slider 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.IconsaxVolumeHigh
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@ -125,6 +128,10 @@ fun AppLargePlayer(
), ),
) { ) {
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) 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 scope = rememberCoroutineScope()
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
var isSeeking by remember { mutableStateOf(false) } var isSeeking by remember { mutableStateOf(false) }
@ -155,18 +162,22 @@ fun AppLargePlayer(
} }
fun onSkipPrevious() { fun onSkipPrevious() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToPrevious() } scope.launch { audioPlayer.skipToPrevious() }
} }
fun onSkipNext() { fun onSkipNext() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToNext() } scope.launch { audioPlayer.skipToNext() }
} }
fun onShuffleToggle() { fun onShuffleToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
} }
fun onLoopToggle() { fun onLoopToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
} }
@ -294,6 +305,7 @@ fun AppLargePlayer(
) { ) {
VariableIconButton( VariableIconButton(
onClick = ::onShuffleToggle, onClick = ::onShuffleToggle,
enabled = !isJamGuest,
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
) { ) {
Icon( Icon(
@ -306,7 +318,7 @@ fun AppLargePlayer(
} }
) )
} }
GhostIconButton(onClick = ::onSkipPrevious) { GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) {
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
} }
IconButton( IconButton(
@ -320,11 +332,12 @@ fun AppLargePlayer(
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
) )
} }
GhostIconButton(onClick = ::onSkipNext) { GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) {
Icon(Iconsax.IconsaxNext, contentDescription = "Next") Icon(Iconsax.IconsaxNext, contentDescription = "Next")
} }
VariableIconButton( VariableIconButton(
onClick = ::onLoopToggle, onClick = ::onLoopToggle,
enabled = !isJamGuest,
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
) { ) {
Icon( Icon(

View File

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

View File

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