App streaming pipeline, dashboard server status, account enable/disable, game-linked plans

- Add C++ native streaming engine (RTMP client, EGL context, streaming engine, JNI bridge)
- Add pre-built arm64-v8a libs (librtmp, libssl, libcrypto, libz) and headers
- Add Kotlin streaming layer (NativeStreamingEngine, StreamingManager, StreamingStats)
- Add AIDL streaming interface (ILckStreamingService, ILckStreamingCallback, StreamingConfig)
- Add LckStreamingServiceImpl with BIND_STREAMING action support
- Add APP_STREAMING execution mode with auto-start/stop on plan lifecycle
- SDK: add bindStreaming(), submitVideoFrame(), submitAudioFrame() to LckControlClient
- Dashboard: replace linked accounts with server status card, move health polling from nav
- Remove health check dot overlay from Dashboard nav icon
- Accounts: add enable/disable toggle per account (persists locally, excluded from default plans)
- Plans: add gameId field linked to game package ID, resolved from ClientTracker for default plans
- Service: pass executionMode+gameId through createStreamPlan, filter enabled accounts in createDefaultPlan
- Room DB migration 4→5: add isEnabled column to linked_accounts, gameId column to stream_plans
- Add docs (hub vs control comparison)
This commit is contained in:
2026-02-28 20:05:21 +01:00
parent 1480a2944b
commit 097cd24ea9
59 changed files with 13609 additions and 89 deletions

View File

@@ -16,7 +16,7 @@ import com.omixlab.lckcontrol.data.local.entity.StreamPlanEntity
StreamPlanEntity::class,
StreamDestinationEntity::class,
],
version = 3,
version = 5,
exportSchema = false,
)
abstract class LckDatabase : RoomDatabase() {
@@ -96,5 +96,18 @@ abstract class LckDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE stream_destinations ADD COLUMN linkedAccountId TEXT NOT NULL DEFAULT ''")
}
}
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE stream_plans ADD COLUMN executionMode TEXT NOT NULL DEFAULT 'IN_GAME'")
}
}
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE linked_accounts ADD COLUMN isEnabled INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE stream_plans ADD COLUMN gameId TEXT NOT NULL DEFAULT ''")
}
}
}
}

View File

@@ -33,4 +33,7 @@ interface LinkedAccountDao {
@Query("DELETE FROM linked_accounts WHERE serviceId = :serviceId")
suspend fun deleteByService(serviceId: String)
@Query("UPDATE linked_accounts SET isEnabled = :isEnabled WHERE id = :id")
suspend fun setEnabled(id: String, isEnabled: Boolean)
}

View File

@@ -10,4 +10,5 @@ data class LinkedAccountEntity(
val displayName: String,
val accountId: String,
val avatarUrl: String? = null,
val isEnabled: Boolean = true,
)

View File

@@ -8,5 +8,7 @@ data class StreamPlanEntity(
@PrimaryKey val planId: String,
val name: String,
val status: String = "DRAFT",
val executionMode: String = "IN_GAME",
val gameId: String = "",
val createdAt: Long = System.currentTimeMillis(),
)

View File

