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