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.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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()
|
||||||
|
|||||||
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.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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user