@@ -67,6 +67,8 @@ data class LinkedAccountResponse(
@JsonClass(generateAdapter = true)
data class CreateStreamPlanRequest(
val name: String,
val executionMode: String? = null,
val gameId: String? = null,
val destinations: List<CreateDestinationRequest>,
)
@@ -85,6 +87,8 @@ data class StreamPlanResponse(
val id: String,
val name: String,
val status: String,
val executionMode: String? = null,
val gameId: String? = null,
val createdAt: String,
val updatedAt: String,
val destinations: List<StreamDestinationResponse>,

View File

@@ -26,6 +26,8 @@ class AccountRepository @Inject constructor(
/** Fetch accounts from backend and sync to Room cache */
suspend fun syncAccounts() {
val remote = apiService.getLinkedAccounts()
// Read local entities to preserve isEnabled across sync
val localMap = accountDao.getAll().associateBy { it.id }
val entities = remote.map { account ->
LinkedAccountEntity(
id = account.id,
@@ -33,12 +35,12 @@ class AccountRepository @Inject constructor(
displayName = account.displayName,
accountId = account.accountId,
avatarUrl = account.avatarUrl,
isEnabled = localMap[account.id]?.isEnabled ?: true,
)
}
// Get current local accounts to detect removals
val local = accountDao.getAll()
// Detect removals
val remoteIds = entities.map { it.id }.toSet()
for (localAccount in local) {
for (localAccount in localMap.values) {
if (localAccount.id !in remoteIds) {
accountDao.deleteById(localAccount.id)
}
@@ -48,6 +50,10 @@ class AccountRepository @Inject constructor(
}
}
suspend fun setAccountEnabled(id: String, enabled: Boolean) {
accountDao.setEnabled(id, enabled)
}
/** Get YouTube OAuth URL from backend (for Custom Tabs) */
suspend fun getYouTubeAuthUrl(): String {
val response = apiService.getYouTubeAuthUrl()
@@ -85,5 +91,6 @@ class AccountRepository @Inject constructor(
accountId = accountId,
avatarUrl = avatarUrl,
isAuthenticated = true, // Backend manages auth state
isEnabled = isEnabled,
)
}

View File

@@ -42,9 +42,16 @@ class StreamPlanRepository @Inject constructor(
}
/** Create plan via backend and cache locally */
suspend fun createPlan(name: String, destinations: List<StreamDestination>): StreamPlan {
suspend fun createPlan(
name: String,
destinations: List<StreamDestination>,
executionMode: String = "IN_GAME",
gameId: String = "",
): StreamPlan {
val request = CreateStreamPlanRequest(
name = name,
executionMode = executionMode,
gameId = gameId.ifBlank { null },
destinations = destinations.map { dest ->
CreateDestinationRequest(
linkedAccountId = dest.linkedAccountId,
@@ -96,7 +103,13 @@ class StreamPlanRepository @Inject constructor(
}
private suspend fun cacheRemotePlan(remote: StreamPlanResponse) {
val planEntity = StreamPlanEntity(planId = remote.id, name = remote.name, status = remote.status)
val planEntity = StreamPlanEntity(
planId = remote.id,
name = remote.name,
status = remote.status,
executionMode = remote.executionMode ?: "IN_GAME",
gameId = remote.gameId ?: "",
)
val destEntities = remote.destinations.map { d ->
StreamDestinationEntity(
id = d.id,
@@ -121,6 +134,8 @@ class StreamPlanRepository @Inject constructor(
planId = plan.planId,
name = plan.name,
status = plan.status,
executionMode = plan.executionMode,
gameId = plan.gameId,
destinations = destinations.map { it.toStreamDestination() },
)

View File

@@ -20,7 +20,7 @@ object DatabaseModule {
@Singleton
fun provideDatabase(@ApplicationContext context: Context): LckDatabase =
Room.databaseBuilder(context, LckDatabase::class.java, "lck_control.db")
.addMigrations(LckDatabase.MIGRATION_1_2, LckDatabase.MIGRATION_2_3)
.addMigrations(LckDatabase.MIGRATION_1_2, LckDatabase.MIGRATION_2_3, LckDatabase.MIGRATION_3_4, LckDatabase.MIGRATION_4_5)
.build()
@Provides

View File

@@ -26,6 +26,9 @@ import com.omixlab.lckcontrol.shared.LinkedAccount
import com.omixlab.lckcontrol.shared.StreamDestination
import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.shared.StreamPlanConfig
import com.omixlab.lckcontrol.shared.StreamingConfig
import com.omixlab.lckcontrol.streaming.StreamingManager
import com.omixlab.lckcontrol.streaming.StreamingState
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -48,15 +51,18 @@ class LckControlService : Service() {
private const val NOTIFICATION_ID = 1
private const val QUEST_APP_ID = "25653777174321448"
private const val TOKEN_REFRESH_INTERVAL_MS = 60_000L
private const val ACTION_BIND_STREAMING = "com.omixlab.lckcontrol.BIND_STREAMING"
}
@Inject lateinit var accountRepository: AccountRepository
@Inject lateinit var streamPlanRepository: StreamPlanRepository
@Inject lateinit var tokenStore: TokenStore
@Inject lateinit var apiService: LckApiService
@Inject lateinit var streamingManager: StreamingManager
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val clientTracker = ClientTracker()
private var streamingServiceImpl: LckStreamingServiceImpl? = null
private val callbacks = object : RemoteCallbackList<ILckControlCallback>() {
override fun onCallbackDied(callback: ILckControlCallback, cookie: Any?) {
val uid = cookie as? Int ?: return
@@ -95,13 +101,20 @@ class LckControlService : Service() {
// ── Stream plans ────────────────────────────────────
override fun createStreamPlan(config: StreamPlanConfig): StreamPlan = runBlocking {
val plan = streamPlanRepository.createPlan(config.name, config.destinations)
val plan = streamPlanRepository.createPlan(
name = config.name,
destinations = config.destinations,
executionMode = config.executionMode,
gameId = config.gameId,
)
broadcastPlansChanged()
plan
}
override fun createDefaultPlan(clientName: String): StreamPlan = runBlocking {
val accounts = accountRepository.getAccounts()
val accounts = accountRepository.getAccounts().filter { it.isEnabled }
val gameId = clientTracker.getAll()
.find { it.clientName == clientName }?.packageName ?: ""
val destinations = accounts.map { account ->
StreamDestination(
service = account.serviceId,
@@ -110,7 +123,11 @@ class LckControlService : Service() {
privacyStatus = "unlisted",
)
}
val plan = streamPlanRepository.createPlan("$clientName Stream", destinations)
val plan = streamPlanRepository.createPlan(
name = "$clientName Stream",
destinations = destinations,
gameId = gameId,
)
broadcastPlansChanged()
plan
}
@@ -137,6 +154,17 @@ class LckControlService : Service() {
try {
streamPlanRepository.startPlan(planId)
val updated = streamPlanRepository.getPlan(planId)
// If APP_STREAMING mode, start the streaming engine
if (updated?.executionMode == "APP_STREAMING") {
streamingManager.startStreaming(
plan = updated,
config = StreamingConfig(),
width = 1920,
height = 1080,
)
}
if (updated != null) broadcastPlanUpdated(updated)
true
} catch (_: Exception) { false }
@@ -147,6 +175,11 @@ class LckControlService : Service() {
if (plan.status == "ENDED") return@runBlocking true
if (plan.status != "LIVE" && plan.status != "READY") return@runBlocking false
try {
// Stop streaming engine if running
if (plan.executionMode == "APP_STREAMING") {
streamingManager.stopStreaming()
}
streamPlanRepository.endPlan(planId)
val updated = streamPlanRepository.getPlan(planId)
if (updated != null) broadcastPlanUpdated(updated)
@@ -222,11 +255,37 @@ class LckControlService : Service() {
}
}
}
// Forward streaming state changes to AIDL callbacks
serviceScope.launch {
streamingManager.state.collect { state ->
streamingServiceImpl?.broadcastStateChanged(state)
}
}
serviceScope.launch {
streamingManager.stats.collect { stats ->
streamingServiceImpl?.broadcastStats(
stats.videoBitrate, stats.audioBitrate, stats.fps, stats.droppedFrames,
)
}
}
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onBind(intent: Intent?): IBinder? {
return when (intent?.action) {
ACTION_BIND_STREAMING -> {
if (streamingServiceImpl == null) {
streamingServiceImpl = LckStreamingServiceImpl(streamingManager)
}
streamingServiceImpl!!.asBinder()
}
else -> binder
}
}
override fun onDestroy() {
streamingManager.stopStreaming()
streamingServiceImpl?.kill()
serviceScope.cancel()
callbacks.kill()
super.onDestroy()

View File

@@ -0,0 +1,138 @@
package com.omixlab.lckcontrol.service
import android.hardware.HardwareBuffer
import android.os.ParcelFileDescriptor
import android.os.RemoteCallbackList
import android.util.Log
import com.omixlab.lckcontrol.shared.ILckStreamingCallback
import com.omixlab.lckcontrol.shared.ILckStreamingService
import com.omixlab.lckcontrol.streaming.StreamingManager
import com.omixlab.lckcontrol.streaming.StreamingState
/**
* AIDL implementation for ILckStreamingService.
* Bridges AIDL IPC calls to the StreamingManager.
* Frame submission methods are one-way for non-blocking game render thread.
*/
class LckStreamingServiceImpl(
private val streamingManager: StreamingManager,
) : ILckStreamingService.Stub() {
companion object {
private const val TAG = "LckStreamingServiceImpl"
}
private val callbacks = RemoteCallbackList<ILckStreamingCallback>()
init {
// Forward state changes to AIDL callbacks
// Note: state observation requires coroutine scope — delegated to LckControlService
}
override fun registerTexturePool(
buffers: Array<HardwareBuffer>,
width: Int,
height: Int,
format: Int,
) {
Log.d(TAG, "registerTexturePool: ${buffers.size} buffers, ${width}x$height")
streamingManager.registerTexturePool(buffers, width, height, format)
}
override fun unregisterTexturePool() {
Log.d(TAG, "unregisterTexturePool")
streamingManager.unregisterTexturePool()
}
override fun submitVideoFrame(
bufferIndex: Int,
timestampNs: Long,
gpuFence: ParcelFileDescriptor?,
) {
val fenceFd = gpuFence?.detachFd() ?: -1
streamingManager.submitVideoFrame(bufferIndex, timestampNs, fenceFd)
}
override fun submitAudioFrame(
pcmData: ByteArray,
timestampNs: Long,
sampleRate: Int,
channels: Int,
bitsPerSample: Int,
) {
streamingManager.submitAudioFrame(pcmData, timestampNs)
}
override fun isStreaming(): Boolean {
return streamingManager.isStreaming()
}
override fun registerStreamingCallback(callback: ILckStreamingCallback) {
callbacks.register(callback)
}
override fun unregisterStreamingCallback(callback: ILckStreamingCallback) {
callbacks.unregister(callback)
}
// ── Broadcast helpers (called from LckControlService coroutine scope) ──
fun broadcastStateChanged(state: StreamingState) {
val stateStr = state.name
val count = callbacks.beginBroadcast()
try {
for (i in 0 until count) {
try {
callbacks.getBroadcastItem(i).onStreamingStateChanged(stateStr)
} catch (_: Exception) {}
}
} finally {
callbacks.finishBroadcast()
}
}
fun broadcastStats(videoBitrate: Long, audioBitrate: Long, fps: Int, droppedFrames: Int) {
val count = callbacks.beginBroadcast()
try {
for (i in 0 until count) {
try {
callbacks.getBroadcastItem(i).onStreamingStats(
videoBitrate, audioBitrate, fps, droppedFrames,
)
} catch (_: Exception) {}
}
} finally {
callbacks.finishBroadcast()
}
}
fun broadcastError(code: Int, message: String) {
val count = callbacks.beginBroadcast()
try {
for (i in 0 until count) {
try {
callbacks.getBroadcastItem(i).onStreamingError(code, message)
} catch (_: Exception) {}
}
} finally {
callbacks.finishBroadcast()
}
}
fun broadcastBufferReleased(bufferIndex: Int) {
val count = callbacks.beginBroadcast()
try {
for (i in 0 until count) {
try {
callbacks.getBroadcastItem(i).onBufferReleased(bufferIndex)
} catch (_: Exception) {}
}
} finally {
callbacks.finishBroadcast()
}
}
fun kill() {
callbacks.kill()
}
}

View File

@@ -0,0 +1,112 @@
package com.omixlab.lckcontrol.streaming
import android.hardware.HardwareBuffer
import android.util.Log
/**
* Thin JNI wrapper around the C++ StreamingEngine.
* All encoding, muxing, and RTMP streaming happens in native code (zero-copy pipeline).
*/
class NativeStreamingEngine {
companion object {
private const val TAG = "NativeStreamingEngine"
init {
System.loadLibrary("lck_streaming")
}
}
private var nativePtr: Long = 0
var onStats: ((StreamingStats) -> Unit)? = null
var onError: ((Int, String) -> Unit)? = null
var onBufferReleased: ((Int) -> Unit)? = null
fun create(
width: Int,
height: Int,
videoBitrate: Int,
audioBitrate: Int,
sampleRate: Int,
channels: Int,
keyframeInterval: Int,
) {
if (nativePtr != 0L) {
Log.w(TAG, "Engine already created, destroying first")
destroy()
}
nativePtr = nativeCreate(width, height, videoBitrate, audioBitrate,
sampleRate, channels, keyframeInterval)
}
fun addDestination(rtmpUrl: String): Int {
check(nativePtr != 0L) { "Engine not created" }
return nativeAddDestination(nativePtr, rtmpUrl)
}
fun start(): Boolean {
check(nativePtr != 0L) { "Engine not created" }
return nativeStart(nativePtr)
}
fun submitVideoFrame(hardwareBuffer: HardwareBuffer, timestampNs: Long, fenceFd: Int) {
if (nativePtr == 0L) return
nativeSubmitVideoFrame(nativePtr, hardwareBuffer, timestampNs, fenceFd)
}
fun submitAudioFrame(pcmData: ByteArray, timestampNs: Long) {
if (nativePtr == 0L) return
nativeSubmitAudioFrame(nativePtr, pcmData, timestampNs)
}
fun stop() {
if (nativePtr == 0L) return
nativeStop(nativePtr)
}
fun destroy() {
if (nativePtr != 0L) {
nativeDestroy(nativePtr)
nativePtr = 0
}
}
fun isRunning(): Boolean {
if (nativePtr == 0L) return false
return nativeIsRunning(nativePtr)
}
// Called from native code (JNI callbacks)
@Suppress("unused")
private fun onNativeStats(videoBitrate: Long, audioBitrate: Long, fps: Int, droppedFrames: Int) {
onStats?.invoke(StreamingStats(videoBitrate, audioBitrate, fps, droppedFrames))
}
@Suppress("unused")
private fun onNativeError(code: Int, message: String) {
Log.e(TAG, "Native error $code: $message")
onError?.invoke(code, message)
}
@Suppress("unused")
private fun onNativeBufferReleased(bufferIndex: Int) {
onBufferReleased?.invoke(bufferIndex)
}
// Native methods
private external fun nativeCreate(
width: Int, height: Int,
videoBitrate: Int, audioBitrate: Int,
sampleRate: Int, channels: Int,
keyframeInterval: Int,
): Long
private external fun nativeAddDestination(ptr: Long, rtmpUrl: String): Int
private external fun nativeStart(ptr: Long): Boolean
private external fun nativeSubmitVideoFrame(ptr: Long, hardwareBuffer: HardwareBuffer, timestampNs: Long, fenceFd: Int)
private external fun nativeSubmitAudioFrame(ptr: Long, pcmData: ByteArray, timestampNs: Long)
private external fun nativeStop(ptr: Long)
private external fun nativeDestroy(ptr: Long)
private external fun nativeIsRunning(ptr: Long): Boolean
}

View File

@@ -0,0 +1,156 @@
package com.omixlab.lckcontrol.streaming
import android.hardware.HardwareBuffer
import android.util.Log
import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.shared.StreamingConfig
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
enum class StreamingState {
IDLE, STARTING, LIVE, STOPPING, ERROR
}
/**
* High-level streaming lifecycle manager.
* Bridges stream plan configuration to the native streaming engine.
* Stream keys and RTMP URLs stay within the app process — never exposed via AIDL.
*/
@Singleton
class StreamingManager @Inject constructor() {
companion object {
private const val TAG = "StreamingManager"
}
private var engine: NativeStreamingEngine? = null
private var texturePoolBuffers: Array<HardwareBuffer>? = null
private val _state = MutableStateFlow(StreamingState.IDLE)
val state: StateFlow<StreamingState> = _state.asStateFlow()
private val _stats = MutableStateFlow(StreamingStats())
val stats: StateFlow<StreamingStats> = _stats.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
/**
* Start streaming for a plan with APP_STREAMING execution mode.
* RTMP URLs are constructed internally from the plan's destinations.
*/
fun startStreaming(plan: StreamPlan, config: StreamingConfig, width: Int, height: Int) {
if (_state.value != StreamingState.IDLE) {
Log.w(TAG, "Cannot start streaming, current state: ${_state.value}")
return
}
val destinations = plan.destinations.filter {
it.rtmpUrl.isNotBlank() && it.streamKey.isNotBlank()
}
if (destinations.isEmpty()) {
_error.value = "No destinations with RTMP credentials"
_state.value = StreamingState.ERROR
return
}
_state.value = StreamingState.STARTING
_error.value = null
try {
val eng = NativeStreamingEngine()
eng.create(
width = width,
height = height,
videoBitrate = config.videoBitrate,
audioBitrate = config.audioBitrate,
sampleRate = config.audioSampleRate,
channels = config.audioChannels,
keyframeInterval = config.keyFrameInterval,
)
// Add RTMP destinations — stream keys stay in-process
for (dest in destinations) {
val fullUrl = "${dest.rtmpUrl}/${dest.streamKey}"
eng.addDestination(fullUrl)
Log.d(TAG, "Added destination: ${dest.service}")
}
eng.onStats = { stats ->
_stats.value = stats
}
eng.onError = { code, message ->
Log.e(TAG, "Streaming error $code: $message")
_error.value = message
_state.value = StreamingState.ERROR
}
if (eng.start()) {
engine = eng
_state.value = StreamingState.LIVE
Log.i(TAG, "Streaming started with ${destinations.size} destinations")
} else {
eng.destroy()
_error.value = "Failed to start streaming engine"
_state.value = StreamingState.ERROR
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start streaming", e)
_error.value = e.message ?: "Unknown error"
_state.value = StreamingState.ERROR
}
}
/**
* Register texture pool buffers from the game.
* Buffers are stored for reference — the native engine receives individual
* buffers via submitVideoFrame.
*/
fun registerTexturePool(buffers: Array<HardwareBuffer>, width: Int, height: Int, format: Int) {
texturePoolBuffers = buffers
Log.d(TAG, "Texture pool registered: ${buffers.size} buffers, ${width}x${height}")
}
fun unregisterTexturePool() {
texturePoolBuffers = null
Log.d(TAG, "Texture pool unregistered")
}
/** Forward a video frame from the game to the native engine. */
fun submitVideoFrame(bufferIndex: Int, timestampNs: Long, fenceFd: Int) {
val buffers = texturePoolBuffers ?: return
if (bufferIndex < 0 || bufferIndex >= buffers.size) return
engine?.submitVideoFrame(buffers[bufferIndex], timestampNs, fenceFd)
}
/** Forward audio PCM from the game to the native engine. */
fun submitAudioFrame(pcmData: ByteArray, timestampNs: Long) {
engine?.submitAudioFrame(pcmData, timestampNs)
}
/** Stop streaming and release all resources. */
fun stopStreaming() {
if (_state.value != StreamingState.LIVE && _state.value != StreamingState.ERROR) {
return
}
_state.value = StreamingState.STOPPING
engine?.let { eng ->
eng.stop()
eng.destroy()
}
engine = null
_state.value = StreamingState.IDLE
_stats.value = StreamingStats()
Log.i(TAG, "Streaming stopped")
}
fun isStreaming(): Boolean = _state.value == StreamingState.LIVE
}

View File

@@ -0,0 +1,8 @@
package com.omixlab.lckcontrol.streaming
data class StreamingStats(
val videoBitrate: Long = 0,
val audioBitrate: Long = 0,
val fps: Int = 0,
val droppedFrames: Int = 0,
)

View File

@@ -23,6 +23,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -80,6 +81,10 @@ fun AccountsScreen(
Text(account.displayName, style = MaterialTheme.typography.titleSmall)
Text(account.serviceId, style = MaterialTheme.typography.bodySmall)
}
Switch(
checked = account.isEnabled,
onCheckedChange = { viewModel.toggleAccountEnabled(account.id, it) },
)
IconButton(onClick = { viewModel.unlinkAccount(account.id) }) {
Icon(Icons.Default.LinkOff, contentDescription = "Unlink")
}

View File

@@ -65,6 +65,16 @@ class AccountsViewModel @Inject constructor(
}
}
fun toggleAccountEnabled(accountId: String, enabled: Boolean) {
viewModelScope.launch {
try {
accountRepository.setAccountEnabled(accountId, enabled)
} catch (e: Exception) {
_linkError.value = e.message ?: "Failed to update account"
}
}
}
fun unlinkAccount(accountId: String) {
viewModelScope.launch {
try {

View File

@@ -9,6 +9,7 @@ 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
@@ -41,8 +42,8 @@ fun DashboardScreen(
onNavigateToPlan: (String) -> Unit,
viewModel: DashboardViewModel = hiltViewModel(),
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val plans by viewModel.plans.collectAsStateWithLifecycle()
val backendHealthy by viewModel.backendHealthy.collectAsStateWithLifecycle()
Scaffold(
topBar = {
@@ -63,35 +64,28 @@ fun DashboardScreen(
) {
item {
Spacer(Modifier.height(8.dp))
Text("Linked Accounts", style = MaterialTheme.typography.titleMedium)
Text("Server Status", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
}
if (accounts.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"No accounts linked yet. Go to Accounts to get started.",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
val (color, label) = when (backendHealthy) {
true -> MaterialTheme.colorScheme.primary to "Connected"
false -> MaterialTheme.colorScheme.error to "Unreachable"
null -> MaterialTheme.colorScheme.outline to "Checking..."
}
Icon(
Icons.Default.Circle,
contentDescription = label,
tint = color,
modifier = Modifier.size(12.dp),
)
}
}
} else {
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
accounts.forEach { account ->
ElevatedCard {
Column(modifier = Modifier.padding(12.dp)) {
Text(account.displayName, style = MaterialTheme.typography.labelLarge)
Text(account.serviceId, style = MaterialTheme.typography.bodySmall)
}
}
Spacer(Modifier.width(12.dp))
Column {
Text("Backend", style = MaterialTheme.typography.titleSmall)
Text(label, style = MaterialTheme.typography.bodySmall, color = color)
}
}
}

View File

@@ -2,32 +2,45 @@ package com.omixlab.lckcontrol.ui.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.repository.AccountRepository
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.LinkedAccount
import com.omixlab.lckcontrol.shared.StreamPlan
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class DashboardViewModel @Inject constructor(
accountRepository: AccountRepository,
private val streamPlanRepository: StreamPlanRepository,
private val apiService: LckApiService,
) : ViewModel() {
val accounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _backendHealthy = MutableStateFlow<Boolean?>(null)
val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow()
init {
viewModelScope.launch {
try { streamPlanRepository.syncPlans() } catch (_: Exception) {}
}
viewModelScope.launch {
while (true) {
_backendHealthy.value = try {
apiService.healthCheck()
true
} catch (_: Exception) {
false
}
delay(5_000)
}
}
}
}

View File

@@ -1,15 +1,11 @@
package com.omixlab.lckcontrol.ui.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
@@ -17,13 +13,8 @@ import androidx.compose.material3.Text
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.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@@ -39,7 +30,6 @@ import com.omixlab.lckcontrol.ui.dashboard.DashboardScreen
import com.omixlab.lckcontrol.ui.login.LoginScreen
import com.omixlab.lckcontrol.ui.plans.CreatePlanScreen
import com.omixlab.lckcontrol.ui.plans.PlanDetailScreen
import kotlinx.coroutines.delay
private data class BottomNavItem(
val screen: Screen,
@@ -62,22 +52,6 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) {
val showBottomBar = currentRoute in bottomNavItems.map { it.screen.route }
val startDestination = if (tokenStore.isLoggedIn()) Screen.Dashboard.route else Screen.Login.route
// Backend health state
var backendHealthy by remember { mutableStateOf<Boolean?>(null) }
// Poll backend health every 5 seconds
LaunchedEffect(Unit) {
while (true) {
backendHealthy = try {
apiService.healthCheck()
true
} catch (_: Exception) {
false
}
delay(5_000)
}
}
// Session validation on app open — if we think we're logged in, verify it
LaunchedEffect(Unit) {
if (tokenStore.isLoggedIn()) {
@@ -101,24 +75,7 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) {
bottomNavItems.forEach { item ->
NavigationBarItem(
icon = {
if (item.screen == Screen.Dashboard && backendHealthy != null) {
Box {
Icon(item.icon, contentDescription = item.label)
Icon(
Icons.Default.Circle,
contentDescription = if (backendHealthy == true) "Backend healthy" else "Backend unreachable",
tint = if (backendHealthy == true)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error,
modifier = Modifier
.size(8.dp)
.align(Alignment.TopEnd),
)
}
} else {
Icon(item.icon, contentDescription = item.label)
}
Icon(item.icon, contentDescription = item.label)
},
label = { Text(item.label) },
selected = currentRoute == item.screen.route,

View File

@@ -20,6 +20,7 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -51,6 +52,8 @@ fun CreatePlanScreen(
viewModel: CreatePlanViewModel = hiltViewModel(),
) {
val planName by viewModel.planName.collectAsStateWithLifecycle()
val executionMode by viewModel.executionMode.collectAsStateWithLifecycle()
val gameId by viewModel.gameId.collectAsStateWithLifecycle()
val destinations by viewModel.destinations.collectAsStateWithLifecycle()
val linkedAccounts by viewModel.linkedAccounts.collectAsStateWithLifecycle()
val isCreating by viewModel.isCreating.collectAsStateWithLifecycle()
@@ -95,6 +98,46 @@ fun CreatePlanScreen(
)
}
item {
Spacer(Modifier.height(8.dp))
Text("Execution Mode", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = executionMode == "IN_GAME",
onClick = { viewModel.setExecutionMode("IN_GAME") },
label = { Text("In-Game") },
)
FilterChip(
selected = executionMode == "APP_STREAMING",
onClick = { viewModel.setExecutionMode("APP_STREAMING") },
label = { Text("App Streaming") },
)
}
if (executionMode == "APP_STREAMING") {
Spacer(Modifier.height(4.dp))
Text(
"The app encodes and streams. Stream keys stay secure.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item {
OutlinedTextField(
value = gameId,
onValueChange = viewModel::setGameId,
label = { Text("Game Package ID") },
placeholder = { Text("com.example.game") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
item {
Spacer(Modifier.height(8.dp))
Row(

View File

@@ -37,6 +37,12 @@ class CreatePlanViewModel @Inject constructor(
private val _planName = MutableStateFlow("")
val planName: StateFlow<String> = _planName.asStateFlow()
private val _executionMode = MutableStateFlow("IN_GAME")
val executionMode: StateFlow<String> = _executionMode.asStateFlow()
private val _gameId = MutableStateFlow("")
val gameId: StateFlow<String> = _gameId.asStateFlow()
private val _destinations = MutableStateFlow<List<DestinationInput>>(emptyList())
val destinations: StateFlow<List<DestinationInput>> = _destinations.asStateFlow()
@@ -50,6 +56,14 @@ class CreatePlanViewModel @Inject constructor(
_planName.value = name
}
fun setExecutionMode(mode: String) {
_executionMode.value = mode
}
fun setGameId(gameId: String) {
_gameId.value = gameId
}
fun addDestination() {
_destinations.value = _destinations.value + DestinationInput()
}
@@ -100,7 +114,7 @@ class CreatePlanViewModel @Inject constructor(
tags = input.tags.split(",").map { it.trim() }.filter { it.isNotBlank() },
)
}
val plan = streamPlanRepository.createPlan(name, streamDests)
val plan = streamPlanRepository.createPlan(name, streamDests, _executionMode.value, _gameId.value)
onCreated(plan.planId)
} catch (e: Exception) {
_error.value = e.message ?: "Failed to create plan"

View File

@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.shared.StreamDestination
import com.omixlab.lckcontrol.streaming.StreamingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -51,6 +52,8 @@ fun PlanDetailScreen(
val plan by viewModel.plan.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val streamingStats by viewModel.streamingStats.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(error) {
@@ -122,6 +125,45 @@ fun PlanDetailScreen(
}
}
// Execution mode
item {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Execution Mode", style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(4.dp))
Text(
when (currentPlan.executionMode) {
"APP_STREAMING" -> "App Streaming"
else -> "In-Game"
},
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
// Game ID
if (currentPlan.gameId.isNotBlank()) {
item {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Game", style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(4.dp))
Text(currentPlan.gameId, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
// Streaming stats (only for APP_STREAMING + LIVE)
if (currentPlan.executionMode == "APP_STREAMING" &&
currentPlan.status == "LIVE" &&
streamingState == StreamingState.LIVE) {
item {
StreamingStatsCard(stats = streamingStats)
}
}
// Action buttons
item {
when (currentPlan.status) {

View File

@@ -5,6 +5,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.streaming.StreamingManager
import com.omixlab.lckcontrol.streaming.StreamingState
import com.omixlab.lckcontrol.streaming.StreamingStats
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -18,6 +21,7 @@ import javax.inject.Inject
class PlanDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val streamPlanRepository: StreamPlanRepository,
private val streamingManager: StreamingManager,
) : ViewModel() {
private val planId: String = savedStateHandle["planId"] ?: ""
@@ -32,6 +36,9 @@ class PlanDetailViewModel @Inject constructor(
}
}
val streamingState: StateFlow<StreamingState> = streamingManager.state
val streamingStats: StateFlow<StreamingStats> = streamingManager.stats
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

View File

@@ -0,0 +1,52 @@
package com.omixlab.lckcontrol.ui.plans
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.omixlab.lckcontrol.streaming.StreamingStats
@Composable
fun StreamingStatsCard(stats: StreamingStats) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Streaming Stats", style = MaterialTheme.typography.titleSmall)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
StatItem("Video", formatBitrate(stats.videoBitrate))
StatItem("Audio", formatBitrate(stats.audioBitrate))
StatItem("FPS", "${stats.fps}")
StatItem("Dropped", "${stats.droppedFrames}")
}
}
}
}
@Composable
private fun StatItem(label: String, value: String) {
Column {
Text(label, style = MaterialTheme.typography.labelSmall)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}
private fun formatBitrate(bps: Long): String {
return when {
bps >= 1_000_000 -> "%.1f Mbps".format(bps / 1_000_000.0)
bps >= 1_000 -> "%.0f kbps".format(bps / 1_000.0)
else -> "$bps bps"
}
}