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:
2026-03-01 10:50:23 +01:00
parent c1ff5351b7
commit c632e22033
35 changed files with 2822 additions and 98 deletions

View File

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

View File

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