fix(simpconnect): stabilize P2P discovery, state sync, playback handoff and multicast lock

This commit is contained in:
Murilo Scolari 2026-10-24 17:59:27 -03:00
parent d28478c2ad
commit e3ce0b331e
6 changed files with 340 additions and 98 deletions

View file

@ -28,6 +28,7 @@ actual fun loadSimpConnectModule() {
com.maxrave.data.simpconnect.SimpConnectRepositoryImpl(
mediaPlayerHandler = get(),
dataStoreManager = get(),
songRepository = get(),
scope = get(org.koin.core.qualifier.named(com.maxrave.common.Config.SERVICE_SCOPE)),
).also { it.start() }
}

View file

@ -1,8 +1,11 @@
package com.maxrave.data.simpconnect
import android.content.Context
import android.net.wifi.WifiManager
import android.os.Build
import com.maxrave.common.Config
import com.maxrave.common.MERGING_DATA_TYPE
import com.maxrave.domain.data.entities.SongEntity
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
@ -13,6 +16,7 @@ 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.data.player.GenericMediaMetadata
import com.maxrave.domain.extension.toGenericMediaItem
import com.maxrave.domain.manager.DataStoreManager
import com.maxrave.domain.mediaservice.handler.MediaPlayerHandler
@ -21,6 +25,10 @@ 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.domain.repository.SongRepository
import com.maxrave.domain.utils.connectArtists
import com.maxrave.domain.utils.toListName
import com.maxrave.domain.utils.toSongEntity
import com.maxrave.logger.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -29,11 +37,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.simpmusic.simpconnect.AuthRequest
import org.simpmusic.simpconnect.ConnectionStatus
import org.simpmusic.simpconnect.DiscoveredDevice
@ -53,8 +64,12 @@ private const val KEY_APPROVED_DEVICES = "simpconnect_approved_devices"
class SimpConnectRepositoryImpl(
private val mediaPlayerHandler: MediaPlayerHandler,
private val dataStoreManager: DataStoreManager,
private val songRepository: SongRepository,
private val scope: CoroutineScope,
) : SimpConnectRepository, SimpConnectServerListener {
) : SimpConnectRepository, SimpConnectServerListener, KoinComponent {
private val context: Context by inject()
private var multicastLock: WifiManager.MulticastLock? = null
private val localDeviceId: String = runBlocking {
val existing = dataStoreManager.getString(KEY_DEVICE_ID).first()
@ -156,6 +171,14 @@ class SimpConnectRepositoryImpl(
started = true
Logger.d(TAG, "Starting SimpConnectRepository for $localDeviceNameString")
runCatching {
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
multicastLock = wifiManager?.createMulticastLock("SimpConnectMulticast")?.apply {
setReferenceCounted(false)
acquire()
}
}
scope.launch(Dispatchers.IO) {
val port =
runCatching { server.start() }
@ -221,12 +244,22 @@ class SimpConnectRepositoryImpl(
scope.launch {
mediaPlayerHandler.nowPlaying.collect { broadcastCurrentState() }
}
scope.launch {
mediaPlayerHandler.nowPlayingState.collect { broadcastCurrentState() }
}
scope.launch {
mediaPlayerHandler.controlState.collect { broadcastCurrentState() }
}
scope.launch {
mediaPlayerHandler.queueData.collect { broadcastCurrentState() }
}
scope.launch {
mediaPlayerHandler.simpleMediaState.collect { state ->
if (state is SimpleMediaState.Ready) {
broadcastCurrentState()
}
}
}
// Periodic ticker to sync playback position to connected controllers while playing
scope.launch {
@ -260,7 +293,7 @@ class SimpConnectRepositoryImpl(
try {
val player = mediaPlayerHandler.player
player.playWhenReady = false
player.stop()
player.pause()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
@ -292,6 +325,12 @@ class SimpConnectRepositoryImpl(
override fun stopDiscovery() {
discovery.stop()
runCatching {
if (multicastLock?.isHeld == true) {
multicastLock?.release()
}
}
multicastLock = null
}
override fun connectToDevice(device: DiscoveredDeviceDomain) {
@ -307,6 +346,7 @@ class SimpConnectRepositoryImpl(
// device in the picker and taps it.
_pendingDevice.value = device
_activeRemoteDevice.value = null
_connectionStatus.value = SimpConnectStatusDomain.CONNECTING
client.connect(wireDevice)
}
@ -314,6 +354,9 @@ class SimpConnectRepositoryImpl(
_pendingDevice.value = null
client.disconnect()
_activeRemoteDevice.value = null
_remotePlaybackState.value = null
_connectionStatus.value = SimpConnectStatusDomain.DISCONNECTED
resumeLocalPlayer()
}
override fun sendPlayPause() {
@ -378,16 +421,23 @@ class SimpConnectRepositoryImpl(
}
override fun handoffToRemote(device: DiscoveredDeviceDomain) {
val nowPlaying = mediaPlayerHandler.nowPlaying.value ?: return
val nowPlaying = mediaPlayerHandler.nowPlaying.value
val songEntity = mediaPlayerHandler.nowPlayingState.value.songEntity
val currentTrackSummary = songEntity?.toTrackSummary() ?: nowPlaying?.toTrackSummary()
if (currentTrackSummary == null) {
connectToDevice(device)
return
}
val queue = mediaPlayerHandler.queueData.value?.data?.listTracks.orEmpty()
// Capture the local session BEFORE connecting: once the handshake completes the local
// player is paused and may already be showing the target's state.
val trackSummary = nowPlaying.toTrackSummary()
val rawQueue = queue.map { it.toTrackSummary() }
// queueData can lag behind the player (radio/restored queue). If it does not contain what is
// actually playing, send only that track instead of starting the wrong song on the target.
val foundIndex = rawQueue.indexOfFirst { it.videoId == trackSummary.videoId }
val wireQueue = if (foundIndex >= 0 || rawQueue.isEmpty()) rawQueue else listOf(trackSummary)
val foundIndex = rawQueue.indexOfFirst { it.videoId == currentTrackSummary.videoId }
val wireQueue = if (foundIndex >= 0 || rawQueue.isEmpty()) rawQueue else listOf(currentTrackSummary)
val startIndex = if (foundIndex >= 0) foundIndex else 0
val wasPlaying = mediaPlayerHandler.controlState.value.isPlaying
// Captured before connecting: the connection callback pauses the local player, which would
@ -396,31 +446,35 @@ class SimpConnectRepositoryImpl(
connectToDevice(device)
scope.launch {
val result =
kotlinx.coroutines.withTimeoutOrNull(20_000L) {
connectionStatus.first {
it == SimpConnectStatusDomain.CONNECTED ||
it == SimpConnectStatusDomain.FAILED ||
it == SimpConnectStatusDomain.REJECTED ||
it == SimpConnectStatusDomain.DISCONNECTED
val finalStatus =
kotlinx.coroutines.withTimeoutOrNull(30_000L) {
client.connectionStatus.first {
it == ConnectionStatus.CONNECTED ||
it == ConnectionStatus.FAILED ||
it == ConnectionStatus.REJECTED ||
it == ConnectionStatus.DISCONNECTED
}
}
if (result != SimpConnectStatusDomain.CONNECTED) {
Logger.w(TAG, "Handoff cancelled: could not connect to ${device.deviceName}")
if (finalStatus != ConnectionStatus.CONNECTED) {
Logger.w(TAG, "Handoff cancelled: could not connect to ${device.deviceName} (status=$finalStatus)")
return@launch
}
if (mediaPlayerHandler.controlState.value.isPlaying) {
mediaPlayerHandler.onPlayerEvent(PlayerEvent.PlayPause)
}
client.sendPlayTrack(
videoId = trackSummary.videoId,
playlistId = null,
playlistName = null,
queue = wireQueue,
startIndex = startIndex,
playWhenReady = wasPlaying,
positionMs = startPosition,
)
val remoteState = _remotePlaybackState.value
val remoteHasTrack = remoteState?.track != null
if (wasPlaying || !remoteHasTrack) {
client.sendPlayTrack(
videoId = currentTrackSummary.videoId,
playlistId = null,
playlistName = null,
queue = wireQueue,
startIndex = startIndex,
playWhenReady = wasPlaying,
positionMs = startPosition,
)
}
}
}
@ -472,17 +526,17 @@ class SimpConnectRepositoryImpl(
_remotePlaybackState.value = null
connectToDevice(device)
val result =
kotlinx.coroutines.withTimeoutOrNull(20_000L) {
connectionStatus.first {
it == SimpConnectStatusDomain.CONNECTED ||
it == SimpConnectStatusDomain.FAILED ||
it == SimpConnectStatusDomain.REJECTED ||
it == SimpConnectStatusDomain.DISCONNECTED
val finalStatus =
kotlinx.coroutines.withTimeoutOrNull(30_000L) {
client.connectionStatus.first {
it == ConnectionStatus.CONNECTED ||
it == ConnectionStatus.FAILED ||
it == ConnectionStatus.REJECTED ||
it == ConnectionStatus.DISCONNECTED
}
}
if (result != SimpConnectStatusDomain.CONNECTED) {
Logger.w(TAG, "Transfer cancelled: could not connect to ${device.deviceName}")
if (finalStatus != ConnectionStatus.CONNECTED) {
Logger.w(TAG, "Transfer cancelled: could not connect to ${device.deviceName} (status=$finalStatus)")
return@launch
}
val videoId = track?.videoId ?: sendQueue.getOrNull(sendIndex)?.videoId ?: return@launch
@ -613,6 +667,7 @@ class SimpConnectRepositoryImpl(
override fun getCurrentState(): SimpConnectMessage.StateSync {
val nowPlaying = mediaPlayerHandler.nowPlaying.value
val songEntity = mediaPlayerHandler.nowPlayingState.value.songEntity
val controlState = mediaPlayerHandler.controlState.value
val duration = mediaPlayerHandler.getPlayerDuration()
val progress = mediaPlayerHandler.getProgress()
@ -625,11 +680,17 @@ class SimpConnectRepositoryImpl(
RepeatState.One -> 2
}
val currentSummary = songEntity?.toTrackSummary() ?: nowPlaying?.toTrackSummary()
val effectiveDuration = duration.takeIf { it > 0L }
?: (songEntity?.durationSeconds?.toLong()?.times(1000L))
?: currentSummary?.durationMs
?: 0L
return SimpConnectMessage.StateSync(
track = nowPlaying?.toTrackSummary(),
track = currentSummary,
isPlaying = controlState.isPlaying,
positionMs = progress.coerceAtLeast(0L),
durationMs = duration.coerceAtLeast(0L),
durationMs = effectiveDuration.coerceAtLeast(0L),
volume = controlState.volume,
isShuffle = controlState.isShuffle,
repeatMode = repeatModeInt,
@ -674,6 +735,9 @@ class SimpConnectRepositoryImpl(
)
} else {
val track = trackSummary.toTrack()
runCatching {
songRepository.insertSong(track.toSongEntity()).firstOrNull()
}
mediaPlayerHandler.loadMediaItem(track, type = MERGING_DATA_TYPE.SONG, index = null)
if (payload.positionMs > 0) {
// Wait for the player to know the duration before translating the position.
@ -702,17 +766,24 @@ class SimpConnectRepositoryImpl(
if (tracks.isEmpty()) return
val domainTracks = tracks.map { it.toTrack() }
val index = startIndex.coerceIn(0, domainTracks.lastIndex)
val currentTrack = domainTracks[index]
val startPos = positionMs.coerceAtLeast(0L)
val player = mediaPlayerHandler.player
player.shuffleModeEnabled = false
player.clearMediaItems()
for (track in domainTracks) {
player.addMediaItem(track.toGenericMediaItem())
// 1. Persist current track into songRepository so nowPlayingState.songEntity can load
runCatching {
songRepository.insertSong(currentTrack.toSongEntity()).firstOrNull()
}
if (domainTracks.size > 1) {
runCatching {
songRepository.insertSongs(domainTracks.map { it.toSongEntity() })
}
}
// 2. Set queue data
mediaPlayerHandler.setQueueData(
QueueData.Data(
listTracks = domainTracks,
firstPlayedTrack = domainTracks[index],
firstPlayedTrack = currentTrack,
playlistId = null,
playlistName = null,
playlistType = null,
@ -720,17 +791,29 @@ class SimpConnectRepositoryImpl(
),
)
mediaPlayerHandler.setCurrentSongIndex(index)
// Not playMediaItemInMediaSource(): it forces playWhenReady = true, so a paused handoff
// started playing for a moment before being paused again.
player.seekTo(index, 0L)
player.prepare()
player.playWhenReady = playWhenReady
if (positionMs > 0L) {
// Let the selected item load so the seek lands on the right stream.
kotlinx.coroutines.delay(400)
player.seekTo(positionMs)
// 3. Clear and set current track
mediaPlayerHandler.clearMediaItems()
currentTrack.durationSeconds?.let {
songRepository.updateDurationSeconds(it, currentTrack.videoId)
}
val genericItem = currentTrack.toSimpConnectMediaItem()
mediaPlayerHandler.addMediaItem(genericItem, playWhenReady = playWhenReady)
// 4. Load full queue or fetch related endless radio
if (domainTracks.size > 1) {
mediaPlayerHandler.loadPlaylistOrAlbum(index)
} else {
mediaPlayerHandler.getRelated(currentTrack.videoId)
}
if (startPos > 0L) {
kotlinx.coroutines.delay(400)
mediaPlayerHandler.player.seekTo(startPos)
}
broadcastCurrentState()
}
// ==========================================
@ -826,6 +909,34 @@ class SimpConnectRepositoryImpl(
resultType = null,
)
}
private fun SongEntity.toTrackSummary(): TrackSummary =
TrackSummary(
videoId = videoId,
title = title,
artist = artistName?.connectArtists().orEmpty(),
album = albumName,
artworkUri = thumbnails,
durationMs = durationSeconds.toLong() * 1000L,
)
private fun Track.toSimpConnectMediaItem(): GenericMediaItem {
val thumb = thumbnails?.lastOrNull()?.url?.ifBlank { null }
?: "https://i.ytimg.com/vi/$videoId/hqdefault.jpg"
return GenericMediaItem(
mediaId = videoId,
uri = videoId,
metadata = GenericMediaMetadata(
title = title,
artist = artists.toListName().connectArtists(),
albumTitle = album?.name,
artworkUri = thumb,
description = MERGING_DATA_TYPE.SONG,
durationMs = durationSeconds?.takeIf { it > 0 }?.toLong()?.times(1000L),
),
customCacheKey = videoId,
)
}
}
/**

View file

@ -27,6 +27,7 @@ actual fun loadSimpConnectModule() {
com.maxrave.data.simpconnect.SimpConnectRepositoryImpl(
mediaPlayerHandler = get(),
dataStoreManager = get(),
songRepository = get(),
scope = get(org.koin.core.qualifier.named(com.maxrave.common.Config.SERVICE_SCOPE)),
).also { it.start() }
}

View file

@ -1,6 +1,7 @@
package com.maxrave.data.simpconnect
import com.maxrave.common.MERGING_DATA_TYPE
import com.maxrave.domain.data.entities.SongEntity
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
@ -11,13 +12,19 @@ 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.data.player.GenericMediaMetadata
import com.maxrave.domain.extension.toGenericMediaItem
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.domain.repository.SongRepository
import com.maxrave.domain.utils.connectArtists
import com.maxrave.domain.utils.toListName
import com.maxrave.domain.utils.toSongEntity
import com.maxrave.logger.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -26,6 +33,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ -51,6 +59,7 @@ private const val KEY_APPROVED_DEVICES = "simpconnect_approved_devices"
class SimpConnectRepositoryImpl(
private val mediaPlayerHandler: MediaPlayerHandler,
private val dataStoreManager: DataStoreManager,
private val songRepository: SongRepository,
private val scope: CoroutineScope,
) : SimpConnectRepository, SimpConnectServerListener {
@ -217,12 +226,22 @@ class SimpConnectRepositoryImpl(
scope.launch {
mediaPlayerHandler.nowPlaying.collect { broadcastCurrentState() }
}
scope.launch {
mediaPlayerHandler.nowPlayingState.collect { broadcastCurrentState() }
}
scope.launch {
mediaPlayerHandler.controlState.collect { broadcastCurrentState() }
}
scope.launch {
mediaPlayerHandler.queueData.collect { broadcastCurrentState() }
}
scope.launch {
mediaPlayerHandler.simpleMediaState.collect { state ->
if (state is SimpleMediaState.Ready) {
broadcastCurrentState()
}
}
}
// Periodic ticker to sync playback position to connected controllers while playing
scope.launch {
@ -256,7 +275,7 @@ class SimpConnectRepositoryImpl(
try {
val player = mediaPlayerHandler.player
player.playWhenReady = false
player.stop()
player.pause()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
@ -301,6 +320,7 @@ class SimpConnectRepositoryImpl(
)
_pendingDevice.value = device
_activeRemoteDevice.value = null
_connectionStatus.value = SimpConnectStatusDomain.CONNECTING
client.connect(wireDevice)
}
@ -308,6 +328,9 @@ class SimpConnectRepositoryImpl(
_pendingDevice.value = null
client.disconnect()
_activeRemoteDevice.value = null
_remotePlaybackState.value = null
_connectionStatus.value = SimpConnectStatusDomain.DISCONNECTED
resumeLocalPlayer()
}
override fun sendPlayPause() {
@ -372,14 +395,23 @@ class SimpConnectRepositoryImpl(
}
override fun handoffToRemote(device: DiscoveredDeviceDomain) {
val nowPlaying = mediaPlayerHandler.nowPlaying.value ?: return
val nowPlaying = mediaPlayerHandler.nowPlaying.value
val songEntity = mediaPlayerHandler.nowPlayingState.value.songEntity
val currentTrackSummary = songEntity?.toTrackSummary() ?: nowPlaying?.toTrackSummary()
if (currentTrackSummary == null) {
connectToDevice(device)
return
}
val queue = mediaPlayerHandler.queueData.value?.data?.listTracks.orEmpty()
val trackSummary = nowPlaying.toTrackSummary()
// Capture the local session BEFORE connecting: once the handshake completes the local
// player is paused and may already be showing the target's state.
val rawQueue = queue.map { it.toTrackSummary() }
// queueData can lag behind the player (radio/restored queue). If it does not contain what is
// actually playing, send only that track instead of starting the wrong song on the target.
val foundIndex = rawQueue.indexOfFirst { it.videoId == trackSummary.videoId }
val wireQueue = if (foundIndex >= 0 || rawQueue.isEmpty()) rawQueue else listOf(trackSummary)
val foundIndex = rawQueue.indexOfFirst { it.videoId == currentTrackSummary.videoId }
val wireQueue = if (foundIndex >= 0 || rawQueue.isEmpty()) rawQueue else listOf(currentTrackSummary)
val startIndex = if (foundIndex >= 0) foundIndex else 0
val wasPlaying = mediaPlayerHandler.controlState.value.isPlaying
// Captured before connecting: the connection callback pauses the local player, which would
@ -388,31 +420,35 @@ class SimpConnectRepositoryImpl(
connectToDevice(device)
scope.launch {
val result =
kotlinx.coroutines.withTimeoutOrNull(20_000L) {
connectionStatus.first {
it == SimpConnectStatusDomain.CONNECTED ||
it == SimpConnectStatusDomain.FAILED ||
it == SimpConnectStatusDomain.REJECTED ||
it == SimpConnectStatusDomain.DISCONNECTED
val finalStatus =
kotlinx.coroutines.withTimeoutOrNull(30_000L) {
client.connectionStatus.first {
it == ConnectionStatus.CONNECTED ||
it == ConnectionStatus.FAILED ||
it == ConnectionStatus.REJECTED ||
it == ConnectionStatus.DISCONNECTED
}
}
if (result != SimpConnectStatusDomain.CONNECTED) {
Logger.w(TAG, "Handoff cancelled: could not connect to ${device.deviceName}")
if (finalStatus != ConnectionStatus.CONNECTED) {
Logger.w(TAG, "Handoff cancelled: could not connect to ${device.deviceName} (status=$finalStatus)")
return@launch
}
if (mediaPlayerHandler.controlState.value.isPlaying) {
mediaPlayerHandler.onPlayerEvent(PlayerEvent.PlayPause)
}
client.sendPlayTrack(
videoId = trackSummary.videoId,
playlistId = null,
playlistName = null,
queue = wireQueue,
startIndex = startIndex,
playWhenReady = wasPlaying,
positionMs = startPosition,
)
val remoteState = _remotePlaybackState.value
val remoteHasTrack = remoteState?.track != null
if (wasPlaying || !remoteHasTrack) {
client.sendPlayTrack(
videoId = currentTrackSummary.videoId,
playlistId = null,
playlistName = null,
queue = wireQueue,
startIndex = startIndex,
playWhenReady = wasPlaying,
positionMs = startPosition,
)
}
}
}
@ -464,17 +500,17 @@ class SimpConnectRepositoryImpl(
_remotePlaybackState.value = null
connectToDevice(device)
val result =
kotlinx.coroutines.withTimeoutOrNull(20_000L) {
connectionStatus.first {
it == SimpConnectStatusDomain.CONNECTED ||
it == SimpConnectStatusDomain.FAILED ||
it == SimpConnectStatusDomain.REJECTED ||
it == SimpConnectStatusDomain.DISCONNECTED
val finalStatus =
kotlinx.coroutines.withTimeoutOrNull(30_000L) {
client.connectionStatus.first {
it == ConnectionStatus.CONNECTED ||
it == ConnectionStatus.FAILED ||
it == ConnectionStatus.REJECTED ||
it == ConnectionStatus.DISCONNECTED
}
}
if (result != SimpConnectStatusDomain.CONNECTED) {
Logger.w(TAG, "Transfer cancelled: could not connect to ${device.deviceName}")
if (finalStatus != ConnectionStatus.CONNECTED) {
Logger.w(TAG, "Transfer cancelled: could not connect to ${device.deviceName} (status=$finalStatus)")
return@launch
}
val videoId = track?.videoId ?: sendQueue.getOrNull(sendIndex)?.videoId ?: return@launch
@ -602,6 +638,7 @@ class SimpConnectRepositoryImpl(
override fun getCurrentState(): SimpConnectMessage.StateSync {
val nowPlaying = mediaPlayerHandler.nowPlaying.value
val songEntity = mediaPlayerHandler.nowPlayingState.value.songEntity
val controlState = mediaPlayerHandler.controlState.value
val duration = mediaPlayerHandler.getPlayerDuration()
val progress = mediaPlayerHandler.getProgress()
@ -614,11 +651,17 @@ class SimpConnectRepositoryImpl(
RepeatState.One -> 2
}
val currentSummary = songEntity?.toTrackSummary() ?: nowPlaying?.toTrackSummary()
val effectiveDuration = duration.takeIf { it > 0L }
?: (songEntity?.durationSeconds?.toLong()?.times(1000L))
?: currentSummary?.durationMs
?: 0L
return SimpConnectMessage.StateSync(
track = nowPlaying?.toTrackSummary(),
track = currentSummary,
isPlaying = controlState.isPlaying,
positionMs = progress.coerceAtLeast(0L),
durationMs = duration.coerceAtLeast(0L),
durationMs = effectiveDuration.coerceAtLeast(0L),
volume = controlState.volume,
isShuffle = controlState.isShuffle,
repeatMode = repeatModeInt,
@ -661,6 +704,9 @@ class SimpConnectRepositoryImpl(
)
} else {
val track = trackSummary.toTrack()
runCatching {
songRepository.insertSong(track.toSongEntity()).firstOrNull()
}
mediaPlayerHandler.loadMediaItem(track, type = MERGING_DATA_TYPE.SONG, index = null)
if (payload.positionMs > 0) {
kotlinx.coroutines.delay(400)
@ -684,17 +730,24 @@ class SimpConnectRepositoryImpl(
if (tracks.isEmpty()) return
val domainTracks = tracks.map { it.toTrack() }
val index = startIndex.coerceIn(0, domainTracks.lastIndex)
val currentTrack = domainTracks[index]
val startPos = positionMs.coerceAtLeast(0L)
val player = mediaPlayerHandler.player
player.shuffleModeEnabled = false
player.clearMediaItems()
for (track in domainTracks) {
player.addMediaItem(track.toGenericMediaItem())
// 1. Persist current track into songRepository so nowPlayingState.songEntity can load
runCatching {
songRepository.insertSong(currentTrack.toSongEntity()).firstOrNull()
}
if (domainTracks.size > 1) {
runCatching {
songRepository.insertSongs(domainTracks.map { it.toSongEntity() })
}
}
// 2. Set queue data
mediaPlayerHandler.setQueueData(
QueueData.Data(
listTracks = domainTracks,
firstPlayedTrack = domainTracks[index],
firstPlayedTrack = currentTrack,
playlistId = null,
playlistName = null,
playlistType = null,
@ -702,16 +755,29 @@ class SimpConnectRepositoryImpl(
),
)
mediaPlayerHandler.setCurrentSongIndex(index)
// Not playMediaItemInMediaSource(): it forces playWhenReady = true, so a paused handoff
// started playing for a moment before being paused again.
player.seekTo(index, 0L)
player.prepare()
player.playWhenReady = playWhenReady
if (positionMs > 0L) {
kotlinx.coroutines.delay(400)
player.seekTo(positionMs)
// 3. Clear and set current track
mediaPlayerHandler.clearMediaItems()
currentTrack.durationSeconds?.let {
songRepository.updateDurationSeconds(it, currentTrack.videoId)
}
val genericItem = currentTrack.toSimpConnectMediaItem()
mediaPlayerHandler.addMediaItem(genericItem, playWhenReady = playWhenReady)
// 4. Load full queue or fetch related endless radio
if (domainTracks.size > 1) {
mediaPlayerHandler.loadPlaylistOrAlbum(index)
} else {
mediaPlayerHandler.getRelated(currentTrack.videoId)
}
if (startPos > 0L) {
kotlinx.coroutines.delay(400)
mediaPlayerHandler.player.seekTo(startPos)
}
broadcastCurrentState()
}
// ==========================================
@ -807,6 +873,34 @@ class SimpConnectRepositoryImpl(
resultType = null,
)
}
private fun SongEntity.toTrackSummary(): TrackSummary =
TrackSummary(
videoId = videoId,
title = title,
artist = artistName?.connectArtists().orEmpty(),
album = albumName,
artworkUri = thumbnails,
durationMs = durationSeconds.toLong() * 1000L,
)
private fun Track.toSimpConnectMediaItem(): GenericMediaItem {
val thumb = thumbnails?.lastOrNull()?.url?.ifBlank { null }
?: "https://i.ytimg.com/vi/$videoId/hqdefault.jpg"
return GenericMediaItem(
mediaId = videoId,
uri = videoId,
metadata = GenericMediaMetadata(
title = title,
artist = artists.toListName().connectArtists(),
albumTitle = album?.name,
artworkUri = thumb,
description = MERGING_DATA_TYPE.SONG,
durationMs = durationSeconds?.takeIf { it > 0 }?.toLong()?.times(1000L),
),
customCacheKey = videoId,
)
}
}
/**

View file

@ -56,6 +56,13 @@ class SimpConnectDiscovery(
}
periodicPingJob = scope.launch(Dispatchers.IO) {
// Wait briefly for the listener socket to bind so the initial ping originates
// from the listening socket instead of an ephemeral one.
var waited = 0
while (socket == null && waited < 600) {
delay(50)
waited += 50
}
// Initial burst
broadcastPingInternal()
delay(1500)
@ -146,6 +153,23 @@ class SimpConnectDiscovery(
when (msg) {
is DiscoveryMessage.Ping -> {
if (msg.deviceId == localDeviceId) return // Ignore own ping
// Register ping sender immediately if valid TCP port is carried
val hostIp = packet.address?.hostAddress
if (hostIp != null && msg.tcpPort > 0) {
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()
}
val tcpPort = getTcpPort()
if (tcpPort <= 0) return // Server not ready yet
@ -163,6 +187,14 @@ class SimpConnectDiscovery(
} catch (e: Exception) {
Logger.w(TAG, "Failed to send pong to ${packet.socketAddress}: ${e.message}")
}
// If ping arrived from an ephemeral port, also unicast to standard discovery port
if (packet.port != SIMP_CONNECT_DISCOVERY_PORT && packet.address != null) {
try {
listeningSocket.send(
DatagramPacket(replyBytes, replyBytes.size, packet.address, SIMP_CONNECT_DISCOVERY_PORT)
)
} catch (_: Exception) {}
}
}
is DiscoveryMessage.Pong -> {
@ -185,11 +217,13 @@ class SimpConnectDiscovery(
}
private fun broadcastPingInternal() {
val port = getTcpPort().takeIf { it > 0 } ?: SIMP_CONNECT_DEFAULT_SERVER_PORT
val ping = DiscoveryMessage.Ping(
magic = SIMP_CONNECT_MAGIC,
deviceId = localDeviceId,
deviceName = localDeviceName(),
platform = localPlatform,
tcpPort = port,
)
val data = simpConnectJson.encodeToString(DiscoveryMessage.serializer(), ping).encodeToByteArray()

View file

@ -23,6 +23,7 @@ sealed class DiscoveryMessage {
val deviceId: String,
val deviceName: String,
val platform: String,
val tcpPort: Int = SIMP_CONNECT_DEFAULT_SERVER_PORT,
) : DiscoveryMessage()
@Serializable