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

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ ovr-platform-util.exe
# Build counter
.buildcount
/.claude
# Tools
node_modules/

View File

@@ -7,6 +7,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Allow querying game packages for icon/label resolution -->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:name=".LckControlApp"
android:allowBackup="true"

View File

@@ -13,6 +13,7 @@ add_library(lck_streaming SHARED
rtmp_client.cpp
rtmp_sink.cpp
egl_context.cpp
composition_pipeline.cpp
streaming_engine.cpp
)

View File

@@ -0,0 +1,433 @@
#include "composition_pipeline.h"
#include <android/log.h>
#include <algorithm>
#include <cstring>
#define TAG "LckComposition"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
// --- Shaders ---
static const char* BASE_VERTEX_SHADER = R"(#version 300 es
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aTexCoord;
out vec2 vTexCoord;
void main() {
gl_Position = vec4(aPos, 0.0, 1.0);
vTexCoord = aTexCoord;
}
)";
// Base pass: renders game frame (OES texture) full-screen to FBO
static const char* BASE_FRAGMENT_SHADER = R"(#version 300 es
#extension GL_OES_EGL_image_external_essl3 : require
precision mediump float;
in vec2 vTexCoord;
out vec4 fragColor;
uniform samplerExternalOES uTexture;
void main() {
fragColor = texture(uTexture, vTexCoord);
}
)";
// Overlay pass: renders 2D layers with MVP transform and opacity
static const char* OVERLAY_VERTEX_SHADER = R"(#version 300 es
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aTexCoord;
uniform mat4 uMVP;
out vec2 vTexCoord;
void main() {
gl_Position = uMVP * vec4(aPos, 0.0, 1.0);
vTexCoord = aTexCoord;
}
)";
static const char* OVERLAY_FRAGMENT_SHADER = R"(#version 300 es
precision mediump float;
in vec2 vTexCoord;
out vec4 fragColor;
uniform sampler2D uTexture;
uniform float uOpacity;
void main() {
vec4 color = texture(uTexture, vTexCoord);
fragColor = vec4(color.rgb, color.a * uOpacity);
}
)";
// Standby pattern: dark gradient with color bars to prove the pipeline is alive
static const char* STANDBY_FRAGMENT_SHADER = R"(#version 300 es
precision mediump float;
in vec2 vTexCoord;
out vec4 fragColor;
void main() {
// Vertical color bars
float x = vTexCoord.x;
vec3 color;
if (x < 0.125) color = vec3(0.75, 0.75, 0.75); // white-ish
else if (x < 0.250) color = vec3(0.75, 0.75, 0.0); // yellow
else if (x < 0.375) color = vec3(0.0, 0.75, 0.75); // cyan
else if (x < 0.500) color = vec3(0.0, 0.75, 0.0); // green
else if (x < 0.625) color = vec3(0.75, 0.0, 0.75); // magenta
else if (x < 0.750) color = vec3(0.75, 0.0, 0.0); // red
else if (x < 0.875) color = vec3(0.0, 0.0, 0.75); // blue
else color = vec3(0.15, 0.15, 0.15); // dark gray
// Darken bottom third
float brightness = vTexCoord.y > 0.33 ? 1.0 : 0.5;
fragColor = vec4(color * brightness, 1.0);
}
)";
// --- Helpers ---
static GLuint CompileShader(GLenum type, const char* source) {
GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint status;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (!status) {
char log[512];
glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
LOGE("Shader compile error: %s", log);
glDeleteShader(shader);
return 0;
}
return shader;
}
static GLuint LinkProgram(GLuint vs, GLuint fs) {
GLuint program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);
glDeleteShader(vs);
glDeleteShader(fs);
GLint status;
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (!status) {
char log[512];
glGetProgramInfoLog(program, sizeof(log), nullptr, log);
LOGE("Program link error: %s", log);
glDeleteProgram(program);
return 0;
}
return program;
}
// Identity 4x4 matrix
static void Mat4Identity(float* m) {
memset(m, 0, 16 * sizeof(float));
m[0] = m[5] = m[10] = m[15] = 1.0f;
}
// --- CompositionPipeline ---
CompositionPipeline::CompositionPipeline() {}
CompositionPipeline::~CompositionPipeline() {
Release();
}
bool CompositionPipeline::Init(int width, int height) {
if (initialized) Release();
fboWidth = width;
fboHeight = height;
// Create FBO color attachment texture (GL_RGBA8)
glGenTextures(1, &fboTexture);
glBindTexture(GL_TEXTURE_2D, fboTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, fboWidth, fboHeight, 0,
GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// Create FBO
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, fboTexture, 0);
GLenum fboStatus = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (fboStatus != GL_FRAMEBUFFER_COMPLETE) {
LOGE("FBO incomplete: 0x%x", fboStatus);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
Release();
return false;
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// Compile base pass program (OES)
{
GLuint vs = CompileShader(GL_VERTEX_SHADER, BASE_VERTEX_SHADER);
GLuint fs = CompileShader(GL_FRAGMENT_SHADER, BASE_FRAGMENT_SHADER);
if (!vs || !fs) { Release(); return false; }
baseProgram = LinkProgram(vs, fs);
if (!baseProgram) { Release(); return false; }
}
// Compile overlay program (sampler2D + MVP + opacity)
{
GLuint vs = CompileShader(GL_VERTEX_SHADER, OVERLAY_VERTEX_SHADER);
GLuint fs = CompileShader(GL_FRAGMENT_SHADER, OVERLAY_FRAGMENT_SHADER);
if (!vs || !fs) { Release(); return false; }
overlayProgram = LinkProgram(vs, fs);
if (!overlayProgram) { Release(); return false; }
}
// Compile standby pattern program
{
GLuint vs = CompileShader(GL_VERTEX_SHADER, BASE_VERTEX_SHADER);
GLuint fs = CompileShader(GL_FRAGMENT_SHADER, STANDBY_FRAGMENT_SHADER);
if (!vs || !fs) { Release(); return false; }
standbyProgram = LinkProgram(vs, fs);
if (!standbyProgram) { Release(); return false; }
}
overlayMvpLoc = glGetUniformLocation(overlayProgram, "uMVP");
overlayOpacityLoc = glGetUniformLocation(overlayProgram, "uOpacity");
overlayTexLoc = glGetUniformLocation(overlayProgram, "uTexture");
// Create shared full-screen quad VAO: pos(x,y) + texcoord(u,v)
float quad[] = {
-1.0f, -1.0f, 0.0f, 0.0f,
1.0f, -1.0f, 1.0f, 0.0f,
-1.0f, 1.0f, 0.0f, 1.0f,
1.0f, 1.0f, 1.0f, 1.0f,
};
glGenVertexArrays(1, &quadVao);
glGenBuffers(1, &quadVbo);
glBindVertexArray(quadVao);
glBindBuffer(GL_ARRAY_BUFFER, quadVbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float)));
glEnableVertexAttribArray(1);
glBindVertexArray(0);
initialized = true;
LOGI("Composition pipeline initialized: %dx%d", fboWidth, fboHeight);
return true;
}
void CompositionPipeline::Compose(GLuint srcOesTexture) {
if (!initialized) return;
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glViewport(0, 0, fboWidth, fboHeight);
glClearColor(0.05f, 0.05f, 0.05f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// Base pass: render game frame, or standby pattern if no game input
if (srcOesTexture != 0) {
RenderBasePass(srcOesTexture);
} else {
RenderStandbyPattern();
}
// Overlay pass: render layers sorted by zOrder
RenderOverlayLayers();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
void CompositionPipeline::RenderBasePass(GLuint srcOesTexture) {
glUseProgram(baseProgram);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_EXTERNAL_OES, srcOesTexture);
glUniform1i(glGetUniformLocation(baseProgram, "uTexture"), 0);
glBindVertexArray(quadVao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
}
void CompositionPipeline::RenderStandbyPattern() {
glUseProgram(standbyProgram);
glBindVertexArray(quadVao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
}
void CompositionPipeline::RenderOverlayLayers() {
// Take a snapshot of layers under lock
std::vector<CompositionLayer> snapshot;
{
std::lock_guard<std::mutex> lock(layerMutex);
snapshot = layers;
}
// Sort by zOrder
std::sort(snapshot.begin(), snapshot.end(),
[](const CompositionLayer& a, const CompositionLayer& b) {
return a.zOrder < b.zOrder;
});
// Enable alpha blending
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glUseProgram(overlayProgram);
for (const auto& layer : snapshot) {
if (!layer.enabled || layer.textureId == 0 || layer.opacity <= 0.0f) continue;
float mvp[16];
BuildTransformMatrix(layer, mvp);
glUniformMatrix4fv(overlayMvpLoc, 1, GL_FALSE, mvp);
glUniform1f(overlayOpacityLoc, layer.opacity);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, layer.textureId);
glUniform1i(overlayTexLoc, 0);
glBindVertexArray(quadVao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
}
glDisable(GL_BLEND);
}
void CompositionPipeline::BuildTransformMatrix(const CompositionLayer& layer, float* m) {
// Build 2D transform matrix in NDC space.
// The quad is [-1,1] full-screen. We need to scale it to layer size
// relative to FBO, then apply position/rotation.
const auto& t = layer.transform;
// Layer size in NDC (layer pixel size / FBO pixel size → fraction → *2 for NDC range)
float layerW = (float)layer.texWidth / (float)fboWidth;
float layerH = (float)layer.texHeight / (float)fboHeight;
float sx = layerW * t.scaleX;
float sy = layerH * t.scaleY;
float cosR = cosf(t.rotation);
float sinR = sinf(t.rotation);
// Build: Translate(pos) * Rotate(r) * Scale(s)
// Column-major 4x4
Mat4Identity(m);
m[0] = sx * cosR;
m[1] = sx * sinR;
m[4] = -sy * sinR;
m[5] = sy * cosR;
m[12] = t.posX;
m[13] = t.posY;
}
int CompositionPipeline::AddLayer(GLuint textureId, int texW, int texH,
const CompositionTransform& transform,
float opacity, int zOrder, const std::string& tag,
bool ownsTexture) {
std::lock_guard<std::mutex> lock(layerMutex);
CompositionLayer layer;
layer.id = nextLayerId++;
layer.textureId = textureId;
layer.texWidth = texW;
layer.texHeight = texH;
layer.transform = transform;
layer.opacity = opacity;
layer.zOrder = zOrder;
layer.tag = tag;
layer.ownsTexture = ownsTexture;
layers.push_back(layer);
LOGI("Added composition layer %d ('%s') z=%d", layer.id, tag.c_str(), zOrder);
return layer.id;
}
void CompositionPipeline::RemoveLayer(int layerId) {
std::lock_guard<std::mutex> lock(layerMutex);
for (auto it = layers.begin(); it != layers.end(); ++it) {
if (it->id == layerId) {
if (it->ownsTexture && it->textureId) {
glDeleteTextures(1, &it->textureId);
}
LOGI("Removed composition layer %d ('%s')", it->id, it->tag.c_str());
layers.erase(it);
return;
}
}
LOGW("RemoveLayer: layer %d not found", layerId);
}
void CompositionPipeline::UpdateLayerTransform(int layerId, const CompositionTransform& transform) {
std::lock_guard<std::mutex> lock(layerMutex);
for (auto& layer : layers) {
if (layer.id == layerId) {
layer.transform = transform;
return;
}
}
}
void CompositionPipeline::UpdateLayerOpacity(int layerId, float opacity) {
std::lock_guard<std::mutex> lock(layerMutex);
for (auto& layer : layers) {
if (layer.id == layerId) {
layer.opacity = opacity;
return;
}
}
}
void CompositionPipeline::SetLayerEnabled(int layerId, bool enabled) {
std::lock_guard<std::mutex> lock(layerMutex);
for (auto& layer : layers) {
if (layer.id == layerId) {
layer.enabled = enabled;
return;
}
}
}
GLuint CompositionPipeline::UploadTexture(const uint8_t* rgbaData, int w, int h) {
if (!rgbaData || w <= 0 || h <= 0) return 0;
GLuint tex;
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0,
GL_RGBA, GL_UNSIGNED_BYTE, rgbaData);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
return tex;
}
void CompositionPipeline::Release() {
// Delete owned layer textures
{
std::lock_guard<std::mutex> lock(layerMutex);
for (auto& layer : layers) {
if (layer.ownsTexture && layer.textureId) {
glDeleteTextures(1, &layer.textureId);
}
}
layers.clear();
}
if (quadVao) { glDeleteVertexArrays(1, &quadVao); quadVao = 0; }
if (quadVbo) { glDeleteBuffers(1, &quadVbo); quadVbo = 0; }
if (baseProgram) { glDeleteProgram(baseProgram); baseProgram = 0; }
if (overlayProgram) { glDeleteProgram(overlayProgram); overlayProgram = 0; }
if (standbyProgram) { glDeleteProgram(standbyProgram); standbyProgram = 0; }
if (fbo) { glDeleteFramebuffers(1, &fbo); fbo = 0; }
if (fboTexture) { glDeleteTextures(1, &fboTexture); fboTexture = 0; }
initialized = false;
LOGI("Composition pipeline released");
}

View File

@@ -0,0 +1,108 @@
#pragma once
#include <GLES3/gl3.h>
#include <GLES2/gl2ext.h>
#include <cmath>
#include <mutex>
#include <string>
#include <vector>
struct CompositionTransform {
float posX = 0.0f; // NDC [-1, 1]
float posY = 0.0f;
float scaleX = 1.0f;
float scaleY = 1.0f;
float rotation = 0.0f; // radians
float anchorX = 0.5f; // [0, 1] within layer
float anchorY = 0.5f;
};
struct CompositionLayer {
int id = -1;
GLuint textureId = 0; // GL_TEXTURE_2D
int texWidth = 0;
int texHeight = 0;
CompositionTransform transform;
float opacity = 1.0f;
bool enabled = true;
int zOrder = 0;
std::string tag;
bool ownsTexture = true; // if true, pipeline deletes texture on removal
};
/**
* GPU composition pipeline.
* Renders a base OES texture (game frame) plus overlay layers to an FBO,
* producing a GL_TEXTURE_2D that can be blit to encoder and preview surfaces.
*/
class CompositionPipeline {
public:
CompositionPipeline();
~CompositionPipeline();
/** Initialize FBO, shaders, and quad geometry at encoder resolution. */
bool Init(int width, int height);
/**
* Compose the scene: render base OES texture + overlay layers to FBO.
* Must be called on the GL thread.
*/
void Compose(GLuint srcOesTexture);
/** Returns the FBO color attachment (GL_TEXTURE_2D). */
GLuint GetComposedTexture() const { return fboTexture; }
/** Layer management — thread-safe. */
int AddLayer(GLuint textureId, int texW, int texH,
const CompositionTransform& transform,
float opacity, int zOrder, const std::string& tag,
bool ownsTexture = true);
void RemoveLayer(int layerId);
void UpdateLayerTransform(int layerId, const CompositionTransform& transform);
void UpdateLayerOpacity(int layerId, float opacity);
void SetLayerEnabled(int layerId, bool enabled);
/** Upload raw RGBA pixels as a GL_TEXTURE_2D. Returns texture ID (0 on failure). */
static GLuint UploadTexture(const uint8_t* rgbaData, int w, int h);
/** Release all GL resources. Must be called on the GL thread. */
void Release();
bool IsInitialized() const { return initialized; }
private:
void RenderBasePass(GLuint srcOesTexture);
void RenderStandbyPattern();
void RenderOverlayLayers();
void BuildTransformMatrix(const CompositionLayer& layer, float* outMat4);
// Standby pattern shader
GLuint standbyProgram = 0;
int fboWidth = 0;
int fboHeight = 0;
bool initialized = false;
// FBO
GLuint fbo = 0;
GLuint fboTexture = 0; // RGBA8 color attachment
// Shaders
GLuint baseProgram = 0; // samplerExternalOES for game frame
GLuint overlayProgram = 0; // sampler2D + uMVP + uOpacity for layers
// Shared quad VAO
GLuint quadVao = 0;
GLuint quadVbo = 0;
// Overlay uniform locations
GLint overlayMvpLoc = -1;
GLint overlayOpacityLoc = -1;
GLint overlayTexLoc = -1;
// Layers (protected by mutex)
std::mutex layerMutex;
std::vector<CompositionLayer> layers;
int nextLayerId = 1;
};

View File

@@ -1,6 +1,7 @@
#include "egl_context.h"
#include <android/log.h>
#include <android/native_window.h>
#include <unistd.h>
#define TAG "LckEglContext"
@@ -201,9 +202,58 @@ bool EglContext::SwapBuffers() {
return eglSwapBuffers(display, surface) == EGL_TRUE;
}
bool EglContext::CreatePreviewSurface(ANativeWindow* window) {
if (!window || display == EGL_NO_DISPLAY) return false;
DestroyPreviewSurface();
previewSurface = eglCreateWindowSurface(display, config, window, nullptr);
if (previewSurface == EGL_NO_SURFACE) {
LOGE("eglCreateWindowSurface (preview) failed: 0x%x", eglGetError());
return false;
}
previewWindow = window;
eglQuerySurface(display, previewSurface, EGL_WIDTH, &previewWidth);
eglQuerySurface(display, previewSurface, EGL_HEIGHT, &previewHeight);
LOGI("Preview surface created: %dx%d", previewWidth, previewHeight);
return true;
}
void EglContext::DestroyPreviewSurface() {
if (previewSurface != EGL_NO_SURFACE && display != EGL_NO_DISPLAY) {
// Make sure preview isn't current before destroying
eglMakeCurrent(display, surface, surface, context);
eglDestroySurface(display, previewSurface);
previewSurface = EGL_NO_SURFACE;
LOGI("Preview surface destroyed");
}
if (previewWindow) {
ANativeWindow_release(previewWindow);
previewWindow = nullptr;
}
previewWidth = 0;
previewHeight = 0;
}
bool EglContext::MakePreviewCurrent() {
if (previewSurface == EGL_NO_SURFACE) return false;
return eglMakeCurrent(display, previewSurface, previewSurface, context) == EGL_TRUE;
}
bool EglContext::MakeEncoderCurrent() {
return eglMakeCurrent(display, surface, surface, context) == EGL_TRUE;
}
bool EglContext::SwapPreviewBuffers() {
if (previewSurface == EGL_NO_SURFACE) return false;
return eglSwapBuffers(display, previewSurface) == EGL_TRUE;
}
void EglContext::Release() {
if (display != EGL_NO_DISPLAY) {
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
DestroyPreviewSurface();
if (surface != EGL_NO_SURFACE) {
eglDestroySurface(display, surface);
surface = EGL_NO_SURFACE;

View File

@@ -39,6 +39,25 @@ public:
/** Swap buffers on the window surface. */
bool SwapBuffers();
/** Create a preview surface from an ANativeWindow. Shares the same EGLContext. */
bool CreatePreviewSurface(ANativeWindow* window);
/** Destroy the preview surface. */
void DestroyPreviewSurface();
/** Make the preview surface current. */
bool MakePreviewCurrent();
/** Make the encoder surface current (restores after preview). */
bool MakeEncoderCurrent();
/** Swap buffers on the preview surface. */
bool SwapPreviewBuffers();
bool HasPreviewSurface() const { return previewSurface != EGL_NO_SURFACE; }
int GetPreviewWidth() const { return previewWidth; }
int GetPreviewHeight() const { return previewHeight; }
/** Release all EGL resources. */
void Release();
@@ -55,6 +74,12 @@ private:
int surfaceWidth = 0;
int surfaceHeight = 0;
// Preview surface (shares EGLContext with encoder surface)
EGLSurface previewSurface = EGL_NO_SURFACE;
ANativeWindow* previewWindow = nullptr;
int previewWidth = 0;
int previewHeight = 0;
// Extension function pointers
PFNEGLCREATESYNCKHRPROC eglCreateSyncKHR = nullptr;
PFNEGLWAITSYNCKHRPROC eglWaitSyncKHR = nullptr;

View File

@@ -2,6 +2,7 @@
#include <jni.h>
#include <android/hardware_buffer_jni.h>
#include <android/native_window_jni.h>
#include <android/log.h>
#define TAG "LckJniBridge"
@@ -159,4 +160,85 @@ Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeIsRunning(
return engine->IsRunning() ? JNI_TRUE : JNI_FALSE;
}
// --- Preview surface ---
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeSetPreviewSurface(
JNIEnv* env, jobject thiz, jlong ptr, jobject surface) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine || !surface) return;
ANativeWindow* window = ANativeWindow_fromSurface(env, surface);
if (window) {
engine->SetPreviewSurface(window);
ANativeWindow_release(window); // SetPreviewSurface acquires its own ref
}
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeRemovePreviewSurface(
JNIEnv* env, jobject thiz, jlong ptr) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->RemovePreviewSurface();
}
// --- Composition layers ---
JNIEXPORT jint JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeAddCompositionLayer(
JNIEnv* env, jobject thiz, jlong ptr,
jbyteArray rgbaData, jint w, jint h,
jfloat posX, jfloat posY, jfloat scaleX, jfloat scaleY,
jfloat rotation, jfloat opacity, jint zOrder, jstring tag) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return -1;
jsize len = env->GetArrayLength(rgbaData);
jbyte* data = env->GetByteArrayElements(rgbaData, nullptr);
const char* tagStr = env->GetStringUTFChars(tag, nullptr);
int layerId = engine->AddCompositionLayer(
reinterpret_cast<const uint8_t*>(data), w, h,
posX, posY, scaleX, scaleY, rotation, opacity, zOrder,
std::string(tagStr));
env->ReleaseStringUTFChars(tag, tagStr);
env->ReleaseByteArrayElements(rgbaData, data, JNI_ABORT);
return layerId;
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeRemoveCompositionLayer(
JNIEnv* env, jobject thiz, jlong ptr, jint layerId) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->RemoveCompositionLayer(layerId);
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeUpdateCompositionLayerTransform(
JNIEnv* env, jobject thiz, jlong ptr, jint layerId,
jfloat posX, jfloat posY, jfloat scaleX, jfloat scaleY, jfloat rotation) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->UpdateCompositionLayerTransform(layerId, posX, posY, scaleX, scaleY, rotation);
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeUpdateCompositionLayerOpacity(
JNIEnv* env, jobject thiz, jlong ptr, jint layerId, jfloat opacity) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->UpdateCompositionLayerOpacity(layerId, opacity);
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeSetCompositionLayerEnabled(
JNIEnv* env, jobject thiz, jlong ptr, jint layerId, jboolean enabled) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->SetCompositionLayerEnabled(layerId, enabled == JNI_TRUE);
}
} // extern "C"

View File

@@ -12,7 +12,7 @@
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
// Shader source for blitting OES texture to framebuffer
// Shader source for blitting OES texture (kept for legacy/direct path)
static const char* BLIT_VERTEX_SHADER = R"(#version 300 es
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aTexCoord;
@@ -34,6 +34,17 @@ void main() {
}
)";
// Blit FBO program: renders composed GL_TEXTURE_2D to a surface
static const char* BLIT_FBO_FRAGMENT_SHADER = R"(#version 300 es
precision mediump float;
in vec2 vTexCoord;
out vec4 fragColor;
uniform sampler2D uTexture;
void main() {
fragColor = texture(uTexture, vTexCoord);
}
)";
static GLuint CompileShader(GLenum type, const char* source) {
GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &source, nullptr);
@@ -190,6 +201,29 @@ bool StreamingEngine::InitBlitResources() {
return false;
}
// Compile blit FBO program (sampler2D for composed texture → surface)
{
GLuint fboVs = CompileShader(GL_VERTEX_SHADER, BLIT_VERTEX_SHADER);
GLuint fboFs = CompileShader(GL_FRAGMENT_SHADER, BLIT_FBO_FRAGMENT_SHADER);
if (!fboVs || !fboFs) return false;
blitFboProgram = glCreateProgram();
glAttachShader(blitFboProgram, fboVs);
glAttachShader(blitFboProgram, fboFs);
glLinkProgram(blitFboProgram);
glDeleteShader(fboVs);
glDeleteShader(fboFs);
GLint fboLinkStatus;
glGetProgramiv(blitFboProgram, GL_LINK_STATUS, &fboLinkStatus);
if (!fboLinkStatus) {
LOGE("Blit FBO program link failed");
glDeleteProgram(blitFboProgram);
blitFboProgram = 0;
return false;
}
}
// Full-screen quad: pos(x,y) + texcoord(u,v)
float quad[] = {
-1.0f, -1.0f, 0.0f, 0.0f,
@@ -209,13 +243,21 @@ bool StreamingEngine::InitBlitResources() {
glEnableVertexAttribArray(1);
glBindVertexArray(0);
// Initialize composition pipeline at encoder resolution
if (!compositionPipeline.Init(width, height)) {
LOGE("Composition pipeline init failed");
return false;
}
return true;
}
void StreamingEngine::ReleaseBlitResources() {
compositionPipeline.Release();
if (blitVao) { glDeleteVertexArrays(1, &blitVao); blitVao = 0; }
if (blitVbo) { glDeleteBuffers(1, &blitVbo); blitVbo = 0; }
if (blitProgram) { glDeleteProgram(blitProgram); blitProgram = 0; }
if (blitFboProgram) { glDeleteProgram(blitFboProgram); blitFboProgram = 0; }
}
bool StreamingEngine::Start() {
@@ -234,6 +276,7 @@ bool StreamingEngine::Start() {
running.store(true);
firstVideoFrame = true;
startTimestampNs = 0;
lastComposeTimeNs = 0;
statsVideoBytes = 0;
statsAudioBytes = 0;
statsFrameCount = 0;
@@ -309,15 +352,64 @@ void StreamingEngine::EncoderThreadFunc() {
// Main encoder loop
while (running.load()) {
// Process pending preview and layer ops (must run on GL thread)
ProcessPendingPreviewOps();
ProcessPendingLayerOps();
// Process video frames
bool hadVideoFrames = false;
{
std::lock_guard<std::mutex> lock(videoMutex);
hadVideoFrames = !videoQueue.empty();
for (auto& frame : videoQueue) {
ProcessVideoFrame(frame);
}
videoQueue.clear();
}
// Generate standby frames when no game input arrives
if (!hadVideoFrames && compositionPipeline.IsInitialized()) {
auto now = std::chrono::steady_clock::now().time_since_epoch();
int64_t nowNs = std::chrono::duration_cast<std::chrono::nanoseconds>(now).count();
int64_t frameIntervalNs = 1000000000LL / framerate;
if (nowNs - lastComposeTimeNs >= frameIntervalNs) {
// Compose standby frame (dark background + overlays, no game texture)
compositionPipeline.Compose(0);
GLuint composedTex = compositionPipeline.GetComposedTexture();
eglContext.MakeEncoderCurrent();
BlitComposedToSurface(composedTex, width, height);
if (firstVideoFrame) {
startTimestampNs = nowNs;
firstVideoFrame = false;
}
eglContext.SetPresentationTime(nowNs - startTimestampNs);
eglContext.SwapBuffers();
if (hasPreview && eglContext.HasPreviewSurface()) {
eglContext.MakePreviewCurrent();
BlitComposedToSurface(composedTex,
eglContext.GetPreviewWidth(),
eglContext.GetPreviewHeight());
eglContext.SwapPreviewBuffers();
eglContext.MakeEncoderCurrent();
}
// Generate silence audio to keep the audio track alive
if (audioEncoder) {
// 1 video frame at 30fps = 1/30s ≈ 1600 samples at 48kHz
int samplesPerFrame = sampleRate / framerate;
int bytesPerFrame = samplesPerFrame * channels * 2; // 16-bit PCM
AudioFrame silenceFrame;
silenceFrame.pcmData.resize(bytesPerFrame, 0);
silenceFrame.timestampNs = nowNs;
ProcessAudioFrame(silenceFrame);
}
lastComposeTimeNs = nowNs;
}
}
// Process audio frames
{
std::lock_guard<std::mutex> lock(audioMutex);
@@ -333,6 +425,9 @@ void StreamingEngine::EncoderThreadFunc() {
DrainAudioEncoder();
}
// Update stats every second regardless of frame output
UpdateStats();
// Don't spin-wait
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
@@ -341,6 +436,8 @@ void StreamingEngine::EncoderThreadFunc() {
LOGI("Encoder thread shutting down");
ReleaseBlitResources();
eglContext.DestroyPreviewSurface();
hasPreview = false;
for (auto* sink : sinks) {
sink->Close();
@@ -376,34 +473,51 @@ void StreamingEngine::ProcessVideoFrame(const VideoFrame& frame) {
// Wait on GPU fence
eglContext.WaitFence(frame.fenceFd);
// Import HardwareBuffer as GL texture
// Import HardwareBuffer as OES texture
GLuint texture = eglContext.ImportHardwareBuffer(frame.buffer);
if (texture == 0) {
LOGW("Failed to import HardwareBuffer as texture");
return;
}
// Blit to encoder surface
BlitToEncoder(texture, frame.timestampNs);
// Compose: game frame + overlay layers → FBO
compositionPipeline.Compose(texture);
GLuint composedTex = compositionPipeline.GetComposedTexture();
// Clean up texture
// Blit composed texture → encoder surface
eglContext.MakeEncoderCurrent();
BlitComposedToSurface(composedTex, width, height);
eglContext.SetPresentationTime(frame.timestampNs);
eglContext.SwapBuffers();
// Blit composed texture → preview surface (if active)
if (hasPreview && eglContext.HasPreviewSurface()) {
eglContext.MakePreviewCurrent();
BlitComposedToSurface(composedTex, eglContext.GetPreviewWidth(),
eglContext.GetPreviewHeight());
eglContext.SwapPreviewBuffers();
eglContext.MakeEncoderCurrent();
}
// Clean up imported texture
glDeleteTextures(1, &texture);
// Track compose time so standby frames don't overlap
auto now = std::chrono::steady_clock::now().time_since_epoch();
lastComposeTimeNs = std::chrono::duration_cast<std::chrono::nanoseconds>(now).count();
}
void StreamingEngine::BlitToEncoder(GLuint srcTexture, int64_t timestampNs) {
glViewport(0, 0, width, height);
void StreamingEngine::BlitComposedToSurface(GLuint composedTex, int viewportW, int viewportH) {
glViewport(0, 0, viewportW, viewportH);
glUseProgram(blitProgram);
glUseProgram(blitFboProgram);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_EXTERNAL_OES, srcTexture);
glUniform1i(glGetUniformLocation(blitProgram, "uTexture"), 0);
glBindTexture(GL_TEXTURE_2D, composedTex);
glUniform1i(glGetUniformLocation(blitFboProgram, "uTexture"), 0);
glBindVertexArray(blitVao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
eglContext.SetPresentationTime(timestampNs);
eglContext.SwapBuffers();
}
void StreamingEngine::ProcessAudioFrame(const AudioFrame& frame) {
@@ -464,8 +578,6 @@ void StreamingEngine::DrainVideoEncoder() {
}
AMediaCodec_releaseOutputBuffer(videoEncoder, outputIndex, false);
UpdateStats();
}
if (outputIndex == AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED) {
@@ -585,3 +697,133 @@ void StreamingEngine::SetErrorCallback(ErrorCallback callback) {
void StreamingEngine::SetBufferReleasedCallback(BufferReleasedCallback callback) {
bufferReleasedCallback = std::move(callback);
}
// --- Preview surface ---
void StreamingEngine::SetPreviewSurface(ANativeWindow* window) {
if (!window) return;
ANativeWindow_acquire(window);
std::lock_guard<std::mutex> lock(previewMutex);
pendingPreviewOps.push_back(PreviewSetOp{window});
}
void StreamingEngine::RemovePreviewSurface() {
std::lock_guard<std::mutex> lock(previewMutex);
pendingPreviewOps.push_back(PreviewRemoveOp{});
}
void StreamingEngine::ProcessPendingPreviewOps() {
std::vector<PreviewOp> ops;
{
std::lock_guard<std::mutex> lock(previewMutex);
ops.swap(pendingPreviewOps);
}
for (auto& op : ops) {
if (auto* setOp = std::get_if<PreviewSetOp>(&op)) {
eglContext.DestroyPreviewSurface();
if (eglContext.CreatePreviewSurface(setOp->window)) {
hasPreview = true;
LOGI("Preview surface set");
} else {
ANativeWindow_release(setOp->window);
hasPreview = false;
}
// MakeEncoderCurrent since CreatePreviewSurface may change current
eglContext.MakeEncoderCurrent();
} else if (std::get_if<PreviewRemoveOp>(&op)) {
eglContext.DestroyPreviewSurface();
hasPreview = false;
eglContext.MakeEncoderCurrent();
LOGI("Preview surface removed");
}
}
}
// --- Composition layer management ---
int StreamingEngine::AddCompositionLayer(const uint8_t* rgbaData, int w, int h,
float posX, float posY,
float scaleX, float scaleY,
float rotation, float opacity, int zOrder,
const std::string& tag) {
int id = nextLayerId.fetch_add(1);
LayerAddOp addOp;
addOp.rgbaData.assign(rgbaData, rgbaData + (w * h * 4));
addOp.w = w;
addOp.h = h;
addOp.posX = posX;
addOp.posY = posY;
addOp.scaleX = scaleX;
addOp.scaleY = scaleY;
addOp.rotation = rotation;
addOp.opacity = opacity;
addOp.zOrder = zOrder;
addOp.tag = tag;
addOp.assignedId = id;
std::lock_guard<std::mutex> lock(layerOpMutex);
pendingLayerOps.push_back(std::move(addOp));
return id;
}
void StreamingEngine::RemoveCompositionLayer(int layerId) {
std::lock_guard<std::mutex> lock(layerOpMutex);
pendingLayerOps.push_back(LayerRemoveOp{layerId});
}
void StreamingEngine::UpdateCompositionLayerTransform(int layerId, float posX, float posY,
float scaleX, float scaleY,
float rotation) {
std::lock_guard<std::mutex> lock(layerOpMutex);
pendingLayerOps.push_back(LayerTransformOp{layerId, posX, posY, scaleX, scaleY, rotation});
}
void StreamingEngine::UpdateCompositionLayerOpacity(int layerId, float opacity) {
std::lock_guard<std::mutex> lock(layerOpMutex);
pendingLayerOps.push_back(LayerOpacityOp{layerId, opacity});
}
void StreamingEngine::SetCompositionLayerEnabled(int layerId, bool enabled) {
std::lock_guard<std::mutex> lock(layerOpMutex);
pendingLayerOps.push_back(LayerEnabledOp{layerId, enabled});
}
void StreamingEngine::ProcessPendingLayerOps() {
std::vector<LayerOp> ops;
{
std::lock_guard<std::mutex> lock(layerOpMutex);
ops.swap(pendingLayerOps);
}
for (auto& op : ops) {
if (auto* addOp = std::get_if<LayerAddOp>(&op)) {
GLuint tex = CompositionPipeline::UploadTexture(
addOp->rgbaData.data(), addOp->w, addOp->h);
if (tex) {
CompositionTransform transform;
transform.posX = addOp->posX;
transform.posY = addOp->posY;
transform.scaleX = addOp->scaleX;
transform.scaleY = addOp->scaleY;
transform.rotation = addOp->rotation;
compositionPipeline.AddLayer(tex, addOp->w, addOp->h, transform,
addOp->opacity, addOp->zOrder, addOp->tag);
}
} else if (auto* removeOp = std::get_if<LayerRemoveOp>(&op)) {
compositionPipeline.RemoveLayer(removeOp->layerId);
} else if (auto* transformOp = std::get_if<LayerTransformOp>(&op)) {
CompositionTransform t;
t.posX = transformOp->posX;
t.posY = transformOp->posY;
t.scaleX = transformOp->scaleX;
t.scaleY = transformOp->scaleY;
t.rotation = transformOp->rotation;
compositionPipeline.UpdateLayerTransform(transformOp->layerId, t);
} else if (auto* opacityOp = std::get_if<LayerOpacityOp>(&op)) {
compositionPipeline.UpdateLayerOpacity(opacityOp->layerId, opacityOp->opacity);
} else if (auto* enabledOp = std::get_if<LayerEnabledOp>(&op)) {
compositionPipeline.SetLayerEnabled(enabledOp->layerId, enabledOp->enabled);
}
}
}

View File

@@ -1,6 +1,7 @@
#pragma once
#include "egl_context.h"
#include "composition_pipeline.h"
#include "rtmp_sink.h"
#include <media/NdkMediaCodec.h>
@@ -14,6 +15,7 @@
#include <mutex>
#include <string>
#include <thread>
#include <variant>
#include <vector>
struct VideoFrame {
@@ -75,6 +77,21 @@ public:
bool IsRunning() const { return running.load(); }
// Preview surface (thread-safe, enqueued for GL thread)
void SetPreviewSurface(ANativeWindow* window);
void RemovePreviewSurface();
// Composition layer management (thread-safe, enqueued for GL thread)
int AddCompositionLayer(const uint8_t* rgbaData, int w, int h,
float posX, float posY, float scaleX, float scaleY,
float rotation, float opacity, int zOrder,
const std::string& tag);
void RemoveCompositionLayer(int layerId);
void UpdateCompositionLayerTransform(int layerId, float posX, float posY,
float scaleX, float scaleY, float rotation);
void UpdateCompositionLayerOpacity(int layerId, float opacity);
void SetCompositionLayerEnabled(int layerId, bool enabled);
private:
// Encoder thread
void EncoderThreadFunc();
@@ -84,8 +101,12 @@ private:
void DrainAudioEncoder();
void UpdateStats();
// Blit HardwareBuffer texture to encoder surface
void BlitToEncoder(GLuint srcTexture, int64_t timestampNs);
// Blit composed texture to a surface (GL_TEXTURE_2D → draw)
void BlitComposedToSurface(GLuint composedTex, int viewportW, int viewportH);
// Process pending operations from other threads
void ProcessPendingPreviewOps();
void ProcessPendingLayerOps();
// Config
int width = 0;
@@ -100,11 +121,17 @@ private:
// EGL
EglContext eglContext;
// Blit resources
// Composition pipeline (FBO-based)
CompositionPipeline compositionPipeline;
// Blit resources — OES program (for legacy/unused path)
GLuint blitProgram = 0;
GLuint blitVao = 0;
GLuint blitVbo = 0;
// Blit FBO program (sampler2D for composed texture → surface)
GLuint blitFboProgram = 0;
// Video encoder
AMediaCodec* videoEncoder = nullptr;
ANativeWindow* encoderSurface = nullptr;
@@ -126,6 +153,33 @@ private:
std::mutex audioMutex;
std::vector<AudioFrame> audioQueue;
// Preview surface — pending ops from non-GL threads
struct PreviewSetOp { ANativeWindow* window; };
struct PreviewRemoveOp {};
using PreviewOp = std::variant<PreviewSetOp, PreviewRemoveOp>;
std::mutex previewMutex;
std::vector<PreviewOp> pendingPreviewOps;
bool hasPreview = false;
// Layer ops — pending ops from non-GL threads
struct LayerAddOp {
std::vector<uint8_t> rgbaData;
int w, h;
float posX, posY, scaleX, scaleY, rotation, opacity;
int zOrder;
std::string tag;
int assignedId;
};
struct LayerRemoveOp { int layerId; };
struct LayerTransformOp { int layerId; float posX, posY, scaleX, scaleY, rotation; };
struct LayerOpacityOp { int layerId; float opacity; };
struct LayerEnabledOp { int layerId; bool enabled; };
using LayerOp = std::variant<LayerAddOp, LayerRemoveOp, LayerTransformOp,
LayerOpacityOp, LayerEnabledOp>;
std::mutex layerOpMutex;
std::vector<LayerOp> pendingLayerOps;
std::atomic<int> nextLayerId{1};
// Stats
std::mutex statsMutex;
StreamingStats currentStats;
@@ -138,6 +192,9 @@ private:
int64_t startTimestampNs = 0;
bool firstVideoFrame = true;
// Standby frame timing
int64_t lastComposeTimeNs = 0;
// Callbacks
StatsCallback statsCallback;
ErrorCallback errorCallback;

View File

@@ -16,7 +16,7 @@ import com.omixlab.lckcontrol.data.local.entity.StreamPlanEntity
StreamPlanEntity::class,
StreamDestinationEntity::class,
],
version = 5,
version = 6,
exportSchema = false,
)
abstract class LckDatabase : RoomDatabase() {
@@ -109,5 +109,12 @@ abstract class LckDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE stream_plans ADD COLUMN gameId TEXT NOT NULL DEFAULT ''")
}
}
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE linked_accounts ADD COLUMN rtmpUrl TEXT")
db.execSQL("ALTER TABLE linked_accounts ADD COLUMN streamKey TEXT")
}
}
}
}

View File

@@ -11,4 +11,6 @@ data class LinkedAccountEntity(
val accountId: String,
val avatarUrl: String? = null,
val isEnabled: Boolean = true,
val rtmpUrl: String? = null,
val streamKey: String? = null,
)

View File

@@ -61,6 +61,15 @@ data class LinkedAccountResponse(
val displayName: String,
val accountId: String,
val avatarUrl: String?,
val rtmpUrl: String? = null,
val streamKey: String? = null,
)
@JsonClass(generateAdapter = true)
data class CreateCustomRtmpRequest(
val displayName: String,
val rtmpUrl: String,
val streamKey: String,
)
// ── Streams ──────────────────────────────────────────────
@@ -83,12 +92,14 @@ data class UpdateStreamPlanRequest(
@JsonClass(generateAdapter = true)
data class CreateDestinationRequest(
val linkedAccountId: String,
val linkedAccountId: String? = null,
val title: String,
val description: String? = null,
val privacyStatus: String? = null,
val gameId: String? = null,
val tags: String? = null,
val rtmpUrl: String? = null,
val streamKey: String? = null,
)
@JsonClass(generateAdapter = true)

View File

@@ -1,10 +1,13 @@
package com.omixlab.lckcontrol.data.remote
import android.util.Base64
import android.util.Log
import com.omixlab.lckcontrol.data.local.TokenStore
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
@@ -13,6 +16,19 @@ class AuthInterceptor @Inject constructor(
private val tokenStore: TokenStore,
) : Interceptor {
companion object {
private const val TAG = "AuthInterceptor"
}
private fun extractSub(jwt: String): String? {
return try {
val parts = jwt.split(".")
if (parts.size < 2) return null
val payload = String(Base64.decode(parts[1], Base64.URL_SAFE or Base64.NO_WRAP))
JSONObject(payload).optString("sub", "").ifEmpty { null }
} catch (_: Exception) { null }
}
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
@@ -23,6 +39,8 @@ class AuthInterceptor @Inject constructor(
}
val jwt = tokenStore.getJwt()
val sub = jwt?.let { extractSub(it) }
Log.d(TAG, "${original.method} ${path} userId=${sub ?: "NO_JWT"}")
val request = if (jwt != null) {
original.newBuilder()
.header("Authorization", "Bearer $jwt")
@@ -35,11 +53,14 @@ class AuthInterceptor @Inject constructor(
// If 401 and we have a refresh token, try to refresh
if (response.code == 401) {
Log.w(TAG, "401 on ${original.method} ${path} — attempting token refresh")
val refreshToken = tokenStore.getRefreshToken()
if (refreshToken != null) {
response.close()
val newTokens = refreshTokenSync(chain, refreshToken)
if (newTokens != null) {
val newSub = extractSub(newTokens.accessToken)
Log.d(TAG, "Token refresh OK, new userId=$newSub (was $sub)")
tokenStore.saveSession(newTokens.accessToken, newTokens.refreshToken)
// Retry original request with new token
val retryRequest = original.newBuilder()
@@ -47,9 +68,12 @@ class AuthInterceptor @Inject constructor(
.build()
return chain.proceed(retryRequest)
} else {
Log.e(TAG, "Token refresh FAILED, clearing session")
// Refresh failed, clear session
tokenStore.clearSession()
}
} else {
Log.e(TAG, "401 but no refresh token available")
}
}

View File

@@ -40,6 +40,9 @@ interface LckApiService {
@POST("providers/twitch/callback")
suspend fun twitchCallback(@Body body: ProviderCallbackRequest): LinkedAccountResponse
@POST("providers/accounts/custom-rtmp")
suspend fun createCustomRtmpAccount(@Body body: CreateCustomRtmpRequest): LinkedAccountResponse
@DELETE("providers/accounts/{id}")
suspend fun unlinkAccount(@Path("id") id: String): SuccessResponse

View File

@@ -2,6 +2,7 @@ package com.omixlab.lckcontrol.data.repository
import com.omixlab.lckcontrol.data.local.dao.LinkedAccountDao
import com.omixlab.lckcontrol.data.local.entity.LinkedAccountEntity
import com.omixlab.lckcontrol.data.remote.CreateCustomRtmpRequest
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.remote.ProviderCallbackRequest
import com.omixlab.lckcontrol.shared.LinkedAccount
@@ -36,6 +37,8 @@ class AccountRepository @Inject constructor(
accountId = account.accountId,
avatarUrl = account.avatarUrl,
isEnabled = localMap[account.id]?.isEnabled ?: true,
rtmpUrl = account.rtmpUrl,
streamKey = account.streamKey,
)
}
// Detect removals
@@ -54,6 +57,12 @@ class AccountRepository @Inject constructor(
accountDao.setEnabled(id, enabled)
}
/** Create a custom RTMP account on backend and sync */
suspend fun createCustomRtmpAccount(displayName: String, rtmpUrl: String, streamKey: String) {
apiService.createCustomRtmpAccount(CreateCustomRtmpRequest(displayName, rtmpUrl, streamKey))
syncAccounts()
}
/** Get YouTube OAuth URL from backend (for Custom Tabs) */
suspend fun getYouTubeAuthUrl(): String {
val response = apiService.getYouTubeAuthUrl()
@@ -92,5 +101,7 @@ class AccountRepository @Inject constructor(
avatarUrl = avatarUrl,
isAuthenticated = true, // Backend manages auth state
isEnabled = isEnabled,
rtmpUrl = rtmpUrl,
streamKey = streamKey,
)
}

View File

@@ -55,12 +55,14 @@ class StreamPlanRepository @Inject constructor(
gameId = gameId.ifBlank { null },
destinations = destinations.map { dest ->
CreateDestinationRequest(
linkedAccountId = dest.linkedAccountId,
linkedAccountId = dest.linkedAccountId.ifBlank { null },
title = dest.title,
description = dest.description,
privacyStatus = dest.privacyStatus,
gameId = dest.gameId,
tags = dest.tags.joinToString(","),
rtmpUrl = dest.rtmpUrl.ifBlank { null },
streamKey = dest.streamKey.ifBlank { null },
)
},
)
@@ -83,12 +85,14 @@ class StreamPlanRepository @Inject constructor(
gameId = gameId.ifBlank { null },
destinations = destinations.map { dest ->
CreateDestinationRequest(
linkedAccountId = dest.linkedAccountId,
linkedAccountId = dest.linkedAccountId.ifBlank { null },
title = dest.title,
description = dest.description,
privacyStatus = dest.privacyStatus,
gameId = dest.gameId,
tags = dest.tags.joinToString(","),
rtmpUrl = dest.rtmpUrl.ifBlank { null },
streamKey = dest.streamKey.ifBlank { null },
)
},
)

View File

@@ -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, LckDatabase.MIGRATION_3_4, LckDatabase.MIGRATION_4_5)
.addMigrations(LckDatabase.MIGRATION_1_2, LckDatabase.MIGRATION_2_3, LckDatabase.MIGRATION_3_4, LckDatabase.MIGRATION_4_5, LckDatabase.MIGRATION_5_6)
.build()
@Provides

View File

@@ -302,15 +302,28 @@ class LckControlService : Service() {
// ── Auth logic ──────────────────────────────────────────
private fun extractJwtSub(jwt: String): String? {
return try {
val parts = jwt.split(".")
if (parts.size < 2) return null
val payload = String(android.util.Base64.decode(parts[1], android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP))
org.json.JSONObject(payload).optString("sub", "").ifEmpty { null }
} catch (_: Exception) { null }
}
private suspend fun doAutoLogin() {
// Try token refresh first
val refreshToken = tokenStore.getRefreshToken()
val oldJwt = tokenStore.getJwt()
val oldSub = oldJwt?.let { extractJwtSub(it) }
Log.d(TAG, "doAutoLogin: hasRefreshToken=${refreshToken != null}, currentUserId=$oldSub")
if (refreshToken != null) {
Log.d(TAG, "Attempting token refresh...")
try {
val response = apiService.refreshSession(RefreshRequest(refreshToken))
val newSub = extractJwtSub(response.accessToken)
Log.d(TAG, "Token refresh successful, userId=$newSub (was $oldSub)")
tokenStore.saveSession(response.accessToken, response.refreshToken)
Log.d(TAG, "Token refresh successful")
broadcastAuthStateChanged(true)
return
} catch (e: Exception) {
@@ -320,6 +333,7 @@ class LckControlService : Service() {
}
// Full Quest SDK login
Log.d(TAG, "Starting Quest SDK login (previous userId=$oldSub)")
doQuestLogin()
}
@@ -358,8 +372,9 @@ class LckControlService : Service() {
)
)
val loginSub = extractJwtSub(response.accessToken)
tokenStore.saveSession(response.accessToken, response.refreshToken)
Log.d(TAG, "Quest SDK login successful")
Log.d(TAG, "Quest SDK login successful, userId=$loginSub")
broadcastAuthStateChanged(true)
}

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

View File

@@ -14,17 +14,20 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
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.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -44,6 +47,11 @@ fun AccountsScreen(
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val linkError by viewModel.linkError.collectAsStateWithLifecycle()
val showDialog by viewModel.showCustomRtmpDialog.collectAsStateWithLifecycle()
val customName by viewModel.customRtmpName.collectAsStateWithLifecycle()
val customUrl by viewModel.customRtmpUrl.collectAsStateWithLifecycle()
val customKey by viewModel.customRtmpKey.collectAsStateWithLifecycle()
val isCreating by viewModel.isCreatingCustomRtmp.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
@@ -54,6 +62,54 @@ fun AccountsScreen(
}
}
if (showDialog) {
AlertDialog(
onDismissRequest = { viewModel.dismissCustomRtmpDialog() },
title = { Text("Add Custom RTMP") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = customName,
onValueChange = viewModel::setCustomRtmpName,
label = { Text("Name") },
placeholder = { Text("Local Test Server") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = customUrl,
onValueChange = viewModel::setCustomRtmpUrl,
label = { Text("RTMP URL") },
placeholder = { Text("rtmp://192.168.1.60:1935/live") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = customKey,
onValueChange = viewModel::setCustomRtmpKey,
label = { Text("Stream Key") },
placeholder = { Text("test") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
},
confirmButton = {
TextButton(
onClick = { viewModel.createCustomRtmpAccount() },
enabled = !isCreating,
) {
Text(if (isCreating) "Saving..." else "Save")
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissCustomRtmpDialog() }) {
Text("Cancel")
}
},
)
}
Scaffold(
topBar = {
TopAppBar(title = { Text("Linked Accounts") })
@@ -79,7 +135,11 @@ fun AccountsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(account.displayName, style = MaterialTheme.typography.titleSmall)
Text(account.serviceId, style = MaterialTheme.typography.bodySmall)
Text(
if (account.serviceId == "CUSTOM_RTMP") account.rtmpUrl ?: "Custom RTMP"
else account.serviceId,
style = MaterialTheme.typography.bodySmall,
)
}
Switch(
checked = account.isEnabled,
@@ -113,6 +173,17 @@ fun AccountsScreen(
}
}
item {
OutlinedButton(
onClick = { viewModel.showCustomRtmpDialog() },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.padding(4.dp))
Text("Add Custom RTMP")
}
}
item { Spacer(Modifier.height(16.dp)) }
}
}

View File

@@ -37,6 +37,22 @@ class AccountsViewModel @Inject constructor(
private val _linkError = MutableStateFlow<String?>(null)
val linkError: StateFlow<String?> = _linkError.asStateFlow()
// Custom RTMP dialog state
private val _showCustomRtmpDialog = MutableStateFlow(false)
val showCustomRtmpDialog: StateFlow<Boolean> = _showCustomRtmpDialog.asStateFlow()
private val _customRtmpName = MutableStateFlow("")
val customRtmpName: StateFlow<String> = _customRtmpName.asStateFlow()
private val _customRtmpUrl = MutableStateFlow("")
val customRtmpUrl: StateFlow<String> = _customRtmpUrl.asStateFlow()
private val _customRtmpKey = MutableStateFlow("")
val customRtmpKey: StateFlow<String> = _customRtmpKey.asStateFlow()
private val _isCreatingCustomRtmp = MutableStateFlow(false)
val isCreatingCustomRtmp: StateFlow<Boolean> = _isCreatingCustomRtmp.asStateFlow()
init {
// Sync accounts from backend on load
viewModelScope.launch {
@@ -85,6 +101,42 @@ class AccountsViewModel @Inject constructor(
}
}
fun showCustomRtmpDialog() {
_customRtmpName.value = ""
_customRtmpUrl.value = ""
_customRtmpKey.value = ""
_showCustomRtmpDialog.value = true
}
fun dismissCustomRtmpDialog() {
_showCustomRtmpDialog.value = false
}
fun setCustomRtmpName(name: String) { _customRtmpName.value = name }
fun setCustomRtmpUrl(url: String) { _customRtmpUrl.value = url }
fun setCustomRtmpKey(key: String) { _customRtmpKey.value = key }
fun createCustomRtmpAccount() {
val name = _customRtmpName.value.trim()
val url = _customRtmpUrl.value.trim()
val key = _customRtmpKey.value.trim()
if (name.isBlank() || url.isBlank() || key.isBlank()) {
_linkError.value = "All fields are required"
return
}
viewModelScope.launch {
_isCreatingCustomRtmp.value = true
try {
accountRepository.createCustomRtmpAccount(name, url, key)
_showCustomRtmpDialog.value = false
} catch (e: Exception) {
_linkError.value = e.message ?: "Failed to create custom RTMP account"
} finally {
_isCreatingCustomRtmp.value = false
}
}
}
fun clearError() {
_linkError.value = null
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
@@ -23,6 +24,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -35,7 +37,10 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.streaming.StreamingState
import com.omixlab.lckcontrol.ui.components.GameInfoRow
import com.omixlab.lckcontrol.ui.plans.StreamPreviewSurface
import com.omixlab.lckcontrol.ui.plans.StreamingStatsCard
import com.omixlab.lckcontrol.util.GameInfoProvider
@OptIn(ExperimentalMaterial3Api::class)
@@ -49,6 +54,8 @@ fun DashboardScreen(
val backendHealthy by viewModel.backendHealthy.collectAsStateWithLifecycle()
val backendVersion by viewModel.backendVersion.collectAsStateWithLifecycle()
val defaultExecutionMode by viewModel.defaultExecutionMode.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val streamingStats by viewModel.streamingStats.collectAsStateWithLifecycle()
Scaffold(
topBar = {
@@ -103,6 +110,28 @@ fun DashboardScreen(
}
}
// Live preview + streaming stats (only for APP_STREAMING plans with active engine)
val hasLiveAppStreaming = plans.any {
it.status == "LIVE" && it.executionMode == "APP_STREAMING"
}
if (hasLiveAppStreaming && streamingState == StreamingState.LIVE) {
item {
Spacer(Modifier.height(8.dp))
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Live Preview", style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(8.dp))
StreamPreviewSurface(
streamingManager = viewModel.streamingManagerInstance,
)
}
}
}
item {
StreamingStatsCard(stats = streamingStats)
}
}
item {
Spacer(Modifier.height(8.dp))
Text("Default Streaming Mode", style = MaterialTheme.typography.titleMedium)
@@ -126,8 +155,22 @@ fun DashboardScreen(
item {
Spacer(Modifier.height(8.dp))
Text("Stream Plans", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Stream Plans", style = MaterialTheme.typography.titleMedium)
if (plans.any { it.status == "ENDED" }) {
IconButton(onClick = viewModel::clearEndedPlans) {
Icon(
Icons.Default.ClearAll,
contentDescription = "Clear ended plans",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
if (plans.isEmpty()) {

View File

@@ -6,6 +6,9 @@ import com.omixlab.lckcontrol.data.local.AppPreferences
import com.omixlab.lckcontrol.data.remote.LckApiService
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 com.omixlab.lckcontrol.util.GameInfoProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
@@ -23,11 +26,16 @@ class DashboardViewModel @Inject constructor(
private val apiService: LckApiService,
private val appPreferences: AppPreferences,
val gameInfoProvider: GameInfoProvider,
private val streamingManager: StreamingManager,
) : ViewModel() {
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val streamingState: StateFlow<StreamingState> = streamingManager.state
val streamingStats: StateFlow<StreamingStats> = streamingManager.stats
val streamingManagerInstance: StreamingManager = streamingManager
private val _backendHealthy = MutableStateFlow<Boolean?>(null)
val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow()
@@ -59,4 +67,13 @@ class DashboardViewModel @Inject constructor(
_defaultExecutionMode.value = mode
appPreferences.setDefaultExecutionMode(mode)
}
fun clearEndedPlans() {
viewModelScope.launch {
val ended = plans.value.filter { it.status == "ENDED" }
for (plan in ended) {
try { streamPlanRepository.deletePlan(plan.planId) } catch (_: Exception) {}
}
}
}
}

View File

@@ -220,13 +220,13 @@ private fun DestinationCard(
}
}
// Account picker (shows "YouTube - DisplayName" per account)
// Account picker (shows linked accounts + "Custom RTMP" option)
ExposedDropdownMenuBox(
expanded = accountExpanded,
onExpandedChange = { accountExpanded = it },
) {
OutlinedTextField(
value = destination.linkedAccountLabel,
value = destination.linkedAccountLabel.ifBlank { if (destination.isCustom) "Custom RTMP" else "" },
onValueChange = {},
readOnly = true,
label = { Text("Account") },
@@ -239,15 +239,39 @@ private fun DestinationCard(
expanded = accountExpanded,
onDismissRequest = { accountExpanded = false },
) {
DropdownMenuItem(
text = { Text("Custom RTMP") },
onClick = {
onUpdate(destination.copy(
isCustom = true,
linkedAccountId = "",
linkedAccountLabel = "",
))
accountExpanded = false
},
)
linkedAccounts.forEach { account ->
val label = "${account.serviceId} - ${account.displayName}"
DropdownMenuItem(
text = { Text(label) },
onClick = {
onUpdate(destination.copy(
linkedAccountId = account.id,
linkedAccountLabel = label,
))
if (account.serviceId == "CUSTOM_RTMP") {
onUpdate(destination.copy(
isCustom = true,
linkedAccountId = account.id,
linkedAccountLabel = label,
rtmpUrl = account.rtmpUrl ?: "",
streamKey = account.streamKey ?: "",
))
} else {
onUpdate(destination.copy(
isCustom = false,
linkedAccountId = account.id,
linkedAccountLabel = label,
rtmpUrl = "",
streamKey = "",
))
}
accountExpanded = false
},
)
@@ -255,6 +279,25 @@ private fun DestinationCard(
}
}
if (destination.isCustom) {
OutlinedTextField(
value = destination.rtmpUrl,
onValueChange = { onUpdate(destination.copy(rtmpUrl = it)) },
label = { Text("RTMP URL") },
placeholder = { Text("rtmp://192.168.1.60:1935/live") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = destination.streamKey,
onValueChange = { onUpdate(destination.copy(streamKey = it)) },
label = { Text("Stream Key") },
placeholder = { Text("test") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
OutlinedTextField(
value = destination.title,
onValueChange = { onUpdate(destination.copy(title = it)) },
@@ -263,52 +306,54 @@ private fun DestinationCard(
singleLine = true,
)
OutlinedTextField(
value = destination.description,
onValueChange = { onUpdate(destination.copy(description = it)) },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
// Privacy status
ExposedDropdownMenuBox(
expanded = privacyExpanded,
onExpandedChange = { privacyExpanded = it },
) {
if (!destination.isCustom) {
OutlinedTextField(
value = destination.privacyStatus,
onValueChange = {},
readOnly = true,
label = { Text("Privacy") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(privacyExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
value = destination.description,
onValueChange = { onUpdate(destination.copy(description = it)) },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
ExposedDropdownMenu(
// Privacy status
ExposedDropdownMenuBox(
expanded = privacyExpanded,
onDismissRequest = { privacyExpanded = false },
onExpandedChange = { privacyExpanded = it },
) {
listOf("public", "unlisted", "private").forEach { status ->
DropdownMenuItem(
text = { Text(status) },
onClick = {
onUpdate(destination.copy(privacyStatus = status))
privacyExpanded = false
},
)
OutlinedTextField(
value = destination.privacyStatus,
onValueChange = {},
readOnly = true,
label = { Text("Privacy") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(privacyExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(
expanded = privacyExpanded,
onDismissRequest = { privacyExpanded = false },
) {
listOf("public", "unlisted", "private").forEach { status ->
DropdownMenuItem(
text = { Text(status) },
onClick = {
onUpdate(destination.copy(privacyStatus = status))
privacyExpanded = false
},
)
}
}
}
}
OutlinedTextField(
value = destination.tags,
onValueChange = { onUpdate(destination.copy(tags = it)) },
label = { Text("Tags (comma-separated)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = destination.tags,
onValueChange = { onUpdate(destination.copy(tags = it)) },
label = { Text("Tags (comma-separated)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
}
}
}

View File

@@ -36,6 +36,9 @@ data class DestinationInput(
val privacyStatus: String = "public",
val gameId: String = "",
val tags: String = "",
val isCustom: Boolean = false,
val rtmpUrl: String = "",
val streamKey: String = "",
)
@HiltViewModel
@@ -137,6 +140,7 @@ class CreatePlanViewModel @Inject constructor(
_destinations.value = plan.destinations.map { dest ->
val account = accounts.find { it.id == dest.linkedAccountId }
val label = if (account != null) "${account.serviceId} - ${account.displayName}" else dest.service
val isCustomRtmp = account?.serviceId == "CUSTOM_RTMP"
DestinationInput(
linkedAccountId = dest.linkedAccountId,
linkedAccountLabel = label,
@@ -145,6 +149,9 @@ class CreatePlanViewModel @Inject constructor(
privacyStatus = dest.privacyStatus,
gameId = dest.gameId,
tags = dest.tags.joinToString(","),
isCustom = isCustomRtmp || dest.service == "CUSTOM",
rtmpUrl = if (isCustomRtmp) account?.rtmpUrl ?: dest.rtmpUrl else dest.rtmpUrl,
streamKey = if (isCustomRtmp) account?.streamKey ?: dest.streamKey else dest.streamKey,
)
}
} catch (e: Exception) {
@@ -193,9 +200,20 @@ class CreatePlanViewModel @Inject constructor(
_error.value = "Add at least one destination"
return
}
if (dests.any { it.linkedAccountId.isBlank() || it.title.isBlank() }) {
_error.value = "All destinations need an account and title"
return
for (dest in dests) {
if (dest.title.isBlank()) {
_error.value = "All destinations need a title"
return
}
if (dest.isCustom) {
if (dest.rtmpUrl.isBlank() || dest.streamKey.isBlank()) {
_error.value = "Custom destinations need RTMP URL and stream key"
return
}
} else if (dest.linkedAccountId.isBlank()) {
_error.value = "All destinations need an account (or use Custom RTMP)"
return
}
}
viewModelScope.launch {
@@ -204,16 +222,25 @@ class CreatePlanViewModel @Inject constructor(
try {
val accounts = linkedAccounts.value
val streamDests = dests.map { input ->
val account = accounts.find { it.id == input.linkedAccountId }
StreamDestination(
service = account?.serviceId ?: "",
linkedAccountId = input.linkedAccountId,
title = input.title,
description = input.description,
privacyStatus = input.privacyStatus,
gameId = input.gameId,
tags = input.tags.split(",").map { it.trim() }.filter { it.isNotBlank() },
)
if (input.isCustom) {
StreamDestination(
service = "CUSTOM",
title = input.title,
rtmpUrl = input.rtmpUrl,
streamKey = input.streamKey,
)
} else {
val account = accounts.find { it.id == input.linkedAccountId }
StreamDestination(
service = account?.serviceId ?: "",
linkedAccountId = input.linkedAccountId,
title = input.title,
description = input.description,
privacyStatus = input.privacyStatus,
gameId = input.gameId,
tags = input.tags.split(",").map { it.trim() }.filter { it.isNotBlank() },
)
}
}
val plan = if (isEditMode) {
streamPlanRepository.updatePlan(editingPlanId!!, name, streamDests, _executionMode.value, _gameId.value)

View File

@@ -167,14 +167,6 @@ fun PlanDetailScreen(
}
}
// 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 {
@@ -213,6 +205,21 @@ fun PlanDetailScreen(
}
}
// Stream preview + stats (when LIVE + APP_STREAMING)
if (currentPlan.status == "LIVE" &&
currentPlan.executionMode == "APP_STREAMING" &&
streamingState == StreamingState.LIVE
) {
item {
Text("Stream Preview", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
StreamPreviewSurface(streamingManager = viewModel.streamingManager)
}
item {
StreamingStatsCard(stats = streamingStats)
}
}
// Destinations
item {
Spacer(Modifier.height(8.dp))

View File

@@ -5,10 +5,11 @@ 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.shared.StreamingConfig
import com.omixlab.lckcontrol.streaming.StreamingManager
import com.omixlab.lckcontrol.util.GameInfoProvider
import com.omixlab.lckcontrol.streaming.StreamingState
import com.omixlab.lckcontrol.streaming.StreamingStats
import com.omixlab.lckcontrol.util.GameInfoProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -22,7 +23,7 @@ import javax.inject.Inject
class PlanDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val streamPlanRepository: StreamPlanRepository,
private val streamingManager: StreamingManager,
val streamingManager: StreamingManager,
val gameInfoProvider: GameInfoProvider,
) : ViewModel() {
@@ -67,6 +68,16 @@ class PlanDetailViewModel @Inject constructor(
_error.value = null
try {
streamPlanRepository.startPlan(planId)
// Start streaming engine for APP_STREAMING plans
val updated = streamPlanRepository.getPlan(planId)
if (updated?.executionMode == "APP_STREAMING") {
streamingManager.startStreaming(
plan = updated,
config = StreamingConfig(),
width = 1920,
height = 1080,
)
}
} catch (e: Exception) {
_error.value = e.message ?: "Failed to start plan"
} finally {
@@ -80,6 +91,10 @@ class PlanDetailViewModel @Inject constructor(
_isLoading.value = true
_error.value = null
try {
// Stop streaming engine if running
if (streamingManager.isStreaming()) {
streamingManager.stopStreaming()
}
streamPlanRepository.endPlan(planId)
} catch (e: Exception) {
_error.value = e.message ?: "Failed to end plan"

View File

@@ -0,0 +1,56 @@
package com.omixlab.lckcontrol.ui.plans
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.omixlab.lckcontrol.streaming.StreamingManager
@Composable
fun StreamPreviewSurface(
streamingManager: StreamingManager,
modifier: Modifier = Modifier,
) {
DisposableEffect(streamingManager) {
onDispose {
streamingManager.removePreviewSurface()
}
}
AndroidView(
factory = { context ->
SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
streamingManager.setPreviewSurface(holder.surface)
}
override fun surfaceChanged(
holder: SurfaceHolder,
format: Int,
width: Int,
height: Int,
) {
// Surface size changed — re-set to update dimensions
streamingManager.setPreviewSurface(holder.surface)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
streamingManager.removePreviewSurface()
}
})
}
},
modifier = modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(12.dp)),
)
}

View File

@@ -2,6 +2,7 @@ package com.omixlab.lckcontrol.util
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap
@@ -26,13 +27,23 @@ class GameInfoProvider @Inject constructor(
return cache.getOrPut(packageName) {
try {
val pm = context.packageManager
val appInfo = pm.getApplicationInfo(packageName, 0)
val appInfo = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
val label = pm.getApplicationLabel(appInfo).toString()
val icon = pm.getApplicationIcon(appInfo).toBitmap().asImageBitmap()
// Use loadIcon for higher density, fall back to getApplicationIcon
val drawable = appInfo.loadIcon(pm)
val size = (48 * context.resources.displayMetrics.density).toInt()
val icon = drawable.toBitmap(size, size).asImageBitmap()
Log.d("GameInfoProvider", "Resolved $packageName -> $label")
GameInfo(packageName, label, icon)
} catch (_: PackageManager.NameNotFoundException) {
Log.w("GameInfoProvider", "Package not found: $packageName")
null
}
}
}
/** Invalidate cache so icons are re-fetched on next resolve */
fun invalidate(packageName: String) {
cache.remove(packageName)
}
}

1063
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "lck-control",
"version": "1.0.0",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"node-media-server": "^4.2.4"
}
}

View File

@@ -11,6 +11,8 @@ data class LinkedAccount(
val avatarUrl: String? = null,
val isAuthenticated: Boolean = false,
val isEnabled: Boolean = true,
val rtmpUrl: String? = null,
val streamKey: String? = null,
) : Parcelable {
constructor(parcel: Parcel) : this(
@@ -21,6 +23,8 @@ data class LinkedAccount(
avatarUrl = parcel.readString(),
isAuthenticated = parcel.readInt() != 0,
isEnabled = if (parcel.dataAvail() > 0) parcel.readInt() != 0 else true,
rtmpUrl = if (parcel.dataAvail() > 0) parcel.readString() else null,
streamKey = if (parcel.dataAvail() > 0) parcel.readString() else null,
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
@@ -31,6 +35,8 @@ data class LinkedAccount(
parcel.writeString(avatarUrl)
parcel.writeInt(if (isAuthenticated) 1 else 0)
parcel.writeInt(if (isEnabled) 1 else 0)
parcel.writeString(rtmpUrl)
parcel.writeString(streamKey)
}
override fun describeContents(): Int = 0

82
tools/rtmp-server.js Normal file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* Local RTMP test server for LCK Control streaming development.
*
* Usage:
* node tools/rtmp-server.js
*
* Then configure a stream plan destination with:
* RTMP URL: rtmp://<PC_IP>:1935/live
* Stream Key: test
*
* View the stream:
* ffplay rtmp://localhost:1935/live/test
* or open http://localhost:8000/live/test.flv in a browser/VLC
*/
const NodeMediaServer = require('node-media-server');
const { spawn } = require('child_process');
const viewers = new Map(); // streamPath -> child process
const config = {
rtmp: {
port: 1935,
chunk_size: 60000,
gop_cache: true,
ping: 30,
ping_timeout: 60,
},
http: {
port: 8000,
allow_origin: '*',
},
};
const nms = new NodeMediaServer(config);
nms.on('prePublish', (session) => {
const streamPath = session.publishStreamPath || session.streamPath || '';
const id = session.id || '';
console.log(`[stream] Client publishing: ${streamPath} (session ${id})`);
const url = `rtmp://localhost:1935${streamPath}`;
if (streamPath && !viewers.has(streamPath)) {
console.log(`[ffplay] Launching viewer for ${url}`);
const child = spawn('ffplay', ['-window_title', streamPath, url], {
stdio: 'ignore',
detached: true,
});
child.unref();
child.on('exit', () => viewers.delete(streamPath));
viewers.set(streamPath, child);
}
});
nms.on('donePublish', (session) => {
const streamPath = session.publishStreamPath || session.streamPath || '';
const id = session.id || '';
console.log(`[stream] Client stopped: ${streamPath} (session ${id})`);
const child = viewers.get(streamPath);
if (child) {
child.kill();
viewers.delete(streamPath);
}
});
nms.on('prePlay', (session) => {
const streamPath = session.playStreamPath || session.streamPath || '';
const id = session.id || '';
console.log(`[viewer] Viewer connected: ${streamPath} (session ${id})`);
});
nms.on('donePlay', (session) => {
const streamPath = session.playStreamPath || session.streamPath || '';
const id = session.id || '';
console.log(`[viewer] Viewer disconnected: ${streamPath} (session ${id})`);
});
nms.run();
console.log('\n--- LCK Control Test RTMP Server ---');
console.log('RTMP: rtmp://localhost:1935/live/test');
console.log('HTTP-FLV: http://localhost:8000/live/test.flv');
console.log('View: ffplay rtmp://localhost:1935/live/test\n');