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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<Map<String, List<ChatMessage>>>(emptyMap())
|
||||
val messages: StateFlow<Map<String, List<ChatMessage>>> = _messages.asStateFlow()
|
||||
|
||||
private val _connectionStatus = MutableStateFlow<Map<String, ChatConnectionStatus>>(emptyMap())
|
||||
val connectionStatus: StateFlow<Map<String, ChatConnectionStatus>> = _connectionStatus.asStateFlow()
|
||||
|
||||
private val _unreadCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val unreadCounts: StateFlow<Map<String, Int>> = _unreadCounts.asStateFlow()
|
||||
|
||||
private val _latestMessage = MutableSharedFlow<ChatMessageEvent>(extraBufferCapacity = 64)
|
||||
val latestMessage: SharedFlow<ChatMessageEvent> = _latestMessage.asSharedFlow()
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private var activeViewKey: String? = null
|
||||
private var reconnectAttempt = 0
|
||||
private var shouldConnect = false
|
||||
private val activeSubscriptions = mutableSetOf<String>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
263
app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatScreen.kt
Normal file
263
app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatScreen.kt
Normal file
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, List<ChatMessage>>> = chatRepository.messages
|
||||
val allConnectionStatus: StateFlow<Map<String, ChatConnectionStatus>> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<List<StreamPlan>> = streamPlanRepository.observePlans()
|
||||
@@ -36,6 +38,8 @@ class DashboardViewModel @Inject constructor(
|
||||
val streamingStats: StateFlow<StreamingStats> = streamingManager.stats
|
||||
val streamingManagerInstance: StreamingManager = streamingManager
|
||||
|
||||
val unreadCounts: StateFlow<Map<String, Int>> = chatRepository.unreadCounts
|
||||
|
||||
private val _backendHealthy = MutableStateFlow<Boolean?>(null)
|
||||
val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow()
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user