diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/player/UnifiedQueue.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/player/UnifiedQueue.kt index 91f30b64..478ec341 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/ui/player/UnifiedQueue.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/player/UnifiedQueue.kt @@ -70,6 +70,8 @@ fun UnifiedQueue( val queueTitle by playerConnection.queueTitle.collectAsState() val shuffleModeEnabled by playerConnection.shuffleModeEnabled.collectAsState() val repeatMode by playerConnection.repeatMode.collectAsState() + val sharedQueue by com.darkxvenom.airbeats.utils.ListenTogetherSync.sharedQueue.collectAsState() + val isTogetherSession = com.darkxvenom.airbeats.utils.ListenTogetherSync.session.collectAsState().value != null var endlessQueue by rememberPreference(AutoLoadMoreKey, defaultValue = true) val listState = rememberLazyListState() val currentItem = queueWindows.getOrNull(currentIndex)?.mediaItem @@ -290,7 +292,10 @@ fun UnifiedQueue( ) } - if (upcoming.isEmpty()) { + val hasUpcomingTimeline = upcoming.isNotEmpty() + val showSharedQueue = !hasUpcomingTimeline && isTogetherSession && sharedQueue.isNotEmpty() + + if (!hasUpcomingTimeline && !showSharedQueue) { item(key = "empty_upcoming") { Text( text = if (endlessQueue) "Finding more songs..." else "No more tracks in queue", @@ -299,7 +304,7 @@ fun UnifiedQueue( modifier = Modifier.padding(vertical = 12.dp) ) } - } else { + } else if (hasUpcomingTimeline) { itemsIndexed(upcoming, key = { _, item -> "${item.index}-${item.value.uid}" }) { _, item -> val metadata = item.value.mediaItem.metadata ?: return@itemsIndexed Row( @@ -328,6 +333,37 @@ fun UnifiedQueue( } } } + } else if (showSharedQueue) { + itemsIndexed(sharedQueue, key = { idx, item -> "shared-${item.songId}-$idx" }) { idx, item -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { playerConnection.playQueueIndex(idx, item.songId) } + .padding(vertical = 4.dp), + ) { + AsyncImage(model = item.thumbnailUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(42.dp).clip(RoundedCornerShape(8.dp))) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text(item.title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium) + if (item.artist.isNotBlank()) { + Text(item.artist, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall) + } + } + Box { + IconButton(onClick = { actionMenuIndex = idx }) { + Icon(painterResource(R.drawable.more_vert), contentDescription = "Queue item options") + } + DropdownMenu(expanded = actionMenuIndex == idx, onDismissRequest = { actionMenuIndex = -1 }) { + DropdownMenuItem(text = { Text("Play now") }, onClick = { playerConnection.playQueueIndex(idx, item.songId); actionMenuIndex = -1 }) + DropdownMenuItem(text = { Text("Move up") }, enabled = idx > 0, onClick = { playerConnection.moveQueueItem(idx, idx - 1, item.songId); actionMenuIndex = -1 }) + DropdownMenuItem(text = { Text("Move down") }, enabled = idx < sharedQueue.lastIndex, onClick = { playerConnection.moveQueueItem(idx, idx + 1, item.songId); actionMenuIndex = -1 }) + DropdownMenuItem(text = { Text("Delete") }, onClick = { playerConnection.removeQueueItem(idx, item.songId); actionMenuIndex = -1 }) + } + } + } + } } } } diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/ListenTogetherScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/ListenTogetherScreen.kt index 450c450c..783da33f 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/ListenTogetherScreen.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/ListenTogetherScreen.kt @@ -409,6 +409,7 @@ fun ListenTogetherScreen( StatusCard( session = activeSession, isHost = isHost || syncedIsHost, + isAdmin = controlsAllowed, isLan = isLan, errorMessage = message, onCopy = { code -> @@ -701,6 +702,7 @@ fun ListenTogetherScreen( ParticipantsCard( participants = currentRoomSession.participantList, isHost = isHost || syncedIsHost, + allowGuestsControl = currentRoomSession.settings.allowGuestsToControlPlayback, onKick = { participant -> kickTarget = participant } ) } @@ -715,6 +717,7 @@ fun ListenTogetherScreen( private fun StatusCard( session: ListenTogetherSession?, isHost: Boolean, + isAdmin: Boolean = false, isLan: Boolean, errorMessage: String?, onCopy: (String) -> Unit, @@ -762,6 +765,7 @@ private fun StatusCard( Text( text = when { session != null && isHost -> stringResource(R.string.together_hosting) + session != null && isAdmin -> stringResource(R.string.together_connected_admin) session != null -> stringResource(R.string.together_connected) else -> stringResource(R.string.together_idle) }, @@ -1701,6 +1705,7 @@ private fun SharedQueueCard( private fun ParticipantsCard( participants: List, isHost: Boolean, + allowGuestsControl: Boolean = false, onKick: (ListenTogetherParticipant) -> Unit, ) { SectionCard( @@ -1724,6 +1729,7 @@ private fun ParticipantsCard( ParticipantRow( participant = participant, isHost = isHost, + allowGuestsControl = allowGuestsControl, onKick = { onKick(participant) } ) if (index < participants.size - 1) { @@ -1742,8 +1748,10 @@ private fun ParticipantsCard( private fun ParticipantRow( participant: ListenTogetherParticipant, isHost: Boolean, + allowGuestsControl: Boolean = false, onKick: () -> Unit, ) { + val isAdmin = participant.isHost || allowGuestsControl ListItem( headlineContent = { Text( @@ -1755,7 +1763,13 @@ private fun ParticipantRow( }, supportingContent = { Text( - text = stringResource(if (participant.isHost) R.string.together_role_host else R.string.together_role_guest), + text = stringResource( + when { + participant.isHost -> R.string.together_role_host + allowGuestsControl -> R.string.together_role_admin + else -> R.string.together_role_guest + } + ), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, @@ -1764,8 +1778,8 @@ private fun ParticipantRow( }, leadingContent = { AccentIcon( - iconResId = if (participant.isHost) R.drawable.fire else R.drawable.person, - accent = if (participant.isHost) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary, + iconResId = if (isAdmin) R.drawable.fire else R.drawable.person, + accent = if (participant.isHost) MaterialTheme.colorScheme.primary else if (allowGuestsControl) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.tertiary, size = 40.dp, ) }, @@ -1787,7 +1801,21 @@ private fun ParticipantRow( modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) ) } - } else if (isHost) { + } else if (allowGuestsControl) { + Surface( + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = "ADMIN", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + if (!participant.isHost && isHost) { IconButton(onClick = onKick) { Icon( painter = painterResource(R.drawable.close), diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherClient.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherClient.kt index 14fad75d..7372c1fe 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherClient.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherClient.kt @@ -306,45 +306,57 @@ data class ListenTogetherPlaybackState( fun toJson(forOnlineWorker: Boolean = false): JSONObject { val cleanArtists = artists.filterNot { it.startsWith(META_PREFIX) || it.startsWith(QUEUE_PREFIX) } val artistsList = mutableListOf() - cleanArtists.forEach { artistsList.add(it) } if (forOnlineWorker) { + // Worker restricts state.artists to max 8 items, max 120 chars each. + // Slot 0: Primary artist name (max 35 chars) + val primaryArtist = cleanArtists.firstOrNull()?.trim()?.take(35)?.ifBlank { null } ?: "AirBeats" + artistsList.add(primaryArtist) + + // Slot 1: Compact metadata string (max 45 chars, well below 120 limit) val metaString = buildString { append(META_PREFIX) append("pb=").append(if (settings.allowGuestsToControlPlayback) "1" else "0") append("&tr=").append(if (settings.allowGuestsToAddTracks) "1" else "0") - if (!actionCommand.isNullOrBlank()) { - append("&cmd=").append(java.net.URLEncoder.encode(actionCommand, "UTF-8")) - } - if (!actionPayload.isNullOrBlank()) { - append("&pl=").append(java.net.URLEncoder.encode(actionPayload, "UTF-8")) - } - if (!actionId.isNullOrBlank()) { - append("&actId=").append(java.net.URLEncoder.encode(actionId, "UTF-8")) - } if (durationMs > 0L) { append("&dur=").append(durationMs) } if (totalQueueSize > 0) { - append("&qSize=").append(totalQueueSize) + append("&qs=").append(totalQueueSize) } } artistsList.add(metaString) + // Slots 2..7: Queue chunks (3 songs per slot, strictly <= 105 chars so worker never slices/corrupts them) if (queue.isNotEmpty()) { - val itemsToPack = queue.take(30) - itemsToPack.chunked(5).forEachIndexed { idx, chunkItems -> + val itemsToPack = queue.take(18) + itemsToPack.chunked(3).take(6).forEachIndexed { idx, chunkItems -> val payload = chunkItems.joinToString(";") { qItem -> val safeTitle = qItem.title .replace(";", " ") .replace(":", " ") + .replace("&", " ") + .replace("#", " ") .trim() - .take(40) + .take(20) "${qItem.songId}:$safeTitle" } - artistsList.add("${QUEUE_PREFIX}$idx:$payload") + artistsList.add("${QUEUE_PREFIX}$idx:$payload".take(115)) } } + } else { + cleanArtists.forEach { artistsList.add(it) } + } + + val rawThumb = (thumbnailUrl ?: if (songId.isNotBlank()) "https://i.ytimg.com/vi/$songId/mqdefault.jpg" else null)?.substringBefore("#") + val effectiveThumbnailUrl = if (forOnlineWorker && !rawThumb.isNullOrBlank() && queue.isNotEmpty()) { + val qPayload = queue.take(15).joinToString(";") { qItem -> + val safeTitle = qItem.title.replace(";", " ").replace(":", " ").replace("#", " ").trim().take(15) + "${qItem.songId}:$safeTitle" + } + "$rawThumb#Q=$qPayload".take(580) + } else { + rawThumb } val queueArray = JSONArray() @@ -354,7 +366,7 @@ data class ListenTogetherPlaybackState( .put("songId", songId) .put("title", title) .put("artists", JSONArray(artistsList)) - .put("thumbnailUrl", thumbnailUrl) + .put("thumbnailUrl", effectiveThumbnailUrl) .put("positionMs", positionMs) .put("isPlaying", isPlaying) .put("durationMs", durationMs) @@ -385,7 +397,6 @@ data class ListenTogetherPlaybackState( // 1. Direct JSON queue array (LAN mode or forward-compatible worker) val directQueueArray = json.optJSONArray("queue") - val hasDirectQueue = (directQueueArray?.length() ?: 0) > 0 directQueueArray?.let { array -> for (i in 0 until array.length()) { array.optJSONObject(i)?.let { obj -> @@ -394,7 +405,30 @@ data class ListenTogetherPlaybackState( } } - // 2. Parse artists array (metadata and tunneled queue) + // 2. Parse tunneled queue from thumbnailUrl hash fragment (#Q=id:title;...) + val rawThumb = json.optString("thumbnailUrl").takeIf { it.isNotBlank() } + val cleanThumb = rawThumb?.substringBefore("#") + if (rawThumb != null && rawThumb.contains("#Q=")) { + val qPayload = rawThumb.substringAfter("#Q=").substringBefore("&") + qPayload.split(";").forEach { entry -> + val colonIdx = entry.indexOf(':') + if (colonIdx > 0) { + val id = entry.substring(0, colonIdx).trim() + val t = entry.substring(colonIdx + 1).trim() + if (id.isNotBlank()) { + parsedQueueItems.add( + ListenTogetherQueueItem( + songId = id, + title = t.ifBlank { "Queued track" }, + thumbnailUrl = "https://i.ytimg.com/vi/$id/mqdefault.jpg", + ) + ) + } + } + } + } + + // 3. Parse artists array (metadata and tunneled queue) json.optJSONArray("artists")?.let { array -> for (i in 0 until array.length()) { val item = array.optString(i) @@ -417,25 +451,24 @@ data class ListenTogetherPlaybackState( params["pl"]?.takeIf { it.isNotBlank() }?.let { parsedPayload = it } params["actId"]?.takeIf { it.isNotBlank() }?.let { parsedActionId = it } params["dur"]?.toLongOrNull()?.let { if (it > 0) parsedDuration = it } - params["qSize"]?.toIntOrNull()?.let { if (it > 0) parsedQueueSize = it } + val qs = params["qs"] ?: params["qSize"] + qs?.toIntOrNull()?.let { if (it > 0) parsedQueueSize = it } } else if (item.startsWith(QUEUE_PREFIX)) { - if (!hasDirectQueue) { - val payload = item.substringAfter(":") - val entries = payload.split(";") - for (entry in entries) { - val colonIdx = entry.indexOf(':') - if (colonIdx > 0) { - val id = entry.substring(0, colonIdx).trim() - val t = entry.substring(colonIdx + 1).trim() - if (id.isNotBlank()) { - parsedQueueItems.add( - ListenTogetherQueueItem( - songId = id, - title = t.ifBlank { "Queued track" }, - thumbnailUrl = "https://i.ytimg.com/vi/$id/mqdefault.jpg", - ) + val payload = item.substringAfter(":") + val entries = payload.split(";") + for (entry in entries) { + val colonIdx = entry.indexOf(':') + if (colonIdx > 0) { + val id = entry.substring(0, colonIdx).trim() + val t = entry.substring(colonIdx + 1).trim() + if (id.isNotBlank()) { + parsedQueueItems.add( + ListenTogetherQueueItem( + songId = id, + title = t.ifBlank { "Queued track" }, + thumbnailUrl = "https://i.ytimg.com/vi/$id/mqdefault.jpg", ) - } + ) } } } @@ -445,11 +478,13 @@ data class ListenTogetherPlaybackState( } } + val distinctQueue = parsedQueueItems.distinctBy { it.songId } + return ListenTogetherPlaybackState( songId = json.optString("songId"), title = json.optString("title"), artists = cleanArtists, - thumbnailUrl = json.optString("thumbnailUrl").takeIf { it.isNotBlank() }, + thumbnailUrl = cleanThumb, positionMs = json.optLong("positionMs"), isPlaying = json.optBoolean("isPlaying"), durationMs = parsedDuration, @@ -458,8 +493,8 @@ data class ListenTogetherPlaybackState( actionCommand = parsedCommand, actionPayload = parsedPayload, actionId = parsedActionId, - queue = parsedQueueItems, - totalQueueSize = parsedQueueSize, + queue = distinctQueue, + totalQueueSize = if (parsedQueueSize > 0) parsedQueueSize else distinctQueue.size, ) } } diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherSync.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherSync.kt index 9fb032f1..8534b404 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherSync.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherSync.kt @@ -45,6 +45,9 @@ object ListenTogetherSync { private var loadingSongId: String? = null private var lastSongLoadAttempt = 0L + private var originalGuestParticipantId: String? = null + private var sessionHostId: String? = null + private val _sharedQueue = MutableStateFlow>(emptyList()) val sharedQueue = _sharedQueue.asStateFlow() @@ -124,7 +127,17 @@ object ListenTogetherSync { ListenTogetherClient.getSession(saved.code).copy(participantId = saved.participantId) } }.onSuccess { - _session.value = it + if (saved.isHost) { + originalGuestParticipantId = null + sessionHostId = it.participantId + _session.value = it + } else { + originalGuestParticipantId = saved.participantId + sessionHostId = it.controllerId.ifBlank { if (saved.isLan) "host" else null } + val hasPermission = it.settings.allowGuestsToControlPlayback + val effectiveId = if (hasPermission && !sessionHostId.isNullOrBlank()) sessionHostId!! else saved.participantId + _session.value = it.copy(participantId = effectiveId) + } lastAppliedVersion = it.stateVersion restartSync() }.onFailure { @@ -154,7 +167,6 @@ object ListenTogetherSync { hostAddress: String? = null, ) { _displayName.value = displayName.ifBlank { ListenTogetherStore.defaultName(context) } - _session.value = session _isHost.value = isHost _connectionMode.value = if (isLan) ListenTogetherConnectionMode.LAN else ListenTogetherConnectionMode.ONLINE lanHostAddress = hostAddress @@ -162,14 +174,35 @@ object ListenTogetherSync { lastAppliedVersion = session.stateVersion loadingSongId = null lastSongLoadAttempt = 0L - ListenTogetherStore.save( - context = context.applicationContext, - session = session, - displayName = _displayName.value, - isHost = isHost, - isLan = isLan, - hostAddress = hostAddress - ) + + if (isHost) { + originalGuestParticipantId = null + sessionHostId = session.participantId + _session.value = session + ListenTogetherStore.save( + context = context.applicationContext, + session = session, + displayName = _displayName.value, + isHost = true, + isLan = isLan, + hostAddress = hostAddress + ) + } else { + originalGuestParticipantId = session.participantId + sessionHostId = session.controllerId.ifBlank { if (isLan) "host" else null } + val hasPermission = session.settings.allowGuestsToControlPlayback + val effectiveId = if (hasPermission && !sessionHostId.isNullOrBlank()) sessionHostId!! else session.participantId + val effectiveSession = session.copy(participantId = effectiveId) + _session.value = effectiveSession + ListenTogetherStore.save( + context = context.applicationContext, + session = session.copy(participantId = originalGuestParticipantId ?: session.participantId), + displayName = _displayName.value, + isHost = false, + isLan = isLan, + hostAddress = hostAddress + ) + } restartSync() } @@ -197,6 +230,8 @@ object ListenTogetherSync { ) ) }.onSuccess { + originalGuestParticipantId = null + sessionHostId = it.participantId _session.value = it _isHost.value = true _connectionMode.value = ListenTogetherConnectionMode.ONLINE @@ -216,13 +251,18 @@ object ListenTogetherSync { runCatching { ListenTogetherClient.joinSession(code, _displayName.value) }.onSuccess { - _session.value = it + originalGuestParticipantId = it.participantId + sessionHostId = it.controllerId.ifBlank { null } _isHost.value = false _connectionMode.value = ListenTogetherConnectionMode.ONLINE lastAppliedVersion = 0L loadingSongId = null lastSongLoadAttempt = 0L - ListenTogetherStore.save(context, it, _displayName.value, false, isLan = false) + val hasPermission = it.settings.allowGuestsToControlPlayback + val effectiveId = if (hasPermission && !sessionHostId.isNullOrBlank()) sessionHostId!! else it.participantId + val effectiveSession = it.copy(participantId = effectiveId) + _session.value = effectiveSession + ListenTogetherStore.save(context, it.copy(participantId = it.participantId), _displayName.value, false, isLan = false) setDisplayMessage("Joined session") restartSync() }.onFailure { @@ -437,6 +477,8 @@ object ListenTogetherSync { lanHostAddress = "$localIp:$port" _connectionMode.value = ListenTogetherConnectionMode.LAN + originalGuestParticipantId = null + sessionHostId = "host" val initialSession = server.buildSessionSnapshot("host") _session.value = initialSession _isHost.value = true @@ -468,14 +510,19 @@ object ListenTogetherSync { }.onSuccess { (cleanAddress, newSession) -> lanHostAddress = cleanAddress _connectionMode.value = ListenTogetherConnectionMode.LAN - _session.value = newSession + originalGuestParticipantId = newSession.participantId + sessionHostId = "host" + val hasPermission = newSession.settings.allowGuestsToControlPlayback + val effectiveId = if (hasPermission) "host" else newSession.participantId + val effectiveSession = newSession.copy(participantId = effectiveId) + _session.value = effectiveSession _isHost.value = false lastAppliedVersion = 0L loadingSongId = null lastSongLoadAttempt = 0L ListenTogetherStore.save( context = context, - session = newSession, + session = newSession.copy(participantId = newSession.participantId), displayName = _displayName.value, isHost = false, isLan = true, @@ -514,6 +561,8 @@ object ListenTogetherSync { lanHostAddress = null loadingSongId = null lastSongLoadAttempt = 0L + originalGuestParticipantId = null + sessionHostId = null playerConnection?.player?.let { player -> if (player.playbackParameters.speed != 1.0f) { player.playbackParameters = androidx.media3.common.PlaybackParameters(1.0f) @@ -620,11 +669,15 @@ object ListenTogetherSync { val host = lanHostAddress ?: return scope.launch { runCatching { - LanTogetherClient.getSession(host, session.participantId) + val hasPermission = _session.value?.settings?.allowGuestsToControlPlayback == true + val participantIdToSend = if (hasPermission) "host" else (originalGuestParticipantId ?: session.participantId) + LanTogetherClient.getSession(host, participantIdToSend) }.onSuccess { remoteSession -> + val hasPermission = remoteSession.settings.allowGuestsToControlPlayback + val effectiveId = if (hasPermission) "host" else (originalGuestParticipantId ?: session.participantId) val remoteQueue = remoteSession.state?.queue?.takeIf { it.isNotEmpty() } ?: remoteSession.queue val keptSession = remoteSession.copy( - participantId = session.participantId, + participantId = effectiveId, queue = remoteQueue ) _session.value = keptSession @@ -674,16 +727,30 @@ object ListenTogetherSync { runCatching { ListenTogetherClient.getSession(session.code) }.onSuccess { remoteSession -> + if (sessionHostId.isNullOrBlank() && remoteSession.controllerId.isNotBlank()) { + sessionHostId = remoteSession.controllerId + } val preservedSettings = _session.value?.settings ?: session.settings val resolvedSettings = if (_isHost.value) { preservedSettings } else { remoteSession.settings } + val hasPlaybackPermission = resolvedSettings.allowGuestsToControlPlayback + val effectiveParticipantId = if (!_isHost.value) { + if (hasPlaybackPermission && !sessionHostId.isNullOrBlank()) { + sessionHostId!! + } else { + originalGuestParticipantId ?: session.participantId + } + } else { + session.participantId + } + val remoteQueue = remoteSession.state?.queue?.takeIf { it.isNotEmpty() } ?: remoteSession.queue val keptSession = remoteSession.copy( - participantId = session.participantId, + participantId = effectiveParticipantId, settings = resolvedSettings, state = remoteSession.state?.copy(settings = resolvedSettings, queue = remoteQueue) ?: remoteSession.state, queue = remoteQueue, @@ -696,6 +763,19 @@ object ListenTogetherSync { val now = System.currentTimeMillis() if (_isHost.value) { + // If an admin guest changed the song or playback state directly, sync immediately + val isRemoteNewer = remoteSession.stateVersion > lastAppliedVersion + val currentSongId = connection.player.currentMediaItem?.mediaId + val isDifferentSong = remoteState.songId.isNotBlank() && remoteState.songId != currentSongId + val isPlayStateChanged = remoteState.isPlaying != connection.player.playWhenReady + + if (isRemoteNewer && (isDifferentSong || isPlayStateChanged) && (now > suppressLocalSyncUntil)) { + applyRemoteState(connection, remoteSession) + lastAppliedVersion = remoteSession.stateVersion + lastControllerPublishAt = now + return@onSuccess + } + val cmd = remoteState.actionCommand val actionId = remoteState.actionId if (!cmd.isNullOrBlank() && actionId != null && actionId != lastHandledActionId) { @@ -1037,6 +1117,14 @@ object ListenTogetherSync { } else { player.pause() } + val from = foundIndex + 1 + val to = player.mediaItemCount + if (to > from) { + player.removeMediaItems(from, to) + } + if (remoteQueue.isNotEmpty()) { + player.addMediaItems(from, remoteQueue.map { it.toMediaItem() }) + } } else { val targetMetadata = remoteState.toMediaMetadata() val upcomingMediaItems = remoteQueue.map { it.toMediaItem() } @@ -1283,11 +1371,78 @@ object ListenTogetherSync { return true } - val targetSongId = extraData?.optString("songId")?.ifBlank { extraData.optString("id") } + val hasControlPermission = settings.allowGuestsToControlPlayback + val adminParticipantId = if (hasControlPermission && !sessionHostId.isNullOrBlank()) sessionHostId!! else activeSession.participantId + + var targetSongId: String? = null + var targetTitle: String? = null + var targetArtist: String? = null + var targetThumb: String? = null + + when (action) { + "play_song" -> { + targetSongId = extraData?.optString("songId")?.ifBlank { extraData.optString("id") } + targetTitle = extraData?.optString("title") + targetArtist = extraData?.optString("artist")?.ifBlank { extraData?.optString("artists") } + targetThumb = extraData?.optString("thumbnailUrl") + } + "play_index", "jump_to" -> { + val targetIndex = extraData?.optInt("index", -1) ?: -1 + targetSongId = extraData?.optString("songId") + if (targetSongId.isNullOrBlank() && targetIndex in 0 until _sharedQueue.value.size) { + val item = _sharedQueue.value[targetIndex] + targetSongId = item.songId + targetTitle = item.title + targetArtist = item.artist + targetThumb = item.thumbnailUrl + } + } + "skip_next", "next" -> { + targetSongId = extraData?.optString("songId") + if (targetSongId.isNullOrBlank() && _sharedQueue.value.isNotEmpty()) { + val item = _sharedQueue.value.first() + targetSongId = item.songId + targetTitle = item.title + targetArtist = item.artist + targetThumb = item.thumbnailUrl + } + } + "skip_previous", "previous" -> { + targetSongId = extraData?.optString("songId") + } + } + if (!targetSongId.isNullOrBlank()) { pendingGuestSongId = targetSongId + suppressLocalSyncUntil = System.currentTimeMillis() + 4000L + + // Instant local playback on guest device for immediate response + playerConnection?.let { conn -> + val p = conn.player + var matchIdx = -1 + for (i in 0 until p.mediaItemCount) { + if (p.getMediaItemAt(i).mediaId == targetSongId) { + matchIdx = i + break + } + } + if (matchIdx >= 0) { + p.seekToDefaultPosition(matchIdx) + p.play() + } else { + val meta = com.darkxvenom.airbeats.models.MediaMetadata( + id = targetSongId, + title = targetTitle ?: "Song", + artists = if (!targetArtist.isNullOrBlank()) listOf(com.darkxvenom.airbeats.models.MediaMetadata.Artist(id = null, name = targetArtist)) else emptyList(), + duration = -1, + thumbnailUrl = targetThumb ?: "https://i.ytimg.com/vi/$targetSongId/mqdefault.jpg", + ) + conn.playQueue(com.darkxvenom.airbeats.playback.queues.ListQueue(items = listOf(meta.toMediaItem()), startIndex = 0)) + } + } + } else { + suppressLocalSyncUntil = System.currentTimeMillis() + 3000L } - suppressLocalSyncUntil = System.currentTimeMillis() + 5000L scope.launch { runCatching { @@ -1295,7 +1450,7 @@ object ListenTogetherSync { val hostAddr = lanHostAddress ?: return@launch LanTogetherClient.sendAction( hostAddress = hostAddr, - participantId = activeSession.participantId, + participantId = if (hasControlPermission) "host" else activeSession.participantId, action = action, positionMs = positionMs, extraData = extraData, @@ -1306,35 +1461,47 @@ object ListenTogetherSync { "play" -> true "pause" -> false "toggle_play_pause" -> !currentState.isPlaying - else -> currentState.isPlaying + else -> true } - val targetPosition = positionMs ?: currentState.positionMs - val newState = currentState.copy( - isPlaying = targetPlaying, + val targetPosition = if (!targetSongId.isNullOrBlank()) 0L else (positionMs ?: currentState.positionMs) + val remainingQueue = if (!targetSongId.isNullOrBlank()) { + _sharedQueue.value.filterNot { it.songId == targetSongId } + } else { + _sharedQueue.value + } + _sharedQueue.value = remainingQueue + + val newState = ListenTogetherPlaybackState( + songId = targetSongId ?: currentState.songId, + title = targetTitle ?: if (targetSongId == null) currentState.title else "Song", + artists = if (!targetArtist.isNullOrBlank()) listOf(targetArtist) else currentState.artists, + thumbnailUrl = targetThumb ?: currentState.thumbnailUrl, positionMs = targetPosition, + isPlaying = targetPlaying, + durationMs = currentState.durationMs, updatedAt = System.currentTimeMillis(), settings = activeSession.settings, actionCommand = action, actionPayload = extraData?.toString(), actionId = UUID.randomUUID().toString().take(8), - queue = _sharedQueue.value, - totalQueueSize = playerConnection?.player?.mediaItemCount ?: 0, + queue = remainingQueue, + totalQueueSize = remainingQueue.size, ) ListenTogetherClient.updateState( code = activeSession.code, - participantId = activeSession.participantId, + participantId = adminParticipantId, state = newState, ) } }.onSuccess { updatedSession -> val preservedSettings = activeSession.settings _session.value = updatedSession.copy( - participantId = activeSession.participantId, + participantId = adminParticipantId, settings = preservedSettings, state = updatedSession.state?.copy(settings = preservedSettings) ?: updatedSession.state ) lastAppliedVersion = updatedSession.stateVersion - suppressLocalSyncUntil = System.currentTimeMillis() + 5000L + suppressLocalSyncUntil = System.currentTimeMillis() + 4000L playerConnection?.let { conn -> scope.launch { delay(100L) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d5e5c850..98cb04c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -474,6 +474,7 @@ Not in a session Hosting session Connected as guest + Connected as admin Error Current playback Nothing playing @@ -492,6 +493,7 @@ People who join this session will appear here. Host Guest + Admin Kick Kick %1$s? Display name