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