Custom RTMP saved accounts, RTMP test server, composition pipeline

- Backend: POST /providers/accounts/custom-rtmp to save reusable RTMP servers
- Backend: Encrypt rtmpUrl/streamKey in existing token fields, decrypt on GET
- Backend: Skip token revocation on DELETE for CUSTOM_RTMP accounts
- Backend: Decrypt CUSTOM_RTMP credentials into destinations on plan create/update
- Android: Add rtmpUrl/streamKey to LinkedAccount entity + shared parcelable (Room v6)
- Android: Add Custom RTMP dialog in AccountsScreen, auto-fill in plan destination picker
- Android: Handle CUSTOM_RTMP accounts in CreatePlanViewModel.loadExistingPlan
- Add local RTMP test server (tools/rtmp-server.js) with auto-ffplay on publish
- Add composition pipeline native code
This commit is contained in:
2026-03-01 10:50:23 +01:00
parent c1ff5351b7
commit c632e22033
35 changed files with 2822 additions and 98 deletions

View File

@@ -16,7 +16,7 @@ import com.omixlab.lckcontrol.data.local.entity.StreamPlanEntity
StreamPlanEntity::class,
StreamDestinationEntity::class,
],
version = 5,
version = 6,
exportSchema = false,
)
abstract class LckDatabase : RoomDatabase() {
@@ -109,5 +109,12 @@ abstract class LckDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE stream_plans ADD COLUMN gameId TEXT NOT NULL DEFAULT ''")
}
}
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE linked_accounts ADD COLUMN rtmpUrl TEXT")
db.execSQL("ALTER TABLE linked_accounts ADD COLUMN streamKey TEXT")
}
}
}
}

View File

@@ -11,4 +11,6 @@ data class LinkedAccountEntity(
val accountId: String,
val avatarUrl: String? = null,
val isEnabled: Boolean = true,
val rtmpUrl: String? = null,
val streamKey: String? = null,
)

View File

