Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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(
Expand Down Expand Up @@ -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 })
}
}
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ fun ListenTogetherScreen(
StatusCard(
session = activeSession,
isHost = isHost || syncedIsHost,
isAdmin = controlsAllowed,
isLan = isLan,
errorMessage = message,
onCopy = { code ->
Expand Down Expand Up @@ -701,6 +702,7 @@ fun ListenTogetherScreen(
ParticipantsCard(
participants = currentRoomSession.participantList,
isHost = isHost || syncedIsHost,
allowGuestsControl = currentRoomSession.settings.allowGuestsToControlPlayback,
onKick = { participant -> kickTarget = participant }
)
}
Expand All @@ -715,6 +717,7 @@ fun ListenTogetherScreen(
private fun StatusCard(
session: ListenTogetherSession?,
isHost: Boolean,
isAdmin: Boolean = false,
isLan: Boolean,
errorMessage: String?,
onCopy: (String) -> Unit,
Expand Down Expand Up @@ -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)
},
Expand Down Expand Up @@ -1701,6 +1705,7 @@ private fun SharedQueueCard(
private fun ParticipantsCard(
participants: List<ListenTogetherParticipant>,
isHost: Boolean,
allowGuestsControl: Boolean = false,
onKick: (ListenTogetherParticipant) -> Unit,
) {
SectionCard(
Expand All @@ -1724,6 +1729,7 @@ private fun ParticipantsCard(
ParticipantRow(
participant = participant,
isHost = isHost,
allowGuestsControl = allowGuestsControl,
onKick = { onKick(participant) }
)
if (index < participants.size - 1) {
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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,
)
},
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()
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()
Expand All @@ -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)
Expand Down Expand Up @@ -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 ->
Expand All @@ -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)
Expand All @@ -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",
)
}
)
}
}
}
Expand All @@ -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,
Expand All @@ -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,
)
}
}
Expand Down
Loading
Loading