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 # Build counter
.buildcount .buildcount
/.claude /.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.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <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 <application
android:name=".LckControlApp" android:name=".LckControlApp"
android:allowBackup="true" android:allowBackup="true"

View File

@@ -13,6 +13,7 @@ add_library(lck_streaming SHARED
rtmp_client.cpp rtmp_client.cpp
rtmp_sink.cpp rtmp_sink.cpp
egl_context.cpp egl_context.cpp
composition_pipeline.cpp
streaming_engine.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 "egl_context.h"
#include <android/log.h> #include <android/log.h>
#include <android/native_window.h>
#include <unistd.h> #include <unistd.h>
#define TAG "LckEglContext" #define TAG "LckEglContext"
@@ -201,9 +202,58 @@ bool EglContext::SwapBuffers() {
return eglSwapBuffers(display, surface) == EGL_TRUE; 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() { void EglContext::Release() {
if (display != EGL_NO_DISPLAY) { if (display != EGL_NO_DISPLAY) {
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
DestroyPreviewSurface();
if (surface != EGL_NO_SURFACE) { if (surface != EGL_NO_SURFACE) {
eglDestroySurface(display, surface); eglDestroySurface(display, surface);
surface = EGL_NO_SURFACE; surface = EGL_NO_SURFACE;

View File

@@ -39,6 +39,25 @@ public:
/** Swap buffers on the window surface. */ /** Swap buffers on the window surface. */
bool SwapBuffers(); 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. */ /** Release all EGL resources. */
void Release(); void Release();
@@ -55,6 +74,12 @@ private:
int surfaceWidth = 0; int surfaceWidth = 0;
int surfaceHeight = 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 // Extension function pointers
PFNEGLCREATESYNCKHRPROC eglCreateSyncKHR = nullptr; PFNEGLCREATESYNCKHRPROC eglCreateSyncKHR = nullptr;
PFNEGLWAITSYNCKHRPROC eglWaitSyncKHR = nullptr; PFNEGLWAITSYNCKHRPROC eglWaitSyncKHR = nullptr;

View File

@@ -2,6 +2,7 @@
#include <jni.h> #include <jni.h>
#include <android/hardware_buffer_jni.h> #include <android/hardware_buffer_jni.h>
#include <android/native_window_jni.h>
#include <android/log.h> #include <android/log.h>
#define TAG "LckJniBridge" #define TAG "LckJniBridge"
@@ -159,4 +160,85 @@ Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeIsRunning(
return engine->IsRunning() ? JNI_TRUE : JNI_FALSE; 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" } // extern "C"

View File

@@ -12,7 +12,7 @@
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, 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__) #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 static const char* BLIT_VERTEX_SHADER = R"(#version 300 es
layout(location = 0) in vec2 aPos; layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aTexCoord; 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) { static GLuint CompileShader(GLenum type, const char* source) {
GLuint shader = glCreateShader(type); GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &source, nullptr); glShaderSource(shader, 1, &source, nullptr);
@@ -190,6 +201,29 @@ bool StreamingEngine::InitBlitResources() {
return false; 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) // Full-screen quad: pos(x,y) + texcoord(u,v)
float quad[] = { float quad[] = {
-1.0f, -1.0f, 0.0f, 0.0f, -1.0f, -1.0f, 0.0f, 0.0f,
@@ -209,13 +243,21 @@ bool StreamingEngine::InitBlitResources() {
glEnableVertexAttribArray(1); glEnableVertexAttribArray(1);
glBindVertexArray(0); glBindVertexArray(0);
// Initialize composition pipeline at encoder resolution
if (!compositionPipeline.Init(width, height)) {
LOGE("Composition pipeline init failed");
return false;
}
return true; return true;
} }
void StreamingEngine::ReleaseBlitResources() { void StreamingEngine::ReleaseBlitResources() {
compositionPipeline.Release();
if (blitVao) { glDeleteVertexArrays(1, &blitVao); blitVao = 0; } if (blitVao) { glDeleteVertexArrays(1, &blitVao); blitVao = 0; }
if (blitVbo) { glDeleteBuffers(1, &blitVbo); blitVbo = 0; } if (blitVbo) { glDeleteBuffers(1, &blitVbo); blitVbo = 0; }
if (blitProgram) { glDeleteProgram(blitProgram); blitProgram = 0; } if (blitProgram) { glDeleteProgram(blitProgram); blitProgram = 0; }
if (blitFboProgram) { glDeleteProgram(blitFboProgram); blitFboProgram = 0; }
} }
bool StreamingEngine::Start() { bool StreamingEngine::Start() {
@@ -234,6 +276,7 @@ bool StreamingEngine::Start() {
running.store(true); running.store(true);
firstVideoFrame = true; firstVideoFrame = true;
startTimestampNs = 0; startTimestampNs = 0;
lastComposeTimeNs = 0;
statsVideoBytes = 0; statsVideoBytes = 0;
statsAudioBytes = 0; statsAudioBytes = 0;
statsFrameCount = 0; statsFrameCount = 0;
@@ -309,15 +352,64 @@ void StreamingEngine::EncoderThreadFunc() {
// Main encoder loop // Main encoder loop
while (running.load()) { while (running.load()) {
// Process pending preview and layer ops (must run on GL thread)
ProcessPendingPreviewOps();
ProcessPendingLayerOps();
// Process video frames // Process video frames
bool hadVideoFrames = false;
{ {
std::lock_guard<std::mutex> lock(videoMutex); std::lock_guard<std::mutex> lock(videoMutex);
hadVideoFrames = !videoQueue.empty();
for (auto& frame : videoQueue) { for (auto& frame : videoQueue) {
ProcessVideoFrame(frame); ProcessVideoFrame(frame);
} }
videoQueue.clear(); 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 // Process audio frames
{ {
std::lock_guard<std::mutex> lock(audioMutex); std::lock_guard<std::mutex> lock(audioMutex);
@@ -333,6 +425,9 @@ void StreamingEngine::EncoderThreadFunc() {
DrainAudioEncoder(); DrainAudioEncoder();
} }
// Update stats every second regardless of frame output
UpdateStats();
// Don't spin-wait // Don't spin-wait
std::this_thread::sleep_for(std::chrono::milliseconds(1)); std::this_thread::sleep_for(std::chrono::milliseconds(1));
} }
@@ -341,6 +436,8 @@ void StreamingEngine::EncoderThreadFunc() {
LOGI("Encoder thread shutting down"); LOGI("Encoder thread shutting down");
ReleaseBlitResources(); ReleaseBlitResources();
eglContext.DestroyPreviewSurface();
hasPreview = false;
for (auto* sink : sinks) { for (auto* sink : sinks) {
sink->Close(); sink->Close();
@@ -376,34 +473,51 @@ void StreamingEngine::ProcessVideoFrame(const VideoFrame& frame) {
// Wait on GPU fence // Wait on GPU fence
eglContext.WaitFence(frame.fenceFd); eglContext.WaitFence(frame.fenceFd);
// Import HardwareBuffer as GL texture // Import HardwareBuffer as OES texture
GLuint texture = eglContext.ImportHardwareBuffer(frame.buffer); GLuint texture = eglContext.ImportHardwareBuffer(frame.buffer);
if (texture == 0) { if (texture == 0) {
LOGW("Failed to import HardwareBuffer as texture"); LOGW("Failed to import HardwareBuffer as texture");
return; return;
} }
// Blit to encoder surface // Compose: game frame + overlay layers → FBO
BlitToEncoder(texture, frame.timestampNs); 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); 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) { void StreamingEngine::BlitComposedToSurface(GLuint composedTex, int viewportW, int viewportH) {
glViewport(0, 0, width, height); glViewport(0, 0, viewportW, viewportH);
glUseProgram(blitProgram); glUseProgram(blitFboProgram);
glActiveTexture(GL_TEXTURE0); glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_EXTERNAL_OES, srcTexture); glBindTexture(GL_TEXTURE_2D, composedTex);
glUniform1i(glGetUniformLocation(blitProgram, "uTexture"), 0); glUniform1i(glGetUniformLocation(blitFboProgram, "uTexture"), 0);
glBindVertexArray(blitVao); glBindVertexArray(blitVao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0); glBindVertexArray(0);
eglContext.SetPresentationTime(timestampNs);
eglContext.SwapBuffers();
} }
void StreamingEngine::ProcessAudioFrame(const AudioFrame& frame) { void StreamingEngine::ProcessAudioFrame(const AudioFrame& frame) {
@@ -464,8 +578,6 @@ void StreamingEngine::DrainVideoEncoder() {
} }
AMediaCodec_releaseOutputBuffer(videoEncoder, outputIndex, false); AMediaCodec_releaseOutputBuffer(videoEncoder, outputIndex, false);
UpdateStats();
} }
if (outputIndex == AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED) { if (outputIndex == AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED) {
@@ -585,3 +697,133 @@ void StreamingEngine::SetErrorCallback(ErrorCallback callback) {
void StreamingEngine::SetBufferReleasedCallback(BufferReleasedCallback callback) { void StreamingEngine::SetBufferReleasedCallback(BufferReleasedCallback callback) {
bufferReleasedCallback = std::move(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 #pragma once
#include "egl_context.h" #include "egl_context.h"
#include "composition_pipeline.h"
#include "rtmp_sink.h" #include "rtmp_sink.h"
#include <media/NdkMediaCodec.h> #include <media/NdkMediaCodec.h>
@@ -14,6 +15,7 @@
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <thread> #include <thread>
#include <variant>
#include <vector> #include <vector>
struct VideoFrame { struct VideoFrame {
@@ -75,6 +77,21 @@ public:
bool IsRunning() const { return running.load(); } 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: private:
// Encoder thread // Encoder thread
void EncoderThreadFunc(); void EncoderThreadFunc();
@@ -84,8 +101,12 @@ private:
void DrainAudioEncoder(); void DrainAudioEncoder();
void UpdateStats(); void UpdateStats();
// Blit HardwareBuffer texture to encoder surface // Blit composed texture to a surface (GL_TEXTURE_2D → draw)
void BlitToEncoder(GLuint srcTexture, int64_t timestampNs); void BlitComposedToSurface(GLuint composedTex, int viewportW, int viewportH);
// Process pending operations from other threads
void ProcessPendingPreviewOps();
void ProcessPendingLayerOps();
// Config // Config
int width = 0; int width = 0;
@@ -100,11 +121,17 @@ private:
// EGL // EGL
EglContext eglContext; EglContext eglContext;
// Blit resources // Composition pipeline (FBO-based)
CompositionPipeline compositionPipeline;
// Blit resources — OES program (for legacy/unused path)
GLuint blitProgram = 0; GLuint blitProgram = 0;
GLuint blitVao = 0; GLuint blitVao = 0;
GLuint blitVbo = 0; GLuint blitVbo = 0;
// Blit FBO program (sampler2D for composed texture → surface)
GLuint blitFboProgram = 0;
// Video encoder // Video encoder
AMediaCodec* videoEncoder = nullptr; AMediaCodec* videoEncoder = nullptr;
ANativeWindow* encoderSurface = nullptr; ANativeWindow* encoderSurface = nullptr;
@@ -126,6 +153,33 @@ private:
std::mutex audioMutex; std::mutex audioMutex;
std::vector<AudioFrame> audioQueue; 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 // Stats
std::mutex statsMutex; std::mutex statsMutex;
StreamingStats currentStats; StreamingStats currentStats;
@@ -138,6 +192,9 @@ private:
int64_t startTimestampNs = 0; int64_t startTimestampNs = 0;
bool firstVideoFrame = true; bool firstVideoFrame = true;
// Standby frame timing
int64_t lastComposeTimeNs = 0;
// Callbacks // Callbacks
StatsCallback statsCallback; StatsCallback statsCallback;
ErrorCallback errorCallback; ErrorCallback errorCallback;

View File

@@ -16,7 +16,7 @@ import com.omixlab.lckcontrol.data.local.entity.StreamPlanEntity
StreamPlanEntity::class, StreamPlanEntity::class,
StreamDestinationEntity::class, StreamDestinationEntity::class,
], ],
version = 5, version = 6,
exportSchema = false, exportSchema = false,
) )
abstract class LckDatabase : RoomDatabase() { 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 ''") 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 accountId: String,
val avatarUrl: String? = null, val avatarUrl: String? = null,
val isEnabled: Boolean = true, 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 displayName: String,
val accountId: String, val accountId: String,
val avatarUrl: 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 ────────────────────────────────────────────── // ── Streams ──────────────────────────────────────────────
@@ -83,12 +92,14 @@ data class UpdateStreamPlanRequest(
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class CreateDestinationRequest( data class CreateDestinationRequest(
val linkedAccountId: String, val linkedAccountId: String? = null,
val title: String, val title: String,
val description: String? = null, val description: String? = null,
val privacyStatus: String? = null, val privacyStatus: String? = null,
val gameId: String? = null, val gameId: String? = null,
val tags: String? = null, val tags: String? = null,
val rtmpUrl: String? = null,
val streamKey: String? = null,
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)

View File

@@ -1,10 +1,13 @@
package com.omixlab.lckcontrol.data.remote package com.omixlab.lckcontrol.data.remote
import android.util.Base64
import android.util.Log
import com.omixlab.lckcontrol.data.local.TokenStore import com.omixlab.lckcontrol.data.local.TokenStore
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import org.json.JSONObject
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -13,6 +16,19 @@ class AuthInterceptor @Inject constructor(
private val tokenStore: TokenStore, private val tokenStore: TokenStore,
) : Interceptor { ) : 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 { override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request() val original = chain.request()
@@ -23,6 +39,8 @@ class AuthInterceptor @Inject constructor(
} }
val jwt = tokenStore.getJwt() 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) { val request = if (jwt != null) {
original.newBuilder() original.newBuilder()
.header("Authorization", "Bearer $jwt") .header("Authorization", "Bearer $jwt")
@@ -35,11 +53,14 @@ class AuthInterceptor @Inject constructor(
// If 401 and we have a refresh token, try to refresh // If 401 and we have a refresh token, try to refresh
if (response.code == 401) { if (response.code == 401) {
Log.w(TAG, "401 on ${original.method} ${path} — attempting token refresh")
val refreshToken = tokenStore.getRefreshToken() val refreshToken = tokenStore.getRefreshToken()
if (refreshToken != null) { if (refreshToken != null) {
response.close() response.close()
val newTokens = refreshTokenSync(chain, refreshToken) val newTokens = refreshTokenSync(chain, refreshToken)
if (newTokens != null) { 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) tokenStore.saveSession(newTokens.accessToken, newTokens.refreshToken)
// Retry original request with new token // Retry original request with new token
val retryRequest = original.newBuilder() val retryRequest = original.newBuilder()
@@ -47,9 +68,12 @@ class AuthInterceptor @Inject constructor(
.build() .build()
return chain.proceed(retryRequest) return chain.proceed(retryRequest)
} else { } else {
Log.e(TAG, "Token refresh FAILED, clearing session")
// Refresh failed, clear session // Refresh failed, clear session
tokenStore.clearSession() tokenStore.clearSession()
} }
} else {
Log.e(TAG, "401 but no refresh token available")
} }
} }

View File

@@ -40,6 +40,9 @@ interface LckApiService {
@POST("providers/twitch/callback") @POST("providers/twitch/callback")
suspend fun twitchCallback(@Body body: ProviderCallbackRequest): LinkedAccountResponse suspend fun twitchCallback(@Body body: ProviderCallbackRequest): LinkedAccountResponse
@POST("providers/accounts/custom-rtmp")
suspend fun createCustomRtmpAccount(@Body body: CreateCustomRtmpRequest): LinkedAccountResponse
@DELETE("providers/accounts/{id}") @DELETE("providers/accounts/{id}")
suspend fun unlinkAccount(@Path("id") id: String): SuccessResponse 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.dao.LinkedAccountDao
import com.omixlab.lckcontrol.data.local.entity.LinkedAccountEntity 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.LckApiService
import com.omixlab.lckcontrol.data.remote.ProviderCallbackRequest import com.omixlab.lckcontrol.data.remote.ProviderCallbackRequest
import com.omixlab.lckcontrol.shared.LinkedAccount import com.omixlab.lckcontrol.shared.LinkedAccount
@@ -36,6 +37,8 @@ class AccountRepository @Inject constructor(
accountId = account.accountId, accountId = account.accountId,
avatarUrl = account.avatarUrl, avatarUrl = account.avatarUrl,
isEnabled = localMap[account.id]?.isEnabled ?: true, isEnabled = localMap[account.id]?.isEnabled ?: true,
rtmpUrl = account.rtmpUrl,
streamKey = account.streamKey,
) )
} }
// Detect removals // Detect removals
@@ -54,6 +57,12 @@ class AccountRepository @Inject constructor(
accountDao.setEnabled(id, enabled) 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) */ /** Get YouTube OAuth URL from backend (for Custom Tabs) */
suspend fun getYouTubeAuthUrl(): String { suspend fun getYouTubeAuthUrl(): String {
val response = apiService.getYouTubeAuthUrl() val response = apiService.getYouTubeAuthUrl()
@@ -92,5 +101,7 @@ class AccountRepository @Inject constructor(
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
isAuthenticated = true, // Backend manages auth state isAuthenticated = true, // Backend manages auth state
isEnabled = isEnabled, isEnabled = isEnabled,
rtmpUrl = rtmpUrl,
streamKey = streamKey,
) )
} }

View File

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

View File

@@ -20,7 +20,7 @@ object DatabaseModule {
@Singleton @Singleton
fun provideDatabase(@ApplicationContext context: Context): LckDatabase = fun provideDatabase(@ApplicationContext context: Context): LckDatabase =
Room.databaseBuilder(context, LckDatabase::class.java, "lck_control.db") 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() .build()
@Provides @Provides

View File

@@ -302,15 +302,28 @@ class LckControlService : Service() {
// ── Auth logic ────────────────────────────────────────── // ── 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() { private suspend fun doAutoLogin() {
// Try token refresh first // Try token refresh first
val refreshToken = tokenStore.getRefreshToken() 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) { if (refreshToken != null) {
Log.d(TAG, "Attempting token refresh...") Log.d(TAG, "Attempting token refresh...")
try { try {
val response = apiService.refreshSession(RefreshRequest(refreshToken)) 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) tokenStore.saveSession(response.accessToken, response.refreshToken)
Log.d(TAG, "Token refresh successful")
broadcastAuthStateChanged(true) broadcastAuthStateChanged(true)
return return
} catch (e: Exception) { } catch (e: Exception) {
@@ -320,6 +333,7 @@ class LckControlService : Service() {
} }
// Full Quest SDK login // Full Quest SDK login
Log.d(TAG, "Starting Quest SDK login (previous userId=$oldSub)")
doQuestLogin() doQuestLogin()
} }
@@ -358,8 +372,9 @@ class LckControlService : Service() {
) )
) )
val loginSub = extractJwtSub(response.accessToken)
tokenStore.saveSession(response.accessToken, response.refreshToken) tokenStore.saveSession(response.accessToken, response.refreshToken)
Log.d(TAG, "Quest SDK login successful") Log.d(TAG, "Quest SDK login successful, userId=$loginSub")
broadcastAuthStateChanged(true) broadcastAuthStateChanged(true)
} }

View File

@@ -2,6 +2,7 @@ package com.omixlab.lckcontrol.streaming
import android.hardware.HardwareBuffer import android.hardware.HardwareBuffer
import android.util.Log import android.util.Log
import android.view.Surface
/** /**
* Thin JNI wrapper around the C++ StreamingEngine. * Thin JNI wrapper around the C++ StreamingEngine.
@@ -77,6 +78,52 @@ class NativeStreamingEngine {
return nativeIsRunning(nativePtr) 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) // Called from native code (JNI callbacks)
@Suppress("unused") @Suppress("unused")
private fun onNativeStats(videoBitrate: Long, audioBitrate: Long, fps: Int, droppedFrames: Int) { 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 nativeStop(ptr: Long)
private external fun nativeDestroy(ptr: Long) private external fun nativeDestroy(ptr: Long)
private external fun nativeIsRunning(ptr: Long): Boolean 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 package com.omixlab.lckcontrol.streaming
import android.graphics.Bitmap
import android.hardware.HardwareBuffer import android.hardware.HardwareBuffer
import android.util.Log import android.util.Log
import android.view.Surface
import com.omixlab.lckcontrol.shared.StreamPlan import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.shared.StreamingConfig import com.omixlab.lckcontrol.shared.StreamingConfig
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import java.nio.ByteBuffer
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -153,4 +156,61 @@ class StreamingManager @Inject constructor() {
} }
fun isStreaming(): Boolean = _state.value == StreamingState.LIVE 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.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -44,6 +47,11 @@ fun AccountsScreen(
) { ) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val linkError by viewModel.linkError.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 snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current 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( Scaffold(
topBar = { topBar = {
TopAppBar(title = { Text("Linked Accounts") }) TopAppBar(title = { Text("Linked Accounts") })
@@ -79,7 +135,11 @@ fun AccountsScreen(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(account.displayName, style = MaterialTheme.typography.titleSmall) 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( Switch(
checked = account.isEnabled, 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)) } item { Spacer(Modifier.height(16.dp)) }
} }
} }

View File

@@ -37,6 +37,22 @@ class AccountsViewModel @Inject constructor(
private val _linkError = MutableStateFlow<String?>(null) private val _linkError = MutableStateFlow<String?>(null)
val linkError: StateFlow<String?> = _linkError.asStateFlow() 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 { init {
// Sync accounts from backend on load // Sync accounts from backend on load
viewModelScope.launch { 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() { fun clearError() {
_linkError.value = null _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.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
@@ -23,6 +24,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -35,7 +37,10 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.shared.StreamPlan import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.streaming.StreamingState
import com.omixlab.lckcontrol.ui.components.GameInfoRow 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 import com.omixlab.lckcontrol.util.GameInfoProvider
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -49,6 +54,8 @@ fun DashboardScreen(
val backendHealthy by viewModel.backendHealthy.collectAsStateWithLifecycle() val backendHealthy by viewModel.backendHealthy.collectAsStateWithLifecycle()
val backendVersion by viewModel.backendVersion.collectAsStateWithLifecycle() val backendVersion by viewModel.backendVersion.collectAsStateWithLifecycle()
val defaultExecutionMode by viewModel.defaultExecutionMode.collectAsStateWithLifecycle() val defaultExecutionMode by viewModel.defaultExecutionMode.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val streamingStats by viewModel.streamingStats.collectAsStateWithLifecycle()
Scaffold( Scaffold(
topBar = { 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 { item {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("Default Streaming Mode", style = MaterialTheme.typography.titleMedium) Text("Default Streaming Mode", style = MaterialTheme.typography.titleMedium)
@@ -126,8 +155,22 @@ fun DashboardScreen(
item { item {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("Stream Plans", style = MaterialTheme.typography.titleMedium) Row(
Spacer(Modifier.height(4.dp)) 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()) { 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.remote.LckApiService
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.StreamPlan 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 com.omixlab.lckcontrol.util.GameInfoProvider
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -23,11 +26,16 @@ class DashboardViewModel @Inject constructor(
private val apiService: LckApiService, private val apiService: LckApiService,
private val appPreferences: AppPreferences, private val appPreferences: AppPreferences,
val gameInfoProvider: GameInfoProvider, val gameInfoProvider: GameInfoProvider,
private val streamingManager: StreamingManager,
) : ViewModel() { ) : ViewModel() {
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans() val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .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) private val _backendHealthy = MutableStateFlow<Boolean?>(null)
val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow() val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow()
@@ -59,4 +67,13 @@ class DashboardViewModel @Inject constructor(
_defaultExecutionMode.value = mode _defaultExecutionMode.value = mode
appPreferences.setDefaultExecutionMode(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( ExposedDropdownMenuBox(
expanded = accountExpanded, expanded = accountExpanded,
onExpandedChange = { accountExpanded = it }, onExpandedChange = { accountExpanded = it },
) { ) {
OutlinedTextField( OutlinedTextField(
value = destination.linkedAccountLabel, value = destination.linkedAccountLabel.ifBlank { if (destination.isCustom) "Custom RTMP" else "" },
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text("Account") }, label = { Text("Account") },
@@ -239,15 +239,39 @@ private fun DestinationCard(
expanded = accountExpanded, expanded = accountExpanded,
onDismissRequest = { accountExpanded = false }, onDismissRequest = { accountExpanded = false },
) { ) {
DropdownMenuItem(
text = { Text("Custom RTMP") },
onClick = {
onUpdate(destination.copy(
isCustom = true,
linkedAccountId = "",
linkedAccountLabel = "",
))
accountExpanded = false
},
)
linkedAccounts.forEach { account -> linkedAccounts.forEach { account ->
val label = "${account.serviceId} - ${account.displayName}" val label = "${account.serviceId} - ${account.displayName}"
DropdownMenuItem( DropdownMenuItem(
text = { Text(label) }, text = { Text(label) },
onClick = { onClick = {
onUpdate(destination.copy( if (account.serviceId == "CUSTOM_RTMP") {
linkedAccountId = account.id, onUpdate(destination.copy(
linkedAccountLabel = label, 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 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( OutlinedTextField(
value = destination.title, value = destination.title,
onValueChange = { onUpdate(destination.copy(title = it)) }, onValueChange = { onUpdate(destination.copy(title = it)) },
@@ -263,52 +306,54 @@ private fun DestinationCard(
singleLine = true, singleLine = true,
) )
OutlinedTextField( if (!destination.isCustom) {
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 },
) {
OutlinedTextField( OutlinedTextField(
value = destination.privacyStatus, value = destination.description,
onValueChange = {}, onValueChange = { onUpdate(destination.copy(description = it)) },
readOnly = true, label = { Text("Description") },
label = { Text("Privacy") }, modifier = Modifier.fillMaxWidth(),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(privacyExpanded) }, minLines = 2,
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
) )
ExposedDropdownMenu(
// Privacy status
ExposedDropdownMenuBox(
expanded = privacyExpanded, expanded = privacyExpanded,
onDismissRequest = { privacyExpanded = false }, onExpandedChange = { privacyExpanded = it },
) { ) {
listOf("public", "unlisted", "private").forEach { status -> OutlinedTextField(
DropdownMenuItem( value = destination.privacyStatus,
text = { Text(status) }, onValueChange = {},
onClick = { readOnly = true,
onUpdate(destination.copy(privacyStatus = status)) label = { Text("Privacy") },
privacyExpanded = false 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( OutlinedTextField(
value = destination.tags, value = destination.tags,
onValueChange = { onUpdate(destination.copy(tags = it)) }, onValueChange = { onUpdate(destination.copy(tags = it)) },
label = { Text("Tags (comma-separated)") }, label = { Text("Tags (comma-separated)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
) )
}
} }
} }
} }

View File

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

View File

@@ -5,10 +5,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.StreamPlan import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.shared.StreamingConfig
import com.omixlab.lckcontrol.streaming.StreamingManager import com.omixlab.lckcontrol.streaming.StreamingManager
import com.omixlab.lckcontrol.util.GameInfoProvider
import com.omixlab.lckcontrol.streaming.StreamingState import com.omixlab.lckcontrol.streaming.StreamingState
import com.omixlab.lckcontrol.streaming.StreamingStats import com.omixlab.lckcontrol.streaming.StreamingStats
import com.omixlab.lckcontrol.util.GameInfoProvider
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -22,7 +23,7 @@ import javax.inject.Inject
class PlanDetailViewModel @Inject constructor( class PlanDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val streamPlanRepository: StreamPlanRepository, private val streamPlanRepository: StreamPlanRepository,
private val streamingManager: StreamingManager, val streamingManager: StreamingManager,
val gameInfoProvider: GameInfoProvider, val gameInfoProvider: GameInfoProvider,
) : ViewModel() { ) : ViewModel() {
@@ -67,6 +68,16 @@ class PlanDetailViewModel @Inject constructor(
_error.value = null _error.value = null
try { try {
streamPlanRepository.startPlan(planId) 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) { } catch (e: Exception) {
_error.value = e.message ?: "Failed to start plan" _error.value = e.message ?: "Failed to start plan"
} finally { } finally {
@@ -80,6 +91,10 @@ class PlanDetailViewModel @Inject constructor(
_isLoading.value = true _isLoading.value = true
_error.value = null _error.value = null
try { try {
// Stop streaming engine if running
if (streamingManager.isStreaming()) {
streamingManager.stopStreaming()
}
streamPlanRepository.endPlan(planId) streamPlanRepository.endPlan(planId)
} catch (e: Exception) { } catch (e: Exception) {
_error.value = e.message ?: "Failed to end plan" _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.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.util.Log
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
@@ -26,13 +27,23 @@ class GameInfoProvider @Inject constructor(
return cache.getOrPut(packageName) { return cache.getOrPut(packageName) {
try { try {
val pm = context.packageManager 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 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) GameInfo(packageName, label, icon)
} catch (_: PackageManager.NameNotFoundException) { } catch (_: PackageManager.NameNotFoundException) {
Log.w("GameInfoProvider", "Package not found: $packageName")
null 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 avatarUrl: String? = null,
val isAuthenticated: Boolean = false, val isAuthenticated: Boolean = false,
val isEnabled: Boolean = true, val isEnabled: Boolean = true,
val rtmpUrl: String? = null,
val streamKey: String? = null,
) : Parcelable { ) : Parcelable {
constructor(parcel: Parcel) : this( constructor(parcel: Parcel) : this(
@@ -21,6 +23,8 @@ data class LinkedAccount(
avatarUrl = parcel.readString(), avatarUrl = parcel.readString(),
isAuthenticated = parcel.readInt() != 0, isAuthenticated = parcel.readInt() != 0,
isEnabled = if (parcel.dataAvail() > 0) parcel.readInt() != 0 else true, 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) { override fun writeToParcel(parcel: Parcel, flags: Int) {
@@ -31,6 +35,8 @@ data class LinkedAccount(
parcel.writeString(avatarUrl) parcel.writeString(avatarUrl)
parcel.writeInt(if (isAuthenticated) 1 else 0) parcel.writeInt(if (isAuthenticated) 1 else 0)
parcel.writeInt(if (isEnabled) 1 else 0) parcel.writeInt(if (isEnabled) 1 else 0)
parcel.writeString(rtmpUrl)
parcel.writeString(streamKey)
} }
override fun describeContents(): Int = 0 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');