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

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

View File

@@ -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()