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:
2026-03-01 22:19:17 +01:00
parent ef221ca132
commit 870139b054
11 changed files with 803 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import com.omixlab.lckcontrol.chat.ChatNotificationManager
import com.omixlab.lckcontrol.data.local.TokenStore import com.omixlab.lckcontrol.data.local.TokenStore
import com.omixlab.lckcontrol.data.remote.LckApiService import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.ui.navigation.AppNavigation import com.omixlab.lckcontrol.ui.navigation.AppNavigation
@@ -16,6 +17,7 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var tokenStore: TokenStore @Inject lateinit var tokenStore: TokenStore
@Inject lateinit var apiService: LckApiService @Inject lateinit var apiService: LckApiService
@Inject lateinit var chatNotificationManager: ChatNotificationManager
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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)
}
} }

View File

@@ -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)
}
}

View File

@@ -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,
)

View File

@@ -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)
}
}
}

View File

@@ -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.Request
import com.meta.horizon.platform.ovr.requests.Users import com.meta.horizon.platform.ovr.requests.Users
import com.omixlab.lckcontrol.R import com.omixlab.lckcontrol.R
import com.omixlab.lckcontrol.chat.ChatNotificationManager
import com.omixlab.lckcontrol.data.local.AppPreferences import com.omixlab.lckcontrol.data.local.AppPreferences
import com.omixlab.lckcontrol.data.local.TokenStore import com.omixlab.lckcontrol.data.local.TokenStore
import com.omixlab.lckcontrol.data.remote.LckApiService import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.remote.MetaCallbackRequest import com.omixlab.lckcontrol.data.remote.MetaCallbackRequest
import com.omixlab.lckcontrol.data.remote.RefreshRequest import com.omixlab.lckcontrol.data.remote.RefreshRequest
import com.omixlab.lckcontrol.data.repository.AccountRepository 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.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.ConnectedClientInfo import com.omixlab.lckcontrol.shared.ConnectedClientInfo
import com.omixlab.lckcontrol.shared.ILckControlCallback import com.omixlab.lckcontrol.shared.ILckControlCallback
@@ -61,6 +63,8 @@ class LckControlService : Service() {
@Inject lateinit var tokenStore: TokenStore @Inject lateinit var tokenStore: TokenStore
@Inject lateinit var apiService: LckApiService @Inject lateinit var apiService: LckApiService
@Inject lateinit var streamingManager: StreamingManager @Inject lateinit var streamingManager: StreamingManager
@Inject lateinit var chatRepository: ChatRepository
@Inject lateinit var chatNotificationManager: ChatNotificationManager
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val clientTracker = ClientTracker() private val clientTracker = ClientTracker()
@@ -295,6 +299,30 @@ class LckControlService : Service() {
streamingManager.onBufferReleased = { bufferIndex -> streamingManager.onBufferReleased = { bufferIndex ->
streamingServiceImpl?.broadcastBufferReleased(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? { override fun onBind(intent: Intent?): IBinder? {
@@ -310,6 +338,7 @@ class LckControlService : Service() {
} }
override fun onDestroy() { override fun onDestroy() {
chatRepository.disconnect()
streamingManager.stopStreaming() streamingManager.stopStreaming()
streamingServiceImpl?.kill() streamingServiceImpl?.kill()
serviceScope.cancel() serviceScope.cancel()

View 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,
)
}
}

View File

@@ -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()
}
}

View File

@@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.Circle
import androidx.compose.material.icons.filled.ClearAll import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -27,12 +28,15 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -48,6 +52,7 @@ import com.omixlab.lckcontrol.util.GameInfoProvider
fun DashboardScreen( fun DashboardScreen(
onNavigateToCreatePlan: () -> Unit, onNavigateToCreatePlan: () -> Unit,
onNavigateToPlan: (String) -> Unit, onNavigateToPlan: (String) -> Unit,
onNavigateToChat: (planId: String, service: String, destinationId: String) -> Unit = { _, _, _ -> },
viewModel: DashboardViewModel = hiltViewModel(), viewModel: DashboardViewModel = hiltViewModel(),
) { ) {
val plans by viewModel.plans.collectAsStateWithLifecycle() val plans by viewModel.plans.collectAsStateWithLifecycle()
@@ -56,6 +61,7 @@ fun DashboardScreen(
val defaultExecutionMode by viewModel.defaultExecutionMode.collectAsStateWithLifecycle() val defaultExecutionMode by viewModel.defaultExecutionMode.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val streamingStats by viewModel.streamingStats.collectAsStateWithLifecycle() val streamingStats by viewModel.streamingStats.collectAsStateWithLifecycle()
val unreadCounts by viewModel.unreadCounts.collectAsStateWithLifecycle()
Scaffold( Scaffold(
topBar = { 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 { item {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("Default Streaming Mode", style = MaterialTheme.typography.titleMedium) 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 @Composable
private fun StatusChip(status: String) { private fun StatusChip(status: String) {
val color = when (status) { val color = when (status) {

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.local.AppPreferences import com.omixlab.lckcontrol.data.local.AppPreferences
import com.omixlab.lckcontrol.data.remote.LckApiService 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.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.StreamPlan import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.streaming.StreamingManager import com.omixlab.lckcontrol.streaming.StreamingManager
@@ -27,6 +28,7 @@ class DashboardViewModel @Inject constructor(
private val appPreferences: AppPreferences, private val appPreferences: AppPreferences,
val gameInfoProvider: GameInfoProvider, val gameInfoProvider: GameInfoProvider,
private val streamingManager: StreamingManager, private val streamingManager: StreamingManager,
private val chatRepository: ChatRepository,
) : ViewModel() { ) : ViewModel() {
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans() val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
@@ -36,6 +38,8 @@ class DashboardViewModel @Inject constructor(
val streamingStats: StateFlow<StreamingStats> = streamingManager.stats val streamingStats: StateFlow<StreamingStats> = streamingManager.stats
val streamingManagerInstance: StreamingManager = streamingManager val streamingManagerInstance: StreamingManager = streamingManager
val unreadCounts: StateFlow<Map<String, Int>> = chatRepository.unreadCounts
private val _backendHealthy = MutableStateFlow<Boolean?>(null) private val _backendHealthy = MutableStateFlow<Boolean?>(null)
val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow() val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow()

View File

@@ -28,6 +28,7 @@ import com.omixlab.lckcontrol.ui.accounts.AccountsScreen
import com.omixlab.lckcontrol.ui.clients.ActiveClientsScreen import com.omixlab.lckcontrol.ui.clients.ActiveClientsScreen
import com.omixlab.lckcontrol.ui.dashboard.DashboardScreen import com.omixlab.lckcontrol.ui.dashboard.DashboardScreen
import com.omixlab.lckcontrol.ui.login.LoginScreen 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.CreatePlanScreen
import com.omixlab.lckcontrol.ui.plans.PlanDetailScreen import com.omixlab.lckcontrol.ui.plans.PlanDetailScreen
@@ -114,6 +115,9 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) {
onNavigateToPlan = { planId -> onNavigateToPlan = { planId ->
navController.navigate(Screen.PlanDetail.createRoute(planId)) navController.navigate(Screen.PlanDetail.createRoute(planId))
}, },
onNavigateToChat = { planId, service, destinationId ->
navController.navigate(Screen.Chat.createRoute(planId, service, destinationId))
},
) )
} }
composable(Screen.Accounts.route) { 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) { composable(Screen.ActiveClients.route) {
ActiveClientsScreen( ActiveClientsScreen(
onNavigateToPlan = { planId -> onNavigateToPlan = { planId ->

View File

@@ -12,4 +12,8 @@ sealed class Screen(val route: String) {
fun createRoute(planId: String) = "plan_detail/$planId" fun createRoute(planId: String) = "plan_detail/$planId"
} }
data object ActiveClients : Screen("active_clients") 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"
}
} }