From 870139b054a74c197f6b8c2eed33da22742c4fda Mon Sep 17 00:00:00 2001 From: omigamedev Date: Sun, 1 Mar 2026 22:19:17 +0100 Subject: [PATCH] YouTube/Twitch live chat integration - WebSocket chat client with auto-reconnect and re-subscribe on connect - ChatScreen with message bubbles, mod/broadcaster badges, send capability - Dashboard Live Chat section with unread badges per destination - Chat notification manager for background notifications - Chat navigation route and ViewModel --- .../com/omixlab/lckcontrol/MainActivity.kt | 12 + .../chat/ChatNotificationManager.kt | 71 +++++ .../lckcontrol/data/remote/ChatModels.kt | 63 +++++ .../data/repository/ChatRepository.kt | 228 +++++++++++++++ .../lckcontrol/service/LckControlService.kt | 29 ++ .../omixlab/lckcontrol/ui/chat/ChatScreen.kt | 263 ++++++++++++++++++ .../lckcontrol/ui/chat/ChatViewModel.kt | 42 +++ .../ui/dashboard/DashboardScreen.kt | 73 +++++ .../ui/dashboard/DashboardViewModel.kt | 4 + .../lckcontrol/ui/navigation/AppNavigation.kt | 14 + .../lckcontrol/ui/navigation/Screen.kt | 4 + 11 files changed, 803 insertions(+) create mode 100644 app/src/main/java/com/omixlab/lckcontrol/chat/ChatNotificationManager.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/data/remote/ChatModels.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/data/repository/ChatRepository.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatScreen.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatViewModel.kt diff --git a/app/src/main/java/com/omixlab/lckcontrol/MainActivity.kt b/app/src/main/java/com/omixlab/lckcontrol/MainActivity.kt index a509e7e..161234f 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/MainActivity.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/MainActivity.kt @@ -4,6 +4,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import com.omixlab.lckcontrol.chat.ChatNotificationManager import com.omixlab.lckcontrol.data.local.TokenStore import com.omixlab.lckcontrol.data.remote.LckApiService import com.omixlab.lckcontrol.ui.navigation.AppNavigation @@ -16,6 +17,7 @@ class MainActivity : ComponentActivity() { @Inject lateinit var tokenStore: TokenStore @Inject lateinit var apiService: LckApiService + @Inject lateinit var chatNotificationManager: ChatNotificationManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -26,4 +28,14 @@ class MainActivity : ComponentActivity() { } } } + + override fun onResume() { + super.onResume() + chatNotificationManager.setAppForeground(true) + } + + override fun onPause() { + super.onPause() + chatNotificationManager.setAppForeground(false) + } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/chat/ChatNotificationManager.kt b/app/src/main/java/com/omixlab/lckcontrol/chat/ChatNotificationManager.kt new file mode 100644 index 0000000..46002be --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/chat/ChatNotificationManager.kt @@ -0,0 +1,71 @@ +package com.omixlab.lckcontrol.chat + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import com.omixlab.lckcontrol.R +import com.omixlab.lckcontrol.data.remote.ChatMessageEvent +import com.omixlab.lckcontrol.data.repository.ChatRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChatNotificationManager @Inject constructor( + @ApplicationContext private val context: Context, + private val chatRepository: ChatRepository, +) { + companion object { + private const val CHANNEL_ID = "lck_chat_messages" + private const val NOTIFICATION_TAG = "chat_msg" + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val notificationManager = context.getSystemService(NotificationManager::class.java) + private var notificationIdCounter = 100 + private var appInForeground = true + + fun init() { + createNotificationChannel() + scope.launch { + chatRepository.latestMessage.collect { event -> + if (!appInForeground) { + showNotification(event) + } + } + } + } + + fun setAppForeground(foreground: Boolean) { + appInForeground = foreground + } + + private fun createNotificationChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + "Chat Messages", + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = "Live chat messages from YouTube and Twitch" + } + notificationManager.createNotificationChannel(channel) + } + + private fun showNotification(event: ChatMessageEvent) { + val title = if (event.service == "YOUTUBE") "YouTube Chat" else "Twitch Chat" + val body = "${event.message.authorName}: ${event.message.text}" + + val notification = android.app.Notification.Builder(context, CHANNEL_ID) + .setContentTitle(title) + .setContentText(body) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setAutoCancel(true) + .build() + + notificationManager.notify(NOTIFICATION_TAG, notificationIdCounter++, notification) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/remote/ChatModels.kt b/app/src/main/java/com/omixlab/lckcontrol/data/remote/ChatModels.kt new file mode 100644 index 0000000..b253bfe --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/data/remote/ChatModels.kt @@ -0,0 +1,63 @@ +package com.omixlab.lckcontrol.data.remote + +import com.squareup.moshi.JsonClass + +// ── Incoming events from WebSocket ────────────────────── + +@JsonClass(generateAdapter = true) +data class ChatMessageEvent( + val type: String, + val planId: String, + val service: String, + val destinationId: String, + val message: ChatMessage, +) + +@JsonClass(generateAdapter = true) +data class ChatMessage( + val id: String, + val authorName: String, + val authorImageUrl: String? = null, + val text: String, + val timestamp: Long, + val isModerator: Boolean = false, + val isBroadcaster: Boolean = false, + val color: String? = null, +) + +@JsonClass(generateAdapter = true) +data class ChatStatusEvent( + val type: String, + val planId: String, + val service: String? = null, + val destinationId: String? = null, + val connected: Boolean? = null, + val error: String? = null, +) + +// ── Outgoing commands to WebSocket ────────────────────── + +@JsonClass(generateAdapter = true) +data class SubscribeCommand( + val type: String = "subscribe", + val planId: String, +) + +@JsonClass(generateAdapter = true) +data class UnsubscribeCommand( + val type: String = "unsubscribe", + val planId: String, +) + +@JsonClass(generateAdapter = true) +data class SendChatCommand( + val type: String = "send_message", + val planId: String, + val destinationId: String, + val text: String, +) + +data class ChatConnectionStatus( + val connected: Boolean, + val error: String? = null, +) diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/repository/ChatRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/data/repository/ChatRepository.kt new file mode 100644 index 0000000..ed1c77a --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/data/repository/ChatRepository.kt @@ -0,0 +1,228 @@ +package com.omixlab.lckcontrol.data.repository + +import android.util.Log +import com.omixlab.lckcontrol.data.local.TokenStore +import com.omixlab.lckcontrol.data.remote.ChatConnectionStatus +import com.omixlab.lckcontrol.data.remote.ChatMessage +import com.omixlab.lckcontrol.data.remote.ChatMessageEvent +import com.omixlab.lckcontrol.data.remote.ChatStatusEvent +import com.omixlab.lckcontrol.data.remote.SendChatCommand +import com.omixlab.lckcontrol.data.remote.SubscribeCommand +import com.omixlab.lckcontrol.data.remote.UnsubscribeCommand +import com.squareup.moshi.Moshi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +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.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChatRepository @Inject constructor( + private val okHttpClient: OkHttpClient, + private val tokenStore: TokenStore, + private val moshi: Moshi, +) { + companion object { + private const val TAG = "ChatRepository" + private const val WS_BASE_URL = "wss://lck.omigame.dev/chat/ws" + private const val MAX_MESSAGES_PER_DEST = 500 + private const val MAX_RECONNECT_DELAY_MS = 30_000L + } + + // Separate client for WebSocket with ping keepalive and no read timeout + private val wsClient = okHttpClient.newBuilder() + .pingInterval(30, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .build() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + // key: "planId:SERVICE:destinationId" + private val _messages = MutableStateFlow>>(emptyMap()) + val messages: StateFlow>> = _messages.asStateFlow() + + private val _connectionStatus = MutableStateFlow>(emptyMap()) + val connectionStatus: StateFlow> = _connectionStatus.asStateFlow() + + private val _unreadCounts = MutableStateFlow>(emptyMap()) + val unreadCounts: StateFlow> = _unreadCounts.asStateFlow() + + private val _latestMessage = MutableSharedFlow(extraBufferCapacity = 64) + val latestMessage: SharedFlow = _latestMessage.asSharedFlow() + + private var webSocket: WebSocket? = null + private var activeViewKey: String? = null + private var reconnectAttempt = 0 + private var shouldConnect = false + private val activeSubscriptions = mutableSetOf() + + private val messageAdapter = moshi.adapter(ChatMessageEvent::class.java) + private val statusAdapter = moshi.adapter(ChatStatusEvent::class.java) + private val subscribeAdapter = moshi.adapter(SubscribeCommand::class.java) + private val unsubscribeAdapter = moshi.adapter(UnsubscribeCommand::class.java) + private val sendAdapter = moshi.adapter(SendChatCommand::class.java) + + fun connect() { + shouldConnect = true + doConnect() + } + + fun disconnect() { + shouldConnect = false + activeSubscriptions.clear() + webSocket?.close(1000, "App disconnecting") + webSocket = null + } + + fun subscribe(planId: String) { + if (!activeSubscriptions.add(planId)) return // already subscribed + val cmd = SubscribeCommand(planId = planId) + val sent = webSocket?.send(subscribeAdapter.toJson(cmd)) + Log.d(TAG, "subscribe($planId) wsConnected=${webSocket != null} sent=$sent") + } + + fun unsubscribe(planId: String) { + activeSubscriptions.remove(planId) + val cmd = UnsubscribeCommand(planId = planId) + webSocket?.send(unsubscribeAdapter.toJson(cmd)) + } + + fun sendMessage(planId: String, destinationId: String, text: String) { + val cmd = SendChatCommand(planId = planId, destinationId = destinationId, text = text) + webSocket?.send(sendAdapter.toJson(cmd)) + } + + fun setActiveView(key: String) { + activeViewKey = key + clearUnread(key) + } + + fun clearActiveView() { + activeViewKey = null + } + + fun clearUnread(key: String) { + _unreadCounts.value = _unreadCounts.value.toMutableMap().apply { remove(key) } + } + + private fun doConnect() { + if (webSocket != null) return + val jwt = tokenStore.getJwt() ?: return + + val request = Request.Builder() + .url("$WS_BASE_URL?token=$jwt") + .build() + + webSocket = wsClient.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(TAG, "WebSocket connected") + reconnectAttempt = 0 + // Re-subscribe to all active plans + for (planId in activeSubscriptions) { + Log.d(TAG, "Re-subscribing to plan $planId") + val cmd = SubscribeCommand(planId = planId) + webSocket.send(subscribeAdapter.toJson(cmd)) + } + } + + override fun onMessage(webSocket: WebSocket, text: String) { + handleMessage(text) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(1000, null) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "WebSocket closed: $code $reason") + this@ChatRepository.webSocket = null + scheduleReconnect() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e(TAG, "WebSocket failure", t) + this@ChatRepository.webSocket = null + scheduleReconnect() + } + }) + } + + private fun scheduleReconnect() { + if (!shouldConnect) return + val delayMs = minOf(1000L * (1 shl reconnectAttempt.coerceAtMost(5)), MAX_RECONNECT_DELAY_MS) + reconnectAttempt++ + Log.d(TAG, "Reconnecting in ${delayMs}ms (attempt $reconnectAttempt)") + scope.launch { + delay(delayMs) + if (shouldConnect && webSocket == null) { + doConnect() + } + } + } + + private fun handleMessage(text: String) { + try { + Log.d(TAG, "WS message received: ${text.take(200)}") + // Peek at type field to dispatch + val json = moshi.adapter(Map::class.java).fromJson(text) ?: return + when (json["type"]) { + "chat_message" -> { + val event = messageAdapter.fromJson(text) ?: return + val key = "${event.planId}:${event.service}:${event.destinationId}" + + // Append to messages (ring buffer capped at MAX_MESSAGES_PER_DEST) + _messages.value = _messages.value.toMutableMap().apply { + val existing = this[key] ?: emptyList() + val updated = if (existing.size >= MAX_MESSAGES_PER_DEST) { + existing.drop(1) + event.message + } else { + existing + event.message + } + this[key] = updated + } + + // Increment unread if not actively viewing + if (activeViewKey != key) { + _unreadCounts.value = _unreadCounts.value.toMutableMap().apply { + this[key] = (this[key] ?: 0) + 1 + } + } + + // Emit for notifications + _latestMessage.tryEmit(event) + } + "chat_status" -> { + val event = statusAdapter.fromJson(text) ?: return + if (event.service != null && event.destinationId != null) { + val key = "${event.planId}:${event.service}:${event.destinationId}" + _connectionStatus.value = _connectionStatus.value.toMutableMap().apply { + this[key] = ChatConnectionStatus( + connected = event.connected ?: false, + error = event.error, + ) + } + } + } + "error" -> { + Log.e(TAG, "Server error: ${json["error"]}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to parse WebSocket message", e) + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt b/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt index 148de83..7be8b8c 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt @@ -13,12 +13,14 @@ import com.meta.horizon.platform.ovr.Core import com.meta.horizon.platform.ovr.requests.Request import com.meta.horizon.platform.ovr.requests.Users import com.omixlab.lckcontrol.R +import com.omixlab.lckcontrol.chat.ChatNotificationManager import com.omixlab.lckcontrol.data.local.AppPreferences import com.omixlab.lckcontrol.data.local.TokenStore import com.omixlab.lckcontrol.data.remote.LckApiService import com.omixlab.lckcontrol.data.remote.MetaCallbackRequest import com.omixlab.lckcontrol.data.remote.RefreshRequest import com.omixlab.lckcontrol.data.repository.AccountRepository +import com.omixlab.lckcontrol.data.repository.ChatRepository import com.omixlab.lckcontrol.data.repository.StreamPlanRepository import com.omixlab.lckcontrol.shared.ConnectedClientInfo import com.omixlab.lckcontrol.shared.ILckControlCallback @@ -61,6 +63,8 @@ class LckControlService : Service() { @Inject lateinit var tokenStore: TokenStore @Inject lateinit var apiService: LckApiService @Inject lateinit var streamingManager: StreamingManager + @Inject lateinit var chatRepository: ChatRepository + @Inject lateinit var chatNotificationManager: ChatNotificationManager private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val clientTracker = ClientTracker() @@ -295,6 +299,30 @@ class LckControlService : Service() { streamingManager.onBufferReleased = { bufferIndex -> streamingServiceImpl?.broadcastBufferReleased(bufferIndex) } + + // Initialize chat notification manager and connect WebSocket + chatNotificationManager.init() + chatRepository.connect() + + // Auto-subscribe/unsubscribe chat when plans go LIVE/ENDED + serviceScope.launch { + streamPlanRepository.observePlans().collect { plans -> + Log.d(TAG, "observePlans emitted ${plans.size} plans") + for (plan in plans) { + val destServices = plan.destinations.map { it.service } + Log.d(TAG, "Plan ${plan.planId} status=${plan.status} destinations=$destServices") + val hasChat = plan.destinations.any { + it.service == "YOUTUBE" || it.service == "TWITCH" + } + if (plan.status == "LIVE" && hasChat) { + Log.d(TAG, "Subscribing chat for plan ${plan.planId}") + chatRepository.subscribe(plan.planId) + } else if (plan.status == "ENDED") { + chatRepository.unsubscribe(plan.planId) + } + } + } + } } override fun onBind(intent: Intent?): IBinder? { @@ -310,6 +338,7 @@ class LckControlService : Service() { } override fun onDestroy() { + chatRepository.disconnect() streamingManager.stopStreaming() streamingServiceImpl?.kill() serviceScope.cancel() diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..1601b46 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatScreen.kt @@ -0,0 +1,263 @@ +package com.omixlab.lckcontrol.ui.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.omixlab.lckcontrol.data.remote.ChatMessage +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val YouTubeRed = Color(0xFFFF0000) +private val TwitchPurple = Color(0xFF9146FF) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen( + onBack: () -> Unit, + viewModel: ChatViewModel = hiltViewModel(), +) { + val messagesMap by viewModel.allMessages.collectAsStateWithLifecycle() + val connectionMap by viewModel.allConnectionStatus.collectAsStateWithLifecycle() + + val chatKey = viewModel.getChatKey() + val messages = messagesMap[chatKey] ?: emptyList() + val status = connectionMap[chatKey] + val isConnected = status?.connected ?: false + + val serviceColor = if (viewModel.service == "YOUTUBE") YouTubeRed else TwitchPurple + val serviceLabel = if (viewModel.service == "YOUTUBE") "YouTube Chat" else "Twitch Chat" + + var inputText by remember { mutableStateOf("") } + val listState = rememberLazyListState() + + LaunchedEffect(Unit) { + viewModel.init() + } + + // Auto-scroll to bottom when new messages arrive + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(messages.lastIndex) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(serviceLabel) + Spacer(Modifier.width(8.dp)) + Icon( + Icons.Default.Circle, + contentDescription = if (isConnected) "Connected" else "Disconnected", + tint = if (isConnected) Color(0xFF4CAF50) else Color(0xFFF44336), + modifier = Modifier.size(8.dp), + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + // Connection error banner + if (status?.error != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer) + .padding(8.dp), + ) { + Text( + "Connection error: ${status.error}", + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + // Messages list + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 8.dp), + state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + item { Spacer(Modifier.height(4.dp)) } + items(messages, key = { it.id }) { message -> + ChatMessageBubble( + message = message, + serviceColor = serviceColor, + isTwitch = viewModel.service == "TWITCH", + ) + } + item { Spacer(Modifier.height(4.dp)) } + } + + // Input bar + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = inputText, + onValueChange = { inputText = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Send a message...") }, + singleLine = true, + shape = RoundedCornerShape(24.dp), + ) + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { + viewModel.sendMessage(inputText) + inputText = "" + }, + enabled = inputText.isNotBlank() && isConnected, + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + tint = if (inputText.isNotBlank() && isConnected) + serviceColor + else + MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun ChatMessageBubble( + message: ChatMessage, + serviceColor: Color, + isTwitch: Boolean, +) { + val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } + val timeStr = timeFormat.format(Date(message.timestamp)) + + // Parse Twitch color + val nameColor = if (isTwitch && !message.color.isNullOrBlank()) { + try { + Color(android.graphics.Color.parseColor(message.color)) + } catch (_: Exception) { + serviceColor + } + } else { + serviceColor + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + // Badges + if (message.isBroadcaster) { + Badge("BC", serviceColor) + Spacer(Modifier.width(4.dp)) + } else if (message.isModerator) { + Badge("MOD", Color(0xFF00AD03)) + Spacer(Modifier.width(4.dp)) + } + + Text( + text = message.authorName, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = nameColor, + ) + Spacer(Modifier.weight(1f)) + Text( + text = timeStr, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(2.dp)) + Text( + text = message.text, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun Badge(label: String, color: Color) { + Box( + modifier = Modifier + .clip(CircleShape) + .background(color) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = Color.White, + fontWeight = FontWeight.Bold, + ) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..c94bb7e --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatViewModel.kt @@ -0,0 +1,42 @@ +package com.omixlab.lckcontrol.ui.chat + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.omixlab.lckcontrol.data.remote.ChatConnectionStatus +import com.omixlab.lckcontrol.data.remote.ChatMessage +import com.omixlab.lckcontrol.data.repository.ChatRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@HiltViewModel +class ChatViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val chatRepository: ChatRepository, +) : ViewModel() { + + val planId: String = savedStateHandle["planId"] ?: "" + val service: String = savedStateHandle["service"] ?: "" + val destinationId: String = savedStateHandle["destinationId"] ?: "" + + private val chatKey = "$planId:$service:$destinationId" + + val allMessages: StateFlow>> = chatRepository.messages + val allConnectionStatus: StateFlow> = chatRepository.connectionStatus + + fun getChatKey(): String = chatKey + + fun init() { + chatRepository.setActiveView(chatKey) + } + + fun sendMessage(text: String) { + if (text.isBlank()) return + chatRepository.sendMessage(planId, destinationId, text) + } + + override fun onCleared() { + chatRepository.clearActiveView() + super.onCleared() + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt index d8c6316..2e8ee13 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.filled.ClearAll import androidx.compose.material3.Card @@ -27,12 +28,15 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -48,6 +52,7 @@ import com.omixlab.lckcontrol.util.GameInfoProvider fun DashboardScreen( onNavigateToCreatePlan: () -> Unit, onNavigateToPlan: (String) -> Unit, + onNavigateToChat: (planId: String, service: String, destinationId: String) -> Unit = { _, _, _ -> }, viewModel: DashboardViewModel = hiltViewModel(), ) { val plans by viewModel.plans.collectAsStateWithLifecycle() @@ -56,6 +61,7 @@ fun DashboardScreen( val defaultExecutionMode by viewModel.defaultExecutionMode.collectAsStateWithLifecycle() val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() val streamingStats by viewModel.streamingStats.collectAsStateWithLifecycle() + val unreadCounts by viewModel.unreadCounts.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -132,6 +138,31 @@ fun DashboardScreen( } } + // Live Chat section — only when a LIVE plan has YouTube/Twitch destinations + val liveChatDestinations = plans.flatMap { plan -> + if (plan.status == "LIVE") { + plan.destinations.filter { + it.service == "YOUTUBE" || it.service == "TWITCH" + }.map { dest -> Triple(plan.planId, dest.service, dest) } + } else emptyList() + } + if (liveChatDestinations.isNotEmpty()) { + item { + Spacer(Modifier.height(8.dp)) + Text("Live Chat", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + } + items(liveChatDestinations, key = { "${it.first}:${it.second}:${it.third.linkedAccountId}" }) { (planId, service, dest) -> + val chatKey = "$planId:$service:${dest.linkedAccountId}" + val unread = unreadCounts[chatKey] ?: 0 + ChatServiceCard( + service = service, + unreadCount = unread, + onClick = { onNavigateToChat(planId, service, dest.linkedAccountId) }, + ) + } + } + item { Spacer(Modifier.height(8.dp)) Text("Default Streaming Mode", style = MaterialTheme.typography.titleMedium) @@ -241,6 +272,48 @@ private fun PlanCard( } } +private val YouTubeRed = Color(0xFFFF0000) +private val TwitchPurple = Color(0xFF9146FF) + +@Composable +private fun ChatServiceCard( + service: String, + unreadCount: Int, + onClick: () -> Unit, +) { + val serviceColor = if (service == "YOUTUBE") YouTubeRed else TwitchPurple + val serviceLabel = if (service == "YOUTUBE") "YouTube Chat" else "Twitch Chat" + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.AutoMirrored.Filled.Chat, + contentDescription = serviceLabel, + tint = serviceColor, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(12.dp)) + Text( + serviceLabel, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + if (unreadCount > 0) { + BadgedBox(badge = { + Badge { Text(if (unreadCount > 99) "99+" else unreadCount.toString()) } + }) {} + } + } + } +} + @Composable private fun StatusChip(status: String) { val color = when (status) { diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt index 558db72..7b63ed9 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.omixlab.lckcontrol.data.local.AppPreferences import com.omixlab.lckcontrol.data.remote.LckApiService +import com.omixlab.lckcontrol.data.repository.ChatRepository import com.omixlab.lckcontrol.data.repository.StreamPlanRepository import com.omixlab.lckcontrol.shared.StreamPlan import com.omixlab.lckcontrol.streaming.StreamingManager @@ -27,6 +28,7 @@ class DashboardViewModel @Inject constructor( private val appPreferences: AppPreferences, val gameInfoProvider: GameInfoProvider, private val streamingManager: StreamingManager, + private val chatRepository: ChatRepository, ) : ViewModel() { val plans: StateFlow> = streamPlanRepository.observePlans() @@ -36,6 +38,8 @@ class DashboardViewModel @Inject constructor( val streamingStats: StateFlow = streamingManager.stats val streamingManagerInstance: StreamingManager = streamingManager + val unreadCounts: StateFlow> = chatRepository.unreadCounts + private val _backendHealthy = MutableStateFlow(null) val backendHealthy: StateFlow = _backendHealthy.asStateFlow() diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt index 6fd0939..78fc768 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt @@ -28,6 +28,7 @@ import com.omixlab.lckcontrol.ui.accounts.AccountsScreen import com.omixlab.lckcontrol.ui.clients.ActiveClientsScreen import com.omixlab.lckcontrol.ui.dashboard.DashboardScreen import com.omixlab.lckcontrol.ui.login.LoginScreen +import com.omixlab.lckcontrol.ui.chat.ChatScreen import com.omixlab.lckcontrol.ui.plans.CreatePlanScreen import com.omixlab.lckcontrol.ui.plans.PlanDetailScreen @@ -114,6 +115,9 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) { onNavigateToPlan = { planId -> navController.navigate(Screen.PlanDetail.createRoute(planId)) }, + onNavigateToChat = { planId, service, destinationId -> + navController.navigate(Screen.Chat.createRoute(planId, service, destinationId)) + }, ) } composable(Screen.Accounts.route) { @@ -149,6 +153,16 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) { }, ) } + composable( + route = Screen.Chat.route, + arguments = listOf( + navArgument("planId") { type = NavType.StringType }, + navArgument("service") { type = NavType.StringType }, + navArgument("destinationId") { type = NavType.StringType }, + ), + ) { + ChatScreen(onBack = { navController.popBackStack() }) + } composable(Screen.ActiveClients.route) { ActiveClientsScreen( onNavigateToPlan = { planId -> diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/Screen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/Screen.kt index b24e6f6..d3a56a8 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/Screen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/Screen.kt @@ -12,4 +12,8 @@ sealed class Screen(val route: String) { fun createRoute(planId: String) = "plan_detail/$planId" } data object ActiveClients : Screen("active_clients") + data object Chat : Screen("chat/{planId}/{service}/{destinationId}") { + fun createRoute(planId: String, service: String, destinationId: String) = + "chat/$planId/$service/$destinationId" + } }