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:
@@ -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 ''")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -10,4 +10,5 @@ data class LinkedAccountEntity(
|
||||
val displayName: String,
|
||||
val accountId: String,
|
||||
val avatarUrl: String? = null,
|
||||
val isEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user