mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 06:34:00 +00:00
feat(jam-session): implement track addition to jam session and enhance UI feedback for jam state
This commit is contained in:
parent
a2b9f4178c
commit
ad994f3ca3
@ -39,6 +39,14 @@ sealed class JamMessage {
|
||||
val items: List<JamMediaItem>,
|
||||
val currentIndex: Int,
|
||||
val shuffleEnabled: Boolean = false,
|
||||
/**
|
||||
* Whether guests should follow the host's current index. True when the
|
||||
* host manually skipped/jumped or loaded a queue; false when the host
|
||||
* merely auto-advanced because its song ended (guests stay put).
|
||||
*/
|
||||
val follow: Boolean = false,
|
||||
/** Host's live play state — late-joining guests start with it. */
|
||||
val isPlaying: Boolean = false,
|
||||
) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
|
||||
@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* A jam session over MQTT (star topology, host-authoritative queue).
|
||||
@ -54,6 +55,13 @@ import kotlin.random.Random
|
||||
* - Guests can only add to the queue (suggest); the host applies suggestions.
|
||||
* - If the host leaves, the participant with the lowest client id takes over.
|
||||
*/
|
||||
private data class PlaybackBroadcast(
|
||||
val queue: List<QueueEntry>,
|
||||
val current: QueueEntry?,
|
||||
val shuffle: Boolean,
|
||||
val playerState: PlayerState,
|
||||
)
|
||||
|
||||
class JamRoomService(
|
||||
private val jamClient: JamRoomClient,
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
@ -83,12 +91,28 @@ class JamRoomService(
|
||||
|
||||
private var localClientId: String = ""
|
||||
private var localDisplayName: String = ""
|
||||
|
||||
/** Display name of the local participant (stamped on items this device adds). */
|
||||
val participantDisplayName: String
|
||||
get() = localDisplayName
|
||||
|
||||
private var hostBroadcastJob: Job? = null
|
||||
|
||||
/** Guest side: last queue snapshot applied to the local player. */
|
||||
private var lastAppliedItems: List<JamMediaItem> = emptyList()
|
||||
private var lastAppliedIndex = -1
|
||||
|
||||
/**
|
||||
* Host side: a song completed, so index changes within this window are
|
||||
* auto-advance (guests must not follow). The completion event, the player
|
||||
* state change and the media transition arrive as separate flow emissions,
|
||||
* so the window covers the whole sequence instead of a single flag.
|
||||
*/
|
||||
private var autoAdvanceDeadlineMs = 0L
|
||||
|
||||
/** Guest side: playback has started at least once (local control is the guest's own). */
|
||||
private var hasStartedPlayback = false
|
||||
|
||||
/** Host side: client ids banned for this session. */
|
||||
private val bannedClientIds = mutableSetOf<String>()
|
||||
|
||||
@ -110,6 +134,20 @@ class JamRoomService(
|
||||
jamClient.presence
|
||||
.onEach { onPresence(it) }
|
||||
.launchIn(scope)
|
||||
// A naturally-completed song means the host's next index change is an
|
||||
// auto-advance — guests must NOT follow those, only manual skips.
|
||||
audioPlayer.completionFlow
|
||||
.onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS }
|
||||
.launchIn(scope)
|
||||
// Once a guest has played on its own (or was started by the host), its
|
||||
// play/pause is its own — the host's play state only starts fresh joiners.
|
||||
audioPlayer.playerStateFlow
|
||||
.onEach { state ->
|
||||
if (_role.value == JamRole.Guest && state == PlayerState.PLAYING) {
|
||||
hasStartedPlayback = true
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
// ---------- Session lifecycle ----------
|
||||
@ -138,6 +176,8 @@ class JamRoomService(
|
||||
lastAppliedIndex = -1
|
||||
bannedClientIds.clear()
|
||||
leaving = false
|
||||
autoAdvanceDeadlineMs = 0L
|
||||
hasStartedPlayback = false
|
||||
startHostBroadcast()
|
||||
persistLastCode(code)
|
||||
code
|
||||
@ -170,6 +210,7 @@ class JamRoomService(
|
||||
lastAppliedItems = emptyList()
|
||||
lastAppliedIndex = -1
|
||||
leaving = false
|
||||
hasStartedPlayback = false
|
||||
persistLastCode(normalized)
|
||||
}
|
||||
}
|
||||
@ -187,6 +228,7 @@ class JamRoomService(
|
||||
lastAppliedItems = emptyList()
|
||||
lastAppliedIndex = -1
|
||||
bannedClientIds.clear()
|
||||
hasStartedPlayback = false
|
||||
}
|
||||
|
||||
// ---------- Controls (called from the UI) ----------
|
||||
@ -250,20 +292,25 @@ class JamRoomService(
|
||||
audioPlayerQueue.queueFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
audioPlayer.shuffleModeFlow,
|
||||
) { queue, current, shuffle -> Triple(queue, current, shuffle) }
|
||||
.onEach { (queue, current, shuffle) ->
|
||||
audioPlayer.playerStateFlow,
|
||||
) { queue, current, shuffle, playerState ->
|
||||
PlaybackBroadcast(queue, current, shuffle, playerState)
|
||||
}
|
||||
.onEach { broadcast ->
|
||||
if (_role.value != JamRole.Host) return@onEach
|
||||
val index = if (current != null) {
|
||||
queue.indexOfFirst { it.matchesEntry(current) }
|
||||
val index = if (broadcast.current != null) {
|
||||
broadcast.queue.indexOfFirst { it.matchesEntry(broadcast.current) }
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
_shuffleEnabled.value = shuffle
|
||||
_shuffleEnabled.value = broadcast.shuffle
|
||||
jamClient.publishState(
|
||||
JamMessage.QueueState(
|
||||
items = queue.map(JamMediaItem::fromQueueEntry),
|
||||
items = broadcast.queue.map(JamMediaItem::fromQueueEntry),
|
||||
currentIndex = index.coerceAtLeast(0),
|
||||
shuffleEnabled = shuffle,
|
||||
shuffleEnabled = broadcast.shuffle,
|
||||
follow = now() > autoAdvanceDeadlineMs,
|
||||
isPlaying = broadcast.playerState == PlayerState.PLAYING,
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -285,25 +332,54 @@ class JamRoomService(
|
||||
_shuffleEnabled.value = state.shuffleEnabled
|
||||
runCatching { audioPlayer.shuffle(state.shuffleEnabled) }
|
||||
|
||||
// Late joiner: the host is already playing, so start immediately. Once
|
||||
// this guest has played on its own, the host's play state is ignored.
|
||||
if (state.isPlaying && !hasStartedPlayback) {
|
||||
runCatching { audioPlayer.play() }
|
||||
.onFailure { log.w(it) { "Failed to start playback on host play state" } }
|
||||
}
|
||||
|
||||
val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
|
||||
val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING
|
||||
|
||||
if (items != lastAppliedItems) {
|
||||
val previous = lastAppliedItems
|
||||
lastAppliedItems = items
|
||||
lastAppliedIndex = state.currentIndex
|
||||
val guestCurrent = audioPlayerQueue.currentQueueEntryFlow.value
|
||||
val guestIndex = items.indexOfFirst { it.matchesEntry(guestCurrent) }
|
||||
|
||||
// The host only appended items (e.g. accepted suggestions): merge
|
||||
// them in without resetting playback or the guest's position.
|
||||
if (previous.isNotEmpty() && items.size > previous.size &&
|
||||
items.take(previous.size) == previous && guestIndex >= 0
|
||||
) {
|
||||
lastAppliedIndex = guestIndex
|
||||
val appended = items.drop(previous.size)
|
||||
runCatching {
|
||||
audioPlayerQueue.addAllToQueue(appended.map { it.toQueueEntry() })
|
||||
}.onFailure { log.w(it) { "Failed to append jam queue items" } }
|
||||
return
|
||||
}
|
||||
|
||||
// Full re-sync. Keep the guest's current track when it still exists
|
||||
// in the synced queue; otherwise take the host's position.
|
||||
val startIndex = if (guestIndex >= 0) guestIndex
|
||||
else state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0))
|
||||
lastAppliedIndex = startIndex
|
||||
runCatching {
|
||||
audioPlayerQueue.load(
|
||||
entries = items.map { it.toQueueEntry() },
|
||||
autoPlay = wasPlaying,
|
||||
startPosition = state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)),
|
||||
startPosition = startIndex,
|
||||
)
|
||||
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
|
||||
return
|
||||
}
|
||||
|
||||
if (state.currentIndex != lastAppliedIndex) {
|
||||
// Same queue content: only follow the host when it moved manually
|
||||
// (skip/jump). Natural auto-advance keeps everyone where they are.
|
||||
if (state.follow && state.currentIndex != lastAppliedIndex) {
|
||||
lastAppliedIndex = state.currentIndex
|
||||
// Queue moved on: follow it, but keep this device's play/pause state.
|
||||
runCatching {
|
||||
audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false)
|
||||
}.onFailure { log.w(it) { "Failed to follow jam queue index" } }
|
||||
@ -459,6 +535,17 @@ class JamRoomService(
|
||||
)
|
||||
}
|
||||
|
||||
private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean {
|
||||
if (entry == null) return false
|
||||
return when (entry) {
|
||||
is QueueEntry.StreamingTrack ->
|
||||
trackId.isNotBlank() && entry.track.id == trackId
|
||||
|
||||
is QueueEntry.LocalTrack ->
|
||||
url.isNotBlank() && entry.url == url && entry.name == title
|
||||
}
|
||||
}
|
||||
|
||||
private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean = when {
|
||||
this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack ->
|
||||
this.track.id == other.track.id
|
||||
@ -469,7 +556,10 @@ class JamRoomService(
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun now(): Long = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
companion object {
|
||||
private const val HOST_TAKEOVER_DELAY_MS = 1_500L
|
||||
private const val AUTO_ADVANCE_WINDOW_MS = 2_000L
|
||||
}
|
||||
}
|
||||
@ -164,52 +164,56 @@ class RemotePlaybackController(
|
||||
_pendingRequest.value = null
|
||||
}
|
||||
|
||||
// ---------- Jam actions ----------
|
||||
|
||||
/**
|
||||
* Routes the pending request into the active jam session. On the host the jam
|
||||
* queue IS the local queue, so the action runs locally; on a guest the content
|
||||
* is suggested to the host, which accepts it into the shared queue.
|
||||
* Adds a single track to the active jam queue. The host applies it to the
|
||||
* local (shared) queue directly; a guest suggests it to the host over MQTT.
|
||||
*/
|
||||
fun playOnJam() {
|
||||
val request = _pendingRequest.value ?: return
|
||||
_pendingRequest.value = null
|
||||
fun addTrackToJam(track: MetadataTrack) {
|
||||
if (jamRoomService.role.value == null) return
|
||||
scope.launch {
|
||||
try {
|
||||
when (jamRoomService.role.value) {
|
||||
JamRole.Host -> executeLocally(request)
|
||||
JamRole.Guest -> suggestToJam(request)
|
||||
JamRole.Host -> audioPlayerQueue.addToQueue(
|
||||
QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantDisplayName)
|
||||
)
|
||||
|
||||
JamRole.Guest -> jamRoomService.suggestTrack(track)
|
||||
null -> return@launch
|
||||
}
|
||||
_events.emit(confirmationMessage(request))
|
||||
_events.emit("Added to the jam queue")
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to send content to jam session" }
|
||||
logger.e(e) { "Failed to add track to jam session" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmationMessage(request: PlaybackDestinationRequest): String = when (request.action) {
|
||||
PlaybackDestinationAction.Play -> "Playing on the jam queue"
|
||||
PlaybackDestinationAction.AddToQueue -> "Added to the jam queue"
|
||||
PlaybackDestinationAction.PlayNext -> "Added to play next in the jam queue"
|
||||
/**
|
||||
* Adds multiple tracks to the active jam queue (host applies locally,
|
||||
* guest suggests to the host).
|
||||
*/
|
||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||
if (tracks.isEmpty() || jamRoomService.role.value == null) return
|
||||
scope.launch {
|
||||
try {
|
||||
when (jamRoomService.role.value) {
|
||||
JamRole.Host -> audioPlayerQueue.addAllToQueue(
|
||||
tracks.map { track ->
|
||||
QueueEntry.StreamingTrack(
|
||||
track = track,
|
||||
url = "",
|
||||
addedBy = jamRoomService.participantDisplayName,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
private suspend fun suggestToJam(request: PlaybackDestinationRequest) {
|
||||
when (request) {
|
||||
is PlaybackDestinationRequest.Collection -> {
|
||||
val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id)
|
||||
if (tracks.isNotEmpty()) {
|
||||
jamRoomService.suggestPlaylist(tracks)
|
||||
logger.i { "Suggested ${tracks.size} track(s) to the jam session" }
|
||||
}
|
||||
}
|
||||
|
||||
is PlaybackDestinationRequest.Track -> {
|
||||
jamRoomService.suggestTrack(request.track)
|
||||
}
|
||||
|
||||
is PlaybackDestinationRequest.Tracks -> {
|
||||
if (request.tracks.isNotEmpty()) {
|
||||
jamRoomService.suggestPlaylist(request.tracks)
|
||||
JamRole.Guest -> jamRoomService.suggestPlaylist(tracks)
|
||||
null -> return@launch
|
||||
}
|
||||
_events.emit("Added ${tracks.size} to the jam queue")
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to add tracks to jam session" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -217,9 +221,7 @@ class RemotePlaybackController(
|
||||
// ---------- Internals ----------
|
||||
|
||||
private fun request(request: PlaybackDestinationRequest) {
|
||||
// The picker offers "This Device", a connected remote device, and an
|
||||
// active jam session — show it whenever more than one destination exists.
|
||||
if (isRemoteConnected() || jamRoomService.role.value != null) {
|
||||
if (isRemoteConnected()) {
|
||||
_pendingRequest.value = request
|
||||
} else {
|
||||
executeLocally(request)
|
||||
|
||||
@ -87,6 +87,8 @@ fun CollectionView(
|
||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
|
||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
|
||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||
isInJam: Boolean = false,
|
||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||
footerContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: @Composable () -> Unit = {},
|
||||
@ -200,6 +202,8 @@ fun CollectionView(
|
||||
onBulkAddToQueue = onBulkAddToQueue,
|
||||
onBulkPlayNext = onBulkPlayNext,
|
||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||
onBulkAddToJam = onBulkAddToJam,
|
||||
isInJam = isInJam,
|
||||
trackOptionsState = trackOptionsState,
|
||||
)
|
||||
}
|
||||
|
||||
@ -143,6 +143,8 @@ fun TrackList(
|
||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
|
||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
|
||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||
isInJam: Boolean = false,
|
||||
currentTrackId: String? = null,
|
||||
isCurrentTrackPlaying: Boolean = false,
|
||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||
@ -274,6 +276,7 @@ fun TrackList(
|
||||
)
|
||||
},
|
||||
trackOptionsState = trackOptionsState(track),
|
||||
isInJam = isInJam,
|
||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
@ -442,7 +445,17 @@ fun TrackList(
|
||||
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
||||
onClick = { onBulkAddToPlaylist(targetTracks) },
|
||||
),
|
||||
) + if (isInJam) {
|
||||
listOf(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam",
|
||||
onClick = { onBulkAddToJam(targetTracks) },
|
||||
),
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
trigger = { onClick ->
|
||||
GroupIconButton(
|
||||
onClick = onClick,
|
||||
@ -497,6 +510,7 @@ fun TrackList(
|
||||
selectedTrackForOptions = null
|
||||
},
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||
isInJam = isInJam,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -555,6 +569,7 @@ private fun TrackListRow(
|
||||
onSelectionToggle: (Boolean) -> Unit,
|
||||
onTrackOptionsAction: (TrackOptionsAction) -> Unit,
|
||||
trackOptionsState: TrackOptionsState,
|
||||
isInJam: Boolean,
|
||||
onShowOptionsClick: () -> Unit,
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
@ -750,6 +765,7 @@ private fun TrackListRow(
|
||||
state = trackOptionsState,
|
||||
onAction = onTrackOptionsAction,
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||
isInJam = isInJam,
|
||||
)
|
||||
} else {
|
||||
GhostIconButton(onClick = onShowOptionsClick) {
|
||||
@ -843,6 +859,7 @@ private fun ShimmerTrackListRow(
|
||||
onSelectionToggle = {},
|
||||
onTrackOptionsAction = {},
|
||||
trackOptionsState = TrackOptionsState(),
|
||||
isInJam = false,
|
||||
onShowOptionsClick = {},
|
||||
onArtistClick = {},
|
||||
onAlbumClick = {},
|
||||
|
||||
@ -56,6 +56,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
||||
|
||||
sealed interface TrackOptionsAction {
|
||||
data object AddToJam : TrackOptionsAction
|
||||
data object StartRadio : TrackOptionsAction
|
||||
data object PlayNext : TrackOptionsAction
|
||||
data object AddToQueue : TrackOptionsAction
|
||||
@ -98,6 +99,7 @@ fun TrackOptions(
|
||||
onAction: (TrackOptionsAction) -> Unit,
|
||||
onAlbumClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
isInJam: Boolean = false,
|
||||
) {
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = buildTrackMenuItems(
|
||||
@ -105,6 +107,7 @@ fun TrackOptions(
|
||||
state = state,
|
||||
onAction = onAction,
|
||||
onAlbumClick = onAlbumClick,
|
||||
isInJam = isInJam,
|
||||
),
|
||||
trigger = { onClick ->
|
||||
GhostIconButton(onClick = onClick) {
|
||||
@ -129,6 +132,7 @@ fun TrackOptionsBottomSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onAction: (TrackOptionsAction) -> Unit,
|
||||
onAlbumClick: () -> Unit,
|
||||
isInJam: Boolean = false,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
@ -151,6 +155,7 @@ fun TrackOptionsBottomSheet(
|
||||
onAlbumClick()
|
||||
onDismiss()
|
||||
},
|
||||
isInJam = isInJam,
|
||||
).forEach { item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@ -241,7 +246,18 @@ private fun buildTrackMenuItems(
|
||||
state: TrackOptionsState,
|
||||
onAction: (TrackOptionsAction) -> Unit,
|
||||
onAlbumClick: () -> Unit,
|
||||
isInJam: Boolean = false,
|
||||
): List<AdaptiveMenuItem> = buildList {
|
||||
if (isInJam) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = "Add to Jam",
|
||||
onClick = { onAction(TrackOptionsAction.AddToJam) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicCircle,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -273,6 +273,10 @@ class AlbumViewModel(
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToJam -> {
|
||||
remotePlaybackController.addTrackToJam(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
queue.find { entry ->
|
||||
@ -333,6 +337,10 @@ class AlbumViewModel(
|
||||
tracks.forEach { track -> downloadManager.enqueue(track) }
|
||||
}
|
||||
|
||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.addTracksToJam(tracks)
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -276,6 +276,10 @@ class ArtistViewModel(
|
||||
startTrack = track,
|
||||
)
|
||||
}
|
||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.addTracksToJam(tracks)
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, artistName)
|
||||
@ -306,6 +310,10 @@ class ArtistViewModel(
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToJam -> {
|
||||
remotePlaybackController.addTrackToJam(track)
|
||||
}
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
queue.find { entry ->
|
||||
|
||||
@ -30,7 +30,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||
import dev.krtirtho.spotube.core.remote.ConnectionState
|
||||
import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction
|
||||
import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
||||
@ -40,8 +39,6 @@ import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
/**
|
||||
@ -53,11 +50,8 @@ import org.koin.compose.koinInject
|
||||
fun PlayDestinationPickerHost() {
|
||||
val controller = koinInject<RemotePlaybackController>()
|
||||
val remoteControlClient = koinInject<RemoteControlClient>()
|
||||
val jamRoomService = koinInject<JamRoomService>()
|
||||
val request by controller.pendingRequest.collectAsStateWithLifecycle()
|
||||
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
|
||||
val jamActive by jamRoomService.role.map { it != null }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
|
||||
val pendingRequest = request ?: return
|
||||
|
||||
@ -143,33 +137,6 @@ fun PlayDestinationPickerHost() {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (jamActive) {
|
||||
ListRowTile(
|
||||
onClick = controller::playOnJam,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMusicPlaylist,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Jam Session",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
subtitle = {
|
||||
Text(
|
||||
text = "$actionLabel in the shared jam queue",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -306,6 +306,10 @@ class PlaylistViewModel(
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToJam -> {
|
||||
remotePlaybackController.addTrackToJam(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
queue.find { entry ->
|
||||
@ -378,6 +382,10 @@ class PlaylistViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.addTracksToJam(tracks)
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -248,6 +248,10 @@ class SavedTracksViewModel(
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToJam -> {
|
||||
remotePlaybackController.addTrackToJam(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
queue.find { entry ->
|
||||
@ -312,6 +316,10 @@ class SavedTracksViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.addTracksToJam(tracks)
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks")
|
||||
}
|
||||
|
||||
@ -86,6 +86,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||
import org.koin.compose.koinInject
|
||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
||||
@ -128,6 +130,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
val blacklistRepository: BlacklistRepository = koinInject()
|
||||
val navigationCommands: NavigationCommands = koinInject()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val jamRoomService: JamRoomService = koinInject()
|
||||
val jamActive by jamRoomService.role.map { it != null }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val selectedType = state.selectedSearchType
|
||||
val scope = rememberCoroutineScope()
|
||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||
@ -188,6 +193,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
}
|
||||
is TrackOptionsAction.AddToJam -> {
|
||||
remotePlaybackController.addTrackToJam(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
@ -248,6 +256,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results")
|
||||
}
|
||||
|
||||
fun bulkAddToJam(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.addTracksToJam(tracks)
|
||||
}
|
||||
|
||||
fun bulkPlayNext(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
|
||||
}
|
||||
@ -327,6 +339,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
onBulkAddToJam = ::bulkAddToJam,
|
||||
isInJam = jamActive,
|
||||
onArtistClick = { artist ->
|
||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||
},
|
||||
@ -356,6 +370,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
onBulkAddToJam = ::bulkAddToJam,
|
||||
isInJam = jamActive,
|
||||
onArtistClick = { artist ->
|
||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||
},
|
||||
@ -637,6 +653,8 @@ private fun SearchAllTab(
|
||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
|
||||
isInJam: Boolean,
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||
@ -694,6 +712,8 @@ private fun SearchAllTab(
|
||||
onBulkAddToQueue = onBulkAddToQueue,
|
||||
onBulkPlayNext = onBulkPlayNext,
|
||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||
onBulkAddToJam = onBulkAddToJam,
|
||||
isInJam = isInJam,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||
@ -781,6 +801,8 @@ private fun SearchTracksTab(
|
||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
|
||||
isInJam: Boolean,
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||
@ -810,6 +832,8 @@ private fun SearchTracksTab(
|
||||
onBulkAddToQueue = onBulkAddToQueue,
|
||||
onBulkPlayNext = onBulkPlayNext,
|
||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||
onBulkAddToJam = onBulkAddToJam,
|
||||
isInJam = isInJam,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,6 +119,7 @@ fun PlayerQueueContent(
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!isReadOnly) {
|
||||
IconButton(
|
||||
onClick = viewModel::clearQueue,
|
||||
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
|
||||
@ -125,6 +127,7 @@ fun PlayerQueueContent(
|
||||
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (displayItems.isEmpty()) {
|
||||
Text(
|
||||
@ -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,6 +260,7 @@ private fun QueueItemRow(
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
if (showOptions) {
|
||||
Box {
|
||||
GhostIconButton(
|
||||
onClick = { showMenu = true },
|
||||
@ -283,5 +289,6 @@ private fun QueueItemRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -21,6 +21,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@ -44,10 +46,13 @@ data class QueueContentUiState(
|
||||
val filterQuery: String = "",
|
||||
val displayItems: List<QueueItemUi> = emptyList(),
|
||||
val isFiltered: Boolean = false,
|
||||
/** Guests cannot reorder/remove/clear the shared jam queue. */
|
||||
val isReadOnly: Boolean = false,
|
||||
)
|
||||
|
||||
class PlayerQueueContentViewModel(
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val jamRoomService: JamRoomService,
|
||||
) : ViewModel() {
|
||||
private val queueVisibilityFlow = MutableStateFlow(false)
|
||||
private val queueFilterFlow = MutableStateFlow("")
|
||||
@ -114,7 +119,8 @@ class PlayerQueueContentViewModel(
|
||||
computedItems,
|
||||
reorderBuffer,
|
||||
queueFilterFlow,
|
||||
) { items, buffer, filterQuery ->
|
||||
jamRoomService.role,
|
||||
) { items, buffer, filterQuery, role ->
|
||||
val normalizedFilter = filterQuery.trim().lowercase()
|
||||
val isFiltered = normalizedFilter.isNotBlank()
|
||||
val filtered = if (isFiltered) {
|
||||
@ -129,6 +135,7 @@ class PlayerQueueContentViewModel(
|
||||
filterQuery = filterQuery,
|
||||
displayItems = buffer ?: filtered,
|
||||
isFiltered = isFiltered,
|
||||
isReadOnly = role == JamRole.Guest,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
@ -156,7 +163,7 @@ class PlayerQueueContentViewModel(
|
||||
}
|
||||
|
||||
fun removeQueueItem(index: Int) {
|
||||
if (index < 0) return
|
||||
if (index < 0 || queueContentUiState.value.isReadOnly) return
|
||||
viewModelScope.launch {
|
||||
val currentQueue = audioPlayerQueue.queueFlow.value
|
||||
if (index < currentQueue.size) {
|
||||
@ -167,12 +174,14 @@ class PlayerQueueContentViewModel(
|
||||
|
||||
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
|
||||
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
|
||||
if (queueContentUiState.value.isReadOnly) return
|
||||
viewModelScope.launch {
|
||||
audioPlayerQueue.move(fromIndex, toIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearQueue() {
|
||||
if (queueContentUiState.value.isReadOnly) return
|
||||
viewModelScope.launch {
|
||||
audioPlayerQueue.clear()
|
||||
}
|
||||
@ -180,11 +189,13 @@ class PlayerQueueContentViewModel(
|
||||
|
||||
fun onDragStart() {
|
||||
if (reorderBuffer.value != null) return
|
||||
if (queueContentUiState.value.isReadOnly) return
|
||||
val currentItems = queueContentUiState.value.displayItems
|
||||
reorderBuffer.value = currentItems.toList()
|
||||
}
|
||||
|
||||
fun onMove(from: Int, to: Int) {
|
||||
if (queueContentUiState.value.isReadOnly) return
|
||||
val buffer = reorderBuffer.value ?: return
|
||||
if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return
|
||||
val item = buffer[from]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user