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
42 changes: 38 additions & 4 deletions app/src/main/java/com/lastwave/app/data/jam/JamProtocol.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ object JamProtocolConstants {
const val MSG_CHAT_MESSAGE = "chat_message"
const val MSG_SYNC_PLAYBACK = "sync_playback"
const val MSG_HOST_CHANGED = "host_changed"
const val MSG_CHANGE_ROOM_MODE = "change_room_mode"
const val MSG_ROOM_MODE_CHANGED = "room_mode_changed"
const val MSG_PONG = "pong"
const val MSG_ERROR = "error"

Expand Down Expand Up @@ -60,17 +62,49 @@ data class SendChatRequest(
data class RoomCreatedPayload(
@SerialName("room_code") val roomCode: String,
@SerialName("user_id") val userId: String,
@SerialName("is_host") val isHost: Boolean,
@SerialName("name") val name: String = "",
@SerialName("is_host") val isHost: Boolean = true,
@SerialName("room_mode") val roomMode: String = JamProtocolConstants.MODE_COLLABORATIVE,
)

@Serializable
data class ParticipantPayload(
@SerialName("user_id") val userId: String,
@SerialName("name") val name: String,
@SerialName("is_host") val isHost: Boolean,
@SerialName("user_id") val userId: String = "",
@SerialName("name") val name: String = "",
@SerialName("is_host") val isHost: Boolean = false,
@SerialName("joined_at") val joinedAt: Long = 0L,
)

@Serializable
data class HostChangedPayload(
@SerialName("new_host_id") val newHostId: String = "",
@SerialName("new_host_name") val newHostName: String = "",
@SerialName("user_id") val userId: String = "",
@SerialName("name") val name: String = "",
) {
val effectiveHostId: String get() = newHostId.ifEmpty { userId }
val effectiveHostName: String get() = newHostName.ifEmpty { name }
}

@Serializable
data class UserLeftPayload(
@SerialName("user_id") val userId: String = "",
@SerialName("name") val name: String = "",
@SerialName("is_host") val isHost: Boolean = false,
@SerialName("total_users") val totalUsers: Int = 0,
)

@Serializable
data class ChangeRoomModeRequest(
@SerialName("room_mode") val roomMode: String,
)

@Serializable
data class RoomModeChangedPayload(
@SerialName("room_mode") val roomMode: String = JamProtocolConstants.MODE_COLLABORATIVE,
@SerialName("changed_by") val changedBy: String = "",
)

@Serializable
data class ChatMessagePayload(
@SerialName("id") val id: String = "",
Expand Down
105 changes: 95 additions & 10 deletions app/src/main/java/com/lastwave/app/data/jam/JamRepository.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
package com.lastwave.app.data.jam

import android.util.Log
import com.lastwave.app.data.local.SettingsPreferences
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
Expand Down Expand Up @@ -51,6 +55,7 @@ sealed interface RemotePlaybackEvent {
@Singleton
class JamRepository @Inject constructor(
private val okHttpClient: OkHttpClient,
private val settingsPreferences: SettingsPreferences,
) {
companion object {
private const val TAG = "LastWaveJam"
Expand Down Expand Up @@ -80,6 +85,14 @@ class JamRepository @Inject constructor(

var serverUrl: String = DEFAULT_JAM_SERVER_WS

init {
scope.launch {
settingsPreferences.settings.collect { settings ->
serverUrl = settings.jamServerUrl.ifBlank { DEFAULT_JAM_SERVER_WS }
}
}
}

fun createRoom(displayName: String, roomMode: String = JamProtocolConstants.MODE_COLLABORATIVE) {
val trimmed = displayName.trim().takeIf { it.isNotEmpty() } ?: "Listener"
connectAndExecute {
Expand Down Expand Up @@ -162,6 +175,32 @@ class JamRepository @Inject constructor(
sendEnvelope(JamEnvelope(type = JamProtocolConstants.MSG_PLAYBACK_ACTION, payload = payload))
}

fun changeRoomMode(roomMode: String) {
val current = _sessionState.value
if (current is JamSessionState.Active && current.isHost) {
val payload = json.encodeToJsonElement(ChangeRoomModeRequest(roomMode = roomMode))
sendEnvelope(JamEnvelope(type = JamProtocolConstants.MSG_CHANGE_ROOM_MODE, payload = payload))
_sessionState.value = current.copy(roomMode = roomMode)
}
}

private var pingJob: Job? = null

private fun startPingLoop() {
pingJob?.cancel()
pingJob = scope.launch {
while (isActive) {
delay(25_000L)
sendEnvelope(JamEnvelope(type = JamProtocolConstants.MSG_PING))
}
}
}

private fun stopPingLoop() {
pingJob?.cancel()
pingJob = null
}

private fun connectAndExecute(targetCode: String? = null, action: () -> Unit) {
disconnect()
_sessionState.value = JamSessionState.Connecting(targetCode)
Expand All @@ -174,6 +213,7 @@ class JamRepository @Inject constructor(
activeWebSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "Jam WebSocket connected to $serverUrl")
startPingLoop()
pendingAction?.invoke()
pendingAction = null
}
Expand All @@ -198,6 +238,29 @@ class JamRepository @Inject constructor(
try {
val envelope = json.decodeFromString<JamEnvelope>(text)
when (envelope.type) {
JamProtocolConstants.MSG_ROOM_CREATED -> {
val payload = envelope.payload?.let { json.decodeFromJsonElement<RoomCreatedPayload>(it) }
if (payload != null) {
val hostParticipant = ParticipantPayload(
userId = payload.userId,
name = payload.name.ifEmpty { "Host" },
isHost = true,
joinedAt = System.currentTimeMillis(),
)
_sessionState.value = JamSessionState.Active(
roomCode = payload.roomCode,
userId = payload.userId,
displayName = payload.name.ifEmpty { "Host" },
isHost = true,
roomMode = payload.roomMode,
participants = listOf(hostParticipant),
currentTrack = null,
isPlaying = false,
positionMs = 0L,
)
}
}

JamProtocolConstants.MSG_SYNC_STATE -> {
val payload = envelope.payload?.let { json.decodeFromJsonElement<SyncStatePayload>(it) }
if (payload != null) {
Expand All @@ -219,24 +282,45 @@ class JamRepository @Inject constructor(
}
}

JamProtocolConstants.MSG_USER_JOINED, JamProtocolConstants.MSG_USER_LEFT, JamProtocolConstants.MSG_HOST_CHANGED -> {
// Update participants in current active state
JamProtocolConstants.MSG_USER_JOINED -> {
val current = _sessionState.value
if (current is JamSessionState.Active && envelope.payload != null) {
val participant = json.decodeFromJsonElement<ParticipantPayload>(envelope.payload)
val updatedList = if (envelope.type == JamProtocolConstants.MSG_USER_JOINED) {
(current.participants.filter { it.userId != participant.userId } + participant)
} else {
current.participants.filter { it.userId != participant.userId }
}
val isHostNow = if (envelope.type == JamProtocolConstants.MSG_HOST_CHANGED) {
participant.userId == current.userId
} else current.isHost
val updatedList = current.participants.filter { it.userId != participant.userId } + participant
_sessionState.value = current.copy(participants = updatedList)
}
}

JamProtocolConstants.MSG_USER_LEFT -> {
val current = _sessionState.value
if (current is JamSessionState.Active && envelope.payload != null) {
val userLeft = json.decodeFromJsonElement<UserLeftPayload>(envelope.payload)
val updatedList = current.participants.filter { it.userId != userLeft.userId }
_sessionState.value = current.copy(participants = updatedList)
}
}

JamProtocolConstants.MSG_HOST_CHANGED -> {
val current = _sessionState.value
if (current is JamSessionState.Active && envelope.payload != null) {
val hostChanged = json.decodeFromJsonElement<HostChangedPayload>(envelope.payload)
val newHostId = hostChanged.effectiveHostId
val updatedList = current.participants.map {
if (it.userId == newHostId) it.copy(isHost = true) else it.copy(isHost = false)
}
val isHostNow = (newHostId == current.userId)
_sessionState.value = current.copy(participants = updatedList, isHost = isHostNow)
}
}

JamProtocolConstants.MSG_ROOM_MODE_CHANGED -> {
val current = _sessionState.value
if (current is JamSessionState.Active && envelope.payload != null) {
val modeChanged = json.decodeFromJsonElement<RoomModeChangedPayload>(envelope.payload)
_sessionState.value = current.copy(roomMode = modeChanged.roomMode)
}
}

JamProtocolConstants.MSG_CHAT_MESSAGE -> {
val msg = envelope.payload?.let { json.decodeFromJsonElement<ChatMessagePayload>(it) }
if (msg != null) {
Expand Down Expand Up @@ -303,6 +387,7 @@ class JamRepository @Inject constructor(
}

private fun disconnect() {
stopPingLoop()
activeWebSocket?.close(1000, "Normal closure")
activeWebSocket = null
_sessionState.value = JamSessionState.Disconnected
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,12 @@ data class MiscSettings(
* Existing progress is preserved so re-enabling resumes where you left off. */
val podcastsEnabled: Boolean = true,
val suppressYouTubeLoginPrompt: Boolean = false,
/** Display name used in LastWave Jam sessions. */
val jamDisplayName: String = "",
/** Custom Jam WebSocket server URL. */
val jamServerUrl: String = "wss://jam.clashprojects.qd.je/ws",
/** Jam Room Mode default: "collaborative" or "host_only". */
val jamRoomMode: String = "collaborative",
)

/** Toggleable sections of the Home tab (see FeedScreen). Hero greeting and
Expand Down Expand Up @@ -342,6 +348,9 @@ class SettingsPreferences @Inject constructor(
val LIKED_SONGS_LOCAL_ONLY = stringPreferencesKey("lw_liked_songs_local_only")
val PODCASTS_ENABLED = booleanPreferencesKey("lw_podcasts_enabled")
val SUPPRESS_YOUTUBE_LOGIN_PROMPT = booleanPreferencesKey("lw_suppress_youtube_login_prompt")
val JAM_DISPLAY_NAME = stringPreferencesKey("lw_jam_display_name")
val JAM_SERVER_URL = stringPreferencesKey("lw_jam_server_url")
val JAM_ROOM_MODE = stringPreferencesKey("lw_jam_room_mode")
}

val settings: Flow<MiscSettings> = dataStore.data
Expand Down Expand Up @@ -432,6 +441,9 @@ class SettingsPreferences @Inject constructor(
likedSongsLocalSync = p.readSafely(Keys.LIKED_SONGS_LOCAL_SYNC) ?: true,
podcastsEnabled = p.readSafely(Keys.PODCASTS_ENABLED) ?: true,
suppressYouTubeLoginPrompt = p.readSafely(Keys.SUPPRESS_YOUTUBE_LOGIN_PROMPT) ?: false,
jamDisplayName = p.readSafely(Keys.JAM_DISPLAY_NAME)?.trim().orEmpty(),
jamServerUrl = p.readSafely(Keys.JAM_SERVER_URL)?.trim().takeIf { !it.isNullOrBlank() } ?: "wss://jam.clashprojects.qd.je/ws",
jamRoomMode = p.readSafely(Keys.JAM_ROOM_MODE)?.trim().takeIf { it == "host_only" || it == "collaborative" } ?: "collaborative",
)
}

Expand Down Expand Up @@ -865,6 +877,19 @@ class SettingsPreferences @Inject constructor(
dataStore.edit { it[Keys.LIKED_SONGS_LOCAL_ONLY] = value }
}

suspend fun setJamDisplayName(name: String) {
dataStore.edit { it[Keys.JAM_DISPLAY_NAME] = name.trim() }
}

suspend fun setJamServerUrl(url: String) {
val trimmed = url.trim().ifEmpty { "wss://jam.clashprojects.qd.je/ws" }
dataStore.edit { it[Keys.JAM_SERVER_URL] = trimmed }
}

suspend fun setJamRoomMode(mode: String) {
dataStore.edit { it[Keys.JAM_ROOM_MODE] = mode }
}

private companion object {
val LOSSLESS_QUALITIES = setOf(-1, 4, 5, 6, 7, 27, 28)
val DOWNLOAD_QUALITIES = setOf(-1, 4, 5, 6, 7, 27, 28)
Expand Down
27 changes: 27 additions & 0 deletions app/src/main/java/com/lastwave/app/ui/home/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Headset
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Schedule
Expand Down Expand Up @@ -176,6 +177,9 @@ fun HomeScreen(
artistAlbumNavigator: com.lastwave.app.ui.navigation.ArtistAlbumNavigator = hiltViewModel<ArtistAlbumNavBridgeHome>().navigator,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val jamViewModel: com.lastwave.app.ui.jam.LastWaveJamViewModel = hiltViewModel()
val jamUiState by jamViewModel.uiState.collectAsStateWithLifecycle()
var showJamSheet by remember { mutableStateOf(false) }
val lifecycleOwner = LocalLifecycleOwner.current

LaunchedEffect(viewModel) {
Expand All @@ -194,6 +198,23 @@ fun HomeScreen(
title = "Statistics",
modifier = Modifier.adaptiveContentWidth(maxWidth = 860.dp),
actions = {
Box {
HeaderActionIcon(
icon = Icons.Filled.Group,
contentDescription = "LastWave Jam",
onClick = { showJamSheet = true },
)
if (jamUiState.isConnected) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(9.dp)
.clip(CircleShape)
.background(Color(0xFFC9FB00)),
)
}
}
HeaderActionIcon(Icons.Filled.Download, "Downloads", onOpenDownloads)
HeaderActionIcon(Icons.Filled.Search, "Search", onOpenSearch)
IconButton(onClick = onOpenSettings) {
Expand Down Expand Up @@ -428,6 +449,12 @@ fun HomeScreen(
)
}

if (showJamSheet) {
com.lastwave.app.ui.jam.LastWaveJamSheet(
viewModel = jamViewModel,
onDismiss = { showJamSheet = false },
)
}
}

private suspend fun snapshotFlowNearEnd(listState: LazyListState, onNearEnd: () -> Unit) {
Expand Down
Loading
Loading