feat(simpconnect): implement P2P discovery, server, client, domain and data repository

This commit is contained in:
Murilo Scolari 2026-10-21 15:12:42 -03:00
parent 7efa4c9b44
commit d1934699b5
17 changed files with 2227 additions and 2 deletions

6
.gitignore vendored
View file

@ -27,4 +27,8 @@ replay_pid*
.kotlin/
# OMC (oh-my-claudecode) local state
/.omc/
/.omc/
# Gradle build directories
build/
.gradle/

View file

@ -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)
}
}
}

View file

@ -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() }
}
},
)
}

View file

@ -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,
)
}
}

View file

@ -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()

View file

@ -5,3 +5,6 @@ actual fun loadMediaService() {
actual fun loadLoginSyncModule() {
}
actual fun loadSimpConnectModule() {
}

View file

@ -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() }
}
},
)
}

View file

@ -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,
)
}
}

View file

@ -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,
)

View file

@ -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)
}

View 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
}

View file

@ -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]
}
}

View file

@ -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}")
}
}
}
}

View file

@ -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 }
}
}

View file

@ -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,
)

View file

@ -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') }
}
}

View file

@ -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()
}