mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 06:34:00 +00:00
feat(jam-session): enhance jam session functionality with improved playback synchronization and error handling
This commit is contained in:
parent
60e38f4868
commit
9ec7704d7b
@ -20,6 +20,7 @@ package dev.krtirtho.spotube.core.audioplayer
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
@ -51,11 +52,19 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
private val appContext: Context = (context as Context).applicationContext
|
private val appContext: Context = (context as Context).applicationContext
|
||||||
|
|
||||||
private fun ensureServiceStarted() {
|
private fun ensureServiceStarted() {
|
||||||
val intent = Intent(appContext, PlaybackService::class.java)
|
// On Android 12+ starting a foreground service from the background throws
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
// (ForegroundServiceStartNotAllowedException) — e.g. when a jam session or
|
||||||
appContext.startForegroundService(intent)
|
// remote control applies playback while the app is backgrounded. Never let
|
||||||
} else {
|
// that crash the app; playback itself runs in-process without the service.
|
||||||
appContext.startService(intent)
|
try {
|
||||||
|
val intent = Intent(appContext, PlaybackService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
appContext.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
appContext.startService(intent)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("AudioPlayer", "Failed to start playback service", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -86,7 +86,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
// On Android 15+ a service created from the background (e.g. by a jam
|
||||||
|
// sync or queue restoration) hits this in startForeground instead of at
|
||||||
|
// the startForegroundService call site. Don't crash: playback keeps
|
||||||
|
// running in-process, just without the notification until the app is
|
||||||
|
// foregrounded and the service can start properly.
|
||||||
|
try {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "startForeground not allowed; continuing without notification", e)
|
||||||
|
}
|
||||||
|
|
||||||
librarySession =
|
librarySession =
|
||||||
MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
|
MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
|
||||||
|
|||||||
@ -183,7 +183,16 @@ val sharedModules = module {
|
|||||||
viewModelOf(::BlacklistViewModel)
|
viewModelOf(::BlacklistViewModel)
|
||||||
viewModel { DevicesViewModel(get()) }
|
viewModel { DevicesViewModel(get()) }
|
||||||
viewModelOf(::RemoteControlViewModel)
|
viewModelOf(::RemoteControlViewModel)
|
||||||
viewModelOf(::JamViewModel)
|
viewModel {
|
||||||
|
JamViewModel(
|
||||||
|
jamSession = get(),
|
||||||
|
deepLinks = get(),
|
||||||
|
shareService = get(),
|
||||||
|
settingsProvider = get(),
|
||||||
|
audioPlayer = get(),
|
||||||
|
audioPlayerQueue = get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
singleOf(::AlbumRepository)
|
singleOf(::AlbumRepository)
|
||||||
@ -229,8 +238,8 @@ val sharedModules = module {
|
|||||||
single { RemoteControlService(get(), get(), get()) } withOptions {
|
single { RemoteControlService(get(), get(), get()) } withOptions {
|
||||||
createdAtStart()
|
createdAtStart()
|
||||||
}
|
}
|
||||||
single { RemotePlaybackController(get(), get(), get(), get()) }
|
single { RemotePlaybackController(get(), get(), get(), get(), get()) }
|
||||||
single { JamSessionService(get(), get()) }
|
single { JamSessionService(get(), get(), get()) }
|
||||||
singleOf(::JamDeepLinkService)
|
singleOf(::JamDeepLinkService)
|
||||||
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
||||||
single<AudioPlayerQueue> {
|
single<AudioPlayerQueue> {
|
||||||
|
|||||||
@ -17,8 +17,10 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.jam
|
package dev.krtirtho.spotube.core.jam
|
||||||
|
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.MediaItem
|
import dev.krtirtho.spotube.core.audioplayer.MediaItem
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@ -72,6 +74,13 @@ sealed class JamMessage {
|
|||||||
@SerialName("participantList")
|
@SerialName("participantList")
|
||||||
data class ParticipantList(val participants: List<JamParticipant>) : JamMessage()
|
data class ParticipantList(val participants: List<JamParticipant>) : JamMessage()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("kick")
|
||||||
|
data class Kick(
|
||||||
|
val participantId: String,
|
||||||
|
val reason: String = "kicked",
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("leave")
|
@SerialName("leave")
|
||||||
data class Leave(val reason: String = "user_left") : JamMessage()
|
data class Leave(val reason: String = "user_left") : JamMessage()
|
||||||
@ -123,6 +132,7 @@ sealed class PlaybackCmd {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class JamMediaItem(
|
data class JamMediaItem(
|
||||||
val url: String,
|
val url: String,
|
||||||
|
val trackId: String = "",
|
||||||
val title: String,
|
val title: String,
|
||||||
val artist: String,
|
val artist: String,
|
||||||
val album: String,
|
val album: String,
|
||||||
@ -131,6 +141,45 @@ data class JamMediaItem(
|
|||||||
val protocol: String,
|
val protocol: String,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
|
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
||||||
|
is QueueEntry.StreamingTrack -> JamMediaItem(
|
||||||
|
url = "",
|
||||||
|
trackId = entry.track.id,
|
||||||
|
title = entry.track.title,
|
||||||
|
artist = entry.track.artists.joinToString(", ") { it.name },
|
||||||
|
album = entry.track.album?.title.orEmpty(),
|
||||||
|
durationMs = entry.track.durationMs,
|
||||||
|
coverUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
|
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
|
.orEmpty(),
|
||||||
|
protocol = entry.protocol.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
is QueueEntry.LocalTrack -> JamMediaItem(
|
||||||
|
url = entry.url,
|
||||||
|
trackId = "",
|
||||||
|
title = entry.name,
|
||||||
|
artist = entry.artists.joinToString(", "),
|
||||||
|
album = entry.album.orEmpty(),
|
||||||
|
durationMs = entry.duration,
|
||||||
|
coverUrl = "",
|
||||||
|
protocol = "PROGRESSIVE",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem(
|
||||||
|
url = "",
|
||||||
|
trackId = track.id,
|
||||||
|
title = track.title,
|
||||||
|
artist = track.artists.joinToString(", ") { it.name },
|
||||||
|
album = track.album?.title.orEmpty(),
|
||||||
|
durationMs = track.durationMs,
|
||||||
|
coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
|
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
|
.orEmpty(),
|
||||||
|
protocol = "PROGRESSIVE",
|
||||||
|
)
|
||||||
|
|
||||||
fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
|
fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
|
||||||
url = item.url,
|
url = item.url,
|
||||||
title = item.title,
|
title = item.title,
|
||||||
@ -149,7 +198,7 @@ data class JamMediaItem(
|
|||||||
coverURL = item.coverUrl,
|
coverURL = item.coverUrl,
|
||||||
url = item.url,
|
url = item.url,
|
||||||
protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
.valueOf(item.protocol),
|
.valueOf(item.protocol.ifBlank { "PROGRESSIVE" }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.jam
|
|||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
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.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@ -49,13 +50,32 @@ data class JamInvite(
|
|||||||
val sdp: String,
|
val sdp: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the peer connections of a jam session (star topology: host relays state
|
||||||
|
* to all guests) and the hello/welcome handshake, participant bookkeeping and
|
||||||
|
* kick/ban. Playback & queue synchronization itself is delegated to
|
||||||
|
* [QueueSyncManager], which runs while a session is active.
|
||||||
|
*/
|
||||||
class JamSessionService(
|
class JamSessionService(
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val settingsProvider: SettingsProvider,
|
private val settingsProvider: SettingsProvider,
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
val logger by injectLogger<JamSessionService>()
|
val logger by injectLogger<JamSessionService>()
|
||||||
private val log = Logger.withTag("JamSessionService")
|
private val log = Logger.withTag("JamSessionService")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playback/queue synchronization. Owned by this service (not a Koin bean) so
|
||||||
|
* the two don't form a circular dependency; it's started/stopped with the
|
||||||
|
* session lifecycle.
|
||||||
|
*/
|
||||||
|
private val queueSyncManager = QueueSyncManager(
|
||||||
|
audioPlayer = audioPlayer,
|
||||||
|
audioPlayerQueue = audioPlayerQueue,
|
||||||
|
jamSession = this,
|
||||||
|
settingsProvider = settingsProvider,
|
||||||
|
)
|
||||||
|
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
classDiscriminator = "type"
|
classDiscriminator = "type"
|
||||||
@ -91,23 +111,36 @@ class JamSessionService(
|
|||||||
/** Host side: guests whose handshake completed. Keyed by invite id. */
|
/** Host side: guests whose handshake completed. Keyed by invite id. */
|
||||||
private val connectedGuests = mutableMapOf<String, WebrtcPeerConnection>()
|
private val connectedGuests = mutableMapOf<String, WebrtcPeerConnection>()
|
||||||
|
|
||||||
|
/** Host side: guest device ids, used for bans. */
|
||||||
|
private val guestDeviceIds = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
/** Host side: latest RTCPeerConnection state per guest ("connecting", "connected", "failed"...). */
|
||||||
|
private val guestConnectionStates = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
/** Host side: device ids banned for this session. */
|
||||||
|
private val bannedDeviceIds = mutableSetOf<String>()
|
||||||
|
|
||||||
/** Guest side: the single connection to the host. */
|
/** Guest side: the single connection to the host. */
|
||||||
private var hostConnection: WebrtcPeerConnection? = null
|
private var hostConnection: WebrtcPeerConnection? = null
|
||||||
|
|
||||||
|
private var hostDisplayName: String = "Host"
|
||||||
|
private var guestDisplayName: String = "Guest"
|
||||||
|
|
||||||
suspend fun createSession(): String {
|
suspend fun createSession(): String {
|
||||||
log.i { "Creating jam session" }
|
log.i { "Creating jam session" }
|
||||||
val hostName = resolveParticipantName(defaultPrefix = "Host")
|
hostDisplayName = resolveParticipantName(defaultPrefix = "Host")
|
||||||
|
|
||||||
_role.value = JamRole.Host
|
_role.value = JamRole.Host
|
||||||
_localParticipantId.value = "host"
|
_localParticipantId.value = "host"
|
||||||
_participants.value = listOf(
|
_participants.value = listOf(
|
||||||
JamParticipant(
|
JamParticipant(
|
||||||
id = "host",
|
id = "host",
|
||||||
displayName = hostName,
|
displayName = hostDisplayName,
|
||||||
isHost = true,
|
isHost = true,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
_isActive.value = true
|
_isActive.value = true
|
||||||
|
queueSyncManager.start()
|
||||||
|
|
||||||
return generateInvite().sdp
|
return generateInvite().sdp
|
||||||
}
|
}
|
||||||
@ -174,12 +207,13 @@ class JamSessionService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
log.i { "Guest $resolvedId ($peerName) joined" }
|
log.i { "Guest $resolvedId ($peerName) joined" }
|
||||||
|
broadcastParticipantList()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun joinSession(offerSdp: String, hostName: String? = null): String {
|
suspend fun joinSession(offerSdp: String, hostName: String? = null): String {
|
||||||
log.i { "Joining jam session" }
|
log.i { "Joining jam session" }
|
||||||
val participantName = resolveParticipantName(defaultPrefix = "Guest")
|
guestDisplayName = resolveParticipantName(defaultPrefix = "Guest")
|
||||||
|
|
||||||
val pc = createWebrtcPeerConnection(
|
val pc = createWebrtcPeerConnection(
|
||||||
iceServers = defaultIceServers(),
|
iceServers = defaultIceServers(),
|
||||||
@ -188,7 +222,7 @@ class JamSessionService(
|
|||||||
|
|
||||||
hostConnection = pc
|
hostConnection = pc
|
||||||
_role.value = JamRole.Guest
|
_role.value = JamRole.Guest
|
||||||
_localParticipantId.value = "guest"
|
_localParticipantId.value = null
|
||||||
_participants.value = listOf(
|
_participants.value = listOf(
|
||||||
JamParticipant(
|
JamParticipant(
|
||||||
id = "host",
|
id = "host",
|
||||||
@ -197,6 +231,7 @@ class JamSessionService(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
_isActive.value = true
|
_isActive.value = true
|
||||||
|
queueSyncManager.start()
|
||||||
|
|
||||||
// The data channel arrives in-band from the host's offer via on_data_channel;
|
// The data channel arrives in-band from the host's offer via on_data_channel;
|
||||||
// we only answer here.
|
// we only answer here.
|
||||||
@ -210,27 +245,52 @@ class JamSessionService(
|
|||||||
val payload = json.encodeToString(JamMessage.serializer(), message)
|
val payload = json.encodeToString(JamMessage.serializer(), message)
|
||||||
when (_role.value) {
|
when (_role.value) {
|
||||||
JamRole.Host -> {
|
JamRole.Host -> {
|
||||||
val targets = if (guestId != null) {
|
if (guestId != null) {
|
||||||
listOfNotNull(connectedGuests[guestId])
|
val pc = connectedGuests[guestId] ?: return
|
||||||
} else {
|
|
||||||
connectedGuests.values.toList()
|
|
||||||
}
|
|
||||||
targets.forEach { pc ->
|
|
||||||
runCatching { pc.sendData(CHANNEL_LABEL, payload) }
|
runCatching { pc.sendData(CHANNEL_LABEL, payload) }
|
||||||
.onFailure { e -> log.w(e) { "Failed to send to guest" } }
|
.onFailure { e ->
|
||||||
|
log.w(e) { "Failed to send to guest $guestId" }
|
||||||
|
onSendFailure(guestId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val dead = mutableListOf<String>()
|
||||||
|
connectedGuests.forEach { (id, pc) ->
|
||||||
|
runCatching { pc.sendData(CHANNEL_LABEL, payload) }
|
||||||
|
.onFailure { e ->
|
||||||
|
log.w(e) { "Failed to send to guest $id" }
|
||||||
|
dead += id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dead.forEach { id -> onSendFailure(id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
JamRole.Guest -> {
|
JamRole.Guest -> {
|
||||||
hostConnection?.sendData(CHANNEL_LABEL, payload)
|
runCatching { hostConnection?.sendData(CHANNEL_LABEL, payload) }
|
||||||
|
.onFailure { e ->
|
||||||
|
log.w(e) { "Failed to send to host" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
null -> log.w { "sendMessage called while no session is active" }
|
null -> log.w { "sendMessage called while no session is active" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A send to a guest failed. If that guest's connection has already given up
|
||||||
|
* (failed/closed), drop them from the session; while the connection is merely
|
||||||
|
* "connecting" the channel may simply not be open yet, so keep them.
|
||||||
|
*/
|
||||||
|
private fun onSendFailure(guestId: String) {
|
||||||
|
val state = guestConnectionStates[guestId]
|
||||||
|
if (state == "failed" || state == "closed" || state == "disconnected") {
|
||||||
|
scope.launch { removeGuest(guestId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun leave() {
|
suspend fun leave() {
|
||||||
log.i { "Leaving jam session" }
|
log.i { "Leaving jam session" }
|
||||||
|
queueSyncManager.stop()
|
||||||
runCatching { sendMessage(JamMessage.Leave()) }
|
runCatching { sendMessage(JamMessage.Leave()) }
|
||||||
shutdownAll()
|
shutdownAll()
|
||||||
_role.value = null
|
_role.value = null
|
||||||
@ -238,6 +298,9 @@ class JamSessionService(
|
|||||||
_isActive.value = false
|
_isActive.value = false
|
||||||
_isConnected.value = false
|
_isConnected.value = false
|
||||||
_localParticipantId.value = null
|
_localParticipantId.value = null
|
||||||
|
guestDeviceIds.clear()
|
||||||
|
guestConnectionStates.clear()
|
||||||
|
bannedDeviceIds.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun broadcastPlaybackCommand(command: PlaybackCmd) {
|
suspend fun broadcastPlaybackCommand(command: PlaybackCmd) {
|
||||||
@ -265,12 +328,44 @@ class JamSessionService(
|
|||||||
sendMessage(JamMessage.SuggestPlaylist(tracks))
|
sendMessage(JamMessage.SuggestPlaylist(tracks))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Host moderation ----------
|
||||||
|
|
||||||
|
suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") {
|
||||||
|
if (_role.value != JamRole.Host) return
|
||||||
|
log.i { "Kicking participant $participantId" }
|
||||||
|
sendMessage(JamMessage.Kick(participantId, reason), guestId = participantId)
|
||||||
|
removeGuest(participantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun banParticipant(participantId: String) {
|
||||||
|
if (_role.value != JamRole.Host) return
|
||||||
|
val deviceId = guestDeviceIds[participantId]
|
||||||
|
if (deviceId != null) {
|
||||||
|
bannedDeviceIds += deviceId
|
||||||
|
log.i { "Banning device $deviceId (participant $participantId)" }
|
||||||
|
}
|
||||||
|
kickParticipant(participantId, "banned by host")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun removeGuest(guestId: String) {
|
||||||
|
val pc = connectedGuests.remove(guestId)
|
||||||
|
runCatching { pc?.shutdown() }
|
||||||
|
guestDeviceIds.remove(guestId)
|
||||||
|
guestConnectionStates.remove(guestId)
|
||||||
|
_participants.update { current ->
|
||||||
|
current.filterNot { it.id == guestId }
|
||||||
|
}
|
||||||
|
broadcastParticipantList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun broadcastParticipantList() {
|
||||||
|
if (_role.value != JamRole.Host) return
|
||||||
|
sendMessage(JamMessage.ParticipantList(_participants.value))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ICE servers for global peer-to-peer jam sessions: multiple STUN servers for
|
* ICE servers for global peer-to-peer jam sessions: multiple STUN servers for
|
||||||
* NAT traversal plus a TURN relay for symmetric NATs and strict firewalls.
|
* NAT traversal plus a TURN relay for symmetric NATs and strict firewalls.
|
||||||
* Unreachable servers no longer stall offer/answer creation — the webrtc
|
|
||||||
* driver completes gathering once every STUN client has answered or timed out,
|
|
||||||
* and [WebrtcPeerConnection] bounds the wait anyway.
|
|
||||||
*/
|
*/
|
||||||
private fun defaultIceServers(): List<IceServerConfig> = listOf(
|
private fun defaultIceServers(): List<IceServerConfig> = listOf(
|
||||||
IceServerConfig(
|
IceServerConfig(
|
||||||
@ -295,6 +390,11 @@ class JamSessionService(
|
|||||||
?: "$defaultPrefix-${randomShortId()}"
|
?: "$defaultPrefix-${randomShortId()}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun localDeviceId(): String {
|
||||||
|
return settingsProvider.settingsState.value?.remoteControlDeviceId
|
||||||
|
?: "device-${randomShortId()}"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-guest handler so messages received on a guest's connection can be
|
* Per-guest handler so messages received on a guest's connection can be
|
||||||
* attributed back to that guest (needed for kick-on-leave and targeted sends).
|
* attributed back to that guest (needed for kick-on-leave and targeted sends).
|
||||||
@ -310,6 +410,10 @@ class JamSessionService(
|
|||||||
|
|
||||||
override fun onConnectionStateChange(state: String) {
|
override fun onConnectionStateChange(state: String) {
|
||||||
log.i { "[$guestId] Connection state: $state" }
|
log.i { "[$guestId] Connection state: $state" }
|
||||||
|
guestConnectionStates[guestId] = state
|
||||||
|
if (state == "failed" || state == "closed") {
|
||||||
|
scope.launch { removeGuest(guestId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDataChannelOpen(label: String) {
|
override fun onDataChannelOpen(label: String) {
|
||||||
@ -323,6 +427,9 @@ class JamSessionService(
|
|||||||
|
|
||||||
override fun onDataChannelClose(label: String) {
|
override fun onDataChannelClose(label: String) {
|
||||||
log.i { "[$guestId] Data channel closed" }
|
log.i { "[$guestId] Data channel closed" }
|
||||||
|
if (_role.value == JamRole.Host) {
|
||||||
|
scope.launch { removeGuest(guestId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -337,11 +444,19 @@ class JamSessionService(
|
|||||||
|
|
||||||
override fun onConnectionStateChange(state: String) {
|
override fun onConnectionStateChange(state: String) {
|
||||||
log.i { "Connection state: $state" }
|
log.i { "Connection state: $state" }
|
||||||
|
if (state == "failed" || state == "closed") {
|
||||||
|
scope.launch { leave() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDataChannelOpen(label: String) {
|
override fun onDataChannelOpen(label: String) {
|
||||||
log.i { "Data channel '$label' open" }
|
log.i { "Data channel '$label' open" }
|
||||||
_isConnected.value = true
|
_isConnected.value = true
|
||||||
|
// Introduce ourselves so the host can fill in our name and hand us
|
||||||
|
// our participant id.
|
||||||
|
scope.launch {
|
||||||
|
sendMessage(JamMessage.Hello(guestDisplayName, localDeviceId()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDataChannelMessage(label: String, data: String) {
|
override fun onDataChannelMessage(label: String, data: String) {
|
||||||
@ -350,6 +465,7 @@ class JamSessionService(
|
|||||||
|
|
||||||
override fun onDataChannelClose(label: String) {
|
override fun onDataChannelClose(label: String) {
|
||||||
log.i { "Data channel closed" }
|
log.i { "Data channel closed" }
|
||||||
|
scope.launch { leave() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -362,15 +478,44 @@ class JamSessionService(
|
|||||||
_incomingSuggestions.tryEmit(message)
|
_incomingSuggestions.tryEmit(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is JamMessage.Hello -> {
|
||||||
|
if (_role.value == JamRole.Host && fromGuestId != null) {
|
||||||
|
handleHello(fromGuestId, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is JamMessage.Welcome -> {
|
||||||
|
if (_role.value == JamRole.Guest) {
|
||||||
|
_localParticipantId.value = message.participantId
|
||||||
|
_participants.update { current ->
|
||||||
|
current.map { participant ->
|
||||||
|
if (participant.isHost) {
|
||||||
|
participant.copy(displayName = message.hostName.ifBlank { participant.displayName })
|
||||||
|
} else {
|
||||||
|
participant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.i { "Welcome: joined as ${message.participantId}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is JamMessage.ParticipantList -> {
|
||||||
|
if (_role.value == JamRole.Guest) {
|
||||||
|
_participants.value = message.participants
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is JamMessage.Kick -> {
|
||||||
|
if (_role.value == JamRole.Guest) {
|
||||||
|
log.i { "Kicked by host: ${message.reason}" }
|
||||||
|
scope.launch { leave() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is JamMessage.Leave -> {
|
is JamMessage.Leave -> {
|
||||||
if (_role.value == JamRole.Host && fromGuestId != null) {
|
if (_role.value == JamRole.Host && fromGuestId != null) {
|
||||||
val leavingPc = connectedGuests.remove(fromGuestId)
|
scope.launch { removeGuest(fromGuestId) }
|
||||||
scope.launch {
|
|
||||||
runCatching { leavingPc?.shutdown() }
|
|
||||||
}
|
|
||||||
_participants.update { current ->
|
|
||||||
current.filterNot { it.id == fromGuestId }
|
|
||||||
}
|
|
||||||
} else if (_role.value == JamRole.Guest) {
|
} else if (_role.value == JamRole.Guest) {
|
||||||
scope.launch { leave() }
|
scope.launch { leave() }
|
||||||
}
|
}
|
||||||
@ -383,6 +528,40 @@ class JamSessionService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleHello(guestId: String, hello: JamMessage.Hello) {
|
||||||
|
val deviceId = hello.deviceId
|
||||||
|
if (deviceId in bannedDeviceIds) {
|
||||||
|
log.w { "Rejecting banned device $deviceId" }
|
||||||
|
scope.launch {
|
||||||
|
sendMessage(
|
||||||
|
JamMessage.Kick(guestId, "banned by host"),
|
||||||
|
guestId = guestId,
|
||||||
|
)
|
||||||
|
removeGuest(guestId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guestDeviceIds[guestId] = deviceId
|
||||||
|
_participants.update { current ->
|
||||||
|
current.map { participant ->
|
||||||
|
if (participant.id == guestId) {
|
||||||
|
participant.copy(displayName = hello.displayName.ifBlank { participant.displayName })
|
||||||
|
} else {
|
||||||
|
participant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
sendMessage(
|
||||||
|
JamMessage.Welcome(hostDisplayName, guestId),
|
||||||
|
guestId = guestId,
|
||||||
|
)
|
||||||
|
broadcastParticipantList()
|
||||||
|
// Give the newly joined guest the current queue + playback state.
|
||||||
|
queueSyncManager.broadcastNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun shutdownAll() {
|
private suspend fun shutdownAll() {
|
||||||
pendingInvites.values.forEach { runCatching { it.shutdown() } }
|
pendingInvites.values.forEach { runCatching { it.shutdown() } }
|
||||||
connectedGuests.values.forEach { runCatching { it.shutdown() } }
|
connectedGuests.values.forEach { runCatching { it.shutdown() } }
|
||||||
|
|||||||
@ -18,46 +18,61 @@
|
|||||||
package dev.krtirtho.spotube.core.jam
|
package dev.krtirtho.spotube.core.jam
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
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.MediaItem
|
||||||
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.modules.settings.SettingsProvider
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages queue synchronization between the host and the jam session.
|
* Keeps playback in sync across a jam session (star topology).
|
||||||
*
|
*
|
||||||
* On the host: observes local playback state and broadcasts queue updates to guests.
|
* On the **host**: applies incoming playback commands and guest suggestions to the
|
||||||
* On the guest: receives queue updates and applies them to local playback.
|
* host's player, and broadcasts the current queue + playback state to all guests
|
||||||
|
* (on queue changes and periodically, so play/pause/seek/position propagate).
|
||||||
*
|
*
|
||||||
* Conflict resolution: the host has authority. When a guest receives a queue state,
|
* On the **guest**: mirrors the host's queue into the local player and applies
|
||||||
* it replaces the local queue. (and (The guest's local queue is essentially read-only
|
* playback commands. The guest's queue is read-only — the host has authority.
|
||||||
* during a jam session.)
|
|
||||||
*/
|
*/
|
||||||
class QueueSyncManager(
|
class QueueSyncManager(
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val jamSession: JamSessionService,
|
private val jamSession: JamSessionService,
|
||||||
private val scope: CoroutineScope,
|
private val settingsProvider: SettingsProvider,
|
||||||
) {
|
) {
|
||||||
private val log = Logger.withTag("QueueSyncManager")
|
private val log = Logger.withTag("QueueSyncManager")
|
||||||
private val json = Json {
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
ignoreUnknownKeys = true
|
|
||||||
classDiscriminator = "type"
|
|
||||||
encodeDefaults = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _isSyncing = MutableStateFlow(false)
|
private val _isSyncing = MutableStateFlow(false)
|
||||||
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
|
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
|
||||||
|
|
||||||
private var hostBroadcastJob: Job? = null
|
private var hostBroadcastJob: Job? = null
|
||||||
|
private var hostCommandJob: Job? = null
|
||||||
private var guestApplyJob: Job? = null
|
private var guestApplyJob: Job? = null
|
||||||
private var guestCommandJob: Job? = null
|
private var guestCommandJob: Job? = null
|
||||||
|
|
||||||
|
/** Guest side: the last applied queue snapshot, used to detect real queue changes. */
|
||||||
|
private var lastAppliedItems: List<JamMediaItem> = emptyList()
|
||||||
|
|
||||||
|
/** Guest side: tracks the player was told to start playing from. */
|
||||||
|
private var lastAppliedCurrentIndex = -1
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
if (_isSyncing.value) return
|
if (_isSyncing.value) return
|
||||||
_isSyncing.value = true
|
_isSyncing.value = true
|
||||||
@ -75,43 +90,87 @@ class QueueSyncManager(
|
|||||||
fun stop() {
|
fun stop() {
|
||||||
_isSyncing.value = false
|
_isSyncing.value = false
|
||||||
hostBroadcastJob?.cancel()
|
hostBroadcastJob?.cancel()
|
||||||
|
hostCommandJob?.cancel()
|
||||||
guestApplyJob?.cancel()
|
guestApplyJob?.cancel()
|
||||||
guestCommandJob?.cancel()
|
guestCommandJob?.cancel()
|
||||||
hostBroadcastJob = null
|
hostBroadcastJob = null
|
||||||
|
hostCommandJob = null
|
||||||
guestApplyJob = null
|
guestApplyJob = null
|
||||||
guestCommandJob = null
|
guestCommandJob = null
|
||||||
|
lastAppliedItems = emptyList()
|
||||||
|
lastAppliedCurrentIndex = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Host side ----------
|
||||||
|
|
||||||
private fun startHostSync() {
|
private fun startHostSync() {
|
||||||
|
// Apply commands/suggestions coming from guests.
|
||||||
|
hostCommandJob = scope.launch {
|
||||||
|
jamSession.role.first { it != null }
|
||||||
|
if (jamSession.role.value != JamRole.Host) return@launch
|
||||||
|
|
||||||
|
jamSession.incomingMessages.collect { message ->
|
||||||
|
when (message) {
|
||||||
|
is JamMessage.PlaybackCommand -> applyPlaybackCommand(message.command)
|
||||||
|
is JamMessage.SuggestTrack -> acceptSuggestion(listOf(message.mediaItem))
|
||||||
|
is JamMessage.SuggestPlaylist -> acceptSuggestion(message.tracks)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast state on queue changes and periodically.
|
||||||
hostBroadcastJob = scope.launch {
|
hostBroadcastJob = scope.launch {
|
||||||
jamSession.role.first { it != null }
|
jamSession.role.first { it != null }
|
||||||
if (jamSession.role.value != JamRole.Host) return@launch
|
if (jamSession.role.value != JamRole.Host) return@launch
|
||||||
|
|
||||||
jamSession.broadcastQueueState(
|
// Queue changes (separate coroutine — collect() never returns).
|
||||||
items = audioPlayer.playlistFlow.value.map(JamMediaItem::fromMediaItem),
|
launch {
|
||||||
currentIndex = audioPlayer.playlistFlow.value.indexOf(
|
audioPlayerQueue.queueFlow.collect {
|
||||||
audioPlayer.currentMediaItemFlow.value
|
broadcastCurrentState()
|
||||||
).coerceAtLeast(0),
|
|
||||||
isPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING,
|
|
||||||
positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
audioPlayer.playlistFlow.collect { playlist ->
|
|
||||||
audioPlayer.playerStateFlow.value.let { state ->
|
|
||||||
audioPlayer.positionFlow.value.let { position ->
|
|
||||||
jamSession.broadcastQueueState(
|
|
||||||
items = playlist.map(JamMediaItem::fromMediaItem),
|
|
||||||
currentIndex = playlist.indexOf(audioPlayer.currentMediaItemFlow.value)
|
|
||||||
.coerceAtLeast(0),
|
|
||||||
isPlaying = state == PlayerState.PLAYING,
|
|
||||||
positionMs = position.inWholeMilliseconds,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Periodic tick so play/pause/seek/position propagate to guests.
|
||||||
|
while (isActive) {
|
||||||
|
delay(2_000)
|
||||||
|
broadcastCurrentState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Immediately pushes the current queue + playback state to all guests. */
|
||||||
|
suspend fun broadcastNow() {
|
||||||
|
if (jamSession.role.value == JamRole.Host) {
|
||||||
|
broadcastCurrentState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun broadcastCurrentState() {
|
||||||
|
val queue = audioPlayerQueue.getQueue()
|
||||||
|
val current = audioPlayerQueue.getCurrentQueueEntry()
|
||||||
|
val currentIndex = if (current != null) {
|
||||||
|
queue.indexOfFirst { it.matchesEntry(current) }
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
jamSession.broadcastQueueState(
|
||||||
|
items = queue.map(JamMediaItem::fromQueueEntry),
|
||||||
|
currentIndex = currentIndex.coerceAtLeast(0),
|
||||||
|
isPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING,
|
||||||
|
positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun acceptSuggestion(items: List<JamMediaItem>) {
|
||||||
|
if (items.isEmpty()) return
|
||||||
|
val entries = items.map { it.toQueueEntry() }
|
||||||
|
log.i { "Accepting ${entries.size} suggested item(s) into the jam queue" }
|
||||||
|
audioPlayerQueue.addAllToQueue(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Guest side ----------
|
||||||
|
|
||||||
private fun startGuestSync() {
|
private fun startGuestSync() {
|
||||||
guestApplyJob = scope.launch {
|
guestApplyJob = scope.launch {
|
||||||
jamSession.incomingMessages.collect { message ->
|
jamSession.incomingMessages.collect { message ->
|
||||||
@ -130,33 +189,162 @@ class QueueSyncManager(
|
|||||||
|
|
||||||
private suspend fun applyQueueState(state: JamMessage.QueueState) {
|
private suspend fun applyQueueState(state: JamMessage.QueueState) {
|
||||||
log.d { "Applying queue state: ${state.items.size} items, current=${state.currentIndex}" }
|
log.d { "Applying queue state: ${state.items.size} items, current=${state.currentIndex}" }
|
||||||
val mediaItems = state.items.map(JamMediaItem::toMediaItem)
|
|
||||||
audioPlayer.load(
|
// Items that carry neither a track id nor a usable URL can't be played
|
||||||
playlist = mediaItems,
|
// on this device — skip them instead of crashing the player.
|
||||||
autoPlay = state.isPlaying,
|
val playableItems = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
|
||||||
startPosition = state.currentIndex.coerceAtLeast(0),
|
|
||||||
|
val queueChanged = playableItems != lastAppliedItems
|
||||||
|
if (queueChanged) {
|
||||||
|
lastAppliedItems = playableItems
|
||||||
|
lastAppliedCurrentIndex = state.currentIndex
|
||||||
|
val mediaItems = playableItems.map { it.toPlayableMediaItem() }
|
||||||
|
runCatching {
|
||||||
|
audioPlayer.load(
|
||||||
|
playlist = mediaItems,
|
||||||
|
autoPlay = state.isPlaying,
|
||||||
|
startPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex.coerceAtLeast(0)),
|
||||||
|
)
|
||||||
|
}.onFailure { e ->
|
||||||
|
log.e(e) { "Failed to apply jam queue to local player" }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same queue: just sync playback state. Avoid seeking on every tick unless
|
||||||
|
// the drift is meaningful.
|
||||||
|
if (state.currentIndex != lastAppliedCurrentIndex) {
|
||||||
|
lastAppliedCurrentIndex = state.currentIndex
|
||||||
|
runCatching { audioPlayer.jumpTo(state.currentIndex.coerceAtLeast(0)) }
|
||||||
|
.onFailure { e -> log.w(e) { "Failed to jump to index ${state.currentIndex}" } }
|
||||||
|
}
|
||||||
|
val currentState = audioPlayer.playerStateFlow.value
|
||||||
|
if (state.isPlaying && currentState != PlayerState.PLAYING) {
|
||||||
|
audioPlayer.play()
|
||||||
|
} else if (!state.isPlaying && currentState == PlayerState.PLAYING) {
|
||||||
|
audioPlayer.pause()
|
||||||
|
}
|
||||||
|
val driftMs = kotlin.math.abs(
|
||||||
|
audioPlayer.positionFlow.value.inWholeMilliseconds - state.positionMs
|
||||||
)
|
)
|
||||||
|
if (driftMs > POSITION_SYNC_THRESHOLD_MS) {
|
||||||
|
runCatching { audioPlayer.seekTo(kotlin.time.Duration.parse("${state.positionMs}ms")) }
|
||||||
|
.onFailure { e -> log.w(e) { "Failed to sync position" } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun applyPlaybackCommand(command: PlaybackCmd) {
|
private suspend fun applyPlaybackCommand(command: PlaybackCmd) {
|
||||||
log.d { "Applying playback command: $command" }
|
log.d { "Applying playback command: $command" }
|
||||||
when (command) {
|
runCatching {
|
||||||
PlaybackCmd.Play -> audioPlayer.play()
|
when (command) {
|
||||||
PlaybackCmd.Pause -> audioPlayer.pause()
|
PlaybackCmd.Play -> audioPlayer.play()
|
||||||
PlaybackCmd.Toggle -> {
|
PlaybackCmd.Pause -> audioPlayer.pause()
|
||||||
if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) {
|
PlaybackCmd.Toggle -> {
|
||||||
audioPlayer.pause()
|
if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) {
|
||||||
} else {
|
audioPlayer.pause()
|
||||||
audioPlayer.play()
|
} else {
|
||||||
|
audioPlayer.play()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms"))
|
||||||
|
PlaybackCmd.SkipNext -> audioPlayer.skipToNext()
|
||||||
|
PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious()
|
||||||
|
is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume)
|
||||||
|
is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop))
|
||||||
|
is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled)
|
||||||
|
is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index)
|
||||||
}
|
}
|
||||||
is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms"))
|
}.onFailure { e ->
|
||||||
PlaybackCmd.SkipNext -> audioPlayer.skipToNext()
|
log.w(e) { "Failed to apply playback command: $command" }
|
||||||
PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious()
|
|
||||||
is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume)
|
|
||||||
is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop))
|
|
||||||
is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled)
|
|
||||||
is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Conversions ----------
|
||||||
|
|
||||||
|
/** Host side: turn a suggested item into a playable queue entry. */
|
||||||
|
private fun JamMediaItem.toQueueEntry(): QueueEntry = when {
|
||||||
|
trackId.isNotBlank() -> QueueEntry.StreamingTrack(
|
||||||
|
track = MetadataTrack(
|
||||||
|
id = trackId,
|
||||||
|
title = title,
|
||||||
|
durationMs = durationMs,
|
||||||
|
trackNumber = null,
|
||||||
|
discNumber = null,
|
||||||
|
artists = listOf(
|
||||||
|
MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null)
|
||||||
|
),
|
||||||
|
album = null,
|
||||||
|
thumbnails = null,
|
||||||
|
explicit = null,
|
||||||
|
popularity = null,
|
||||||
|
isrcCode = null,
|
||||||
|
externalUri = null,
|
||||||
|
),
|
||||||
|
url = "",
|
||||||
|
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
|
||||||
|
.getOrDefault(StreamProtocol.PROGRESSIVE),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> QueueEntry.LocalTrack(
|
||||||
|
name = title,
|
||||||
|
artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() },
|
||||||
|
duration = durationMs,
|
||||||
|
album = album.ifBlank { null },
|
||||||
|
coverBytes = null,
|
||||||
|
url = url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guest side: build a playable MediaItem. Streaming tracks have their stream
|
||||||
|
* URL resolved through this device's own playback proxy (the host never sends
|
||||||
|
* usable URLs — each guest must fetch from its own plugins).
|
||||||
|
*/
|
||||||
|
private suspend fun JamMediaItem.toPlayableMediaItem(): MediaItem {
|
||||||
|
if (trackId.isNotBlank()) {
|
||||||
|
val proxyUrl = buildStreamingUrl(trackId, protocol)
|
||||||
|
return MediaItem(
|
||||||
|
title = title,
|
||||||
|
artist = artist,
|
||||||
|
album = album,
|
||||||
|
duration = kotlin.time.Duration.parse("${durationMs}ms"),
|
||||||
|
coverURL = coverUrl,
|
||||||
|
url = proxyUrl,
|
||||||
|
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
|
||||||
|
.getOrDefault(StreamProtocol.PROGRESSIVE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return JamMediaItem.toMediaItem(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildStreamingUrl(trackId: String, protocol: String): String {
|
||||||
|
val port = settingsProvider.settingsState
|
||||||
|
.first()
|
||||||
|
?.playbackProxyServerPort ?: return ""
|
||||||
|
val baseUrl = "http://127.0.0.1:$port"
|
||||||
|
val streamProtocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
|
||||||
|
.getOrDefault(StreamProtocol.PROGRESSIVE)
|
||||||
|
return when (streamProtocol) {
|
||||||
|
StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId"
|
||||||
|
StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean {
|
||||||
|
return when {
|
||||||
|
this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack ->
|
||||||
|
this.track.id == other.track.id
|
||||||
|
|
||||||
|
this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack ->
|
||||||
|
this.url == other.url && this.name == other.name
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Seek the guest only when its position drifts more than this from the host. */
|
||||||
|
private const val POSITION_SYNC_THRESHOLD_MS = 3_000L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -26,6 +26,7 @@ import dev.krtirtho.spotube.modules.artist.ArtistRepository
|
|||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
|
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
|
||||||
|
|
||||||
class CollectionPlaybackHelper(
|
class CollectionPlaybackHelper(
|
||||||
private val albumRepository: AlbumRepository,
|
private val albumRepository: AlbumRepository,
|
||||||
@ -264,6 +265,21 @@ class CollectionPlaybackHelper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the tracks of a collection without loading them into the local
|
||||||
|
* queue — used to suggest a collection into a jam session from a guest.
|
||||||
|
*/
|
||||||
|
suspend fun resolveCollectionTracks(type: RemoteCollectionType, id: String): List<MetadataTrack> =
|
||||||
|
when (type) {
|
||||||
|
RemoteCollectionType.Playlist -> fetchAllPlaylistTracks(id).asTracks()
|
||||||
|
RemoteCollectionType.Album -> fetchAllAlbumTracks(id).asTracks()
|
||||||
|
RemoteCollectionType.ArtistTopTracks -> fetchArtistTopTracks(id).asTracks()
|
||||||
|
RemoteCollectionType.SavedTracks -> fetchAllSavedTracks().asTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<QueueEntry>.asTracks(): List<MetadataTrack> =
|
||||||
|
mapNotNull { (it as? QueueEntry.StreamingTrack)?.track }
|
||||||
|
|
||||||
private suspend fun fetchAllSavedTracks(): List<QueueEntry> {
|
private suspend fun fetchAllSavedTracks(): List<QueueEntry> {
|
||||||
val allTracks = mutableListOf<MetadataTrack>()
|
val allTracks = mutableListOf<MetadataTrack>()
|
||||||
var pagination = savedTracksRepository.getSavedTracks()
|
var pagination = savedTracksRepository.getSavedTracks()
|
||||||
|
|||||||
@ -21,6 +21,9 @@ import co.touchlab.kermit.Logger
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
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.JamMediaItem
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamSessionService
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@ -84,6 +87,7 @@ class RemotePlaybackController(
|
|||||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
|
private val jamSession: JamSessionService,
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
private val logger = Logger.withTag("RemotePlaybackController")
|
private val logger = Logger.withTag("RemotePlaybackController")
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
@ -154,6 +158,49 @@ class RemotePlaybackController(
|
|||||||
_pendingRequest.value = null
|
_pendingRequest.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
fun playOnJam() {
|
||||||
|
val request = _pendingRequest.value ?: return
|
||||||
|
_pendingRequest.value = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
when (jamSession.role.value) {
|
||||||
|
JamRole.Host -> executeLocally(request)
|
||||||
|
JamRole.Guest -> suggestToJam(request)
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.e(e) { "Failed to send content to jam session" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun suggestToJam(request: PlaybackDestinationRequest) {
|
||||||
|
when (request) {
|
||||||
|
is PlaybackDestinationRequest.Collection -> {
|
||||||
|
val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id)
|
||||||
|
if (tracks.isNotEmpty()) {
|
||||||
|
jamSession.suggestPlaylist(tracks.map { it.toJamMediaItem() })
|
||||||
|
logger.i { "Suggested ${tracks.size} track(s) to the jam session" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaybackDestinationRequest.Track -> {
|
||||||
|
jamSession.suggestTrack(request.track.toJamMediaItem())
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaybackDestinationRequest.Tracks -> {
|
||||||
|
if (request.tracks.isNotEmpty()) {
|
||||||
|
jamSession.suggestPlaylist(request.tracks.map { it.toJamMediaItem() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Internals ----------
|
// ---------- Internals ----------
|
||||||
|
|
||||||
private fun request(request: PlaybackDestinationRequest) {
|
private fun request(request: PlaybackDestinationRequest) {
|
||||||
@ -321,4 +368,6 @@ class RemotePlaybackController(
|
|||||||
album?.id == other.album?.id &&
|
album?.id == other.album?.id &&
|
||||||
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
|
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun MetadataTrack.toJamMediaItem(): JamMediaItem = JamMediaItem.fromTrack(this)
|
||||||
@ -30,6 +30,7 @@ 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.JamSessionService
|
||||||
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
|
||||||
@ -39,19 +40,22 @@ 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 org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Globally hosted dialog shown when a remote device is connected and the user
|
* Globally hosted dialog shown when the user tries to play / add to queue /
|
||||||
* tries to play / add to queue / play next. Lets the user choose between the
|
* play next and there is more than one place it could go (a connected remote
|
||||||
* local device and the connected remote device(s).
|
* device and/or an active jam session). Lets the user choose the destination.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayDestinationPickerHost() {
|
fun PlayDestinationPickerHost() {
|
||||||
val controller = koinInject<RemotePlaybackController>()
|
val controller = koinInject<RemotePlaybackController>()
|
||||||
val remoteControlClient = koinInject<RemoteControlClient>()
|
val remoteControlClient = koinInject<RemoteControlClient>()
|
||||||
|
val jamSession = koinInject<JamSessionService>()
|
||||||
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 jamSession.isActive.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val pendingRequest = request ?: return
|
val pendingRequest = request ?: return
|
||||||
|
|
||||||
@ -111,30 +115,59 @@ fun PlayDestinationPickerHost() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
ListRowTile(
|
if (connectionState is ConnectionState.Connected) {
|
||||||
onClick = controller::playOnRemote,
|
ListRowTile(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onClick = controller::playOnRemote,
|
||||||
leading = {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Icon(
|
leading = {
|
||||||
imageVector = Iconsax.IconsaxMirroringScreen,
|
Icon(
|
||||||
contentDescription = null,
|
imageVector = Iconsax.IconsaxMirroringScreen,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
contentDescription = null,
|
||||||
)
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
},
|
)
|
||||||
title = {
|
},
|
||||||
Text(
|
title = {
|
||||||
text = remoteDeviceName,
|
Text(
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
text = remoteDeviceName,
|
||||||
)
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
},
|
)
|
||||||
subtitle = {
|
},
|
||||||
Text(
|
subtitle = {
|
||||||
text = "$actionLabel on the connected device",
|
Text(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
text = "$actionLabel on the connected device",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
},
|
)
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
|||||||
@ -17,17 +17,25 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.jam
|
package dev.krtirtho.spotube.modules.jam
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@ -45,15 +53,31 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -99,16 +123,34 @@ fun JamScreen(
|
|||||||
|
|
||||||
state.role == JamRole.Host -> HostSessionView(
|
state.role == JamRole.Host -> HostSessionView(
|
||||||
state = state,
|
state = state,
|
||||||
|
playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value,
|
||||||
onNewInvite = viewModel::generateNewInvite,
|
onNewInvite = viewModel::generateNewInvite,
|
||||||
onSubmitAnswer = viewModel::submitAnswerPasted,
|
onSubmitAnswer = viewModel::submitAnswerPasted,
|
||||||
onShare = viewModel::share,
|
onShare = viewModel::share,
|
||||||
onLeave = viewModel::leave,
|
onLeave = viewModel::leave,
|
||||||
|
onTogglePlayPause = viewModel::togglePlayPause,
|
||||||
|
onSkipNext = viewModel::skipNext,
|
||||||
|
onSkipPrevious = viewModel::skipPrevious,
|
||||||
|
onSeek = viewModel::seek,
|
||||||
|
onJumpTo = viewModel::jumpTo,
|
||||||
|
onToggleShuffle = viewModel::toggleShuffle,
|
||||||
|
onCycleLoop = viewModel::cycleLoopMode,
|
||||||
|
onKick = viewModel::kickParticipant,
|
||||||
|
onBan = viewModel::banParticipant,
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> GuestSessionView(
|
else -> GuestSessionView(
|
||||||
state = state,
|
state = state,
|
||||||
|
playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value,
|
||||||
onShare = viewModel::share,
|
onShare = viewModel::share,
|
||||||
onLeave = viewModel::leave,
|
onLeave = viewModel::leave,
|
||||||
|
onTogglePlayPause = viewModel::togglePlayPause,
|
||||||
|
onSkipNext = viewModel::skipNext,
|
||||||
|
onSkipPrevious = viewModel::skipPrevious,
|
||||||
|
onSeek = viewModel::seek,
|
||||||
|
onJumpTo = viewModel::jumpTo,
|
||||||
|
onToggleShuffle = viewModel::toggleShuffle,
|
||||||
|
onCycleLoop = viewModel::cycleLoopMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -220,16 +262,35 @@ private fun IncomingInviteView(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun HostSessionView(
|
private fun HostSessionView(
|
||||||
state: JamUiState,
|
state: JamUiState,
|
||||||
|
playerState: JamPlayerUiState,
|
||||||
onNewInvite: () -> Unit,
|
onNewInvite: () -> Unit,
|
||||||
onSubmitAnswer: (String) -> Unit,
|
onSubmitAnswer: (String) -> Unit,
|
||||||
onShare: (String) -> Unit,
|
onShare: (String) -> Unit,
|
||||||
onLeave: () -> Unit,
|
onLeave: () -> Unit,
|
||||||
|
onTogglePlayPause: () -> Unit,
|
||||||
|
onSkipNext: () -> Unit,
|
||||||
|
onSkipPrevious: () -> Unit,
|
||||||
|
onSeek: (Long) -> Unit,
|
||||||
|
onJumpTo: (Int) -> Unit,
|
||||||
|
onToggleShuffle: () -> Unit,
|
||||||
|
onCycleLoop: () -> Unit,
|
||||||
|
onKick: (String) -> Unit,
|
||||||
|
onBan: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboardManager.current
|
||||||
var pastedAnswer by rememberSaveable { mutableStateOf("") }
|
var pastedAnswer by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
ParticipantsSection(state.participants)
|
ParticipantsSection(state.participants, isHost = true, onKick = onKick, onBan = onBan)
|
||||||
|
|
||||||
|
JamNowPlayingView(
|
||||||
|
playerState = playerState,
|
||||||
|
onTogglePlayPause = onTogglePlayPause,
|
||||||
|
onSkipNext = onSkipNext,
|
||||||
|
onSkipPrevious = onSkipPrevious,
|
||||||
|
onToggleShuffle = onToggleShuffle,
|
||||||
|
onCycleLoop = onCycleLoop,
|
||||||
|
)
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
@ -279,6 +340,13 @@ private fun HostSessionView(
|
|||||||
Text("Accept Answer")
|
Text("Accept Answer")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
JamQueueView(
|
||||||
|
queue = playerState.queue,
|
||||||
|
onJumpTo = onJumpTo,
|
||||||
|
)
|
||||||
|
|
||||||
LeaveButton(onLeave)
|
LeaveButton(onLeave)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -286,21 +354,37 @@ private fun HostSessionView(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun GuestSessionView(
|
private fun GuestSessionView(
|
||||||
state: JamUiState,
|
state: JamUiState,
|
||||||
|
playerState: JamPlayerUiState,
|
||||||
onShare: (String) -> Unit,
|
onShare: (String) -> Unit,
|
||||||
onLeave: () -> Unit,
|
onLeave: () -> Unit,
|
||||||
|
onTogglePlayPause: () -> Unit,
|
||||||
|
onSkipNext: () -> Unit,
|
||||||
|
onSkipPrevious: () -> Unit,
|
||||||
|
onSeek: (Long) -> Unit,
|
||||||
|
onJumpTo: (Int) -> Unit,
|
||||||
|
onToggleShuffle: () -> Unit,
|
||||||
|
onCycleLoop: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboardManager.current
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
ParticipantsSection(state.participants)
|
ParticipantsSection(state.participants, isHost = false, onKick = {}, onBan = {})
|
||||||
|
|
||||||
val answerLink = state.answerLink
|
val answerLink = state.answerLink
|
||||||
when {
|
when {
|
||||||
state.isConnected -> {
|
state.isConnected -> {
|
||||||
Text(
|
JamNowPlayingView(
|
||||||
text = "Connected to the session",
|
playerState = playerState,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
onTogglePlayPause = onTogglePlayPause,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
onSkipNext = onSkipNext,
|
||||||
|
onSkipPrevious = onSkipPrevious,
|
||||||
|
onToggleShuffle = onToggleShuffle,
|
||||||
|
onCycleLoop = onCycleLoop,
|
||||||
|
)
|
||||||
|
|
||||||
|
JamQueueView(
|
||||||
|
queue = playerState.queue,
|
||||||
|
onJumpTo = onJumpTo,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -331,7 +415,231 @@ private fun GuestSessionView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ParticipantsSection(participants: List<dev.krtirtho.spotube.core.jam.JamParticipant>) {
|
private fun JamNowPlayingView(
|
||||||
|
playerState: JamPlayerUiState,
|
||||||
|
onTogglePlayPause: () -> Unit,
|
||||||
|
onSkipNext: () -> Unit,
|
||||||
|
onSkipPrevious: () -> Unit,
|
||||||
|
onToggleShuffle: () -> Unit,
|
||||||
|
onCycleLoop: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = playerState.currentCoverUrl?.takeIf { it.isNotBlank() },
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium),
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = playerState.currentTitle ?: "Nothing playing",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = playerState.currentArtist ?: "—",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = formatJamDuration(playerState.positionMs),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatJamDuration(playerState.durationMs),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onToggleShuffle,
|
||||||
|
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxShuffle,
|
||||||
|
contentDescription = "Shuffle",
|
||||||
|
tint = if (playerState.shuffleEnabled) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onSkipPrevious,
|
||||||
|
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
|
||||||
|
) {
|
||||||
|
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onTogglePlayPause,
|
||||||
|
theme = LocalBaseUITheme.current.iconButtons.primary.copyShape(CircleShape),
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (playerState.isPlaying) {
|
||||||
|
Iconsax.IconsaxPause
|
||||||
|
} else {
|
||||||
|
Iconsax.IconsaxPlay
|
||||||
|
},
|
||||||
|
contentDescription = if (playerState.isPlaying) "Pause" else "Play",
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onSkipNext,
|
||||||
|
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
|
||||||
|
) {
|
||||||
|
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onCycleLoop,
|
||||||
|
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxRepeateMusic,
|
||||||
|
contentDescription = "Loop mode",
|
||||||
|
tint = if (playerState.loopMode != "none") {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun JamQueueView(
|
||||||
|
queue: List<JamQueueUiItem>,
|
||||||
|
onJumpTo: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { expanded = !expanded },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Queue (${queue.size})",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxArrowDown4,
|
||||||
|
contentDescription = if (expanded) "Collapse queue" else "Expand queue",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.graphicsLayer { rotationZ = if (expanded) 180f else 0f },
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queue.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "The queue is empty. Add tracks from anywhere in the app — the jam queue is shared.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else if (expanded) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 280.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(queue) { index, item ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onJumpTo(index) }
|
||||||
|
.padding(vertical = 6.dp, horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = item.coverUrl.takeIf { it.isNotBlank() },
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(MaterialTheme.shapes.small),
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = item.title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = if (item.isCurrent) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = item.artist,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = formatJamDuration(item.durationMs),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Tap to view the shared queue.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ParticipantsSection(
|
||||||
|
participants: List<dev.krtirtho.spotube.core.jam.JamParticipant>,
|
||||||
|
isHost: Boolean,
|
||||||
|
onKick: (String) -> Unit,
|
||||||
|
onBan: (String) -> Unit,
|
||||||
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Participants (${participants.size})",
|
text = "Participants (${participants.size})",
|
||||||
@ -357,11 +665,32 @@ private fun ParticipantsSection(participants: List<dev.krtirtho.spotube.core.jam
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (isHost && !participant.isHost) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onKick(participant.id) },
|
||||||
|
modifier = Modifier.height(32.dp),
|
||||||
|
) {
|
||||||
|
Text("Kick", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onBan(participant.id) },
|
||||||
|
modifier = Modifier.height(32.dp),
|
||||||
|
) {
|
||||||
|
Text("Ban", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatJamDuration(ms: Long): String {
|
||||||
|
val totalSeconds = (ms / 1000).coerceAtLeast(0)
|
||||||
|
val minutes = totalSeconds / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
return "$minutes:${seconds.toString().padStart(2, '0')}"
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ShareableLinkBox(
|
private fun ShareableLinkBox(
|
||||||
label: String,
|
label: String,
|
||||||
|
|||||||
@ -20,18 +20,31 @@ package dev.krtirtho.spotube.modules.jam
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dev.krtirtho.spotube.PlatformType
|
import dev.krtirtho.spotube.PlatformType
|
||||||
|
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.MediaItem
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService
|
import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService
|
||||||
import dev.krtirtho.spotube.core.jam.JamInviteCodec
|
import dev.krtirtho.spotube.core.jam.JamInviteCodec
|
||||||
import dev.krtirtho.spotube.core.jam.JamInviteLink
|
import dev.krtirtho.spotube.core.jam.JamInviteLink
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamLoopMapping
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamMediaItem
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamMessage
|
||||||
import dev.krtirtho.spotube.core.jam.JamParticipant
|
import dev.krtirtho.spotube.core.jam.JamParticipant
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
import dev.krtirtho.spotube.core.jam.JamSessionService
|
import dev.krtirtho.spotube.core.jam.JamSessionService
|
||||||
|
import dev.krtirtho.spotube.core.jam.PlaybackCmd
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
import dev.krtirtho.spotube.getPlatform
|
import dev.krtirtho.spotube.getPlatform
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@ -50,11 +63,36 @@ data class JamUiState(
|
|||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class JamQueueUiItem(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val artist: String,
|
||||||
|
val album: String,
|
||||||
|
val durationMs: Long,
|
||||||
|
val coverUrl: String,
|
||||||
|
val isCurrent: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class JamPlayerUiState(
|
||||||
|
val queue: List<JamQueueUiItem> = emptyList(),
|
||||||
|
val currentIndex: Int = -1,
|
||||||
|
val currentTitle: String? = null,
|
||||||
|
val currentArtist: String? = null,
|
||||||
|
val currentCoverUrl: String? = null,
|
||||||
|
val isPlaying: Boolean = false,
|
||||||
|
val positionMs: Long = 0,
|
||||||
|
val durationMs: Long = 0,
|
||||||
|
val shuffleEnabled: Boolean = false,
|
||||||
|
val loopMode: String = "none",
|
||||||
|
)
|
||||||
|
|
||||||
class JamViewModel(
|
class JamViewModel(
|
||||||
private val jamSession: JamSessionService,
|
private val jamSession: JamSessionService,
|
||||||
private val deepLinks: JamDeepLinkService,
|
private val deepLinks: JamDeepLinkService,
|
||||||
private val shareService: ShareService,
|
private val shareService: ShareService,
|
||||||
private val settingsProvider: SettingsProvider,
|
private val settingsProvider: SettingsProvider,
|
||||||
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(JamUiState())
|
private val _uiState = MutableStateFlow(JamUiState())
|
||||||
@ -98,6 +136,144 @@ class JamViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The jam player state: the shared queue + current playback, built from the
|
||||||
|
* local player (the host's queue IS the jam queue; on guests the synced
|
||||||
|
* mirror lives in the local player).
|
||||||
|
*/
|
||||||
|
val jamPlayerState: StateFlow<JamPlayerUiState> = combine(
|
||||||
|
audioPlayerQueue.queueFlow,
|
||||||
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
|
audioPlayer.playlistFlow,
|
||||||
|
audioPlayer.currentMediaItemFlow,
|
||||||
|
audioPlayer.playerStateFlow,
|
||||||
|
audioPlayer.positionFlow,
|
||||||
|
audioPlayer.durationFlow,
|
||||||
|
audioPlayer.loopStateFlow,
|
||||||
|
audioPlayer.shuffleModeFlow,
|
||||||
|
) { values ->
|
||||||
|
val queue: List<QueueEntry> = values[0] as List<QueueEntry>
|
||||||
|
val currentEntry: QueueEntry? = values[1] as QueueEntry?
|
||||||
|
val playlist: List<MediaItem> = values[2] as List<MediaItem>
|
||||||
|
val currentItem: MediaItem? = values[3] as MediaItem?
|
||||||
|
val playerState: PlayerState = values[4] as PlayerState
|
||||||
|
val position: kotlin.time.Duration = values[5] as kotlin.time.Duration
|
||||||
|
val duration: kotlin.time.Duration = values[6] as kotlin.time.Duration
|
||||||
|
val loop: LoopState = values[7] as LoopState
|
||||||
|
val shuffle: Boolean = values[8] as Boolean
|
||||||
|
|
||||||
|
val isHost = jamSession.role.value == JamRole.Host
|
||||||
|
|
||||||
|
val items: List<JamQueueUiItem>
|
||||||
|
val currentIndex: Int
|
||||||
|
val currentTitle: String?
|
||||||
|
val currentArtist: String?
|
||||||
|
val currentCoverUrl: String?
|
||||||
|
|
||||||
|
if (isHost) {
|
||||||
|
val queueItems = queue.map { JamMediaItem.fromQueueEntry(it) }
|
||||||
|
val index = if (currentEntry != null) {
|
||||||
|
queue.indexOfFirst { entry -> entry.matchesQueueEntry(currentEntry) }
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
items = queueItems.mapIndexed { i, item ->
|
||||||
|
item.toUiItem(i == index)
|
||||||
|
}
|
||||||
|
currentIndex = index
|
||||||
|
currentTitle = queueItems.getOrNull(index)?.title
|
||||||
|
currentArtist = queueItems.getOrNull(index)?.artist
|
||||||
|
currentCoverUrl = queueItems.getOrNull(index)?.coverUrl
|
||||||
|
} else {
|
||||||
|
val index = playlist.indexOf(currentItem)
|
||||||
|
items = playlist.mapIndexed { i, item ->
|
||||||
|
JamMediaItem.fromMediaItem(item).toUiItem(i == index)
|
||||||
|
}
|
||||||
|
currentIndex = index
|
||||||
|
currentTitle = currentItem?.title
|
||||||
|
currentArtist = currentItem?.artist
|
||||||
|
currentCoverUrl = currentItem?.coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
JamPlayerUiState(
|
||||||
|
queue = items,
|
||||||
|
currentIndex = currentIndex,
|
||||||
|
currentTitle = currentTitle,
|
||||||
|
currentArtist = currentArtist,
|
||||||
|
currentCoverUrl = currentCoverUrl,
|
||||||
|
isPlaying = playerState == PlayerState.PLAYING,
|
||||||
|
positionMs = position.inWholeMilliseconds,
|
||||||
|
durationMs = duration.inWholeMilliseconds,
|
||||||
|
shuffleEnabled = shuffle,
|
||||||
|
loopMode = loop.name.lowercase(),
|
||||||
|
)
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamPlayerUiState())
|
||||||
|
|
||||||
|
// ---------- Playback controls ----------
|
||||||
|
|
||||||
|
fun togglePlayPause() = sendOrApply(PlaybackCmd.Toggle)
|
||||||
|
|
||||||
|
fun skipNext() = sendOrApply(PlaybackCmd.SkipNext)
|
||||||
|
|
||||||
|
fun skipPrevious() = sendOrApply(PlaybackCmd.SkipPrevious)
|
||||||
|
|
||||||
|
fun seek(positionMs: Long) = sendOrApply(PlaybackCmd.Seek(positionMs))
|
||||||
|
|
||||||
|
fun jumpTo(index: Int) = sendOrApply(PlaybackCmd.JumpTo(index))
|
||||||
|
|
||||||
|
fun toggleShuffle() = sendOrApply(PlaybackCmd.SetShuffle(!jamPlayerState.value.shuffleEnabled))
|
||||||
|
|
||||||
|
fun cycleLoopMode() {
|
||||||
|
val next = when (jamPlayerState.value.loopMode) {
|
||||||
|
"none" -> "one"
|
||||||
|
"one" -> "all"
|
||||||
|
else -> "none"
|
||||||
|
}
|
||||||
|
sendOrApply(PlaybackCmd.SetLoop(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendOrApply(command: PlaybackCmd) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (jamSession.role.value == JamRole.Host) {
|
||||||
|
applyCommandLocally(command)
|
||||||
|
} else {
|
||||||
|
jamSession.sendMessage(JamMessage.PlaybackCommand(command))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applyCommandLocally(command: PlaybackCmd) {
|
||||||
|
when (command) {
|
||||||
|
PlaybackCmd.Play -> audioPlayer.play()
|
||||||
|
PlaybackCmd.Pause -> audioPlayer.pause()
|
||||||
|
PlaybackCmd.Toggle -> {
|
||||||
|
if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) {
|
||||||
|
audioPlayer.pause()
|
||||||
|
} else {
|
||||||
|
audioPlayer.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms"))
|
||||||
|
PlaybackCmd.SkipNext -> audioPlayer.skipToNext()
|
||||||
|
PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious()
|
||||||
|
is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume)
|
||||||
|
is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop))
|
||||||
|
is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled)
|
||||||
|
is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Host moderation ----------
|
||||||
|
|
||||||
|
fun kickParticipant(participantId: String) {
|
||||||
|
viewModelScope.launch { jamSession.kickParticipant(participantId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun banParticipant(participantId: String) {
|
||||||
|
viewModelScope.launch { jamSession.banParticipant(participantId) }
|
||||||
|
}
|
||||||
|
|
||||||
fun createSession() {
|
fun createSession() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
@ -228,4 +404,26 @@ class JamViewModel(
|
|||||||
|
|
||||||
private fun localName(): String =
|
private fun localName(): String =
|
||||||
settingsProvider.settingsState.value?.jamParticipantName.orEmpty()
|
settingsProvider.settingsState.value?.jamParticipantName.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JamMediaItem.toUiItem(isCurrent: Boolean): JamQueueUiItem = JamQueueUiItem(
|
||||||
|
id = if (trackId.isNotBlank()) trackId else url,
|
||||||
|
title = title,
|
||||||
|
artist = artist,
|
||||||
|
album = album,
|
||||||
|
durationMs = durationMs,
|
||||||
|
coverUrl = coverUrl,
|
||||||
|
isCurrent = isCurrent,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun QueueEntry.matchesQueueEntry(other: QueueEntry): Boolean {
|
||||||
|
return when {
|
||||||
|
this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack ->
|
||||||
|
this.track.id == other.track.id
|
||||||
|
|
||||||
|
this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack ->
|
||||||
|
this.url == other.url && this.name == other.name
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -78,7 +78,7 @@ struct DataChannelEntry {
|
|||||||
pub struct WebrtcPeerConnection {
|
pub struct WebrtcPeerConnection {
|
||||||
pc: Arc<dyn PeerConnection>,
|
pc: Arc<dyn PeerConnection>,
|
||||||
handler: Arc<dyn WebrtcEventHandler>,
|
handler: Arc<dyn WebrtcEventHandler>,
|
||||||
channels: Mutex<Vec<DataChannelEntry>>,
|
channels: Arc<Mutex<Vec<DataChannelEntry>>>,
|
||||||
gather_rx: Mutex<webrtc::runtime::Receiver<()>>,
|
gather_rx: Mutex<webrtc::runtime::Receiver<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,9 +122,11 @@ pub async fn create_webrtc_peer_connection(
|
|||||||
setting_engine.set_multicast_dns_mode(MulticastDnsMode::Disabled);
|
setting_engine.set_multicast_dns_mode(MulticastDnsMode::Disabled);
|
||||||
|
|
||||||
let (gather_tx, gather_rx) = channel::<()>(1);
|
let (gather_tx, gather_rx) = channel::<()>(1);
|
||||||
|
let channels = Arc::new(Mutex::new(Vec::new()));
|
||||||
let pc_handler = Arc::new(PeerHandlerBridge {
|
let pc_handler = Arc::new(PeerHandlerBridge {
|
||||||
handler: Arc::clone(&handler),
|
handler: Arc::clone(&handler),
|
||||||
gather_tx,
|
gather_tx,
|
||||||
|
channels: Arc::clone(&channels),
|
||||||
});
|
});
|
||||||
|
|
||||||
let pc = PeerConnectionBuilder::new()
|
let pc = PeerConnectionBuilder::new()
|
||||||
@ -140,7 +142,7 @@ pub async fn create_webrtc_peer_connection(
|
|||||||
Ok(Arc::new(WebrtcPeerConnection {
|
Ok(Arc::new(WebrtcPeerConnection {
|
||||||
pc: Arc::new(pc) as Arc<dyn PeerConnection>,
|
pc: Arc::new(pc) as Arc<dyn PeerConnection>,
|
||||||
handler,
|
handler,
|
||||||
channels: Mutex::new(Vec::new()),
|
channels,
|
||||||
gather_rx: Mutex::new(gather_rx),
|
gather_rx: Mutex::new(gather_rx),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -150,17 +152,34 @@ impl WebrtcPeerConnection {
|
|||||||
/// candidates (non-trickle exchange). Must be called after `set_local_description`,
|
/// candidates (non-trickle exchange). Must be called after `set_local_description`,
|
||||||
/// which is what starts gathering.
|
/// which is what starts gathering.
|
||||||
///
|
///
|
||||||
/// Bounded by a timeout so a stalled gatherer (e.g. a platform that never reports
|
/// Robust against a stalled gatherer (e.g. an unreachable STUN server): once at
|
||||||
/// completion) can never hang `create_offer`/`create_answer` forever — the SDP
|
/// least one candidate has landed in the local description, a short grace period
|
||||||
/// with the candidates gathered so far is returned instead.
|
/// is enough — the SDP must never leave candidate-less. Hard cap at 5s.
|
||||||
async fn wait_for_ice_gathering(&self) {
|
async fn wait_for_ice_gathering(&self) {
|
||||||
let mut gather_rx = self.gather_rx.lock().clone();
|
let mut gather_rx = self.gather_rx.lock().clone();
|
||||||
match tokio::time::timeout(Duration::from_secs(5), gather_rx.recv()).await {
|
let started = std::time::Instant::now();
|
||||||
Ok(_) => {}
|
|
||||||
Err(_) => {
|
loop {
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
if elapsed >= Duration::from_secs(5) {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"ICE gathering did not complete within 5s; returning SDP with the candidates gathered so far"
|
"ICE gathering did not complete within 5s; using the candidates gathered so far"
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::time::timeout(Duration::from_millis(100), gather_rx.recv()).await {
|
||||||
|
Ok(Some(())) => return, // gathering complete
|
||||||
|
Ok(None) => return, // handler dropped
|
||||||
|
Err(_) => {} // timed out, keep waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grace period once candidates are present, so the SDP always carries them.
|
||||||
|
if elapsed >= Duration::from_secs(1) {
|
||||||
|
let sdp = self.pc.local_description().await.map(|d| d.sdp);
|
||||||
|
if sdp.as_deref().map_or(false, |s| s.contains("a=candidate:")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -251,6 +270,7 @@ impl WebrtcPeerConnection {
|
|||||||
struct PeerHandlerBridge {
|
struct PeerHandlerBridge {
|
||||||
handler: Arc<dyn WebrtcEventHandler>,
|
handler: Arc<dyn WebrtcEventHandler>,
|
||||||
gather_tx: webrtc::runtime::Sender<()>,
|
gather_tx: webrtc::runtime::Sender<()>,
|
||||||
|
channels: Arc<Mutex<Vec<DataChannelEntry>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@ -272,6 +292,15 @@ impl PeerConnectionEventHandler for PeerHandlerBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn on_data_channel(&self, dc: Arc<dyn DataChannel>) {
|
async fn on_data_channel(&self, dc: Arc<dyn DataChannel>) {
|
||||||
|
// Register in-band (remote-initiated) channels so send_data() can find
|
||||||
|
// them — without this, the answering peer can never send anything.
|
||||||
|
let label = match dc.label().await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
self.channels
|
||||||
|
.lock()
|
||||||
|
.push(DataChannelEntry { dc: Arc::clone(&dc), label });
|
||||||
spawn_data_channel_poll_loop(dc, Arc::clone(&self.handler));
|
spawn_data_channel_poll_loop(dc, Arc::clone(&self.handler));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user