diff --git a/app/src/main/java/com/lastwave/app/data/jam/JamProtocol.kt b/app/src/main/java/com/lastwave/app/data/jam/JamProtocol.kt index 0e63025629..e391a20a92 100644 --- a/app/src/main/java/com/lastwave/app/data/jam/JamProtocol.kt +++ b/app/src/main/java/com/lastwave/app/data/jam/JamProtocol.kt @@ -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" @@ -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 = "", diff --git a/app/src/main/java/com/lastwave/app/data/jam/JamRepository.kt b/app/src/main/java/com/lastwave/app/data/jam/JamRepository.kt index e7ee0699f5..194bfeb16e 100644 --- a/app/src/main/java/com/lastwave/app/data/jam/JamRepository.kt +++ b/app/src/main/java/com/lastwave/app/data/jam/JamRepository.kt @@ -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 @@ -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" @@ -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 { @@ -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) @@ -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 } @@ -198,6 +238,29 @@ class JamRepository @Inject constructor( try { val envelope = json.decodeFromString(text) when (envelope.type) { + JamProtocolConstants.MSG_ROOM_CREATED -> { + val payload = envelope.payload?.let { json.decodeFromJsonElement(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(it) } if (payload != null) { @@ -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(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(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(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(envelope.payload) + _sessionState.value = current.copy(roomMode = modeChanged.roomMode) + } + } + JamProtocolConstants.MSG_CHAT_MESSAGE -> { val msg = envelope.payload?.let { json.decodeFromJsonElement(it) } if (msg != null) { @@ -303,6 +387,7 @@ class JamRepository @Inject constructor( } private fun disconnect() { + stopPingLoop() activeWebSocket?.close(1000, "Normal closure") activeWebSocket = null _sessionState.value = JamSessionState.Disconnected diff --git a/app/src/main/java/com/lastwave/app/data/local/SettingsPreferences.kt b/app/src/main/java/com/lastwave/app/data/local/SettingsPreferences.kt index 716498a216..e5f657ef15 100644 --- a/app/src/main/java/com/lastwave/app/data/local/SettingsPreferences.kt +++ b/app/src/main/java/com/lastwave/app/data/local/SettingsPreferences.kt @@ -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 @@ -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 = dataStore.data @@ -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", ) } @@ -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) diff --git a/app/src/main/java/com/lastwave/app/ui/home/HomeScreen.kt b/app/src/main/java/com/lastwave/app/ui/home/HomeScreen.kt index 969a8fbfe9..8177321060 100644 --- a/app/src/main/java/com/lastwave/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/lastwave/app/ui/home/HomeScreen.kt @@ -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 @@ -176,6 +177,9 @@ fun HomeScreen( artistAlbumNavigator: com.lastwave.app.ui.navigation.ArtistAlbumNavigator = hiltViewModel().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) { @@ -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) { @@ -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) { diff --git a/app/src/main/java/com/lastwave/app/ui/jam/LastWaveJamSheet.kt b/app/src/main/java/com/lastwave/app/ui/jam/LastWaveJamSheet.kt index 7f7e3207ab..f82b0e6465 100644 --- a/app/src/main/java/com/lastwave/app/ui/jam/LastWaveJamSheet.kt +++ b/app/src/main/java/com/lastwave/app/ui/jam/LastWaveJamSheet.kt @@ -3,6 +3,7 @@ package com.lastwave.app.ui.jam import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,16 +16,26 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -37,10 +48,19 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -55,15 +75,28 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.lastwave.app.R import com.lastwave.app.data.jam.ChatMessagePayload +import com.lastwave.app.data.jam.JamProtocolConstants +import com.lastwave.app.data.jam.JamRepository +import com.lastwave.app.data.jam.ParticipantPayload +import com.lastwave.app.ui.common.ArtworkImage private val LimeAccent = Color(0xFFC9FB00) private val DarkTeal = Color(0xFF012226) +private fun formatMs(ms: Long): String { + if (ms <= 0L) return "00:00" + val totalSec = ms / 1000 + val min = totalSec / 60 + val sec = totalSec % 60 + return String.format("%02d:%02d", min, sec) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun LastWaveJamSheet( @@ -76,7 +109,18 @@ fun LastWaveJamSheet( val clipboard = LocalClipboardManager.current val context = LocalContext.current + var selectedTabIndex by remember { mutableIntStateOf(0) } var chatInputText by remember { mutableStateOf("") } + var showAdvancedServer by remember { mutableStateOf(false) } + var customServerUrlInput by remember(uiState.serverUrl) { mutableStateOf(uiState.serverUrl) } + + val chatListState = rememberLazyListState() + + LaunchedEffect(chatMessages.size) { + if (chatMessages.isNotEmpty()) { + chatListState.animateScrollToItem(chatMessages.size - 1) + } + } ModalBottomSheet( onDismissRequest = onDismiss, @@ -128,7 +172,7 @@ fun LastWaveJamSheet( .padding(horizontal = 6.dp, vertical = 2.dp), ) { Text( - text = if (uiState.isConnected) "LIVE" else "SYNC", + text = if (uiState.isConnected) "LIVE" else if (uiState.isConnecting) "CONNECTING" else "LISTEN TOGETHER", color = LimeAccent, fontSize = 10.sp, fontWeight = FontWeight.Bold, @@ -144,103 +188,299 @@ fun LastWaveJamSheet( } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(14.dp)) if (!uiState.isConnected) { - // Not in session: Start or Join - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + // Pre-connection: SimpMusic / Metrolist segmented tabs + TabRow( + selectedTabIndex = selectedTabIndex, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)), + indicator = { tabPositions -> + TabRowDefaults.SecondaryIndicator( + Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), + color = LimeAccent, + ) + }, ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + Tab( + selected = selectedTabIndex == 0, + onClick = { selectedTabIndex = 0 }, + text = { Text("Join Room", fontWeight = FontWeight.Bold) }, + ) + Tab( + selected = selectedTabIndex == 1, + onClick = { selectedTabIndex = 1 }, + text = { Text("Create Room", fontWeight = FontWeight.Bold) }, + ) + } + + Spacer(modifier = Modifier.height(14.dp)) + + // Display Name field (persisted across sessions) + OutlinedTextField( + value = uiState.pendingDisplayName, + onValueChange = { viewModel.setPendingDisplayName(it) }, + label = { Text("Your Display Name") }, + placeholder = { Text("Listener") }, + leadingIcon = { + Icon(Icons.Filled.AccountCircle, contentDescription = null, tint = LimeAccent) + }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = LimeAccent, + ), + ) + + Spacer(modifier = Modifier.height(14.dp)) + + if (selectedTabIndex == 0) { + // Join Room Tab + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), ) { - Text( - text = "Listen Together in Real Time", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Enter 6-Character Room Code", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) - OutlinedTextField( - value = uiState.pendingDisplayName, - onValueChange = { viewModel.setPendingDisplayName(it) }, - label = { Text("Display Name") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LimeAccent, - ), - ) + OutlinedTextField( + value = uiState.pendingInputCode, + onValueChange = { viewModel.setPendingCode(it.take(6)) }, + label = { Text("Room Code") }, + placeholder = { Text("e.g. L86NSD") }, + trailingIcon = { + TextButton( + onClick = { + val clip = clipboard.getText()?.text?.trim().orEmpty() + if (clip.isNotBlank()) { + viewModel.setPendingCode(clip.take(6)) + } + }, + ) { + Text("PASTE", color = LimeAccent, fontWeight = FontWeight.Bold, fontSize = 12.sp) + } + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleMedium.copy( + fontFamily = FontFamily.Monospace, + letterSpacing = 4.sp, + fontWeight = FontWeight.Bold, + ), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = LimeAccent, + ), + ) - Button( - onClick = { viewModel.startJamSession() }, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Color.White, - contentColor = Color.Black, - ), + Button( + onClick = { viewModel.joinJamSession() }, + enabled = uiState.pendingInputCode.length >= 4 && !uiState.isConnecting, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = LimeAccent, + contentColor = DarkTeal, + ), + ) { + Text( + text = if (uiState.isConnecting) "Connecting to Room..." else "Join Jam Room", + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + ) + } + } + } + } else { + // Create Room Tab + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon(Icons.Filled.Group, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Start a Jam Session", fontWeight = FontWeight.Bold) + Text( + text = "Room Permissions & Control", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + + // Collaborative Option + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .border( + width = if (uiState.roomMode == JamProtocolConstants.MODE_COLLABORATIVE) 1.5.dp else 1.dp, + color = if (uiState.roomMode == JamProtocolConstants.MODE_COLLABORATIVE) LimeAccent else MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(10.dp), + ) + .clickable { viewModel.setPendingRoomMode(JamProtocolConstants.MODE_COLLABORATIVE) } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = uiState.roomMode == JamProtocolConstants.MODE_COLLABORATIVE, + onClick = { viewModel.setPendingRoomMode(JamProtocolConstants.MODE_COLLABORATIVE) }, + colors = RadioButtonDefaults.colors(selectedColor = LimeAccent), + ) + Spacer(Modifier.width(8.dp)) + Column { + Text("Collaborative (Default)", fontWeight = FontWeight.Bold) + Text( + "Anyone in room can play, pause, seek, and change songs", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Host Only Option + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .border( + width = if (uiState.roomMode == JamProtocolConstants.MODE_HOST_ONLY) 1.5.dp else 1.dp, + color = if (uiState.roomMode == JamProtocolConstants.MODE_HOST_ONLY) LimeAccent else MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(10.dp), + ) + .clickable { viewModel.setPendingRoomMode(JamProtocolConstants.MODE_HOST_ONLY) } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = uiState.roomMode == JamProtocolConstants.MODE_HOST_ONLY, + onClick = { viewModel.setPendingRoomMode(JamProtocolConstants.MODE_HOST_ONLY) }, + colors = RadioButtonDefaults.colors(selectedColor = LimeAccent), + ) + Spacer(Modifier.width(8.dp)) + Column { + Text("Host Only / Broadcast", fontWeight = FontWeight.Bold) + Text( + "Only you control playback; guests listen along in real-time sync", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Button( + onClick = { viewModel.startJamSession() }, + enabled = !uiState.isConnecting, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = LimeAccent, + contentColor = DarkTeal, + ), + ) { + Icon(Icons.Filled.Group, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text( + text = if (uiState.isConnecting) "Creating Room..." else "Start Jam Session", + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + ) + } } } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(10.dp)) - // Join with code - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + // Expandable Custom Server Settings + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { showAdvancedServer = !showAdvancedServer } + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(Icons.Filled.Settings, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Text("Custom Server Settings", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Icon( + if (showAdvancedServer) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility(visible = showAdvancedServer) { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = "Have a Room Code?", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - OutlinedTextField( - value = uiState.pendingInputCode, - onValueChange = { viewModel.setPendingCode(it) }, - label = { Text("6-Character Room Code") }, - placeholder = { Text("WAVE88") }, + value = customServerUrlInput, + onValueChange = { customServerUrlInput = it }, + label = { Text("WebSocket Server URL", fontSize = 12.sp) }, singleLine = true, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LimeAccent, - ), + shape = RoundedCornerShape(10.dp), ) - - OutlinedButton( - onClick = { viewModel.joinJamSession() }, - enabled = uiState.pendingInputCode.isNotBlank(), - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - shape = RoundedCornerShape(12.dp), + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, ) { - Text("Join Room with Code", fontWeight = FontWeight.SemiBold) + TextButton( + onClick = { + viewModel.resetServerUrl() + customServerUrlInput = JamRepository.DEFAULT_JAM_SERVER_WS + Toast.makeText(context, "Reset to default server", Toast.LENGTH_SHORT).show() + }, + ) { + Text("Reset Default", fontSize = 12.sp) + } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + viewModel.setCustomServerUrl(customServerUrlInput) + Toast.makeText(context, "Server URL saved", Toast.LENGTH_SHORT).show() + }, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Text("Save Server", fontSize = 12.sp) + } } } } } else { - // In an active session + // In Active Room State Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(18.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), ) { Column( @@ -258,20 +498,21 @@ fun LastWaveJamSheet( Text( text = uiState.roomCode, - fontSize = 32.sp, + fontSize = 34.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, - letterSpacing = 4.sp, + letterSpacing = 5.sp, + color = LimeAccent, ) Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(top = 2.dp), ) { OutlinedButton( onClick = { clipboard.setText(AnnotatedString(uiState.roomCode)) - Toast.makeText(context, "Code copied", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Code copied: ${uiState.roomCode}", Toast.LENGTH_SHORT).show() }, shape = RoundedCornerShape(10.dp), ) { @@ -290,26 +531,205 @@ fun LastWaveJamSheet( ) { Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) - Text("Share Link", fontWeight = FontWeight.Bold) + Text("Share Invite", fontWeight = FontWeight.Bold) } } - // Participants - Text( - text = "${uiState.participants.size} in room · ${if (uiState.isHost) "You are Host" else "Host Sync"}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Room Mode toggle (Host can live-toggle between Collaborative and Host-Only) + if (uiState.isHost) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 4.dp), + ) { + Text( + text = "Mode:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(if (uiState.roomMode == JamProtocolConstants.MODE_COLLABORATIVE) LimeAccent.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surfaceContainerHigh) + .clickable { viewModel.changeActiveRoomMode(JamProtocolConstants.MODE_COLLABORATIVE) } + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + text = "Collaborative", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = if (uiState.roomMode == JamProtocolConstants.MODE_COLLABORATIVE) LimeAccent else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(if (uiState.roomMode == JamProtocolConstants.MODE_HOST_ONLY) LimeAccent.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surfaceContainerHigh) + .clickable { viewModel.changeActiveRoomMode(JamProtocolConstants.MODE_HOST_ONLY) } + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + text = "Host Only", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = if (uiState.roomMode == JamProtocolConstants.MODE_HOST_ONLY) LimeAccent else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + Text( + text = "● Live Sync · ${if (uiState.roomMode == JamProtocolConstants.MODE_HOST_ONLY) "Host Only" else "Collaborative"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + // Synced Now Playing Card + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .size(46.dp) + .clip(RoundedCornerShape(8.dp)), + ) { + ArtworkImage( + name = uiState.currentTrackTitle, + artist = uiState.currentTrackArtist, + embeddedUrl = uiState.currentArtworkUrl, + fallbackIcon = Icons.Filled.MusicNote, + modifier = Modifier.size(46.dp), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = uiState.currentTrackTitle.ifBlank { "No track playing" }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = uiState.currentTrackArtist.ifBlank { "Jam Sync" } + + if (uiState.durationMs > 0L) " · ${formatMs(uiState.currentPositionMs)} / ${formatMs(uiState.durationMs)}" else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + // Playback Controls (Enabled if host or collaborative) + val canControl = uiState.isHost || uiState.roomMode == JamProtocolConstants.MODE_COLLABORATIVE + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = { viewModel.togglePlayback() }, + enabled = canControl, + ) { + Icon( + imageVector = if (uiState.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (uiState.isPlaying) "Pause" else "Play", + tint = if (canControl) LimeAccent else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + IconButton( + onClick = { viewModel.skipNext() }, + enabled = canControl, + ) { + Icon( + imageVector = Icons.Filled.SkipNext, + contentDescription = "Skip", + tint = if (canControl) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + } } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(10.dp)) - // Chat Messages List + // Participants Horizontal Strip + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Listeners (${uiState.participants.size})", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 6.dp), + ) + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(uiState.participants) { p -> + Row( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(if (p.isHost) LimeAccent else MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + text = p.name.firstOrNull()?.uppercase() ?: "L", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = if (p.isHost) DarkTeal else Color.White, + ) + } + Text( + text = p.name, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + if (p.isHost) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(LimeAccent.copy(alpha = 0.2f)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) { + Text("HOST", fontSize = 9.sp, fontWeight = FontWeight.Bold, color = LimeAccent) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + // In-room live chat Card( modifier = Modifier .fillMaxWidth() - .height(180.dp), + .height(170.dp), shape = RoundedCornerShape(14.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), ) { @@ -319,6 +739,7 @@ fun LastWaveJamSheet( .padding(12.dp), ) { LazyColumn( + state = chatListState, modifier = Modifier .weight(1f) .fillMaxWidth(), @@ -327,7 +748,7 @@ fun LastWaveJamSheet( if (chatMessages.isEmpty()) { item { Text( - text = "In-room live chat is ready. Say hello!", + text = "In-room chat is ready. Say hello!", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 12.dp), @@ -382,7 +803,7 @@ fun LastWaveJamSheet( } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(10.dp)) Button( onClick = { viewModel.leaveSession() }, @@ -393,11 +814,11 @@ fun LastWaveJamSheet( contentColor = MaterialTheme.colorScheme.onErrorContainer, ), ) { - Text("Leave Jam Session", fontWeight = FontWeight.Bold) + Text(if (uiState.isHost) "Leave / End Jam Session" else "Leave Jam Session", fontWeight = FontWeight.Bold) } } - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(16.dp)) } } } diff --git a/app/src/main/java/com/lastwave/app/ui/jam/LastWaveJamViewModel.kt b/app/src/main/java/com/lastwave/app/ui/jam/LastWaveJamViewModel.kt index 16072671f1..66894da9c0 100644 --- a/app/src/main/java/com/lastwave/app/ui/jam/LastWaveJamViewModel.kt +++ b/app/src/main/java/com/lastwave/app/ui/jam/LastWaveJamViewModel.kt @@ -11,7 +11,10 @@ import com.lastwave.app.data.jam.JamSessionState import com.lastwave.app.data.jam.ParticipantPayload import com.lastwave.app.data.jam.RemotePlaybackEvent import com.lastwave.app.data.jam.TrackInfoPayload +import com.lastwave.app.data.local.SettingsPreferences import com.lastwave.app.playback.MusicPlayer +import com.lastwave.app.playback.MusicPlayerState +import com.lastwave.app.playback.PlaybackProgressState import com.lastwave.app.playback.PlayableTrack import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -32,13 +35,18 @@ data class JamUiState( val isHost: Boolean = false, val isConnected: Boolean = false, val isConnecting: Boolean = false, + val roomMode: String = JamProtocolConstants.MODE_COLLABORATIVE, val participants: List = emptyList(), val errorMessage: String? = null, val pendingInputCode: String = "", val pendingDisplayName: String = "", val currentTrackTitle: String = "", val currentTrackArtist: String = "", + val currentArtworkUrl: String? = null, val isPlaying: Boolean = false, + val currentPositionMs: Long = 0L, + val durationMs: Long = 0L, + val serverUrl: String = JamRepository.DEFAULT_JAM_SERVER_WS, ) @HiltViewModel @@ -46,6 +54,7 @@ class LastWaveJamViewModel @Inject constructor( @ApplicationContext private val context: Context, private val jamRepository: JamRepository, private val musicPlayer: MusicPlayer, + private val settingsPreferences: SettingsPreferences, ) : ViewModel() { private val _pendingRoomCode = MutableStateFlow("") @@ -54,17 +63,38 @@ class LastWaveJamViewModel @Inject constructor( private val _pendingDisplayName = MutableStateFlow("Listener") val pendingDisplayName: StateFlow = _pendingDisplayName.asStateFlow() + private val _pendingRoomMode = MutableStateFlow(JamProtocolConstants.MODE_COLLABORATIVE) + val pendingRoomMode: StateFlow = _pendingRoomMode.asStateFlow() + val chatMessages: StateFlow> = jamRepository.chatMessages // Prevent echoing remote events back to the server private var isApplyingRemoteAction = false val uiState: StateFlow = combine( - jamRepository.sessionState, - _pendingRoomCode, - _pendingDisplayName, - musicPlayer.state, - ) { session, code, name, playerState -> + combine(jamRepository.sessionState, _pendingRoomCode, _pendingDisplayName, _pendingRoomMode) { session, code, name, mode -> + SessionInput(session, code, name, mode) + }, + combine(musicPlayer.state, musicPlayer.progressState, settingsPreferences.settings) { playerState, progress, settings -> + PlayerAndSettings(playerState, progress, settings.jamServerUrl.ifBlank { JamRepository.DEFAULT_JAM_SERVER_WS }) + } + ) { input, extra -> + val session = input.session + val code = input.code + val name = input.name + val mode = input.mode + val playerState = extra.playerState + val progress = extra.progress + val currentTrack = playerState.current + val activeSession = session as? JamSessionState.Active + + val title = currentTrack?.title ?: activeSession?.currentTrack?.title.orEmpty() + val artist = currentTrack?.artist ?: activeSession?.currentTrack?.artist.orEmpty() + val artworkUrl = currentTrack?.artworkUrl ?: activeSession?.currentTrack?.artworkUrl + val isPlaying = playerState.isPlaying + val pos = progress.positionMs + val dur = progress.durationMs + when (session) { is JamSessionState.Active -> JamUiState( sessionState = session, @@ -72,35 +102,87 @@ class LastWaveJamViewModel @Inject constructor( isHost = session.isHost, isConnected = true, isConnecting = false, + roomMode = session.roomMode, participants = session.participants, pendingInputCode = session.roomCode, pendingDisplayName = session.displayName, - currentTrackTitle = playerState.current?.title ?: session.currentTrack?.title.orEmpty(), - currentTrackArtist = playerState.current?.artist ?: session.currentTrack?.artist.orEmpty(), - isPlaying = playerState.isPlaying, + currentTrackTitle = title, + currentTrackArtist = artist, + currentArtworkUrl = artworkUrl, + isPlaying = isPlaying, + currentPositionMs = pos, + durationMs = dur, + serverUrl = extra.serverUrl, ) is JamSessionState.Connecting -> JamUiState( sessionState = session, roomCode = session.roomCode.orEmpty(), isConnecting = true, + roomMode = mode, pendingInputCode = code, pendingDisplayName = name, + currentTrackTitle = title, + currentTrackArtist = artist, + currentArtworkUrl = artworkUrl, + isPlaying = isPlaying, + currentPositionMs = pos, + durationMs = dur, + serverUrl = extra.serverUrl, ) is JamSessionState.Error -> JamUiState( sessionState = session, errorMessage = session.message, + roomMode = mode, pendingInputCode = code, pendingDisplayName = name, + currentTrackTitle = title, + currentTrackArtist = artist, + currentArtworkUrl = artworkUrl, + isPlaying = isPlaying, + currentPositionMs = pos, + durationMs = dur, + serverUrl = extra.serverUrl, ) JamSessionState.Disconnected -> JamUiState( sessionState = session, + roomMode = mode, pendingInputCode = code, pendingDisplayName = name, + currentTrackTitle = title, + currentTrackArtist = artist, + currentArtworkUrl = artworkUrl, + isPlaying = isPlaying, + currentPositionMs = pos, + durationMs = dur, + serverUrl = extra.serverUrl, ) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), JamUiState()) + private data class SessionInput( + val session: JamSessionState, + val code: String, + val name: String, + val mode: String, + ) + + private data class PlayerAndSettings( + val playerState: MusicPlayerState, + val progress: PlaybackProgressState, + val serverUrl: String, + ) + init { + // Hydrate saved preferences + viewModelScope.launch { + settingsPreferences.settings.collect { settings -> + if (_pendingDisplayName.value == "Listener" && settings.jamDisplayName.isNotBlank()) { + _pendingDisplayName.value = settings.jamDisplayName + } + _pendingRoomMode.value = settings.jamRoomMode + } + } + // Listen to remote playback events and update local player viewModelScope.launch { jamRepository.remotePlaybackEvents.collect { event -> @@ -206,15 +288,60 @@ class LastWaveJamViewModel @Inject constructor( fun setPendingDisplayName(name: String) { _pendingDisplayName.value = name + viewModelScope.launch { + settingsPreferences.setJamDisplayName(name) + } + } + + fun setPendingRoomMode(mode: String) { + _pendingRoomMode.value = mode + viewModelScope.launch { + settingsPreferences.setJamRoomMode(mode) + } + } + + fun changeActiveRoomMode(mode: String) { + _pendingRoomMode.value = mode + jamRepository.changeRoomMode(mode) + viewModelScope.launch { + settingsPreferences.setJamRoomMode(mode) + } + } + + fun setCustomServerUrl(url: String) { + viewModelScope.launch { + settingsPreferences.setJamServerUrl(url) + } + } + + fun resetServerUrl() { + viewModelScope.launch { + settingsPreferences.setJamServerUrl(JamRepository.DEFAULT_JAM_SERVER_WS) + } + } + + fun togglePlayback() { + val isAllowed = !uiState.value.isConnected || uiState.value.isHost || uiState.value.roomMode == JamProtocolConstants.MODE_COLLABORATIVE + if (isAllowed) { + musicPlayer.togglePlayPause() + } + } + + fun skipNext() { + val isAllowed = !uiState.value.isConnected || uiState.value.isHost || uiState.value.roomMode == JamProtocolConstants.MODE_COLLABORATIVE + if (isAllowed) { + musicPlayer.next() + } } - fun startJamSession() { + fun startJamSession(roomMode: String? = null) { val name = _pendingDisplayName.value.ifBlank { "Host" } - jamRepository.createRoom(displayName = name) + val mode = roomMode ?: _pendingRoomMode.value + jamRepository.createRoom(displayName = name, roomMode = mode) } fun joinJamSession(code: String? = null) { - val targetCode = code ?: _pendingRoomCode.value + val targetCode = (code ?: _pendingRoomCode.value).trim().uppercase() val name = _pendingDisplayName.value.ifBlank { "Listener" } jamRepository.joinRoom(roomCode = targetCode, displayName = name) }