feat(simpconnect): implement P2P discovery, server, client, domain and data repository
This commit is contained in:
parent
7efa4c9b44
commit
d1934699b5
17 changed files with 2227 additions and 2 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -27,4 +27,8 @@ replay_pid*
|
|||
.kotlin/
|
||||
|
||||
# OMC (oh-my-claudecode) local state
|
||||
/.omc/
|
||||
/.omc/
|
||||
|
||||
# Gradle build directories
|
||||
build/
|
||||
.gradle/
|
||||
|
|
@ -120,6 +120,8 @@ kotlin {
|
|||
implementation(libs.room.ktx)
|
||||
// Login sync: the phone sends sign-ins to the Desktop
|
||||
implementation(projects.loginSync)
|
||||
// SimpConnect: P2P Spotify Connect style local session sync
|
||||
implementation(projects.simpConnect)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,6 +143,8 @@ kotlin {
|
|||
implementation(libs.jna.platform)
|
||||
// Login sync: the Desktop receives sign-ins from the phone
|
||||
implementation(projects.loginSync)
|
||||
// SimpConnect: P2P Spotify Connect style local session sync
|
||||
implementation(projects.simpConnect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,3 +20,17 @@ actual fun loadLoginSyncModule() {
|
|||
},
|
||||
)
|
||||
}
|
||||
|
||||
actual fun loadSimpConnectModule() {
|
||||
loadKoinModules(
|
||||
module {
|
||||
single<com.maxrave.domain.repository.SimpConnectRepository>(createdAtStart = true) {
|
||||
com.maxrave.data.simpconnect.SimpConnectRepositoryImpl(
|
||||
mediaPlayerHandler = get(),
|
||||
dataStoreManager = get(),
|
||||
scope = get(org.koin.core.qualifier.named(com.maxrave.common.Config.SERVICE_SCOPE)),
|
||||
).also { it.start() }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,524 @@
|
|||
package com.maxrave.data.simpconnect
|
||||
|
||||
import android.os.Build
|
||||
import com.maxrave.common.Config
|
||||
import com.maxrave.common.MERGING_DATA_TYPE
|
||||
import com.maxrave.domain.data.model.browse.album.Track
|
||||
import com.maxrave.domain.data.model.searchResult.songs.Album
|
||||
import com.maxrave.domain.data.model.searchResult.songs.Artist
|
||||
import com.maxrave.domain.data.model.searchResult.songs.Thumbnail
|
||||
import com.maxrave.domain.data.model.simpconnect.DiscoveredDeviceDomain
|
||||
import com.maxrave.domain.data.model.simpconnect.RemotePlaybackStateDomain
|
||||
import com.maxrave.domain.data.model.simpconnect.SimpConnectAuthRequestDomain
|
||||
import com.maxrave.domain.data.model.simpconnect.SimpConnectStatusDomain
|
||||
import com.maxrave.domain.data.model.simpconnect.TrackSummaryDomain
|
||||
import com.maxrave.domain.data.player.GenericMediaItem
|
||||
import com.maxrave.domain.manager.DataStoreManager
|
||||
import com.maxrave.domain.mediaservice.handler.MediaPlayerHandler
|
||||
import com.maxrave.domain.mediaservice.handler.PlayerEvent
|
||||
import com.maxrave.domain.mediaservice.handler.QueueData
|
||||
import com.maxrave.domain.mediaservice.handler.RepeatState
|
||||
import com.maxrave.domain.mediaservice.handler.SimpleMediaState
|
||||
import com.maxrave.domain.repository.SimpConnectRepository
|
||||
import com.maxrave.logger.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.simpmusic.simpconnect.AuthRequest
|
||||
import org.simpmusic.simpconnect.ConnectionStatus
|
||||
import org.simpmusic.simpconnect.DiscoveredDevice
|
||||
import org.simpmusic.simpconnect.InMemorySimpConnectAuthStore
|
||||
import org.simpmusic.simpconnect.SimpConnectAuthStore
|
||||
import org.simpmusic.simpconnect.SimpConnectClient
|
||||
import org.simpmusic.simpconnect.SimpConnectDiscovery
|
||||
import org.simpmusic.simpconnect.SimpConnectMessage
|
||||
import org.simpmusic.simpconnect.SimpConnectServer
|
||||
import org.simpmusic.simpconnect.SimpConnectServerListener
|
||||
import org.simpmusic.simpconnect.TrackSummary
|
||||
import java.util.UUID
|
||||
|
||||
private const val TAG = "SimpConnectRepoAndroid"
|
||||
private const val KEY_DEVICE_ID = "simpconnect_local_device_id"
|
||||
|
||||
class SimpConnectRepositoryImpl(
|
||||
private val mediaPlayerHandler: MediaPlayerHandler,
|
||||
private val dataStoreManager: DataStoreManager,
|
||||
private val scope: CoroutineScope,
|
||||
) : SimpConnectRepository, SimpConnectServerListener {
|
||||
|
||||
private val localDeviceId: String = runBlocking {
|
||||
val existing = dataStoreManager.getString(KEY_DEVICE_ID).first()
|
||||
if (!existing.isNullOrBlank()) {
|
||||
existing
|
||||
} else {
|
||||
val generated = UUID.randomUUID().toString()
|
||||
dataStoreManager.putString(KEY_DEVICE_ID, generated)
|
||||
generated
|
||||
}
|
||||
}
|
||||
|
||||
private val localDeviceNameString: String = Build.MODEL ?: "Android Device"
|
||||
|
||||
private val authStore: SimpConnectAuthStore = InMemorySimpConnectAuthStore()
|
||||
|
||||
private val _localDevice = MutableStateFlow(
|
||||
DiscoveredDeviceDomain(
|
||||
deviceId = localDeviceId,
|
||||
deviceName = localDeviceNameString,
|
||||
platform = "android",
|
||||
hostAddress = "127.0.0.1",
|
||||
tcpPort = 8765,
|
||||
isLocal = true,
|
||||
)
|
||||
)
|
||||
override val localDevice: StateFlow<DiscoveredDeviceDomain> = _localDevice.asStateFlow()
|
||||
|
||||
private val _discoveredDevices = MutableStateFlow<List<DiscoveredDeviceDomain>>(emptyList())
|
||||
override val discoveredDevices: StateFlow<List<DiscoveredDeviceDomain>> = _discoveredDevices.asStateFlow()
|
||||
|
||||
private val _activeRemoteDevice = MutableStateFlow<DiscoveredDeviceDomain?>(null)
|
||||
override val activeRemoteDevice: StateFlow<DiscoveredDeviceDomain?> = _activeRemoteDevice.asStateFlow()
|
||||
|
||||
private val _remotePlaybackState = MutableStateFlow<RemotePlaybackStateDomain?>(null)
|
||||
override val remotePlaybackState: StateFlow<RemotePlaybackStateDomain?> = _remotePlaybackState.asStateFlow()
|
||||
|
||||
private val _connectionStatus = MutableStateFlow(SimpConnectStatusDomain.IDLE)
|
||||
override val connectionStatus: StateFlow<SimpConnectStatusDomain> = _connectionStatus.asStateFlow()
|
||||
|
||||
private val _incomingAuthRequest = MutableStateFlow<SimpConnectAuthRequestDomain?>(null)
|
||||
override val incomingAuthRequest: StateFlow<SimpConnectAuthRequestDomain?> = _incomingAuthRequest.asStateFlow()
|
||||
|
||||
private val server: SimpConnectServer = SimpConnectServer(
|
||||
localDeviceId = localDeviceId,
|
||||
localDeviceName = { localDeviceNameString },
|
||||
authStore = authStore,
|
||||
listener = this,
|
||||
onAuthRequest = { req: AuthRequest ->
|
||||
_incomingAuthRequest.value = SimpConnectAuthRequestDomain(
|
||||
deviceId = req.deviceId,
|
||||
deviceName = req.deviceName,
|
||||
onResponse = { approved ->
|
||||
req.onResponse(approved)
|
||||
_incomingAuthRequest.value = null
|
||||
}
|
||||
)
|
||||
},
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
private val discovery: SimpConnectDiscovery = SimpConnectDiscovery(
|
||||
localDeviceId = localDeviceId,
|
||||
localDeviceName = { localDeviceNameString },
|
||||
localPlatform = "android",
|
||||
getTcpPort = { server.getPort() },
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
private val client: SimpConnectClient = SimpConnectClient(
|
||||
localDeviceId = localDeviceId,
|
||||
localDeviceName = { localDeviceNameString },
|
||||
authStore = authStore,
|
||||
onHandoffReceived = { payload -> handleHandoffReceived(payload) },
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
private var started = false
|
||||
|
||||
override fun start() {
|
||||
if (started) return
|
||||
started = true
|
||||
Logger.d(TAG, "Starting SimpConnectRepository for $localDeviceNameString")
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val port = server.start()
|
||||
_localDevice.value = _localDevice.value.copy(tcpPort = port)
|
||||
discovery.start()
|
||||
}
|
||||
|
||||
// Bridge discovery updates to domain flow
|
||||
scope.launch {
|
||||
discovery.discoveredDevices.collect { list ->
|
||||
_discoveredDevices.value = list.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge client state
|
||||
scope.launch {
|
||||
client.connectionStatus.collect { status ->
|
||||
_connectionStatus.value = when (status) {
|
||||
ConnectionStatus.IDLE -> SimpConnectStatusDomain.IDLE
|
||||
ConnectionStatus.CONNECTING -> SimpConnectStatusDomain.CONNECTING
|
||||
ConnectionStatus.WAITING_FOR_APPROVAL -> SimpConnectStatusDomain.WAITING_FOR_APPROVAL
|
||||
ConnectionStatus.CONNECTED -> SimpConnectStatusDomain.CONNECTED
|
||||
ConnectionStatus.DISCONNECTED -> SimpConnectStatusDomain.DISCONNECTED
|
||||
ConnectionStatus.REJECTED -> SimpConnectStatusDomain.REJECTED
|
||||
ConnectionStatus.FAILED -> SimpConnectStatusDomain.FAILED
|
||||
}
|
||||
if (status == ConnectionStatus.DISCONNECTED || status == ConnectionStatus.FAILED || status == ConnectionStatus.IDLE) {
|
||||
_activeRemoteDevice.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge remote playback state
|
||||
scope.launch {
|
||||
client.remotePlaybackState.collect { remote ->
|
||||
_remotePlaybackState.value = remote?.toDomain()
|
||||
}
|
||||
}
|
||||
|
||||
// Watch local player and broadcast state to connected controllers
|
||||
scope.launch {
|
||||
mediaPlayerHandler.nowPlaying.collect { broadcastCurrentState() }
|
||||
}
|
||||
scope.launch {
|
||||
mediaPlayerHandler.controlState.collect { broadcastCurrentState() }
|
||||
}
|
||||
scope.launch {
|
||||
mediaPlayerHandler.simpleMediaState.collect { broadcastCurrentState() }
|
||||
}
|
||||
scope.launch {
|
||||
mediaPlayerHandler.queueData.collect { broadcastCurrentState() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun refreshDiscovery() {
|
||||
discovery.broadcastPing()
|
||||
}
|
||||
|
||||
override fun stopDiscovery() {
|
||||
discovery.stop()
|
||||
}
|
||||
|
||||
override fun connectToDevice(device: DiscoveredDeviceDomain) {
|
||||
val wireDevice = DiscoveredDevice(
|
||||
deviceId = device.deviceId,
|
||||
deviceName = device.deviceName,
|
||||
platform = device.platform,
|
||||
hostAddress = device.hostAddress,
|
||||
tcpPort = device.tcpPort,
|
||||
lastSeenMs = System.currentTimeMillis(),
|
||||
)
|
||||
_activeRemoteDevice.value = device
|
||||
client.connect(wireDevice)
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
client.disconnect()
|
||||
_activeRemoteDevice.value = null
|
||||
}
|
||||
|
||||
override fun sendPlayPause() {
|
||||
client.sendPlayPause()
|
||||
}
|
||||
|
||||
override fun sendPlay() {
|
||||
client.sendPlay()
|
||||
}
|
||||
|
||||
override fun sendPause() {
|
||||
client.sendPause()
|
||||
}
|
||||
|
||||
override fun sendNext() {
|
||||
client.sendNext()
|
||||
}
|
||||
|
||||
override fun sendPrevious() {
|
||||
client.sendPrevious()
|
||||
}
|
||||
|
||||
override fun sendSeekTo(positionMs: Long) {
|
||||
client.sendSeekTo(positionMs)
|
||||
}
|
||||
|
||||
override fun sendSetVolume(volume: Float) {
|
||||
client.sendSetVolume(volume)
|
||||
}
|
||||
|
||||
override fun sendSetShuffle(enabled: Boolean) {
|
||||
client.sendSetShuffle(enabled)
|
||||
}
|
||||
|
||||
override fun sendSetRepeat(mode: Int) {
|
||||
client.sendSetRepeat(mode)
|
||||
}
|
||||
|
||||
override fun sendPlayTrack(
|
||||
videoId: String,
|
||||
playlistId: String?,
|
||||
playlistName: String?,
|
||||
queue: List<TrackSummaryDomain>,
|
||||
startIndex: Int,
|
||||
) {
|
||||
val wireQueue = queue.map { it.toWire() }
|
||||
client.sendPlayTrack(videoId, playlistId, playlistName, wireQueue, startIndex)
|
||||
}
|
||||
|
||||
override fun requestHandoff() {
|
||||
client.requestHandoff()
|
||||
}
|
||||
|
||||
override fun handoffToRemote(device: DiscoveredDeviceDomain) {
|
||||
connectToDevice(device)
|
||||
val nowPlaying = mediaPlayerHandler.nowPlaying.value
|
||||
val queue = mediaPlayerHandler.queueData.value?.data?.listTracks.orEmpty()
|
||||
val currentIndex = mediaPlayerHandler.currentSongIndex()
|
||||
|
||||
scope.launch {
|
||||
// Pause local playback
|
||||
mediaPlayerHandler.onPlayerEvent(PlayerEvent.PlayPause)
|
||||
|
||||
nowPlaying?.let { item ->
|
||||
val trackSummary = item.toTrackSummary()
|
||||
val wireQueue = queue.map { it.toTrackSummary() }
|
||||
client.sendPlayTrack(
|
||||
videoId = trackSummary.videoId,
|
||||
playlistId = null,
|
||||
playlistName = null,
|
||||
queue = wireQueue,
|
||||
startIndex = currentIndex.coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SimpConnectServerListener callbacks (Target mode)
|
||||
// ==========================================
|
||||
|
||||
override fun onPlayPause() {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.PlayPause) }
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
val isPlaying = mediaPlayerHandler.controlState.value.isPlaying
|
||||
if (!isPlaying) {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.PlayPause) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
val isPlaying = mediaPlayerHandler.controlState.value.isPlaying
|
||||
if (isPlaying) {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.PlayPause) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNext() {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.Next) }
|
||||
}
|
||||
|
||||
override fun onPrevious() {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.Previous) }
|
||||
}
|
||||
|
||||
override fun onSeekTo(positionMs: Long) {
|
||||
val duration = mediaPlayerHandler.getPlayerDuration()
|
||||
if (duration > 0) {
|
||||
val fraction = (positionMs.toFloat() / duration.toFloat()).coerceIn(0f, 1f)
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.UpdateProgress(fraction)) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSetVolume(volume: Float) {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.UpdateVolume(volume)) }
|
||||
}
|
||||
|
||||
override fun onSetShuffle(enabled: Boolean) {
|
||||
val currentShuffle = mediaPlayerHandler.controlState.value.isShuffle
|
||||
if (currentShuffle != enabled) {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.Shuffle) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSetRepeat(mode: Int) {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.Repeat) }
|
||||
}
|
||||
|
||||
override fun onPlayTrack(message: SimpConnectMessage.PlayTrack) {
|
||||
scope.launch {
|
||||
val track = Track(
|
||||
album = null,
|
||||
artists = emptyList(),
|
||||
duration = null,
|
||||
durationSeconds = null,
|
||||
isAvailable = true,
|
||||
isExplicit = false,
|
||||
likeStatus = null,
|
||||
thumbnails = emptyList(),
|
||||
title = "",
|
||||
videoId = message.videoId,
|
||||
videoType = null,
|
||||
category = null,
|
||||
feedbackTokens = null,
|
||||
resultType = null,
|
||||
)
|
||||
mediaPlayerHandler.loadMediaItem(track, type = MERGING_DATA_TYPE.SONG, index = message.startIndex)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentState(): SimpConnectMessage.StateSync {
|
||||
val nowPlaying = mediaPlayerHandler.nowPlaying.value
|
||||
val controlState = mediaPlayerHandler.controlState.value
|
||||
val duration = mediaPlayerHandler.getPlayerDuration()
|
||||
val progress = mediaPlayerHandler.getProgress()
|
||||
val queue = mediaPlayerHandler.queueData.value?.data?.listTracks.orEmpty()
|
||||
val currentIndex = mediaPlayerHandler.currentSongIndex()
|
||||
|
||||
val repeatModeInt = when (controlState.repeatState) {
|
||||
RepeatState.None -> 0
|
||||
RepeatState.All -> 1
|
||||
RepeatState.One -> 2
|
||||
}
|
||||
|
||||
return SimpConnectMessage.StateSync(
|
||||
track = nowPlaying?.toTrackSummary(),
|
||||
isPlaying = controlState.isPlaying,
|
||||
positionMs = progress,
|
||||
durationMs = duration,
|
||||
volume = 1f,
|
||||
isShuffle = controlState.isShuffle,
|
||||
repeatMode = repeatModeInt,
|
||||
queue = queue.map { it.toTrackSummary() },
|
||||
queueIndex = currentIndex,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getHandoffPayload(): SimpConnectMessage.HandoffPayload {
|
||||
val state = getCurrentState()
|
||||
return SimpConnectMessage.HandoffPayload(
|
||||
track = state.track,
|
||||
positionMs = state.positionMs,
|
||||
queue = state.queue,
|
||||
queueIndex = state.queueIndex,
|
||||
isPlaying = state.isPlaying,
|
||||
)
|
||||
}
|
||||
|
||||
private fun broadcastCurrentState() {
|
||||
if (server.connectedControllersCount.value > 0) {
|
||||
server.broadcastStateSync(getCurrentState())
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleHandoffReceived(payload: SimpConnectMessage.HandoffPayload) {
|
||||
Logger.d(TAG, "Handoff payload received: ${payload.track?.title}")
|
||||
// Pause remote playback & disconnect
|
||||
client.sendPause()
|
||||
disconnect()
|
||||
|
||||
val trackSummary = payload.track ?: return
|
||||
scope.launch {
|
||||
val track = trackSummary.toTrack()
|
||||
mediaPlayerHandler.loadMediaItem(track, type = MERGING_DATA_TYPE.SONG, index = payload.queueIndex)
|
||||
if (payload.positionMs > 0) {
|
||||
// Seek to transferred position after short delay
|
||||
kotlinx.coroutines.delay(300)
|
||||
val duration = mediaPlayerHandler.getPlayerDuration()
|
||||
if (duration > 0) {
|
||||
val fraction = (payload.positionMs.toFloat() / duration.toFloat()).coerceIn(0f, 1f)
|
||||
mediaPlayerHandler.onPlayerEvent(PlayerEvent.UpdateProgress(fraction))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Mappers
|
||||
// ==========================================
|
||||
|
||||
private fun DiscoveredDevice.toDomain(): DiscoveredDeviceDomain {
|
||||
return DiscoveredDeviceDomain(
|
||||
deviceId = deviceId,
|
||||
deviceName = deviceName,
|
||||
platform = platform,
|
||||
hostAddress = hostAddress,
|
||||
tcpPort = tcpPort,
|
||||
isLocal = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun org.simpmusic.simpconnect.RemotePlaybackState.toDomain(): RemotePlaybackStateDomain {
|
||||
return RemotePlaybackStateDomain(
|
||||
targetDevice = targetDevice.toDomain(),
|
||||
track = track?.toDomain(),
|
||||
isPlaying = isPlaying,
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs,
|
||||
volume = volume,
|
||||
isShuffle = isShuffle,
|
||||
repeatMode = repeatMode,
|
||||
queue = queue.map { it.toDomain() },
|
||||
queueIndex = queueIndex,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TrackSummary.toDomain(): TrackSummaryDomain {
|
||||
return TrackSummaryDomain(
|
||||
videoId = videoId,
|
||||
title = title,
|
||||
artist = artist,
|
||||
album = album,
|
||||
artworkUri = artworkUri,
|
||||
durationMs = durationMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TrackSummaryDomain.toWire(): TrackSummary {
|
||||
return TrackSummary(
|
||||
videoId = videoId,
|
||||
title = title,
|
||||
artist = artist,
|
||||
album = album,
|
||||
artworkUri = artworkUri,
|
||||
durationMs = durationMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun GenericMediaItem.toTrackSummary(): TrackSummary {
|
||||
return TrackSummary(
|
||||
videoId = mediaId,
|
||||
title = metadata.title.orEmpty(),
|
||||
artist = metadata.artist.orEmpty(),
|
||||
album = metadata.albumTitle,
|
||||
artworkUri = metadata.artworkUri,
|
||||
durationMs = metadata.durationMs ?: 0L,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Track.toTrackSummary(): TrackSummary {
|
||||
return TrackSummary(
|
||||
videoId = videoId,
|
||||
title = title,
|
||||
artist = artists?.firstOrNull()?.name.orEmpty(),
|
||||
album = album?.name,
|
||||
artworkUri = thumbnails?.lastOrNull()?.url,
|
||||
durationMs = (durationSeconds ?: 0) * 1000L,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TrackSummary.toTrack(): Track {
|
||||
return Track(
|
||||
album = album?.let { Album(name = it, id = "") },
|
||||
artists = listOf(Artist(name = artist, id = null)),
|
||||
duration = null,
|
||||
durationSeconds = (durationMs / 1000).toInt(),
|
||||
isAvailable = true,
|
||||
isExplicit = false,
|
||||
likeStatus = null,
|
||||
thumbnails = artworkUri?.let { listOf(Thumbnail(url = it, width = 500, height = 500)) },
|
||||
title = title,
|
||||
videoId = videoId,
|
||||
videoType = null,
|
||||
category = null,
|
||||
feedbackTokens = null,
|
||||
resultType = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -23,9 +23,13 @@ fun loadAllModules(appIdentity: AppIdentity) {
|
|||
loadKoinModules(listenTogetherModule)
|
||||
loadMediaService()
|
||||
loadLoginSyncModule()
|
||||
loadSimpConnectModule()
|
||||
}
|
||||
|
||||
expect fun loadMediaService()
|
||||
|
||||
/** Login sync is one-sided per platform: Desktop hosts, Android sends, iOS takes no part. */
|
||||
expect fun loadLoginSyncModule()
|
||||
expect fun loadLoginSyncModule()
|
||||
|
||||
/** SimpConnect local P2P Spotify Connect style device sync. */
|
||||
expect fun loadSimpConnectModule()
|
||||
|
|
@ -5,3 +5,6 @@ actual fun loadMediaService() {
|
|||
|
||||
actual fun loadLoginSyncModule() {
|
||||
}
|
||||
|
||||
actual fun loadSimpConnectModule() {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,3 +19,17 @@ actual fun loadLoginSyncModule() {
|
|||
},
|
||||
)
|
||||
}
|
||||
|
||||
actual fun loadSimpConnectModule() {
|
||||
loadKoinModules(
|
||||
module {
|
||||
single<com.maxrave.domain.repository.SimpConnectRepository>(createdAtStart = true) {
|
||||
com.maxrave.data.simpconnect.SimpConnectRepositoryImpl(
|
||||
mediaPlayerHandler = get(),
|
||||
dataStoreManager = get(),
|
||||
scope = get(org.koin.core.qualifier.named(com.maxrave.common.Config.SERVICE_SCOPE)),
|
||||
).also { it.start() }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,523 @@
|
|||
package com.maxrave.data.simpconnect
|
||||
|
||||
import com.maxrave.common.MERGING_DATA_TYPE
|
||||
import com.maxrave.domain.data.model.browse.album.Track
|
||||
import com.maxrave.domain.data.model.searchResult.songs.Album
|
||||
import com.maxrave.domain.data.model.searchResult.songs.Artist
|
||||
import com.maxrave.domain.data.model.searchResult.songs.Thumbnail
|
||||
import com.maxrave.domain.data.model.simpconnect.DiscoveredDeviceDomain
|
||||
import com.maxrave.domain.data.model.simpconnect.RemotePlaybackStateDomain
|
||||
import com.maxrave.domain.data.model.simpconnect.SimpConnectAuthRequestDomain
|
||||
import com.maxrave.domain.data.model.simpconnect.SimpConnectStatusDomain
|
||||
import com.maxrave.domain.data.model.simpconnect.TrackSummaryDomain
|
||||
import com.maxrave.domain.data.player.GenericMediaItem
|
||||
import com.maxrave.domain.manager.DataStoreManager
|
||||
import com.maxrave.domain.mediaservice.handler.MediaPlayerHandler
|
||||
import com.maxrave.domain.mediaservice.handler.PlayerEvent
|
||||
import com.maxrave.domain.mediaservice.handler.RepeatState
|
||||
import com.maxrave.domain.repository.SimpConnectRepository
|
||||
import com.maxrave.logger.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.simpmusic.loginsync.localDeviceName
|
||||
import org.simpmusic.simpconnect.AuthRequest
|
||||
import org.simpmusic.simpconnect.ConnectionStatus
|
||||
import org.simpmusic.simpconnect.DiscoveredDevice
|
||||
import org.simpmusic.simpconnect.InMemorySimpConnectAuthStore
|
||||
import org.simpmusic.simpconnect.SimpConnectAuthStore
|
||||
import org.simpmusic.simpconnect.SimpConnectClient
|
||||
import org.simpmusic.simpconnect.SimpConnectDiscovery
|
||||
import org.simpmusic.simpconnect.SimpConnectMessage
|
||||
import org.simpmusic.simpconnect.SimpConnectServer
|
||||
import org.simpmusic.simpconnect.SimpConnectServerListener
|
||||
import org.simpmusic.simpconnect.TrackSummary
|
||||
import java.util.UUID
|
||||
|
||||
private const val TAG = "SimpConnectRepoJvm"
|
||||
private const val KEY_DEVICE_ID = "simpconnect_local_device_id"
|
||||
|
||||
class SimpConnectRepositoryImpl(
|
||||
private val mediaPlayerHandler: MediaPlayerHandler,
|
||||
private val dataStoreManager: DataStoreManager,
|
||||
private val scope: CoroutineScope,
|
||||
) : SimpConnectRepository, SimpConnectServerListener {
|
||||
|
||||
private val localDeviceId: String = runBlocking {
|
||||
val existing = dataStoreManager.getString(KEY_DEVICE_ID).first()
|
||||
if (!existing.isNullOrBlank()) {
|
||||
existing
|
||||
} else {
|
||||
val generated = UUID.randomUUID().toString()
|
||||
dataStoreManager.putString(KEY_DEVICE_ID, generated)
|
||||
generated
|
||||
}
|
||||
}
|
||||
|
||||
private val localDeviceNameString: String by lazy {
|
||||
runCatching { localDeviceName().name }.getOrDefault("SimpMusic Desktop")
|
||||
}
|
||||
|
||||
private val authStore: SimpConnectAuthStore = InMemorySimpConnectAuthStore()
|
||||
|
||||
private val _localDevice = MutableStateFlow(
|
||||
DiscoveredDeviceDomain(
|
||||
deviceId = localDeviceId,
|
||||
deviceName = localDeviceNameString,
|
||||
platform = "desktop",
|
||||
hostAddress = "127.0.0.1",
|
||||
tcpPort = 8765,
|
||||
isLocal = true,
|
||||
)
|
||||
)
|
||||
override val localDevice: StateFlow<DiscoveredDeviceDomain> = _localDevice.asStateFlow()
|
||||
|
||||
private val _discoveredDevices = MutableStateFlow<List<DiscoveredDeviceDomain>>(emptyList())
|
||||
override val discoveredDevices: StateFlow<List<DiscoveredDeviceDomain>> = _discoveredDevices.asStateFlow()
|
||||
|
||||
private val _activeRemoteDevice = MutableStateFlow<DiscoveredDeviceDomain?>(null)
|
||||
override val activeRemoteDevice: StateFlow<DiscoveredDeviceDomain?> = _activeRemoteDevice.asStateFlow()
|
||||
|
||||
private val _remotePlaybackState = MutableStateFlow<RemotePlaybackStateDomain?>(null)
|
||||
override val remotePlaybackState: StateFlow<RemotePlaybackStateDomain?> = _remotePlaybackState.asStateFlow()
|
||||
|
||||
private val _connectionStatus = MutableStateFlow(SimpConnectStatusDomain.IDLE)
|
||||
override val connectionStatus: StateFlow<SimpConnectStatusDomain> = _connectionStatus.asStateFlow()
|
||||
|
||||
private val _incomingAuthRequest = MutableStateFlow<SimpConnectAuthRequestDomain?>(null)
|
||||
override val incomingAuthRequest: StateFlow<SimpConnectAuthRequestDomain?> = _incomingAuthRequest.asStateFlow()
|
||||
|
||||
private val server: SimpConnectServer = SimpConnectServer(
|
||||
localDeviceId = localDeviceId,
|
||||
localDeviceName = { localDeviceNameString },
|
||||
authStore = authStore,
|
||||
listener = this,
|
||||
onAuthRequest = { req: AuthRequest ->
|
||||
_incomingAuthRequest.value = SimpConnectAuthRequestDomain(
|
||||
deviceId = req.deviceId,
|
||||
deviceName = req.deviceName,
|
||||
onResponse = { approved ->
|
||||
req.onResponse(approved)
|
||||
_incomingAuthRequest.value = null
|
||||
}
|
||||
)
|
||||
},
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
private val discovery: SimpConnectDiscovery = SimpConnectDiscovery(
|
||||
localDeviceId = localDeviceId,
|
||||
localDeviceName = { localDeviceNameString },
|
||||
localPlatform = "desktop",
|
||||
getTcpPort = { server.getPort() },
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
private val client: SimpConnectClient = SimpConnectClient(
|
||||
localDeviceId = localDeviceId,
|
||||
localDeviceName = { localDeviceNameString },
|
||||
authStore = authStore,
|
||||
onHandoffReceived = { payload -> handleHandoffReceived(payload) },
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
private var started = false
|
||||
|
||||
override fun start() {
|
||||
if (started) return
|
||||
started = true
|
||||
Logger.d(TAG, "Starting SimpConnectRepository for $localDeviceNameString")
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val port = server.start()
|
||||
_localDevice.value = _localDevice.value.copy(tcpPort = port)
|
||||
discovery.start()
|
||||
}
|
||||
|
||||
// Bridge discovery updates to domain flow
|
||||
scope.launch {
|
||||
discovery.discoveredDevices.collect { list ->
|
||||
_discoveredDevices.value = list.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge client state
|
||||
scope.launch {
|
||||
client.connectionStatus.collect { status ->
|
||||
_connectionStatus.value = when (status) {
|
||||
ConnectionStatus.IDLE -> SimpConnectStatusDomain.IDLE
|
||||
ConnectionStatus.CONNECTING -> SimpConnectStatusDomain.CONNECTING
|
||||
ConnectionStatus.WAITING_FOR_APPROVAL -> SimpConnectStatusDomain.WAITING_FOR_APPROVAL
|
||||
ConnectionStatus.CONNECTED -> SimpConnectStatusDomain.CONNECTED
|
||||
ConnectionStatus.DISCONNECTED -> SimpConnectStatusDomain.DISCONNECTED
|
||||
ConnectionStatus.REJECTED -> SimpConnectStatusDomain.REJECTED
|
||||
ConnectionStatus.FAILED -> SimpConnectStatusDomain.FAILED
|
||||
}
|
||||
if (status == ConnectionStatus.DISCONNECTED || status == ConnectionStatus.FAILED || status == ConnectionStatus.IDLE) {
|
||||
_activeRemoteDevice.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge remote playback state
|
||||
scope.launch {
|
||||
client.remotePlaybackState.collect { remote ->
|
||||
_remotePlaybackState.value = remote?.toDomain()
|
||||
}
|
||||
}
|
||||
|
||||
// Watch local player and broadcast state to connected controllers
|
||||
scope.launch {
|
||||
mediaPlayerHandler.nowPlaying.collect { broadcastCurrentState() }
|
||||
}
|
||||
scope.launch {
|
||||
mediaPlayerHandler.controlState.collect { broadcastCurrentState() }
|
||||
}
|
||||
scope.launch {
|
||||
mediaPlayerHandler.simpleMediaState.collect { broadcastCurrentState() }
|
||||
}
|
||||
scope.launch {
|
||||
mediaPlayerHandler.queueData.collect { broadcastCurrentState() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun refreshDiscovery() {
|
||||
discovery.broadcastPing()
|
||||
}
|
||||
|
||||
override fun stopDiscovery() {
|
||||
discovery.stop()
|
||||
}
|
||||
|
||||
override fun connectToDevice(device: DiscoveredDeviceDomain) {
|
||||
val wireDevice = DiscoveredDevice(
|
||||
deviceId = device.deviceId,
|
||||
deviceName = device.deviceName,
|
||||
platform = device.platform,
|
||||
hostAddress = device.hostAddress,
|
||||
tcpPort = device.tcpPort,
|
||||
lastSeenMs = System.currentTimeMillis(),
|
||||
)
|
||||
_activeRemoteDevice.value = device
|
||||
client.connect(wireDevice)
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
client.disconnect()
|
||||
_activeRemoteDevice.value = null
|
||||
}
|
||||
|
||||
override fun sendPlayPause() {
|
||||
client.sendPlayPause()
|
||||
}
|
||||
|
||||
override fun sendPlay() {
|
||||
client.sendPlay()
|
||||
}
|
||||
|
||||
override fun sendPause() {
|
||||
client.sendPause()
|
||||
}
|
||||
|
||||
override fun sendNext() {
|
||||
client.sendNext()
|
||||
}
|
||||
|
||||
override fun sendPrevious() {
|
||||
client.sendPrevious()
|
||||
}
|
||||
|
||||
override fun sendSeekTo(positionMs: Long) {
|
||||
client.sendSeekTo(positionMs)
|
||||
}
|
||||
|
||||
override fun sendSetVolume(volume: Float) {
|
||||
client.sendSetVolume(volume)
|
||||
}
|
||||
|
||||
override fun sendSetShuffle(enabled: Boolean) {
|
||||
client.sendSetShuffle(enabled)
|
||||
}
|
||||
|
||||
override fun sendSetRepeat(mode: Int) {
|
||||
client.sendSetRepeat(mode)
|
||||
}
|
||||
|
||||
override fun sendPlayTrack(
|
||||
videoId: String,
|
||||
playlistId: String?,
|
||||
playlistName: String?,
|
||||
queue: List<TrackSummaryDomain>,
|
||||
startIndex: Int,
|
||||
) {
|
||||
val wireQueue = queue.map { it.toWire() }
|
||||
client.sendPlayTrack(videoId, playlistId, playlistName, wireQueue, startIndex)
|
||||
}
|
||||
|
||||
override fun requestHandoff() {
|
||||
client.requestHandoff()
|
||||
}
|
||||
|
||||
override fun handoffToRemote(device: DiscoveredDeviceDomain) {
|
||||
connectToDevice(device)
|
||||
val nowPlaying = mediaPlayerHandler.nowPlaying.value
|
||||
val queue = mediaPlayerHandler.queueData.value?.data?.listTracks.orEmpty()
|
||||
val currentIndex = mediaPlayerHandler.currentSongIndex()
|
||||
|
||||
scope.launch {
|
||||
// Pause local playback
|
||||
mediaPlayerHandler.onPlayerEvent(PlayerEvent.PlayPause)
|
||||
|
||||
nowPlaying?.let { item ->
|
||||
val trackSummary = item.toTrackSummary()
|
||||
val wireQueue = queue.map { it.toTrackSummary() }
|
||||
client.sendPlayTrack(
|
||||
videoId = trackSummary.videoId,
|
||||
playlistId = null,
|
||||
playlistName = null,
|
||||
queue = wireQueue,
|
||||
startIndex = currentIndex.coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SimpConnectServerListener callbacks (Target mode)
|
||||
// ==========================================
|
||||
|
||||
override fun onPlayPause() {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.PlayPause) }
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
val isPlaying = mediaPlayerHandler.controlState.value.isPlaying
|
||||
if (!isPlaying) {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.PlayPause) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
val isPlaying = mediaPlayerHandler.controlState.value.isPlaying
|
||||
if (isPlaying) {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.PlayPause) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNext() {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.Next) }
|
||||
}
|
||||
|
||||
override fun onPrevious() {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.Previous) }
|
||||
}
|
||||
|
||||
override fun onSeekTo(positionMs: Long) {
|
||||
val duration = mediaPlayerHandler.getPlayerDuration()
|
||||
if (duration > 0) {
|
||||
val fraction = (positionMs.toFloat() / duration.toFloat()).coerceIn(0f, 1f)
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.UpdateProgress(fraction)) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSetVolume(volume: Float) {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.UpdateVolume(volume)) }
|
||||
}
|
||||
|
||||
override fun onSetShuffle(enabled: Boolean) {
|
||||
val currentShuffle = mediaPlayerHandler.controlState.value.isShuffle
|
||||
if (currentShuffle != enabled) {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.Shuffle) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSetRepeat(mode: Int) {
|
||||
scope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.Repeat) }
|
||||
}
|
||||
|
||||
override fun onPlayTrack(message: SimpConnectMessage.PlayTrack) {
|
||||
scope.launch {
|
||||
val track = Track(
|
||||
album = null,
|
||||
artists = emptyList(),
|
||||
duration = null,
|
||||
durationSeconds = null,
|
||||
isAvailable = true,
|
||||
isExplicit = false,
|
||||
likeStatus = null,
|
||||
thumbnails = emptyList(),
|
||||
title = "",
|
||||
videoId = message.videoId,
|
||||
videoType = null,
|
||||
category = null,
|
||||
feedbackTokens = null,
|
||||
resultType = null,
|
||||
)
|
||||
mediaPlayerHandler.loadMediaItem(track, type = MERGING_DATA_TYPE.SONG, index = message.startIndex)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentState(): SimpConnectMessage.StateSync {
|
||||
val nowPlaying = mediaPlayerHandler.nowPlaying.value
|
||||
val controlState = mediaPlayerHandler.controlState.value
|
||||
val duration = mediaPlayerHandler.getPlayerDuration()
|
||||
val progress = mediaPlayerHandler.getProgress()
|
||||
val queue = mediaPlayerHandler.queueData.value?.data?.listTracks.orEmpty()
|
||||
val currentIndex = mediaPlayerHandler.currentSongIndex()
|
||||
|
||||
val repeatModeInt = when (controlState.repeatState) {
|
||||
RepeatState.None -> 0
|
||||
RepeatState.All -> 1
|
||||
RepeatState.One -> 2
|
||||
}
|
||||
|
||||
return SimpConnectMessage.StateSync(
|
||||
track = nowPlaying?.toTrackSummary(),
|
||||
isPlaying = controlState.isPlaying,
|
||||
positionMs = progress,
|
||||
durationMs = duration,
|
||||
volume = 1f,
|
||||
isShuffle = controlState.isShuffle,
|
||||
repeatMode = repeatModeInt,
|
||||
queue = queue.map { it.toTrackSummary() },
|
||||
queueIndex = currentIndex,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getHandoffPayload(): SimpConnectMessage.HandoffPayload {
|
||||
val state = getCurrentState()
|
||||
return SimpConnectMessage.HandoffPayload(
|
||||
track = state.track,
|
||||
positionMs = state.positionMs,
|
||||
queue = state.queue,
|
||||
queueIndex = state.queueIndex,
|
||||
isPlaying = state.isPlaying,
|
||||
)
|
||||
}
|
||||
|
||||
private fun broadcastCurrentState() {
|
||||
if (server.connectedControllersCount.value > 0) {
|
||||
server.broadcastStateSync(getCurrentState())
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleHandoffReceived(payload: SimpConnectMessage.HandoffPayload) {
|
||||
Logger.d(TAG, "Handoff payload received: ${payload.track?.title}")
|
||||
// Pause remote playback & disconnect
|
||||
client.sendPause()
|
||||
disconnect()
|
||||
|
||||
val trackSummary = payload.track ?: return
|
||||
scope.launch {
|
||||
val track = trackSummary.toTrack()
|
||||
mediaPlayerHandler.loadMediaItem(track, type = MERGING_DATA_TYPE.SONG, index = payload.queueIndex)
|
||||
if (payload.positionMs > 0) {
|
||||
// Seek to transferred position after short delay
|
||||
kotlinx.coroutines.delay(300)
|
||||
val duration = mediaPlayerHandler.getPlayerDuration()
|
||||
if (duration > 0) {
|
||||
val fraction = (payload.positionMs.toFloat() / duration.toFloat()).coerceIn(0f, 1f)
|
||||
mediaPlayerHandler.onPlayerEvent(PlayerEvent.UpdateProgress(fraction))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Mappers
|
||||
// ==========================================
|
||||
|
||||
private fun DiscoveredDevice.toDomain(): DiscoveredDeviceDomain {
|
||||
return DiscoveredDeviceDomain(
|
||||
deviceId = deviceId,
|
||||
deviceName = deviceName,
|
||||
platform = platform,
|
||||
hostAddress = hostAddress,
|
||||
tcpPort = tcpPort,
|
||||
isLocal = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun org.simpmusic.simpconnect.RemotePlaybackState.toDomain(): RemotePlaybackStateDomain {
|
||||
return RemotePlaybackStateDomain(
|
||||
targetDevice = targetDevice.toDomain(),
|
||||
track = track?.toDomain(),
|
||||
isPlaying = isPlaying,
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs,
|
||||
volume = volume,
|
||||
isShuffle = isShuffle,
|
||||
repeatMode = repeatMode,
|
||||
queue = queue.map { it.toDomain() },
|
||||
queueIndex = queueIndex,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TrackSummary.toDomain(): TrackSummaryDomain {
|
||||
return TrackSummaryDomain(
|
||||
videoId = videoId,
|
||||
title = title,
|
||||
artist = artist,
|
||||
album = album,
|
||||
artworkUri = artworkUri,
|
||||
durationMs = durationMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TrackSummaryDomain.toWire(): TrackSummary {
|
||||
return TrackSummary(
|
||||
videoId = videoId,
|
||||
title = title,
|
||||
artist = artist,
|
||||
album = album,
|
||||
artworkUri = artworkUri,
|
||||
durationMs = durationMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun GenericMediaItem.toTrackSummary(): TrackSummary {
|
||||
return TrackSummary(
|
||||
videoId = mediaId,
|
||||
title = metadata.title.orEmpty(),
|
||||
artist = metadata.artist.orEmpty(),
|
||||
album = metadata.albumTitle,
|
||||
artworkUri = metadata.artworkUri,
|
||||
durationMs = metadata.durationMs ?: 0L,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Track.toTrackSummary(): TrackSummary {
|
||||
return TrackSummary(
|
||||
videoId = videoId,
|
||||
title = title,
|
||||
artist = artists?.firstOrNull()?.name.orEmpty(),
|
||||
album = album?.name,
|
||||
artworkUri = thumbnails?.lastOrNull()?.url,
|
||||
durationMs = (durationSeconds ?: 0) * 1000L,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TrackSummary.toTrack(): Track {
|
||||
return Track(
|
||||
album = album?.let { Album(name = it, id = "") },
|
||||
artists = listOf(Artist(name = artist, id = null)),
|
||||
duration = null,
|
||||
durationSeconds = (durationMs / 1000).toInt(),
|
||||
isAvailable = true,
|
||||
isExplicit = false,
|
||||
likeStatus = null,
|
||||
thumbnails = artworkUri?.let { listOf(Thumbnail(url = it, width = 500, height = 500)) },
|
||||
title = title,
|
||||
videoId = videoId,
|
||||
videoType = null,
|
||||
category = null,
|
||||
feedbackTokens = null,
|
||||
resultType = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package com.maxrave.domain.data.model.simpconnect
|
||||
|
||||
data class DiscoveredDeviceDomain(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val platform: String,
|
||||
val hostAddress: String,
|
||||
val tcpPort: Int,
|
||||
val isLocal: Boolean = false,
|
||||
)
|
||||
|
||||
data class TrackSummaryDomain(
|
||||
val videoId: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val album: String? = null,
|
||||
val artworkUri: String? = null,
|
||||
val durationMs: Long = 0L,
|
||||
)
|
||||
|
||||
data class RemotePlaybackStateDomain(
|
||||
val targetDevice: DiscoveredDeviceDomain,
|
||||
val track: TrackSummaryDomain?,
|
||||
val isPlaying: Boolean,
|
||||
val positionMs: Long,
|
||||
val durationMs: Long,
|
||||
val volume: Float,
|
||||
val isShuffle: Boolean,
|
||||
val repeatMode: Int,
|
||||
val queue: List<TrackSummaryDomain> = emptyList(),
|
||||
val queueIndex: Int = 0,
|
||||
)
|
||||
|
||||
enum class SimpConnectStatusDomain {
|
||||
IDLE,
|
||||
CONNECTING,
|
||||
WAITING_FOR_APPROVAL,
|
||||
CONNECTED,
|
||||
DISCONNECTED,
|
||||
REJECTED,
|
||||
FAILED,
|
||||
}
|
||||
|
||||
data class SimpConnectAuthRequestDomain(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val onResponse: (Boolean) -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.maxrave.domain.repository
|
||||
|
||||
import com.maxrave.domain.data.model.simpconnect.DiscoveredDeviceDomain
|
||||
import com.maxrave.domain.data.model.simpconnect.RemotePlaybackStateDomain
|
||||
import com.maxrave.domain.data.model.simpconnect.SimpConnectAuthRequestDomain
|
||||
import com.maxrave.domain.data.model.simpconnect.SimpConnectStatusDomain
|
||||
import com.maxrave.domain.data.model.simpconnect.TrackSummaryDomain
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface SimpConnectRepository {
|
||||
val localDevice: StateFlow<DiscoveredDeviceDomain>
|
||||
val discoveredDevices: StateFlow<List<DiscoveredDeviceDomain>>
|
||||
val activeRemoteDevice: StateFlow<DiscoveredDeviceDomain?>
|
||||
val remotePlaybackState: StateFlow<RemotePlaybackStateDomain?>
|
||||
val connectionStatus: StateFlow<SimpConnectStatusDomain>
|
||||
val incomingAuthRequest: StateFlow<SimpConnectAuthRequestDomain?>
|
||||
|
||||
fun start()
|
||||
fun refreshDiscovery()
|
||||
fun stopDiscovery()
|
||||
|
||||
fun connectToDevice(device: DiscoveredDeviceDomain)
|
||||
fun disconnect()
|
||||
|
||||
fun sendPlayPause()
|
||||
fun sendPlay()
|
||||
fun sendPause()
|
||||
fun sendNext()
|
||||
fun sendPrevious()
|
||||
fun sendSeekTo(positionMs: Long)
|
||||
fun sendSetVolume(volume: Float)
|
||||
fun sendSetShuffle(enabled: Boolean)
|
||||
fun sendSetRepeat(mode: Int)
|
||||
fun sendPlayTrack(
|
||||
videoId: String,
|
||||
playlistId: String? = null,
|
||||
playlistName: String? = null,
|
||||
queue: List<TrackSummaryDomain> = emptyList(),
|
||||
startIndex: Int = 0,
|
||||
)
|
||||
|
||||
/** Pulls playback from the connected remote device to the local player. */
|
||||
fun requestHandoff()
|
||||
|
||||
/** Pushes current local playback to [device] and enters remote control mode. */
|
||||
fun handoffToRemote(device: DiscoveredDeviceDomain)
|
||||
}
|
||||
43
service/simpConnect/build.gradle.kts
Normal file
43
service/simpConnect/build.gradle.kts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import com.android.build.gradle.internal.tasks.CompileArtProfileTask
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
alias(libs.plugins.android.lint)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "org.simpmusic.simpconnect"
|
||||
compileSdk = 37
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
jvm()
|
||||
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation(libs.kotlin.stdlib)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(projects.ktorExt)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
implementation(libs.ktor.server.core)
|
||||
implementation(libs.ktor.server.cio)
|
||||
implementation(libs.ktor.server.websockets)
|
||||
implementation(projects.common)
|
||||
}
|
||||
}
|
||||
|
||||
commonTest {
|
||||
dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<CompileArtProfileTask> {
|
||||
enabled = false
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package org.simpmusic.simpconnect
|
||||
|
||||
interface SimpConnectAuthStore {
|
||||
fun isDeviceApproved(deviceId: String, authKey: String?): Boolean
|
||||
fun saveApprovedDevice(deviceId: String, authKey: String)
|
||||
fun getSavedAuthKey(deviceId: String): String?
|
||||
}
|
||||
|
||||
class InMemorySimpConnectAuthStore : SimpConnectAuthStore {
|
||||
private val approvedKeys = mutableMapOf<String, String>()
|
||||
|
||||
override fun isDeviceApproved(deviceId: String, authKey: String?): Boolean {
|
||||
if (authKey.isNullOrBlank()) return false
|
||||
val saved = approvedKeys[deviceId]
|
||||
return saved != null && saved == authKey
|
||||
}
|
||||
|
||||
override fun saveApprovedDevice(deviceId: String, authKey: String) {
|
||||
approvedKeys[deviceId] = authKey
|
||||
}
|
||||
|
||||
override fun getSavedAuthKey(deviceId: String): String? {
|
||||
return approvedKeys[deviceId]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
package org.simpmusic.simpconnect
|
||||
|
||||
import com.maxrave.ktorext.getEngine
|
||||
import com.maxrave.logger.Logger
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.plugins.websocket.webSocket
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.websocket.CloseReason
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.close
|
||||
import io.ktor.websocket.readText
|
||||
import io.ktor.websocket.send
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val TAG = "SimpConnectClient"
|
||||
|
||||
class SimpConnectClient(
|
||||
private val localDeviceId: String,
|
||||
private val localDeviceName: () -> String,
|
||||
private val authStore: SimpConnectAuthStore,
|
||||
private val onHandoffReceived: (SimpConnectMessage.HandoffPayload) -> Unit,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val httpClient = HttpClient(getEngine()) {
|
||||
install(WebSockets)
|
||||
}
|
||||
|
||||
private val _connectionStatus = MutableStateFlow(ConnectionStatus.IDLE)
|
||||
val connectionStatus: StateFlow<ConnectionStatus> = _connectionStatus.asStateFlow()
|
||||
|
||||
private val _activeTargetDevice = MutableStateFlow<DiscoveredDevice?>(null)
|
||||
val activeTargetDevice: StateFlow<DiscoveredDevice?> = _activeTargetDevice.asStateFlow()
|
||||
|
||||
private val _remotePlaybackState = MutableStateFlow<RemotePlaybackState?>(null)
|
||||
val remotePlaybackState: StateFlow<RemotePlaybackState?> = _remotePlaybackState.asStateFlow()
|
||||
|
||||
private var connectionJob: Job? = null
|
||||
private var heartbeatJob: Job? = null
|
||||
private var currentSession: DefaultClientWebSocketSession? = null
|
||||
|
||||
fun connect(target: DiscoveredDevice) {
|
||||
disconnect()
|
||||
Logger.d(TAG, "Connecting to remote device: ${target.deviceName} (${target.hostAddress}:${target.tcpPort})")
|
||||
_activeTargetDevice.value = target
|
||||
_connectionStatus.value = ConnectionStatus.CONNECTING
|
||||
|
||||
connectionJob = scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
httpClient.webSocket(
|
||||
method = HttpMethod.Get,
|
||||
host = target.hostAddress,
|
||||
port = target.tcpPort,
|
||||
path = "/simpconnect",
|
||||
) {
|
||||
currentSession = this
|
||||
runSession(this, target)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e !is CancellationException) {
|
||||
Logger.e(TAG, "Connection to ${target.deviceName} failed: ${e.message}")
|
||||
_connectionStatus.value = ConnectionStatus.FAILED
|
||||
}
|
||||
} finally {
|
||||
currentSession = null
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = null
|
||||
if (_connectionStatus.value == ConnectionStatus.CONNECTING ||
|
||||
_connectionStatus.value == ConnectionStatus.CONNECTED ||
|
||||
_connectionStatus.value == ConnectionStatus.WAITING_FOR_APPROVAL
|
||||
) {
|
||||
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
}
|
||||
_remotePlaybackState.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
if (_connectionStatus.value == ConnectionStatus.IDLE) return
|
||||
Logger.d(TAG, "Disconnecting from remote session")
|
||||
connectionJob?.cancel()
|
||||
connectionJob = null
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = null
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
currentSession?.close(CloseReason(CloseReason.Codes.NORMAL, "User disconnected"))
|
||||
}
|
||||
currentSession = null
|
||||
}
|
||||
|
||||
_activeTargetDevice.value = null
|
||||
_connectionStatus.value = ConnectionStatus.IDLE
|
||||
_remotePlaybackState.value = null
|
||||
}
|
||||
|
||||
fun sendPlayPause() = sendCommand(SimpConnectMessage.PlayPause)
|
||||
fun sendPlay() = sendCommand(SimpConnectMessage.Play)
|
||||
fun sendPause() = sendCommand(SimpConnectMessage.Pause)
|
||||
fun sendNext() = sendCommand(SimpConnectMessage.Next)
|
||||
fun sendPrevious() = sendCommand(SimpConnectMessage.Previous)
|
||||
fun sendSeekTo(positionMs: Long) = sendCommand(SimpConnectMessage.SeekTo(positionMs))
|
||||
fun sendSetVolume(volume: Float) = sendCommand(SimpConnectMessage.SetVolume(volume))
|
||||
fun sendSetShuffle(enabled: Boolean) = sendCommand(SimpConnectMessage.SetShuffle(enabled))
|
||||
fun sendSetRepeat(mode: Int) = sendCommand(SimpConnectMessage.SetRepeat(mode))
|
||||
fun sendPlayTrack(
|
||||
videoId: String,
|
||||
playlistId: String? = null,
|
||||
playlistName: String? = null,
|
||||
queue: List<TrackSummary> = emptyList(),
|
||||
startIndex: Int = 0,
|
||||
) = sendCommand(SimpConnectMessage.PlayTrack(videoId, playlistId, playlistName, queue, startIndex))
|
||||
|
||||
fun requestHandoff() = sendCommand(SimpConnectMessage.HandoffRequest)
|
||||
|
||||
private fun sendCommand(msg: SimpConnectMessage) {
|
||||
val session = currentSession ?: return
|
||||
val json = simpConnectJson.encodeToString(SimpConnectMessage.serializer(), msg)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (session.isActive) {
|
||||
session.send(Frame.Text(json))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to send command ${msg::class.simpleName}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runSession(session: DefaultClientWebSocketSession, target: DiscoveredDevice) {
|
||||
// 1. Handshake: send Hello
|
||||
val savedKey = authStore.getSavedAuthKey(target.deviceId)
|
||||
val hello = SimpConnectMessage.Hello(
|
||||
deviceId = localDeviceId,
|
||||
deviceName = localDeviceName(),
|
||||
authKey = savedKey,
|
||||
)
|
||||
session.send(Frame.Text(simpConnectJson.encodeToString(SimpConnectMessage.serializer(), hello)))
|
||||
|
||||
// 2. Await HelloAck
|
||||
var authenticated = false
|
||||
while (!authenticated && session.isActive) {
|
||||
val frame = session.incoming.receive()
|
||||
if (frame is Frame.Text) {
|
||||
val ack = runCatching {
|
||||
simpConnectJson.decodeFromString(SimpConnectMessage.serializer(), frame.readText())
|
||||
}.getOrNull()
|
||||
|
||||
if (ack is SimpConnectMessage.HelloAck) {
|
||||
when (ack.status) {
|
||||
SimpConnectMessage.HandshakeStatus.NEEDS_APPROVAL -> {
|
||||
_connectionStatus.value = ConnectionStatus.WAITING_FOR_APPROVAL
|
||||
Logger.d(TAG, "Waiting for user approval on ${target.deviceName}")
|
||||
}
|
||||
SimpConnectMessage.HandshakeStatus.ACCEPTED -> {
|
||||
authenticated = true
|
||||
if (ack.authKey != null) {
|
||||
authStore.saveApprovedDevice(target.deviceId, ack.authKey)
|
||||
}
|
||||
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||
Logger.d(TAG, "Connection accepted by ${target.deviceName}")
|
||||
}
|
||||
SimpConnectMessage.HandshakeStatus.REJECTED -> {
|
||||
_connectionStatus.value = ConnectionStatus.REJECTED
|
||||
Logger.w(TAG, "Connection rejected by ${target.deviceName}")
|
||||
session.close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Rejected"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Start Heartbeat
|
||||
heartbeatJob = scope.launch(Dispatchers.IO) {
|
||||
while (isActive && session.isActive) {
|
||||
delay(10_000)
|
||||
try {
|
||||
session.send(Frame.Text(simpConnectJson.encodeToString(SimpConnectMessage.serializer(), SimpConnectMessage.Ping)))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Message loop
|
||||
try {
|
||||
for (frame in session.incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val msg = runCatching {
|
||||
simpConnectJson.decodeFromString(SimpConnectMessage.serializer(), frame.readText())
|
||||
}.getOrNull() ?: continue
|
||||
|
||||
when (msg) {
|
||||
is SimpConnectMessage.StateSync -> {
|
||||
_remotePlaybackState.value = RemotePlaybackState(
|
||||
targetDevice = target,
|
||||
track = msg.track,
|
||||
isPlaying = msg.isPlaying,
|
||||
positionMs = msg.positionMs,
|
||||
durationMs = msg.durationMs,
|
||||
volume = msg.volume,
|
||||
isShuffle = msg.isShuffle,
|
||||
repeatMode = msg.repeatMode,
|
||||
queue = msg.queue,
|
||||
queueIndex = msg.queueIndex,
|
||||
lastSyncTimestamp = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
is SimpConnectMessage.HandoffPayload -> {
|
||||
onHandoffReceived(msg)
|
||||
}
|
||||
is SimpConnectMessage.Pong -> {
|
||||
// Pong received
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e !is CancellationException && e !is ClosedReceiveChannelException) {
|
||||
Logger.w(TAG, "Error in client message loop: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
package org.simpmusic.simpconnect
|
||||
|
||||
import com.maxrave.logger.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.SocketTimeoutException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private const val TAG = "SimpConnectDiscovery"
|
||||
private const val DEVICE_EXPIRATION_MS = 25_000L
|
||||
|
||||
class SimpConnectDiscovery(
|
||||
private val localDeviceId: String,
|
||||
private val localDeviceName: () -> String,
|
||||
private val localPlatform: String,
|
||||
private val getTcpPort: () -> Int,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val _discoveredDevices = MutableStateFlow<List<DiscoveredDevice>>(emptyList())
|
||||
val discoveredDevices: StateFlow<List<DiscoveredDevice>> = _discoveredDevices.asStateFlow()
|
||||
|
||||
private val deviceMap = ConcurrentHashMap<String, DiscoveredDevice>()
|
||||
|
||||
private var listenerJob: Job? = null
|
||||
private var cleanupJob: Job? = null
|
||||
private var periodicPingJob: Job? = null
|
||||
|
||||
private var socket: DatagramSocket? = null
|
||||
|
||||
fun start() {
|
||||
if (listenerJob != null) return
|
||||
Logger.d(TAG, "Starting discovery for deviceId: $localDeviceId")
|
||||
|
||||
listenerJob = scope.launch(Dispatchers.IO) {
|
||||
runListenerLoop()
|
||||
}
|
||||
|
||||
cleanupJob = scope.launch(Dispatchers.Default) {
|
||||
runCleanupLoop()
|
||||
}
|
||||
|
||||
periodicPingJob = scope.launch(Dispatchers.IO) {
|
||||
// Initial burst
|
||||
broadcastPingInternal()
|
||||
delay(1500)
|
||||
broadcastPingInternal()
|
||||
|
||||
while (isActive) {
|
||||
delay(12_000)
|
||||
broadcastPingInternal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
Logger.d(TAG, "Stopping discovery")
|
||||
listenerJob?.cancel()
|
||||
listenerJob = null
|
||||
cleanupJob?.cancel()
|
||||
cleanupJob = null
|
||||
periodicPingJob?.cancel()
|
||||
periodicPingJob = null
|
||||
|
||||
try {
|
||||
socket?.close()
|
||||
} catch (_: Exception) {}
|
||||
socket = null
|
||||
|
||||
deviceMap.clear()
|
||||
_discoveredDevices.value = emptyList()
|
||||
}
|
||||
|
||||
fun broadcastPing() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
broadcastPingInternal()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runListenerLoop() {
|
||||
val buf = ByteArray(2048)
|
||||
while (scope.isActive) {
|
||||
try {
|
||||
val sock = DatagramSocket(null).apply {
|
||||
reuseAddress = true
|
||||
broadcast = true
|
||||
soTimeout = 2000
|
||||
bind(InetSocketAddress(SIMP_CONNECT_DISCOVERY_PORT))
|
||||
}
|
||||
socket = sock
|
||||
Logger.d(TAG, "Discovery UDP socket bound to port $SIMP_CONNECT_DISCOVERY_PORT")
|
||||
|
||||
while (scope.isActive && !sock.isClosed) {
|
||||
val packet = DatagramPacket(buf, buf.size)
|
||||
try {
|
||||
sock.receive(packet)
|
||||
handlePacket(packet, sock)
|
||||
} catch (_: SocketTimeoutException) {
|
||||
// Regular timeout to allow checking scope.isActive
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
if (!sock.isClosed) {
|
||||
Logger.w(TAG, "Socket receive error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) break
|
||||
Logger.e(TAG, "Failed to bind or error in listener: ${e.message}")
|
||||
try {
|
||||
socket?.close()
|
||||
} catch (_: Exception) {}
|
||||
socket = null
|
||||
// Wait a bit before retrying bind
|
||||
Thread.sleep(3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePacket(packet: DatagramPacket, listeningSocket: DatagramSocket) {
|
||||
val text = packet.data.decodeToString(startIndex = packet.offset, endIndex = packet.offset + packet.length).trim()
|
||||
if (!text.contains(SIMP_CONNECT_MAGIC)) return
|
||||
|
||||
val msg = runCatching {
|
||||
simpConnectJson.decodeFromString(DiscoveryMessage.serializer(), text)
|
||||
}.getOrNull() ?: return
|
||||
|
||||
when (msg) {
|
||||
is DiscoveryMessage.Ping -> {
|
||||
if (msg.deviceId == localDeviceId) return // Ignore own ping
|
||||
val tcpPort = getTcpPort()
|
||||
if (tcpPort <= 0) return // Server not ready yet
|
||||
|
||||
val pong = DiscoveryMessage.Pong(
|
||||
magic = SIMP_CONNECT_MAGIC,
|
||||
deviceId = localDeviceId,
|
||||
deviceName = localDeviceName(),
|
||||
platform = localPlatform,
|
||||
tcpPort = tcpPort,
|
||||
)
|
||||
val replyBytes = simpConnectJson.encodeToString(DiscoveryMessage.serializer(), pong).encodeToByteArray()
|
||||
val replyPacket = DatagramPacket(replyBytes, replyBytes.size, packet.socketAddress)
|
||||
try {
|
||||
listeningSocket.send(replyPacket)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to send pong to ${packet.socketAddress}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
is DiscoveryMessage.Pong -> {
|
||||
if (msg.deviceId == localDeviceId) return // Ignore own pong
|
||||
val hostIp = packet.address?.hostAddress ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
val device = DiscoveredDevice(
|
||||
deviceId = msg.deviceId,
|
||||
deviceName = msg.deviceName,
|
||||
platform = msg.platform,
|
||||
hostAddress = hostIp,
|
||||
tcpPort = msg.tcpPort,
|
||||
lastSeenMs = now,
|
||||
)
|
||||
deviceMap[msg.deviceId] = device
|
||||
publishDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun broadcastPingInternal() {
|
||||
val ping = DiscoveryMessage.Ping(
|
||||
magic = SIMP_CONNECT_MAGIC,
|
||||
deviceId = localDeviceId,
|
||||
deviceName = localDeviceName(),
|
||||
platform = localPlatform,
|
||||
)
|
||||
val data = simpConnectJson.encodeToString(DiscoveryMessage.serializer(), ping).encodeToByteArray()
|
||||
|
||||
val targets = mutableSetOf<InetAddress>()
|
||||
runCatching {
|
||||
targets.add(InetAddress.getByName("255.255.255.255"))
|
||||
}
|
||||
|
||||
// Add interface broadcast addresses
|
||||
runCatching {
|
||||
NetworkInterface.getNetworkInterfaces()?.toList().orEmpty()
|
||||
.filter { it.isUp && !it.isLoopback && !it.isVirtual }
|
||||
.flatMap { it.interfaceAddresses }
|
||||
.mapNotNull { it.broadcast }
|
||||
.forEach { targets.add(it) }
|
||||
}
|
||||
|
||||
val sendSock = socket ?: runCatching {
|
||||
DatagramSocket().apply {
|
||||
broadcast = true
|
||||
soTimeout = 1000
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
if (sendSock == null) return
|
||||
|
||||
for (target in targets) {
|
||||
try {
|
||||
val packet = DatagramPacket(data, data.size, target, SIMP_CONNECT_DISCOVERY_PORT)
|
||||
sendSock.send(packet)
|
||||
} catch (_: Exception) {
|
||||
// Ignore per-interface route errors
|
||||
}
|
||||
}
|
||||
|
||||
if (sendSock !== socket) {
|
||||
runCatching { sendSock.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runCleanupLoop() {
|
||||
while (scope.isActive) {
|
||||
delay(5000)
|
||||
val now = System.currentTimeMillis()
|
||||
var changed = false
|
||||
val it = deviceMap.entries.iterator()
|
||||
while (it.hasNext()) {
|
||||
val entry = it.next()
|
||||
if (now - entry.value.lastSeenMs > DEVICE_EXPIRATION_MS) {
|
||||
it.remove()
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
publishDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishDevices() {
|
||||
_discoveredDevices.value = deviceMap.values.toList().sortedBy { it.deviceName }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package org.simpmusic.simpconnect
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DiscoveredDevice(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val platform: String,
|
||||
val hostAddress: String,
|
||||
val tcpPort: Int,
|
||||
val lastSeenMs: Long,
|
||||
)
|
||||
|
||||
data class RemotePlaybackState(
|
||||
val targetDevice: DiscoveredDevice,
|
||||
val track: TrackSummary?,
|
||||
val isPlaying: Boolean,
|
||||
val positionMs: Long,
|
||||
val durationMs: Long,
|
||||
val volume: Float,
|
||||
val isShuffle: Boolean,
|
||||
val repeatMode: Int,
|
||||
val queue: List<TrackSummary> = emptyList(),
|
||||
val queueIndex: Int = 0,
|
||||
val lastSyncTimestamp: Long = 0L,
|
||||
)
|
||||
|
||||
enum class ConnectionStatus {
|
||||
IDLE,
|
||||
CONNECTING,
|
||||
WAITING_FOR_APPROVAL,
|
||||
CONNECTED,
|
||||
DISCONNECTED,
|
||||
REJECTED,
|
||||
FAILED,
|
||||
}
|
||||
|
||||
data class AuthRequest(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val onResponse: (Boolean) -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
package org.simpmusic.simpconnect
|
||||
|
||||
import com.maxrave.logger.Logger
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import io.ktor.server.websocket.webSocket
|
||||
import io.ktor.websocket.CloseReason
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.WebSocketSession
|
||||
import io.ktor.websocket.close
|
||||
import io.ktor.websocket.readText
|
||||
import io.ktor.websocket.send
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.Collections
|
||||
import kotlin.random.Random
|
||||
|
||||
private const val TAG = "SimpConnectServer"
|
||||
|
||||
interface SimpConnectServerListener {
|
||||
fun onPlayPause()
|
||||
fun onPlay()
|
||||
fun onPause()
|
||||
fun onNext()
|
||||
fun onPrevious()
|
||||
fun onSeekTo(positionMs: Long)
|
||||
fun onSetVolume(volume: Float)
|
||||
fun onSetShuffle(enabled: Boolean)
|
||||
fun onSetRepeat(mode: Int)
|
||||
fun onPlayTrack(message: SimpConnectMessage.PlayTrack)
|
||||
fun getCurrentState(): SimpConnectMessage.StateSync
|
||||
fun getHandoffPayload(): SimpConnectMessage.HandoffPayload
|
||||
}
|
||||
|
||||
class SimpConnectServer(
|
||||
private val localDeviceId: String,
|
||||
private val localDeviceName: () -> String,
|
||||
private val authStore: SimpConnectAuthStore,
|
||||
private val listener: SimpConnectServerListener,
|
||||
private val onAuthRequest: (AuthRequest) -> Unit,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
private var boundPort: Int = 0
|
||||
|
||||
private val connectedSessions = Collections.synchronizedSet(mutableSetOf<WebSocketSession>())
|
||||
|
||||
private val _connectedControllersCount = MutableStateFlow(0)
|
||||
val connectedControllersCount = _connectedControllersCount.asStateFlow()
|
||||
|
||||
fun getPort(): Int = boundPort
|
||||
|
||||
suspend fun start(preferredPort: Int = SIMP_CONNECT_DEFAULT_SERVER_PORT): Int {
|
||||
stop()
|
||||
|
||||
var chosenPort = preferredPort
|
||||
var embedded: EmbeddedServer<*, *>? = null
|
||||
|
||||
// Try preferred port first, then fall back to dynamic port 0
|
||||
val portsToTry = listOf(preferredPort, 0)
|
||||
for (p in portsToTry) {
|
||||
try {
|
||||
embedded = embeddedServer(CIO, port = p, host = "0.0.0.0") {
|
||||
install(WebSockets)
|
||||
routing {
|
||||
webSocket("/simpconnect") {
|
||||
handleSession(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
embedded.start(wait = false)
|
||||
chosenPort = embedded.engine.resolvedConnectors().first().port
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Port $p failed to bind: ${e.message}")
|
||||
runCatching { embedded?.stop(gracePeriodMillis = 0, timeoutMillis = 500) }
|
||||
embedded = null
|
||||
}
|
||||
}
|
||||
|
||||
if (embedded == null) {
|
||||
Logger.e(TAG, "Failed to start SimpConnectServer on any port")
|
||||
return 0
|
||||
}
|
||||
|
||||
server = embedded
|
||||
boundPort = chosenPort
|
||||
Logger.d(TAG, "SimpConnectServer started on port $boundPort")
|
||||
return boundPort
|
||||
}
|
||||
|
||||
suspend fun stop() {
|
||||
val s = server ?: return
|
||||
server = null
|
||||
boundPort = 0
|
||||
connectedSessions.clear()
|
||||
_connectedControllersCount.value = 0
|
||||
withContext(NonCancellable + Dispatchers.IO) {
|
||||
runCatching { s.stop(gracePeriodMillis = 100, timeoutMillis = 500) }
|
||||
}
|
||||
Logger.d(TAG, "SimpConnectServer stopped")
|
||||
}
|
||||
|
||||
fun broadcastStateSync(state: SimpConnectMessage.StateSync) {
|
||||
if (connectedSessions.isEmpty()) return
|
||||
val json = simpConnectJson.encodeToString(SimpConnectMessage.serializer(), state)
|
||||
val frame = Frame.Text(json)
|
||||
val sessionsCopy = synchronized(connectedSessions) { connectedSessions.toList() }
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
for (session in sessionsCopy) {
|
||||
try {
|
||||
if (session.isActive) {
|
||||
session.send(frame)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to send StateSync to controller: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleSession(session: WebSocketSession) {
|
||||
Logger.d(TAG, "New incoming WebSocket connection")
|
||||
|
||||
// 1. Handshake: Await Hello
|
||||
val firstFrame = try {
|
||||
session.incoming.receive()
|
||||
} catch (_: ClosedReceiveChannelException) {
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Error waiting for hello: ${e.message}")
|
||||
return
|
||||
}
|
||||
|
||||
if (firstFrame !is Frame.Text) {
|
||||
session.close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Expected text frame"))
|
||||
return
|
||||
}
|
||||
|
||||
val hello = runCatching {
|
||||
simpConnectJson.decodeFromString(SimpConnectMessage.serializer(), firstFrame.readText())
|
||||
}.getOrNull()
|
||||
|
||||
if (hello !is SimpConnectMessage.Hello) {
|
||||
session.close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Expected Hello message"))
|
||||
return
|
||||
}
|
||||
|
||||
Logger.d(TAG, "Received Hello from ${hello.deviceName} (${hello.deviceId})")
|
||||
|
||||
// 2. Auth check
|
||||
val isPreApproved = authStore.isDeviceApproved(hello.deviceId, hello.authKey)
|
||||
val sessionKey: String
|
||||
|
||||
if (isPreApproved && hello.authKey != null) {
|
||||
sessionKey = hello.authKey
|
||||
val ack = SimpConnectMessage.HelloAck(
|
||||
status = SimpConnectMessage.HandshakeStatus.ACCEPTED,
|
||||
deviceName = localDeviceName(),
|
||||
authKey = sessionKey,
|
||||
)
|
||||
session.send(Frame.Text(simpConnectJson.encodeToString(SimpConnectMessage.serializer(), ack)))
|
||||
} else {
|
||||
// Inform client that approval is pending on screen
|
||||
val pendingAck = SimpConnectMessage.HelloAck(
|
||||
status = SimpConnectMessage.HandshakeStatus.NEEDS_APPROVAL,
|
||||
deviceName = localDeviceName(),
|
||||
)
|
||||
session.send(Frame.Text(simpConnectJson.encodeToString(SimpConnectMessage.serializer(), pendingAck)))
|
||||
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
val request = AuthRequest(
|
||||
deviceId = hello.deviceId,
|
||||
deviceName = hello.deviceName,
|
||||
onResponse = { approved -> deferred.complete(approved) }
|
||||
)
|
||||
onAuthRequest(request)
|
||||
|
||||
val approved = withTimeoutOrNull(60_000L) {
|
||||
deferred.await()
|
||||
} ?: false
|
||||
|
||||
if (!approved) {
|
||||
val rejectAck = SimpConnectMessage.HelloAck(
|
||||
status = SimpConnectMessage.HandshakeStatus.REJECTED,
|
||||
deviceName = localDeviceName(),
|
||||
)
|
||||
session.send(Frame.Text(simpConnectJson.encodeToString(SimpConnectMessage.serializer(), rejectAck)))
|
||||
session.close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Connection rejected"))
|
||||
return
|
||||
}
|
||||
|
||||
sessionKey = generateAuthKey()
|
||||
authStore.saveApprovedDevice(hello.deviceId, sessionKey)
|
||||
|
||||
val ack = SimpConnectMessage.HelloAck(
|
||||
status = SimpConnectMessage.HandshakeStatus.ACCEPTED,
|
||||
deviceName = localDeviceName(),
|
||||
authKey = sessionKey,
|
||||
)
|
||||
session.send(Frame.Text(simpConnectJson.encodeToString(SimpConnectMessage.serializer(), ack)))
|
||||
}
|
||||
|
||||
// 3. Connection accepted!
|
||||
connectedSessions.add(session)
|
||||
_connectedControllersCount.value = connectedSessions.size
|
||||
Logger.d(TAG, "Controller ${hello.deviceName} connected. Total controllers: ${_connectedControllersCount.value}")
|
||||
|
||||
// Send current state right away
|
||||
try {
|
||||
val initialState = listener.getCurrentState()
|
||||
session.send(Frame.Text(simpConnectJson.encodeToString(SimpConnectMessage.serializer(), initialState)))
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to send initial StateSync: ${e.message}")
|
||||
}
|
||||
|
||||
// 4. Message loop
|
||||
try {
|
||||
for (frame in session.incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val msg = runCatching {
|
||||
simpConnectJson.decodeFromString(SimpConnectMessage.serializer(), frame.readText())
|
||||
}.getOrNull() ?: continue
|
||||
|
||||
when (msg) {
|
||||
is SimpConnectMessage.PlayPause -> listener.onPlayPause()
|
||||
is SimpConnectMessage.Play -> listener.onPlay()
|
||||
is SimpConnectMessage.Pause -> listener.onPause()
|
||||
is SimpConnectMessage.Next -> listener.onNext()
|
||||
is SimpConnectMessage.Previous -> listener.onPrevious()
|
||||
is SimpConnectMessage.SeekTo -> listener.onSeekTo(msg.positionMs)
|
||||
is SimpConnectMessage.SetVolume -> listener.onSetVolume(msg.volume)
|
||||
is SimpConnectMessage.SetShuffle -> listener.onSetShuffle(msg.enabled)
|
||||
is SimpConnectMessage.SetRepeat -> listener.onSetRepeat(msg.mode)
|
||||
is SimpConnectMessage.PlayTrack -> listener.onPlayTrack(msg)
|
||||
is SimpConnectMessage.HandoffRequest -> {
|
||||
val payload = listener.getHandoffPayload()
|
||||
session.send(Frame.Text(simpConnectJson.encodeToString(SimpConnectMessage.serializer(), payload)))
|
||||
}
|
||||
is SimpConnectMessage.Ping -> {
|
||||
session.send(Frame.Text(simpConnectJson.encodeToString(SimpConnectMessage.serializer(), SimpConnectMessage.Pong)))
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e !is CancellationException && e !is ClosedReceiveChannelException) {
|
||||
Logger.w(TAG, "Controller session error: ${e.message}")
|
||||
}
|
||||
} finally {
|
||||
connectedSessions.remove(session)
|
||||
_connectedControllersCount.value = connectedSessions.size
|
||||
Logger.d(TAG, "Controller ${hello.deviceName} disconnected. Remaining: ${_connectedControllersCount.value}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateAuthKey(): String {
|
||||
val bytes = Random.Default.nextBytes(16)
|
||||
return bytes.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
package org.simpmusic.simpconnect
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
const val SIMP_CONNECT_DISCOVERY_PORT = 8764
|
||||
const val SIMP_CONNECT_DEFAULT_SERVER_PORT = 8765
|
||||
const val SIMP_CONNECT_MAGIC = "SIMPCONNECT_V1"
|
||||
|
||||
val simpConnectJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class DiscoveryMessage {
|
||||
@Serializable
|
||||
@SerialName("ping")
|
||||
data class Ping(
|
||||
val magic: String = SIMP_CONNECT_MAGIC,
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val platform: String,
|
||||
) : DiscoveryMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("pong")
|
||||
data class Pong(
|
||||
val magic: String = SIMP_CONNECT_MAGIC,
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val platform: String,
|
||||
val tcpPort: Int,
|
||||
) : DiscoveryMessage()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TrackSummary(
|
||||
val videoId: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val album: String? = null,
|
||||
val artworkUri: String? = null,
|
||||
val durationMs: Long = 0L,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed class SimpConnectMessage {
|
||||
// ========== Handshake ==========
|
||||
|
||||
@Serializable
|
||||
@SerialName("hello")
|
||||
data class Hello(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val authKey: String? = null,
|
||||
) : SimpConnectMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("hello_ack")
|
||||
data class HelloAck(
|
||||
val status: HandshakeStatus,
|
||||
val deviceName: String,
|
||||
val authKey: String? = null,
|
||||
) : SimpConnectMessage()
|
||||
|
||||
@Serializable
|
||||
enum class HandshakeStatus {
|
||||
ACCEPTED,
|
||||
NEEDS_APPROVAL,
|
||||
REJECTED,
|
||||
}
|
||||
|
||||
// ========== Playback State Sync ==========
|
||||
|
||||
@Serializable
|
||||
@SerialName("state_sync")
|
||||
data class StateSync(
|
||||
val track: TrackSummary?,
|
||||
val isPlaying: Boolean,
|
||||
val positionMs: Long,
|
||||
val durationMs: Long,
|
||||
val volume: Float,
|
||||
val isShuffle: Boolean,
|
||||
val repeatMode: Int,
|
||||
val queue: List<TrackSummary> = emptyList(),
|
||||
val queueIndex: Int = 0,
|
||||
) : SimpConnectMessage()
|
||||
|
||||
// ========== Commands ==========
|
||||
|
||||
@Serializable
|
||||
@SerialName("cmd_play_pause")
|
||||
data object PlayPause : SimpConnectMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("cmd_play")
|
||||
data object Play : SimpConnectMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("cmd_pause")
|
||||
data object Pause : SimpConnectMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("cmd_next")
|
||||
data object Next : SimpConnectMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("cmd_prev")
|
||||
data object Previous : SimpConnectMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("cmd_seek")
|
||||
data class SeekTo(val positionMs: Long) : SimpConnectMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("cmd_volume")
|
||||
data class SetVolume(val volume: Float) : SimpConnectMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("cmd_shuffle")
|
||||
data class SetShuffle(val enabled: Boolean) : SimpConnectMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("cmd_repeat")
|
||||
data class SetRepeat(val mode: Int) : SimpConnectMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("cmd_play_track")
|
||||
data class PlayTrack(
|
||||
val videoId: String,
|
||||
val playlistId: String? = null,
|
||||
val playlistName: String? = null,
|
||||
val queue: List<TrackSummary> = emptyList(),
|
||||
val startIndex: Int = 0,
|
||||
) : SimpConnectMessage()
|
||||
|
||||
// ========== Playback Handoff ==========
|
||||
|
||||
@Serializable
|
||||
@SerialName("handoff_request")
|
||||
data object HandoffRequest : SimpConnectMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("handoff_payload")
|
||||
data class HandoffPayload(
|
||||
val track: TrackSummary?,
|
||||
val positionMs: Long,
|
||||
val queue: List<TrackSummary>,
|
||||
val queueIndex: Int,
|
||||
val isPlaying: Boolean,
|
||||
) : SimpConnectMessage()
|
||||
|
||||
// ========== Heartbeat ==========
|
||||
|
||||
@Serializable
|
||||
@SerialName("ping")
|
||||
data object Ping : SimpConnectMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("pong")
|
||||
data object Pong : SimpConnectMessage()
|
||||
}
|
||||
Loading…
Reference in a new issue