@@ -61,6 +61,15 @@ data class LinkedAccountResponse(
val displayName: String,
val accountId: String,
val avatarUrl: String?,
val rtmpUrl: String? = null,
val streamKey: String? = null,
)
@JsonClass(generateAdapter = true)
data class CreateCustomRtmpRequest(
val displayName: String,
val rtmpUrl: String,
val streamKey: String,
)
// ── Streams ──────────────────────────────────────────────
@@ -83,12 +92,14 @@ data class UpdateStreamPlanRequest(
@JsonClass(generateAdapter = true)
data class CreateDestinationRequest(
val linkedAccountId: String,
val linkedAccountId: String? = null,
val title: String,
val description: String? = null,
val privacyStatus: String? = null,
val gameId: String? = null,
val tags: String? = null,
val rtmpUrl: String? = null,
val streamKey: String? = null,
)
@JsonClass(generateAdapter = true)

View File

@@ -1,10 +1,13 @@
package com.omixlab.lckcontrol.data.remote
import android.util.Base64
import android.util.Log
import com.omixlab.lckcontrol.data.local.TokenStore
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
@@ -13,6 +16,19 @@ class AuthInterceptor @Inject constructor(
private val tokenStore: TokenStore,
) : Interceptor {
companion object {
private const val TAG = "AuthInterceptor"
}
private fun extractSub(jwt: String): String? {
return try {
val parts = jwt.split(".")
if (parts.size < 2) return null
val payload = String(Base64.decode(parts[1], Base64.URL_SAFE or Base64.NO_WRAP))
JSONObject(payload).optString("sub", "").ifEmpty { null }
} catch (_: Exception) { null }
}
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
@@ -23,6 +39,8 @@ class AuthInterceptor @Inject constructor(
}
val jwt = tokenStore.getJwt()
val sub = jwt?.let { extractSub(it) }
Log.d(TAG, "${original.method} ${path} userId=${sub ?: "NO_JWT"}")
val request = if (jwt != null) {
original.newBuilder()
.header("Authorization", "Bearer $jwt")
@@ -35,11 +53,14 @@ class AuthInterceptor @Inject constructor(
// If 401 and we have a refresh token, try to refresh
if (response.code == 401) {
Log.w(TAG, "401 on ${original.method} ${path} — attempting token refresh")
val refreshToken = tokenStore.getRefreshToken()
if (refreshToken != null) {
response.close()
val newTokens = refreshTokenSync(chain, refreshToken)
if (newTokens != null) {
val newSub = extractSub(newTokens.accessToken)
Log.d(TAG, "Token refresh OK, new userId=$newSub (was $sub)")
tokenStore.saveSession(newTokens.accessToken, newTokens.refreshToken)
// Retry original request with new token
val retryRequest = original.newBuilder()
@@ -47,9 +68,12 @@ class AuthInterceptor @Inject constructor(
.build()
return chain.proceed(retryRequest)
} else {
Log.e(TAG, "Token refresh FAILED, clearing session")
// Refresh failed, clear session
tokenStore.clearSession()
}
} else {
Log.e(TAG, "401 but no refresh token available")
}
}

View File

@@ -40,6 +40,9 @@ interface LckApiService {
@POST("providers/twitch/callback")
suspend fun twitchCallback(@Body body: ProviderCallbackRequest): LinkedAccountResponse
@POST("providers/accounts/custom-rtmp")
suspend fun createCustomRtmpAccount(@Body body: CreateCustomRtmpRequest): LinkedAccountResponse
@DELETE("providers/accounts/{id}")
suspend fun unlinkAccount(@Path("id") id: String): SuccessResponse

View File

@@ -2,6 +2,7 @@ package com.omixlab.lckcontrol.data.repository
import com.omixlab.lckcontrol.data.local.dao.LinkedAccountDao
import com.omixlab.lckcontrol.data.local.entity.LinkedAccountEntity
import com.omixlab.lckcontrol.data.remote.CreateCustomRtmpRequest
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.remote.ProviderCallbackRequest
import com.omixlab.lckcontrol.shared.LinkedAccount
@@ -36,6 +37,8 @@ class AccountRepository @Inject constructor(
accountId = account.accountId,
avatarUrl = account.avatarUrl,
isEnabled = localMap[account.id]?.isEnabled ?: true,
rtmpUrl = account.rtmpUrl,
streamKey = account.streamKey,
)
}
// Detect removals
@@ -54,6 +57,12 @@ class AccountRepository @Inject constructor(
accountDao.setEnabled(id, enabled)
}
/** Create a custom RTMP account on backend and sync */
suspend fun createCustomRtmpAccount(displayName: String, rtmpUrl: String, streamKey: String) {
apiService.createCustomRtmpAccount(CreateCustomRtmpRequest(displayName, rtmpUrl, streamKey))
syncAccounts()
}
/** Get YouTube OAuth URL from backend (for Custom Tabs) */
suspend fun getYouTubeAuthUrl(): String {
val response = apiService.getYouTubeAuthUrl()
@@ -92,5 +101,7 @@ class AccountRepository @Inject constructor(
avatarUrl = avatarUrl,
isAuthenticated = true, // Backend manages auth state
isEnabled = isEnabled,
rtmpUrl = rtmpUrl,
streamKey = streamKey,
)
}

View File

@@ -55,12 +55,14 @@ class StreamPlanRepository @Inject constructor(
gameId = gameId.ifBlank { null },
destinations = destinations.map { dest ->
CreateDestinationRequest(
linkedAccountId = dest.linkedAccountId,
linkedAccountId = dest.linkedAccountId.ifBlank { null },
title = dest.title,
description = dest.description,
privacyStatus = dest.privacyStatus,
gameId = dest.gameId,
tags = dest.tags.joinToString(","),
rtmpUrl = dest.rtmpUrl.ifBlank { null },
streamKey = dest.streamKey.ifBlank { null },
)
},
)
@@ -83,12 +85,14 @@ class StreamPlanRepository @Inject constructor(
gameId = gameId.ifBlank { null },
destinations = destinations.map { dest ->
CreateDestinationRequest(
linkedAccountId = dest.linkedAccountId,
linkedAccountId = dest.linkedAccountId.ifBlank { null },
title = dest.title,
description = dest.description,
privacyStatus = dest.privacyStatus,
gameId = dest.gameId,
tags = dest.tags.joinToString(","),
rtmpUrl = dest.rtmpUrl.ifBlank { null },
streamKey = dest.streamKey.ifBlank { null },
)
},
)

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, LckDatabase.MIGRATION_3_4, LckDatabase.MIGRATION_4_5)
.addMigrations(LckDatabase.MIGRATION_1_2, LckDatabase.MIGRATION_2_3, LckDatabase.MIGRATION_3_4, LckDatabase.MIGRATION_4_5, LckDatabase.MIGRATION_5_6)
.build()
@Provides

View File

@@ -302,15 +302,28 @@ class LckControlService : Service() {
// ── Auth logic ──────────────────────────────────────────
private fun extractJwtSub(jwt: String): String? {
return try {
val parts = jwt.split(".")
if (parts.size < 2) return null
val payload = String(android.util.Base64.decode(parts[1], android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP))
org.json.JSONObject(payload).optString("sub", "").ifEmpty { null }
} catch (_: Exception) { null }
}
private suspend fun doAutoLogin() {
// Try token refresh first
val refreshToken = tokenStore.getRefreshToken()
val oldJwt = tokenStore.getJwt()
val oldSub = oldJwt?.let { extractJwtSub(it) }
Log.d(TAG, "doAutoLogin: hasRefreshToken=${refreshToken != null}, currentUserId=$oldSub")
if (refreshToken != null) {
Log.d(TAG, "Attempting token refresh...")
try {
val response = apiService.refreshSession(RefreshRequest(refreshToken))
val newSub = extractJwtSub(response.accessToken)
Log.d(TAG, "Token refresh successful, userId=$newSub (was $oldSub)")
tokenStore.saveSession(response.accessToken, response.refreshToken)
Log.d(TAG, "Token refresh successful")
broadcastAuthStateChanged(true)
return
} catch (e: Exception) {
@@ -320,6 +333,7 @@ class LckControlService : Service() {
}
// Full Quest SDK login
Log.d(TAG, "Starting Quest SDK login (previous userId=$oldSub)")
doQuestLogin()
}
@@ -358,8 +372,9 @@ class LckControlService : Service() {
)
)
val loginSub = extractJwtSub(response.accessToken)
tokenStore.saveSession(response.accessToken, response.refreshToken)
Log.d(TAG, "Quest SDK login successful")
Log.d(TAG, "Quest SDK login successful, userId=$loginSub")
broadcastAuthStateChanged(true)
}

View File

@@ -2,6 +2,7 @@ package com.omixlab.lckcontrol.streaming
import android.hardware.HardwareBuffer
import android.util.Log
import android.view.Surface
/**
* Thin JNI wrapper around the C++ StreamingEngine.
@@ -77,6 +78,52 @@ class NativeStreamingEngine {
return nativeIsRunning(nativePtr)
}
// Preview surface
fun setPreviewSurface(surface: Surface) {
if (nativePtr == 0L) return
nativeSetPreviewSurface(nativePtr, surface)
}
fun removePreviewSurface() {
if (nativePtr == 0L) return
nativeRemovePreviewSurface(nativePtr)
}
// Composition layers
fun addCompositionLayer(
rgbaData: ByteArray, w: Int, h: Int,
posX: Float, posY: Float, scaleX: Float, scaleY: Float,
rotation: Float, opacity: Float, zOrder: Int, tag: String,
): Int {
if (nativePtr == 0L) return -1
return nativeAddCompositionLayer(nativePtr, rgbaData, w, h,
posX, posY, scaleX, scaleY, rotation, opacity, zOrder, tag)
}
fun removeCompositionLayer(layerId: Int) {
if (nativePtr == 0L) return
nativeRemoveCompositionLayer(nativePtr, layerId)
}
fun updateCompositionLayerTransform(
layerId: Int, posX: Float, posY: Float,
scaleX: Float, scaleY: Float, rotation: Float,
) {
if (nativePtr == 0L) return
nativeUpdateCompositionLayerTransform(nativePtr, layerId,
posX, posY, scaleX, scaleY, rotation)
}
fun updateCompositionLayerOpacity(layerId: Int, opacity: Float) {
if (nativePtr == 0L) return
nativeUpdateCompositionLayerOpacity(nativePtr, layerId, opacity)
}
fun setCompositionLayerEnabled(layerId: Int, enabled: Boolean) {
if (nativePtr == 0L) return
nativeSetCompositionLayerEnabled(nativePtr, layerId, enabled)
}
// Called from native code (JNI callbacks)
@Suppress("unused")
private fun onNativeStats(videoBitrate: Long, audioBitrate: Long, fps: Int, droppedFrames: Int) {
@@ -109,4 +156,22 @@ class NativeStreamingEngine {
private external fun nativeStop(ptr: Long)
private external fun nativeDestroy(ptr: Long)
private external fun nativeIsRunning(ptr: Long): Boolean
// Preview surface
private external fun nativeSetPreviewSurface(ptr: Long, surface: Surface)
private external fun nativeRemovePreviewSurface(ptr: Long)
// Composition layers
private external fun nativeAddCompositionLayer(
ptr: Long, rgbaData: ByteArray, w: Int, h: Int,
posX: Float, posY: Float, scaleX: Float, scaleY: Float,
rotation: Float, opacity: Float, zOrder: Int, tag: String,
): Int
private external fun nativeRemoveCompositionLayer(ptr: Long, layerId: Int)
private external fun nativeUpdateCompositionLayerTransform(
ptr: Long, layerId: Int, posX: Float, posY: Float,
scaleX: Float, scaleY: Float, rotation: Float,
)
private external fun nativeUpdateCompositionLayerOpacity(ptr: Long, layerId: Int, opacity: Float)
private external fun nativeSetCompositionLayerEnabled(ptr: Long, layerId: Int, enabled: Boolean)
}

View File

@@ -1,12 +1,15 @@
package com.omixlab.lckcontrol.streaming
import android.graphics.Bitmap
import android.hardware.HardwareBuffer
import android.util.Log
import android.view.Surface
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 java.nio.ByteBuffer
import javax.inject.Inject
import javax.inject.Singleton
@@ -153,4 +156,61 @@ class StreamingManager @Inject constructor() {
}
fun isStreaming(): Boolean = _state.value == StreamingState.LIVE
// --- Preview surface ---
fun setPreviewSurface(surface: Surface) {
engine?.setPreviewSurface(surface)
}
fun removePreviewSurface() {
engine?.removePreviewSurface()
}
// --- Composition layers ---
fun addCompositionLayer(
bitmap: Bitmap,
posX: Float, posY: Float,
scaleX: Float, scaleY: Float,
rotation: Float, opacity: Float,
zOrder: Int, tag: String,
): Int {
val rgba = bitmapToRgba(bitmap)
return engine?.addCompositionLayer(
rgba, bitmap.width, bitmap.height,
posX, posY, scaleX, scaleY, rotation, opacity, zOrder, tag,
) ?: -1
}
fun removeCompositionLayer(layerId: Int) {
engine?.removeCompositionLayer(layerId)
}
fun updateCompositionLayerTransform(
layerId: Int, posX: Float, posY: Float,
scaleX: Float, scaleY: Float, rotation: Float,
) {
engine?.updateCompositionLayerTransform(layerId, posX, posY, scaleX, scaleY, rotation)
}
fun updateCompositionLayerOpacity(layerId: Int, opacity: Float) {
engine?.updateCompositionLayerOpacity(layerId, opacity)
}
fun setCompositionLayerEnabled(layerId: Int, enabled: Boolean) {
engine?.setCompositionLayerEnabled(layerId, enabled)
}
private fun bitmapToRgba(bitmap: Bitmap): ByteArray {
val argbBitmap = if (bitmap.config != Bitmap.Config.ARGB_8888) {
bitmap.copy(Bitmap.Config.ARGB_8888, false)
} else {
bitmap
}
val buffer = ByteBuffer.allocate(argbBitmap.byteCount)
argbBitmap.copyPixelsToBuffer(buffer)
if (argbBitmap !== bitmap) argbBitmap.recycle()
return buffer.array()
}
}

View File

@@ -14,17 +14,20 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
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.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -44,6 +47,11 @@ fun AccountsScreen(
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val linkError by viewModel.linkError.collectAsStateWithLifecycle()
val showDialog by viewModel.showCustomRtmpDialog.collectAsStateWithLifecycle()
val customName by viewModel.customRtmpName.collectAsStateWithLifecycle()
val customUrl by viewModel.customRtmpUrl.collectAsStateWithLifecycle()
val customKey by viewModel.customRtmpKey.collectAsStateWithLifecycle()
val isCreating by viewModel.isCreatingCustomRtmp.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
@@ -54,6 +62,54 @@ fun AccountsScreen(
}
}
if (showDialog) {
AlertDialog(
onDismissRequest = { viewModel.dismissCustomRtmpDialog() },
title = { Text("Add Custom RTMP") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = customName,
onValueChange = viewModel::setCustomRtmpName,
label = { Text("Name") },
placeholder = { Text("Local Test Server") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = customUrl,
onValueChange = viewModel::setCustomRtmpUrl,
label = { Text("RTMP URL") },
placeholder = { Text("rtmp://192.168.1.60:1935/live") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = customKey,
onValueChange = viewModel::setCustomRtmpKey,
label = { Text("Stream Key") },
placeholder = { Text("test") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
},
confirmButton = {
TextButton(
onClick = { viewModel.createCustomRtmpAccount() },
enabled = !isCreating,
) {
Text(if (isCreating) "Saving..." else "Save")
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissCustomRtmpDialog() }) {
Text("Cancel")
}
},
)
}
Scaffold(
topBar = {
TopAppBar(title = { Text("Linked Accounts") })
@@ -79,7 +135,11 @@ fun AccountsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(account.displayName, style = MaterialTheme.typography.titleSmall)
Text(account.serviceId, style = MaterialTheme.typography.bodySmall)
Text(
if (account.serviceId == "CUSTOM_RTMP") account.rtmpUrl ?: "Custom RTMP"
else account.serviceId,
style = MaterialTheme.typography.bodySmall,
)
}
Switch(
checked = account.isEnabled,
@@ -113,6 +173,17 @@ fun AccountsScreen(
}
}
item {
OutlinedButton(
onClick = { viewModel.showCustomRtmpDialog() },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.padding(4.dp))
Text("Add Custom RTMP")
}
}
item { Spacer(Modifier.height(16.dp)) }
}
}

View File

@@ -37,6 +37,22 @@ class AccountsViewModel @Inject constructor(
private val _linkError = MutableStateFlow<String?>(null)
val linkError: StateFlow<String?> = _linkError.asStateFlow()
// Custom RTMP dialog state
private val _showCustomRtmpDialog = MutableStateFlow(false)
val showCustomRtmpDialog: StateFlow<Boolean> = _showCustomRtmpDialog.asStateFlow()
private val _customRtmpName = MutableStateFlow("")
val customRtmpName: StateFlow<String> = _customRtmpName.asStateFlow()
private val _customRtmpUrl = MutableStateFlow("")
val customRtmpUrl: StateFlow<String> = _customRtmpUrl.asStateFlow()
private val _customRtmpKey = MutableStateFlow("")
val customRtmpKey: StateFlow<String> = _customRtmpKey.asStateFlow()
private val _isCreatingCustomRtmp = MutableStateFlow(false)
val isCreatingCustomRtmp: StateFlow<Boolean> = _isCreatingCustomRtmp.asStateFlow()
init {
// Sync accounts from backend on load
viewModelScope.launch {
@@ -85,6 +101,42 @@ class AccountsViewModel @Inject constructor(
}
}
fun showCustomRtmpDialog() {
_customRtmpName.value = ""
_customRtmpUrl.value = ""
_customRtmpKey.value = ""
_showCustomRtmpDialog.value = true
}
fun dismissCustomRtmpDialog() {
_showCustomRtmpDialog.value = false
}
fun setCustomRtmpName(name: String) { _customRtmpName.value = name }
fun setCustomRtmpUrl(url: String) { _customRtmpUrl.value = url }
fun setCustomRtmpKey(key: String) { _customRtmpKey.value = key }
fun createCustomRtmpAccount() {
val name = _customRtmpName.value.trim()
val url = _customRtmpUrl.value.trim()
val key = _customRtmpKey.value.trim()
if (name.isBlank() || url.isBlank() || key.isBlank()) {
_linkError.value = "All fields are required"
return
}
viewModelScope.launch {
_isCreatingCustomRtmp.value = true
try {
accountRepository.createCustomRtmpAccount(name, url, key)
_showCustomRtmpDialog.value = false
} catch (e: Exception) {
_linkError.value = e.message ?: "Failed to create custom RTMP account"
} finally {
_isCreatingCustomRtmp.value = false
}
}
}
fun clearError() {
_linkError.value = null
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
@@ -23,6 +24,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -35,7 +37,10 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.streaming.StreamingState
import com.omixlab.lckcontrol.ui.components.GameInfoRow
import com.omixlab.lckcontrol.ui.plans.StreamPreviewSurface
import com.omixlab.lckcontrol.ui.plans.StreamingStatsCard
import com.omixlab.lckcontrol.util.GameInfoProvider
@OptIn(ExperimentalMaterial3Api::class)
@@ -49,6 +54,8 @@ fun DashboardScreen(
val backendHealthy by viewModel.backendHealthy.collectAsStateWithLifecycle()
val backendVersion by viewModel.backendVersion.collectAsStateWithLifecycle()
val defaultExecutionMode by viewModel.defaultExecutionMode.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val streamingStats by viewModel.streamingStats.collectAsStateWithLifecycle()
Scaffold(
topBar = {
@@ -103,6 +110,28 @@ fun DashboardScreen(
}
}
// Live preview + streaming stats (only for APP_STREAMING plans with active engine)
val hasLiveAppStreaming = plans.any {
it.status == "LIVE" && it.executionMode == "APP_STREAMING"
}
if (hasLiveAppStreaming && streamingState == StreamingState.LIVE) {
item {
Spacer(Modifier.height(8.dp))
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Live Preview", style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(8.dp))
StreamPreviewSurface(
streamingManager = viewModel.streamingManagerInstance,
)
}
}
}
item {
StreamingStatsCard(stats = streamingStats)
}
}
item {
Spacer(Modifier.height(8.dp))
Text("Default Streaming Mode", style = MaterialTheme.typography.titleMedium)
@@ -126,8 +155,22 @@ fun DashboardScreen(
item {
Spacer(Modifier.height(8.dp))
Text("Stream Plans", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Stream Plans", style = MaterialTheme.typography.titleMedium)
if (plans.any { it.status == "ENDED" }) {
IconButton(onClick = viewModel::clearEndedPlans) {
Icon(
Icons.Default.ClearAll,
contentDescription = "Clear ended plans",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
if (plans.isEmpty()) {

View File

@@ -6,6 +6,9 @@ import com.omixlab.lckcontrol.data.local.AppPreferences
import com.omixlab.lckcontrol.data.remote.LckApiService
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 com.omixlab.lckcontrol.util.GameInfoProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
@@ -23,11 +26,16 @@ class DashboardViewModel @Inject constructor(
private val apiService: LckApiService,
private val appPreferences: AppPreferences,
val gameInfoProvider: GameInfoProvider,
private val streamingManager: StreamingManager,
) : ViewModel() {
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val streamingState: StateFlow<StreamingState> = streamingManager.state
val streamingStats: StateFlow<StreamingStats> = streamingManager.stats
val streamingManagerInstance: StreamingManager = streamingManager
private val _backendHealthy = MutableStateFlow<Boolean?>(null)
val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow()
@@ -59,4 +67,13 @@ class DashboardViewModel @Inject constructor(
_defaultExecutionMode.value = mode
appPreferences.setDefaultExecutionMode(mode)
}
fun clearEndedPlans() {
viewModelScope.launch {
val ended = plans.value.filter { it.status == "ENDED" }
for (plan in ended) {
try { streamPlanRepository.deletePlan(plan.planId) } catch (_: Exception) {}
}
}
}
}

View File

@@ -220,13 +220,13 @@ private fun DestinationCard(
}
}
// Account picker (shows "YouTube - DisplayName" per account)
// Account picker (shows linked accounts + "Custom RTMP" option)
ExposedDropdownMenuBox(
expanded = accountExpanded,
onExpandedChange = { accountExpanded = it },
) {
OutlinedTextField(
value = destination.linkedAccountLabel,
value = destination.linkedAccountLabel.ifBlank { if (destination.isCustom) "Custom RTMP" else "" },
onValueChange = {},
readOnly = true,
label = { Text("Account") },
@@ -239,15 +239,39 @@ private fun DestinationCard(
expanded = accountExpanded,
onDismissRequest = { accountExpanded = false },
) {
DropdownMenuItem(
text = { Text("Custom RTMP") },
onClick = {
onUpdate(destination.copy(
isCustom = true,
linkedAccountId = "",
linkedAccountLabel = "",
))
accountExpanded = false
},
)
linkedAccounts.forEach { account ->
val label = "${account.serviceId} - ${account.displayName}"
DropdownMenuItem(
text = { Text(label) },
onClick = {
onUpdate(destination.copy(
linkedAccountId = account.id,
linkedAccountLabel = label,
))
if (account.serviceId == "CUSTOM_RTMP") {
onUpdate(destination.copy(
isCustom = true,
linkedAccountId = account.id,
linkedAccountLabel = label,
rtmpUrl = account.rtmpUrl ?: "",
streamKey = account.streamKey ?: "",
))
} else {
onUpdate(destination.copy(
isCustom = false,
linkedAccountId = account.id,
linkedAccountLabel = label,
rtmpUrl = "",
streamKey = "",
))
}
accountExpanded = false
},
)
@@ -255,6 +279,25 @@ private fun DestinationCard(
}
}
if (destination.isCustom) {
OutlinedTextField(
value = destination.rtmpUrl,
onValueChange = { onUpdate(destination.copy(rtmpUrl = it)) },
label = { Text("RTMP URL") },
placeholder = { Text("rtmp://192.168.1.60:1935/live") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = destination.streamKey,
onValueChange = { onUpdate(destination.copy(streamKey = it)) },
label = { Text("Stream Key") },
placeholder = { Text("test") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
OutlinedTextField(
value = destination.title,
onValueChange = { onUpdate(destination.copy(title = it)) },
@@ -263,52 +306,54 @@ private fun DestinationCard(
singleLine = true,
)
OutlinedTextField(
value = destination.description,
onValueChange = { onUpdate(destination.copy(description = it)) },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
// Privacy status
ExposedDropdownMenuBox(
expanded = privacyExpanded,
onExpandedChange = { privacyExpanded = it },
) {
if (!destination.isCustom) {
OutlinedTextField(
value = destination.privacyStatus,
onValueChange = {},
readOnly = true,
label = { Text("Privacy") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(privacyExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
value = destination.description,
onValueChange = { onUpdate(destination.copy(description = it)) },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
ExposedDropdownMenu(
// Privacy status
ExposedDropdownMenuBox(
expanded = privacyExpanded,
onDismissRequest = { privacyExpanded = false },
onExpandedChange = { privacyExpanded = it },
) {
listOf("public", "unlisted", "private").forEach { status ->
DropdownMenuItem(
text = { Text(status) },
onClick = {
onUpdate(destination.copy(privacyStatus = status))
privacyExpanded = false
},
)
OutlinedTextField(
value = destination.privacyStatus,
onValueChange = {},
readOnly = true,
label = { Text("Privacy") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(privacyExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(
expanded = privacyExpanded,
onDismissRequest = { privacyExpanded = false },
) {
listOf("public", "unlisted", "private").forEach { status ->
DropdownMenuItem(
text = { Text(status) },
onClick = {
onUpdate(destination.copy(privacyStatus = status))
privacyExpanded = false
},
)
}
}
}
}
OutlinedTextField(
value = destination.tags,
onValueChange = { onUpdate(destination.copy(tags = it)) },
label = { Text("Tags (comma-separated)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = destination.tags,
onValueChange = { onUpdate(destination.copy(tags = it)) },
label = { Text("Tags (comma-separated)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
}
}
}

View File

@@ -36,6 +36,9 @@ data class DestinationInput(
val privacyStatus: String = "public",
val gameId: String = "",
val tags: String = "",
val isCustom: Boolean = false,
val rtmpUrl: String = "",
val streamKey: String = "",
)
@HiltViewModel
@@ -137,6 +140,7 @@ class CreatePlanViewModel @Inject constructor(
_destinations.value = plan.destinations.map { dest ->
val account = accounts.find { it.id == dest.linkedAccountId }
val label = if (account != null) "${account.serviceId} - ${account.displayName}" else dest.service
val isCustomRtmp = account?.serviceId == "CUSTOM_RTMP"
DestinationInput(
linkedAccountId = dest.linkedAccountId,
linkedAccountLabel = label,
@@ -145,6 +149,9 @@ class CreatePlanViewModel @Inject constructor(
privacyStatus = dest.privacyStatus,
gameId = dest.gameId,
tags = dest.tags.joinToString(","),
isCustom = isCustomRtmp || dest.service == "CUSTOM",
rtmpUrl = if (isCustomRtmp) account?.rtmpUrl ?: dest.rtmpUrl else dest.rtmpUrl,
streamKey = if (isCustomRtmp) account?.streamKey ?: dest.streamKey else dest.streamKey,
)
}
} catch (e: Exception) {
@@ -193,9 +200,20 @@ class CreatePlanViewModel @Inject constructor(
_error.value = "Add at least one destination"
return
}
if (dests.any { it.linkedAccountId.isBlank() || it.title.isBlank() }) {
_error.value = "All destinations need an account and title"
return
for (dest in dests) {
if (dest.title.isBlank()) {
_error.value = "All destinations need a title"
return
}
if (dest.isCustom) {
if (dest.rtmpUrl.isBlank() || dest.streamKey.isBlank()) {
_error.value = "Custom destinations need RTMP URL and stream key"
return
}
} else if (dest.linkedAccountId.isBlank()) {
_error.value = "All destinations need an account (or use Custom RTMP)"
return
}
}
viewModelScope.launch {
@@ -204,16 +222,25 @@ class CreatePlanViewModel @Inject constructor(
try {
val accounts = linkedAccounts.value
val streamDests = dests.map { input ->
val account = accounts.find { it.id == input.linkedAccountId }
StreamDestination(
service = account?.serviceId ?: "",
linkedAccountId = input.linkedAccountId,
title = input.title,
description = input.description,
privacyStatus = input.privacyStatus,
gameId = input.gameId,
tags = input.tags.split(",").map { it.trim() }.filter { it.isNotBlank() },
)
if (input.isCustom) {
StreamDestination(
service = "CUSTOM",
title = input.title,
rtmpUrl = input.rtmpUrl,
streamKey = input.streamKey,
)
} else {
val account = accounts.find { it.id == input.linkedAccountId }
StreamDestination(
service = account?.serviceId ?: "",
linkedAccountId = input.linkedAccountId,
title = input.title,
description = input.description,
privacyStatus = input.privacyStatus,
gameId = input.gameId,
tags = input.tags.split(",").map { it.trim() }.filter { it.isNotBlank() },
)
}
}
val plan = if (isEditMode) {
streamPlanRepository.updatePlan(editingPlanId!!, name, streamDests, _executionMode.value, _gameId.value)

View File

@@ -167,14 +167,6 @@ fun PlanDetailScreen(
}
}
// 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 {
@@ -213,6 +205,21 @@ fun PlanDetailScreen(
}
}
// Stream preview + stats (when LIVE + APP_STREAMING)
if (currentPlan.status == "LIVE" &&
currentPlan.executionMode == "APP_STREAMING" &&
streamingState == StreamingState.LIVE
) {
item {
Text("Stream Preview", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
StreamPreviewSurface(streamingManager = viewModel.streamingManager)
}
item {
StreamingStatsCard(stats = streamingStats)
}
}
// Destinations
item {
Spacer(Modifier.height(8.dp))

View File

@@ -5,10 +5,11 @@ 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.shared.StreamingConfig
import com.omixlab.lckcontrol.streaming.StreamingManager
import com.omixlab.lckcontrol.util.GameInfoProvider
import com.omixlab.lckcontrol.streaming.StreamingState
import com.omixlab.lckcontrol.streaming.StreamingStats
import com.omixlab.lckcontrol.util.GameInfoProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -22,7 +23,7 @@ import javax.inject.Inject
class PlanDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val streamPlanRepository: StreamPlanRepository,
private val streamingManager: StreamingManager,
val streamingManager: StreamingManager,
val gameInfoProvider: GameInfoProvider,
) : ViewModel() {
@@ -67,6 +68,16 @@ class PlanDetailViewModel @Inject constructor(
_error.value = null
try {
streamPlanRepository.startPlan(planId)
// Start streaming engine for APP_STREAMING plans
val updated = streamPlanRepository.getPlan(planId)
if (updated?.executionMode == "APP_STREAMING") {
streamingManager.startStreaming(
plan = updated,
config = StreamingConfig(),
width = 1920,
height = 1080,
)
}
} catch (e: Exception) {
_error.value = e.message ?: "Failed to start plan"
} finally {
@@ -80,6 +91,10 @@ class PlanDetailViewModel @Inject constructor(
_isLoading.value = true
_error.value = null
try {
// Stop streaming engine if running
if (streamingManager.isStreaming()) {
streamingManager.stopStreaming()
}
streamPlanRepository.endPlan(planId)
} catch (e: Exception) {
_error.value = e.message ?: "Failed to end plan"

View File

@@ -0,0 +1,56 @@
package com.omixlab.lckcontrol.ui.plans
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.omixlab.lckcontrol.streaming.StreamingManager
@Composable
fun StreamPreviewSurface(
streamingManager: StreamingManager,
modifier: Modifier = Modifier,
) {
DisposableEffect(streamingManager) {
onDispose {
streamingManager.removePreviewSurface()
}
}
AndroidView(
factory = { context ->
SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
streamingManager.setPreviewSurface(holder.surface)
}
override fun surfaceChanged(
holder: SurfaceHolder,
format: Int,
width: Int,
height: Int,
) {
// Surface size changed — re-set to update dimensions
streamingManager.setPreviewSurface(holder.surface)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
streamingManager.removePreviewSurface()
}
})
}
},
modifier = modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(12.dp)),
)
}

View File

@@ -2,6 +2,7 @@ package com.omixlab.lckcontrol.util
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap
@@ -26,13 +27,23 @@ class GameInfoProvider @Inject constructor(
return cache.getOrPut(packageName) {
try {
val pm = context.packageManager
val appInfo = pm.getApplicationInfo(packageName, 0)
val appInfo = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
val label = pm.getApplicationLabel(appInfo).toString()
val icon = pm.getApplicationIcon(appInfo).toBitmap().asImageBitmap()
// Use loadIcon for higher density, fall back to getApplicationIcon
val drawable = appInfo.loadIcon(pm)
val size = (48 * context.resources.displayMetrics.density).toInt()
val icon = drawable.toBitmap(size, size).asImageBitmap()
Log.d("GameInfoProvider", "Resolved $packageName -> $label")
GameInfo(packageName, label, icon)
} catch (_: PackageManager.NameNotFoundException) {
Log.w("GameInfoProvider", "Package not found: $packageName")
null
}
}
}
/** Invalidate cache so icons are re-fetched on next resolve */
fun invalidate(packageName: String) {
cache.remove(packageName)
}
}