diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt index ac9e35d5..a51b8cb5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt @@ -39,6 +39,14 @@ sealed class JamMessage { val items: List, 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 diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt index 8107e1cd..d9618e3c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt @@ -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, + 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 = 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() @@ -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 } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 7857b1f7..2367ae1a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -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) { + 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) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt index 02082d96..44ef5360 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt @@ -87,6 +87,8 @@ fun CollectionView( onBulkAddToQueue: (List) -> Unit = {}, onBulkPlayNext: (List) -> Unit = {}, onBulkAddToPlaylist: (List) -> Unit = {}, + onBulkAddToJam: (List) -> 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, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index 8c75b3f1..4873cb30 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -143,6 +143,8 @@ fun TrackList( onBulkAddToQueue: (List) -> Unit = {}, onBulkPlayNext: (List) -> Unit = {}, onBulkAddToPlaylist: (List) -> Unit = {}, + onBulkAddToJam: (List) -> 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 = {}, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index c401e631..e0cf1874 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -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 = buildList { + if (isInJam) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = "Add to Jam", + onClick = { onAction(TrackOptionsAction.AddToJam) }, + ), + ) + } + add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicCircle, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index 8a91a2c1..dbd7da14 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index eb2b2ee0..3da95baa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -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) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" remotePlaybackController.requestTracksAddToQueue(tracks, title) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index 5b76c144..b0fcdbf7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -63,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, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index 2baa9e9d..8c89001a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -276,6 +276,10 @@ class ArtistViewModel( startTrack = track, ) } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { 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 -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index 906c1492..a8b8213e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -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() val remoteControlClient = koinInject() - val jamRoomService = koinInject() 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 = { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index 7a5c0d58..09dc458b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index 8380e8af..d1ef1088 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -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) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" remotePlaybackController.requestTracksAddToQueue(tracks, title) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index 045a0380..4fa10f2a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 72d52113..1025bdbd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -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) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index b8f699d8..35c50407 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -86,6 +86,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) { + remotePlaybackController.addTracksToJam(tracks) + } + fun bulkPlayNext(tracks: List) { 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) -> Unit, onBulkPlayNext: (List) -> Unit, onBulkAddToPlaylist: (List) -> Unit, + onBulkAddToJam: (List) -> 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) -> Unit, onBulkPlayNext: (List) -> Unit, onBulkAddToPlaylist: (List) -> Unit, + onBulkAddToJam: (List) -> 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, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index c43ef433..96172f80 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index 9d7ffbd1..a6e09477 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 997da78f..0628e243 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -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) + }, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index c9d1a870..ced76d8e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -21,6 +21,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 = 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]