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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ ovr-platform-util.exe
|
|||||||
# Build counter
|
# Build counter
|
||||||
.buildcount
|
.buildcount
|
||||||
/.claude
|
/.claude
|
||||||
|
|
||||||
|
# Tools
|
||||||
|
node_modules/
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
433
app/src/main/cpp/composition_pipeline.cpp
Normal file
433
app/src/main/cpp/composition_pipeline.cpp
Normal 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");
|
||||||
|
}
|
||||||
108
app/src/main/cpp/composition_pipeline.h
Normal file
108
app/src/main/cpp/composition_pipeline.h
Normal 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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
1063
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
82
tools/rtmp-server.js
Normal 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');
|
||||||
Reference in New Issue
Block a user