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

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

View File

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

View File

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