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