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
|
||||
.buildcount
|
||||
/.claude
|
||||
|
||||
# Tools
|
||||
node_modules/
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Allow querying game packages for icon/label resolution -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".LckControlApp"
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -13,6 +13,7 @@ add_library(lck_streaming SHARED
|
||||
rtmp_client.cpp
|
||||
rtmp_sink.cpp
|
||||
egl_context.cpp
|
||||
composition_pipeline.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 <android/log.h>
|
||||
#include <android/native_window.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define TAG "LckEglContext"
|
||||
@@ -201,9 +202,58 @@ bool EglContext::SwapBuffers() {
|
||||
return eglSwapBuffers(display, surface) == EGL_TRUE;
|
||||
}
|
||||
|
||||
bool EglContext::CreatePreviewSurface(ANativeWindow* window) {
|
||||
if (!window || display == EGL_NO_DISPLAY) return false;
|
||||
|
||||
DestroyPreviewSurface();
|
||||
|
||||
previewSurface = eglCreateWindowSurface(display, config, window, nullptr);
|
||||
if (previewSurface == EGL_NO_SURFACE) {
|
||||
LOGE("eglCreateWindowSurface (preview) failed: 0x%x", eglGetError());
|
||||
return false;
|
||||
}
|
||||
|
||||
previewWindow = window;
|
||||
eglQuerySurface(display, previewSurface, EGL_WIDTH, &previewWidth);
|
||||
eglQuerySurface(display, previewSurface, EGL_HEIGHT, &previewHeight);
|
||||
LOGI("Preview surface created: %dx%d", previewWidth, previewHeight);
|
||||
return true;
|
||||
}
|
||||
|
||||
void EglContext::DestroyPreviewSurface() {
|
||||
if (previewSurface != EGL_NO_SURFACE && display != EGL_NO_DISPLAY) {
|
||||
// Make sure preview isn't current before destroying
|
||||
eglMakeCurrent(display, surface, surface, context);
|
||||
eglDestroySurface(display, previewSurface);
|
||||
previewSurface = EGL_NO_SURFACE;
|
||||
LOGI("Preview surface destroyed");
|
||||
}
|
||||
if (previewWindow) {
|
||||
ANativeWindow_release(previewWindow);
|
||||
previewWindow = nullptr;
|
||||
}
|
||||
previewWidth = 0;
|
||||
previewHeight = 0;
|
||||
}
|
||||
|
||||
bool EglContext::MakePreviewCurrent() {
|
||||
if (previewSurface == EGL_NO_SURFACE) return false;
|
||||
return eglMakeCurrent(display, previewSurface, previewSurface, context) == EGL_TRUE;
|
||||
}
|
||||
|
||||
bool EglContext::MakeEncoderCurrent() {
|
||||
return eglMakeCurrent(display, surface, surface, context) == EGL_TRUE;
|
||||
}
|
||||
|
||||
bool EglContext::SwapPreviewBuffers() {
|
||||
if (previewSurface == EGL_NO_SURFACE) return false;
|
||||
return eglSwapBuffers(display, previewSurface) == EGL_TRUE;
|
||||
}
|
||||
|
||||
void EglContext::Release() {
|
||||
if (display != EGL_NO_DISPLAY) {
|
||||
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
||||
DestroyPreviewSurface();
|
||||
if (surface != EGL_NO_SURFACE) {
|
||||
eglDestroySurface(display, surface);
|
||||
surface = EGL_NO_SURFACE;
|
||||
|
||||
@@ -39,6 +39,25 @@ public:
|
||||
/** Swap buffers on the window surface. */
|
||||
bool SwapBuffers();
|
||||
|
||||
/** Create a preview surface from an ANativeWindow. Shares the same EGLContext. */
|
||||
bool CreatePreviewSurface(ANativeWindow* window);
|
||||
|
||||
/** Destroy the preview surface. */
|
||||
void DestroyPreviewSurface();
|
||||
|
||||
/** Make the preview surface current. */
|
||||
bool MakePreviewCurrent();
|
||||
|
||||
/** Make the encoder surface current (restores after preview). */
|
||||
bool MakeEncoderCurrent();
|
||||
|
||||
/** Swap buffers on the preview surface. */
|
||||
bool SwapPreviewBuffers();
|
||||
|
||||
bool HasPreviewSurface() const { return previewSurface != EGL_NO_SURFACE; }
|
||||
int GetPreviewWidth() const { return previewWidth; }
|
||||
int GetPreviewHeight() const { return previewHeight; }
|
||||
|
||||
/** Release all EGL resources. */
|
||||
void Release();
|
||||
|
||||
@@ -55,6 +74,12 @@ private:
|
||||
int surfaceWidth = 0;
|
||||
int surfaceHeight = 0;
|
||||
|
||||
// Preview surface (shares EGLContext with encoder surface)
|
||||
EGLSurface previewSurface = EGL_NO_SURFACE;
|
||||
ANativeWindow* previewWindow = nullptr;
|
||||
int previewWidth = 0;
|
||||
int previewHeight = 0;
|
||||
|
||||
// Extension function pointers
|
||||
PFNEGLCREATESYNCKHRPROC eglCreateSyncKHR = nullptr;
|
||||
PFNEGLWAITSYNCKHRPROC eglWaitSyncKHR = nullptr;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <jni.h>
|
||||
#include <android/hardware_buffer_jni.h>
|
||||
#include <android/native_window_jni.h>
|
||||
#include <android/log.h>
|
||||
|
||||
#define TAG "LckJniBridge"
|
||||
@@ -159,4 +160,85 @@ Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeIsRunning(
|
||||
return engine->IsRunning() ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
// --- Preview surface ---
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeSetPreviewSurface(
|
||||
JNIEnv* env, jobject thiz, jlong ptr, jobject surface) {
|
||||
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
|
||||
if (!engine || !surface) return;
|
||||
|
||||
ANativeWindow* window = ANativeWindow_fromSurface(env, surface);
|
||||
if (window) {
|
||||
engine->SetPreviewSurface(window);
|
||||
ANativeWindow_release(window); // SetPreviewSurface acquires its own ref
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeRemovePreviewSurface(
|
||||
JNIEnv* env, jobject thiz, jlong ptr) {
|
||||
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
|
||||
if (!engine) return;
|
||||
engine->RemovePreviewSurface();
|
||||
}
|
||||
|
||||
// --- Composition layers ---
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeAddCompositionLayer(
|
||||
JNIEnv* env, jobject thiz, jlong ptr,
|
||||
jbyteArray rgbaData, jint w, jint h,
|
||||
jfloat posX, jfloat posY, jfloat scaleX, jfloat scaleY,
|
||||
jfloat rotation, jfloat opacity, jint zOrder, jstring tag) {
|
||||
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
|
||||
if (!engine) return -1;
|
||||
|
||||
jsize len = env->GetArrayLength(rgbaData);
|
||||
jbyte* data = env->GetByteArrayElements(rgbaData, nullptr);
|
||||
const char* tagStr = env->GetStringUTFChars(tag, nullptr);
|
||||
|
||||
int layerId = engine->AddCompositionLayer(
|
||||
reinterpret_cast<const uint8_t*>(data), w, h,
|
||||
posX, posY, scaleX, scaleY, rotation, opacity, zOrder,
|
||||
std::string(tagStr));
|
||||
|
||||
env->ReleaseStringUTFChars(tag, tagStr);
|
||||
env->ReleaseByteArrayElements(rgbaData, data, JNI_ABORT);
|
||||
return layerId;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeRemoveCompositionLayer(
|
||||
JNIEnv* env, jobject thiz, jlong ptr, jint layerId) {
|
||||
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
|
||||
if (!engine) return;
|
||||
engine->RemoveCompositionLayer(layerId);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeUpdateCompositionLayerTransform(
|
||||
JNIEnv* env, jobject thiz, jlong ptr, jint layerId,
|
||||
jfloat posX, jfloat posY, jfloat scaleX, jfloat scaleY, jfloat rotation) {
|
||||
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
|
||||
if (!engine) return;
|
||||
engine->UpdateCompositionLayerTransform(layerId, posX, posY, scaleX, scaleY, rotation);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeUpdateCompositionLayerOpacity(
|
||||
JNIEnv* env, jobject thiz, jlong ptr, jint layerId, jfloat opacity) {
|
||||
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
|
||||
if (!engine) return;
|
||||
engine->UpdateCompositionLayerOpacity(layerId, opacity);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeSetCompositionLayerEnabled(
|
||||
JNIEnv* env, jobject thiz, jlong ptr, jint layerId, jboolean enabled) {
|
||||
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
|
||||
if (!engine) return;
|
||||
engine->SetCompositionLayerEnabled(layerId, enabled == JNI_TRUE);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
|
||||
|
||||
// Shader source for blitting OES texture to framebuffer
|
||||
// Shader source for blitting OES texture (kept for legacy/direct path)
|
||||
static const char* BLIT_VERTEX_SHADER = R"(#version 300 es
|
||||
layout(location = 0) in vec2 aPos;
|
||||
layout(location = 1) in vec2 aTexCoord;
|
||||
@@ -34,6 +34,17 @@ void main() {
|
||||
}
|
||||
)";
|
||||
|
||||
// Blit FBO program: renders composed GL_TEXTURE_2D to a surface
|
||||
static const char* BLIT_FBO_FRAGMENT_SHADER = R"(#version 300 es
|
||||
precision mediump float;
|
||||
in vec2 vTexCoord;
|
||||
out vec4 fragColor;
|
||||
uniform sampler2D uTexture;
|
||||
void main() {
|
||||
fragColor = texture(uTexture, vTexCoord);
|
||||
}
|
||||
)";
|
||||
|
||||
static GLuint CompileShader(GLenum type, const char* source) {
|
||||
GLuint shader = glCreateShader(type);
|
||||
glShaderSource(shader, 1, &source, nullptr);
|
||||
@@ -190,6 +201,29 @@ bool StreamingEngine::InitBlitResources() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compile blit FBO program (sampler2D for composed texture → surface)
|
||||
{
|
||||
GLuint fboVs = CompileShader(GL_VERTEX_SHADER, BLIT_VERTEX_SHADER);
|
||||
GLuint fboFs = CompileShader(GL_FRAGMENT_SHADER, BLIT_FBO_FRAGMENT_SHADER);
|
||||
if (!fboVs || !fboFs) return false;
|
||||
|
||||
blitFboProgram = glCreateProgram();
|
||||
glAttachShader(blitFboProgram, fboVs);
|
||||
glAttachShader(blitFboProgram, fboFs);
|
||||
glLinkProgram(blitFboProgram);
|
||||
glDeleteShader(fboVs);
|
||||
glDeleteShader(fboFs);
|
||||
|
||||
GLint fboLinkStatus;
|
||||
glGetProgramiv(blitFboProgram, GL_LINK_STATUS, &fboLinkStatus);
|
||||
if (!fboLinkStatus) {
|
||||
LOGE("Blit FBO program link failed");
|
||||
glDeleteProgram(blitFboProgram);
|
||||
blitFboProgram = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen quad: pos(x,y) + texcoord(u,v)
|
||||
float quad[] = {
|
||||
-1.0f, -1.0f, 0.0f, 0.0f,
|
||||
@@ -209,13 +243,21 @@ bool StreamingEngine::InitBlitResources() {
|
||||
glEnableVertexAttribArray(1);
|
||||
glBindVertexArray(0);
|
||||
|
||||
// Initialize composition pipeline at encoder resolution
|
||||
if (!compositionPipeline.Init(width, height)) {
|
||||
LOGE("Composition pipeline init failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void StreamingEngine::ReleaseBlitResources() {
|
||||
compositionPipeline.Release();
|
||||
if (blitVao) { glDeleteVertexArrays(1, &blitVao); blitVao = 0; }
|
||||
if (blitVbo) { glDeleteBuffers(1, &blitVbo); blitVbo = 0; }
|
||||
if (blitProgram) { glDeleteProgram(blitProgram); blitProgram = 0; }
|
||||
if (blitFboProgram) { glDeleteProgram(blitFboProgram); blitFboProgram = 0; }
|
||||
}
|
||||
|
||||
bool StreamingEngine::Start() {
|
||||
@@ -234,6 +276,7 @@ bool StreamingEngine::Start() {
|
||||
running.store(true);
|
||||
firstVideoFrame = true;
|
||||
startTimestampNs = 0;
|
||||
lastComposeTimeNs = 0;
|
||||
statsVideoBytes = 0;
|
||||
statsAudioBytes = 0;
|
||||
statsFrameCount = 0;
|
||||
@@ -309,15 +352,64 @@ void StreamingEngine::EncoderThreadFunc() {
|
||||
|
||||
// Main encoder loop
|
||||
while (running.load()) {
|
||||
// Process pending preview and layer ops (must run on GL thread)
|
||||
ProcessPendingPreviewOps();
|
||||
ProcessPendingLayerOps();
|
||||
|
||||
// Process video frames
|
||||
bool hadVideoFrames = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(videoMutex);
|
||||
hadVideoFrames = !videoQueue.empty();
|
||||
for (auto& frame : videoQueue) {
|
||||
ProcessVideoFrame(frame);
|
||||
}
|
||||
videoQueue.clear();
|
||||
}
|
||||
|
||||
// Generate standby frames when no game input arrives
|
||||
if (!hadVideoFrames && compositionPipeline.IsInitialized()) {
|
||||
auto now = std::chrono::steady_clock::now().time_since_epoch();
|
||||
int64_t nowNs = std::chrono::duration_cast<std::chrono::nanoseconds>(now).count();
|
||||
int64_t frameIntervalNs = 1000000000LL / framerate;
|
||||
if (nowNs - lastComposeTimeNs >= frameIntervalNs) {
|
||||
// Compose standby frame (dark background + overlays, no game texture)
|
||||
compositionPipeline.Compose(0);
|
||||
GLuint composedTex = compositionPipeline.GetComposedTexture();
|
||||
|
||||
eglContext.MakeEncoderCurrent();
|
||||
BlitComposedToSurface(composedTex, width, height);
|
||||
if (firstVideoFrame) {
|
||||
startTimestampNs = nowNs;
|
||||
firstVideoFrame = false;
|
||||
}
|
||||
eglContext.SetPresentationTime(nowNs - startTimestampNs);
|
||||
eglContext.SwapBuffers();
|
||||
|
||||
if (hasPreview && eglContext.HasPreviewSurface()) {
|
||||
eglContext.MakePreviewCurrent();
|
||||
BlitComposedToSurface(composedTex,
|
||||
eglContext.GetPreviewWidth(),
|
||||
eglContext.GetPreviewHeight());
|
||||
eglContext.SwapPreviewBuffers();
|
||||
eglContext.MakeEncoderCurrent();
|
||||
}
|
||||
|
||||
// Generate silence audio to keep the audio track alive
|
||||
if (audioEncoder) {
|
||||
// 1 video frame at 30fps = 1/30s ≈ 1600 samples at 48kHz
|
||||
int samplesPerFrame = sampleRate / framerate;
|
||||
int bytesPerFrame = samplesPerFrame * channels * 2; // 16-bit PCM
|
||||
AudioFrame silenceFrame;
|
||||
silenceFrame.pcmData.resize(bytesPerFrame, 0);
|
||||
silenceFrame.timestampNs = nowNs;
|
||||
ProcessAudioFrame(silenceFrame);
|
||||
}
|
||||
|
||||
lastComposeTimeNs = nowNs;
|
||||
}
|
||||
}
|
||||
|
||||
// Process audio frames
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(audioMutex);
|
||||
@@ -333,6 +425,9 @@ void StreamingEngine::EncoderThreadFunc() {
|
||||
DrainAudioEncoder();
|
||||
}
|
||||
|
||||
// Update stats every second regardless of frame output
|
||||
UpdateStats();
|
||||
|
||||
// Don't spin-wait
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
@@ -341,6 +436,8 @@ void StreamingEngine::EncoderThreadFunc() {
|
||||
LOGI("Encoder thread shutting down");
|
||||
|
||||
ReleaseBlitResources();
|
||||
eglContext.DestroyPreviewSurface();
|
||||
hasPreview = false;
|
||||
|
||||
for (auto* sink : sinks) {
|
||||
sink->Close();
|
||||
@@ -376,34 +473,51 @@ void StreamingEngine::ProcessVideoFrame(const VideoFrame& frame) {
|
||||
// Wait on GPU fence
|
||||
eglContext.WaitFence(frame.fenceFd);
|
||||
|
||||
// Import HardwareBuffer as GL texture
|
||||
// Import HardwareBuffer as OES texture
|
||||
GLuint texture = eglContext.ImportHardwareBuffer(frame.buffer);
|
||||
if (texture == 0) {
|
||||
LOGW("Failed to import HardwareBuffer as texture");
|
||||
return;
|
||||
}
|
||||
|
||||
// Blit to encoder surface
|
||||
BlitToEncoder(texture, frame.timestampNs);
|
||||
// Compose: game frame + overlay layers → FBO
|
||||
compositionPipeline.Compose(texture);
|
||||
GLuint composedTex = compositionPipeline.GetComposedTexture();
|
||||
|
||||
// Clean up texture
|
||||
// Blit composed texture → encoder surface
|
||||
eglContext.MakeEncoderCurrent();
|
||||
BlitComposedToSurface(composedTex, width, height);
|
||||
eglContext.SetPresentationTime(frame.timestampNs);
|
||||
eglContext.SwapBuffers();
|
||||
|
||||
// Blit composed texture → preview surface (if active)
|
||||
if (hasPreview && eglContext.HasPreviewSurface()) {
|
||||
eglContext.MakePreviewCurrent();
|
||||
BlitComposedToSurface(composedTex, eglContext.GetPreviewWidth(),
|
||||
eglContext.GetPreviewHeight());
|
||||
eglContext.SwapPreviewBuffers();
|
||||
eglContext.MakeEncoderCurrent();
|
||||
}
|
||||
|
||||
// Clean up imported texture
|
||||
glDeleteTextures(1, &texture);
|
||||
|
||||
// Track compose time so standby frames don't overlap
|
||||
auto now = std::chrono::steady_clock::now().time_since_epoch();
|
||||
lastComposeTimeNs = std::chrono::duration_cast<std::chrono::nanoseconds>(now).count();
|
||||
}
|
||||
|
||||
void StreamingEngine::BlitToEncoder(GLuint srcTexture, int64_t timestampNs) {
|
||||
glViewport(0, 0, width, height);
|
||||
void StreamingEngine::BlitComposedToSurface(GLuint composedTex, int viewportW, int viewportH) {
|
||||
glViewport(0, 0, viewportW, viewportH);
|
||||
|
||||
glUseProgram(blitProgram);
|
||||
glUseProgram(blitFboProgram);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_EXTERNAL_OES, srcTexture);
|
||||
glUniform1i(glGetUniformLocation(blitProgram, "uTexture"), 0);
|
||||
glBindTexture(GL_TEXTURE_2D, composedTex);
|
||||
glUniform1i(glGetUniformLocation(blitFboProgram, "uTexture"), 0);
|
||||
|
||||
glBindVertexArray(blitVao);
|
||||
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||
glBindVertexArray(0);
|
||||
|
||||
eglContext.SetPresentationTime(timestampNs);
|
||||
eglContext.SwapBuffers();
|
||||
}
|
||||
|
||||
void StreamingEngine::ProcessAudioFrame(const AudioFrame& frame) {
|
||||
@@ -464,8 +578,6 @@ void StreamingEngine::DrainVideoEncoder() {
|
||||
}
|
||||
|
||||
AMediaCodec_releaseOutputBuffer(videoEncoder, outputIndex, false);
|
||||
|
||||
UpdateStats();
|
||||
}
|
||||
|
||||
if (outputIndex == AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
@@ -585,3 +697,133 @@ void StreamingEngine::SetErrorCallback(ErrorCallback callback) {
|
||||
void StreamingEngine::SetBufferReleasedCallback(BufferReleasedCallback callback) {
|
||||
bufferReleasedCallback = std::move(callback);
|
||||
}
|
||||
|
||||
// --- Preview surface ---
|
||||
|
||||
void StreamingEngine::SetPreviewSurface(ANativeWindow* window) {
|
||||
if (!window) return;
|
||||
ANativeWindow_acquire(window);
|
||||
std::lock_guard<std::mutex> lock(previewMutex);
|
||||
pendingPreviewOps.push_back(PreviewSetOp{window});
|
||||
}
|
||||
|
||||
void StreamingEngine::RemovePreviewSurface() {
|
||||
std::lock_guard<std::mutex> lock(previewMutex);
|
||||
pendingPreviewOps.push_back(PreviewRemoveOp{});
|
||||
}
|
||||
|
||||
void StreamingEngine::ProcessPendingPreviewOps() {
|
||||
std::vector<PreviewOp> ops;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(previewMutex);
|
||||
ops.swap(pendingPreviewOps);
|
||||
}
|
||||
|
||||
for (auto& op : ops) {
|
||||
if (auto* setOp = std::get_if<PreviewSetOp>(&op)) {
|
||||
eglContext.DestroyPreviewSurface();
|
||||
if (eglContext.CreatePreviewSurface(setOp->window)) {
|
||||
hasPreview = true;
|
||||
LOGI("Preview surface set");
|
||||
} else {
|
||||
ANativeWindow_release(setOp->window);
|
||||
hasPreview = false;
|
||||
}
|
||||
// MakeEncoderCurrent since CreatePreviewSurface may change current
|
||||
eglContext.MakeEncoderCurrent();
|
||||
} else if (std::get_if<PreviewRemoveOp>(&op)) {
|
||||
eglContext.DestroyPreviewSurface();
|
||||
hasPreview = false;
|
||||
eglContext.MakeEncoderCurrent();
|
||||
LOGI("Preview surface removed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Composition layer management ---
|
||||
|
||||
int StreamingEngine::AddCompositionLayer(const uint8_t* rgbaData, int w, int h,
|
||||
float posX, float posY,
|
||||
float scaleX, float scaleY,
|
||||
float rotation, float opacity, int zOrder,
|
||||
const std::string& tag) {
|
||||
int id = nextLayerId.fetch_add(1);
|
||||
LayerAddOp addOp;
|
||||
addOp.rgbaData.assign(rgbaData, rgbaData + (w * h * 4));
|
||||
addOp.w = w;
|
||||
addOp.h = h;
|
||||
addOp.posX = posX;
|
||||
addOp.posY = posY;
|
||||
addOp.scaleX = scaleX;
|
||||
addOp.scaleY = scaleY;
|
||||
addOp.rotation = rotation;
|
||||
addOp.opacity = opacity;
|
||||
addOp.zOrder = zOrder;
|
||||
addOp.tag = tag;
|
||||
addOp.assignedId = id;
|
||||
|
||||
std::lock_guard<std::mutex> lock(layerOpMutex);
|
||||
pendingLayerOps.push_back(std::move(addOp));
|
||||
return id;
|
||||
}
|
||||
|
||||
void StreamingEngine::RemoveCompositionLayer(int layerId) {
|
||||
std::lock_guard<std::mutex> lock(layerOpMutex);
|
||||
pendingLayerOps.push_back(LayerRemoveOp{layerId});
|
||||
}
|
||||
|
||||
void StreamingEngine::UpdateCompositionLayerTransform(int layerId, float posX, float posY,
|
||||
float scaleX, float scaleY,
|
||||
float rotation) {
|
||||
std::lock_guard<std::mutex> lock(layerOpMutex);
|
||||
pendingLayerOps.push_back(LayerTransformOp{layerId, posX, posY, scaleX, scaleY, rotation});
|
||||
}
|
||||
|
||||
void StreamingEngine::UpdateCompositionLayerOpacity(int layerId, float opacity) {
|
||||
std::lock_guard<std::mutex> lock(layerOpMutex);
|
||||
pendingLayerOps.push_back(LayerOpacityOp{layerId, opacity});
|
||||
}
|
||||
|
||||
void StreamingEngine::SetCompositionLayerEnabled(int layerId, bool enabled) {
|
||||
std::lock_guard<std::mutex> lock(layerOpMutex);
|
||||
pendingLayerOps.push_back(LayerEnabledOp{layerId, enabled});
|
||||
}
|
||||
|
||||
void StreamingEngine::ProcessPendingLayerOps() {
|
||||
std::vector<LayerOp> ops;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(layerOpMutex);
|
||||
ops.swap(pendingLayerOps);
|
||||
}
|
||||
|
||||
for (auto& op : ops) {
|
||||
if (auto* addOp = std::get_if<LayerAddOp>(&op)) {
|
||||
GLuint tex = CompositionPipeline::UploadTexture(
|
||||
addOp->rgbaData.data(), addOp->w, addOp->h);
|
||||
if (tex) {
|
||||
CompositionTransform transform;
|
||||
transform.posX = addOp->posX;
|
||||
transform.posY = addOp->posY;
|
||||
transform.scaleX = addOp->scaleX;
|
||||
transform.scaleY = addOp->scaleY;
|
||||
transform.rotation = addOp->rotation;
|
||||
compositionPipeline.AddLayer(tex, addOp->w, addOp->h, transform,
|
||||
addOp->opacity, addOp->zOrder, addOp->tag);
|
||||
}
|
||||
} else if (auto* removeOp = std::get_if<LayerRemoveOp>(&op)) {
|
||||
compositionPipeline.RemoveLayer(removeOp->layerId);
|
||||
} else if (auto* transformOp = std::get_if<LayerTransformOp>(&op)) {
|
||||
CompositionTransform t;
|
||||
t.posX = transformOp->posX;
|
||||
t.posY = transformOp->posY;
|
||||
t.scaleX = transformOp->scaleX;
|
||||
t.scaleY = transformOp->scaleY;
|
||||
t.rotation = transformOp->rotation;
|
||||
compositionPipeline.UpdateLayerTransform(transformOp->layerId, t);
|
||||
} else if (auto* opacityOp = std::get_if<LayerOpacityOp>(&op)) {
|
||||
compositionPipeline.UpdateLayerOpacity(opacityOp->layerId, opacityOp->opacity);
|
||||
} else if (auto* enabledOp = std::get_if<LayerEnabledOp>(&op)) {
|
||||
compositionPipeline.SetLayerEnabled(enabledOp->layerId, enabledOp->enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "egl_context.h"
|
||||
#include "composition_pipeline.h"
|
||||
#include "rtmp_sink.h"
|
||||
|
||||
#include <media/NdkMediaCodec.h>
|
||||
@@ -14,6 +15,7 @@
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
struct VideoFrame {
|
||||
@@ -75,6 +77,21 @@ public:
|
||||
|
||||
bool IsRunning() const { return running.load(); }
|
||||
|
||||
// Preview surface (thread-safe, enqueued for GL thread)
|
||||
void SetPreviewSurface(ANativeWindow* window);
|
||||
void RemovePreviewSurface();
|
||||
|
||||
// Composition layer management (thread-safe, enqueued for GL thread)
|
||||
int AddCompositionLayer(const uint8_t* rgbaData, int w, int h,
|
||||
float posX, float posY, float scaleX, float scaleY,
|
||||
float rotation, float opacity, int zOrder,
|
||||
const std::string& tag);
|
||||
void RemoveCompositionLayer(int layerId);
|
||||
void UpdateCompositionLayerTransform(int layerId, float posX, float posY,
|
||||
float scaleX, float scaleY, float rotation);
|
||||
void UpdateCompositionLayerOpacity(int layerId, float opacity);
|
||||
void SetCompositionLayerEnabled(int layerId, bool enabled);
|
||||
|
||||
private:
|
||||
// Encoder thread
|
||||
void EncoderThreadFunc();
|
||||
@@ -84,8 +101,12 @@ private:
|
||||
void DrainAudioEncoder();
|
||||
void UpdateStats();
|
||||
|
||||
// Blit HardwareBuffer texture to encoder surface
|
||||
void BlitToEncoder(GLuint srcTexture, int64_t timestampNs);
|
||||
// Blit composed texture to a surface (GL_TEXTURE_2D → draw)
|
||||
void BlitComposedToSurface(GLuint composedTex, int viewportW, int viewportH);
|
||||
|
||||
// Process pending operations from other threads
|
||||
void ProcessPendingPreviewOps();
|
||||
void ProcessPendingLayerOps();
|
||||
|
||||
// Config
|
||||
int width = 0;
|
||||
@@ -100,11 +121,17 @@ private:
|
||||
// EGL
|
||||
EglContext eglContext;
|
||||
|
||||
// Blit resources
|
||||
// Composition pipeline (FBO-based)
|
||||
CompositionPipeline compositionPipeline;
|
||||
|
||||
// Blit resources — OES program (for legacy/unused path)
|
||||
GLuint blitProgram = 0;
|
||||
GLuint blitVao = 0;
|
||||
GLuint blitVbo = 0;
|
||||
|
||||
// Blit FBO program (sampler2D for composed texture → surface)
|
||||
GLuint blitFboProgram = 0;
|
||||
|
||||
// Video encoder
|
||||
AMediaCodec* videoEncoder = nullptr;
|
||||
ANativeWindow* encoderSurface = nullptr;
|
||||
@@ -126,6 +153,33 @@ private:
|
||||
std::mutex audioMutex;
|
||||
std::vector<AudioFrame> audioQueue;
|
||||
|
||||
// Preview surface — pending ops from non-GL threads
|
||||
struct PreviewSetOp { ANativeWindow* window; };
|
||||
struct PreviewRemoveOp {};
|
||||
using PreviewOp = std::variant<PreviewSetOp, PreviewRemoveOp>;
|
||||
std::mutex previewMutex;
|
||||
std::vector<PreviewOp> pendingPreviewOps;
|
||||
bool hasPreview = false;
|
||||
|
||||
// Layer ops — pending ops from non-GL threads
|
||||
struct LayerAddOp {
|
||||
std::vector<uint8_t> rgbaData;
|
||||
int w, h;
|
||||
float posX, posY, scaleX, scaleY, rotation, opacity;
|
||||
int zOrder;
|
||||
std::string tag;
|
||||
int assignedId;
|
||||
};
|
||||
struct LayerRemoveOp { int layerId; };
|
||||
struct LayerTransformOp { int layerId; float posX, posY, scaleX, scaleY, rotation; };
|
||||
struct LayerOpacityOp { int layerId; float opacity; };
|
||||
struct LayerEnabledOp { int layerId; bool enabled; };
|
||||
using LayerOp = std::variant<LayerAddOp, LayerRemoveOp, LayerTransformOp,
|
||||
LayerOpacityOp, LayerEnabledOp>;
|
||||
std::mutex layerOpMutex;
|
||||
std::vector<LayerOp> pendingLayerOps;
|
||||
std::atomic<int> nextLayerId{1};
|
||||
|
||||
// Stats
|
||||
std::mutex statsMutex;
|
||||
StreamingStats currentStats;
|
||||
@@ -138,6 +192,9 @@ private:
|
||||
int64_t startTimestampNs = 0;
|
||||
bool firstVideoFrame = true;
|
||||
|
||||
// Standby frame timing
|
||||
int64_t lastComposeTimeNs = 0;
|
||||
|
||||
// Callbacks
|
||||
StatsCallback statsCallback;
|
||||
ErrorCallback errorCallback;
|
||||
|
||||
@@ -16,7 +16,7 @@ import com.omixlab.lckcontrol.data.local.entity.StreamPlanEntity
|
||||
StreamPlanEntity::class,
|
||||
StreamDestinationEntity::class,
|
||||
],
|
||||
version = 5,
|
||||
version = 6,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class LckDatabase : RoomDatabase() {
|
||||
@@ -109,5 +109,12 @@ abstract class LckDatabase : RoomDatabase() {
|
||||
db.execSQL("ALTER TABLE stream_plans ADD COLUMN gameId TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE linked_accounts ADD COLUMN rtmpUrl TEXT")
|
||||
db.execSQL("ALTER TABLE linked_accounts ADD COLUMN streamKey TEXT")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,6 @@ data class LinkedAccountEntity(
|
||||
val accountId: String,
|
||||
val avatarUrl: String? = null,
|
||||
val isEnabled: Boolean = true,
|
||||
val rtmpUrl: String? = null,
|
||||
val streamKey: String? = null,
|
||||
)
|
||||
|
||||
@@ -61,6 +61,15 @@ data class LinkedAccountResponse(
|
||||
val displayName: String,
|
||||
val accountId: String,
|
||||
val avatarUrl: String?,
|
||||
val rtmpUrl: String? = null,
|
||||
val streamKey: String? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreateCustomRtmpRequest(
|
||||
val displayName: String,
|
||||
val rtmpUrl: String,
|
||||
val streamKey: String,
|
||||
)
|
||||
|
||||
// ── Streams ──────────────────────────────────────────────
|
||||
@@ -83,12 +92,14 @@ data class UpdateStreamPlanRequest(
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreateDestinationRequest(
|
||||
val linkedAccountId: String,
|
||||
val linkedAccountId: String? = null,
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val privacyStatus: String? = null,
|
||||
val gameId: String? = null,
|
||||
val tags: String? = null,
|
||||
val rtmpUrl: String? = null,
|
||||
val streamKey: String? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.omixlab.lckcontrol.data.remote
|
||||
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.omixlab.lckcontrol.data.local.TokenStore
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -13,6 +16,19 @@ class AuthInterceptor @Inject constructor(
|
||||
private val tokenStore: TokenStore,
|
||||
) : Interceptor {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AuthInterceptor"
|
||||
}
|
||||
|
||||
private fun extractSub(jwt: String): String? {
|
||||
return try {
|
||||
val parts = jwt.split(".")
|
||||
if (parts.size < 2) return null
|
||||
val payload = String(Base64.decode(parts[1], Base64.URL_SAFE or Base64.NO_WRAP))
|
||||
JSONObject(payload).optString("sub", "").ifEmpty { null }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
|
||||
@@ -23,6 +39,8 @@ class AuthInterceptor @Inject constructor(
|
||||
}
|
||||
|
||||
val jwt = tokenStore.getJwt()
|
||||
val sub = jwt?.let { extractSub(it) }
|
||||
Log.d(TAG, "${original.method} ${path} userId=${sub ?: "NO_JWT"}")
|
||||
val request = if (jwt != null) {
|
||||
original.newBuilder()
|
||||
.header("Authorization", "Bearer $jwt")
|
||||
@@ -35,11 +53,14 @@ class AuthInterceptor @Inject constructor(
|
||||
|
||||
// If 401 and we have a refresh token, try to refresh
|
||||
if (response.code == 401) {
|
||||
Log.w(TAG, "401 on ${original.method} ${path} — attempting token refresh")
|
||||
val refreshToken = tokenStore.getRefreshToken()
|
||||
if (refreshToken != null) {
|
||||
response.close()
|
||||
val newTokens = refreshTokenSync(chain, refreshToken)
|
||||
if (newTokens != null) {
|
||||
val newSub = extractSub(newTokens.accessToken)
|
||||
Log.d(TAG, "Token refresh OK, new userId=$newSub (was $sub)")
|
||||
tokenStore.saveSession(newTokens.accessToken, newTokens.refreshToken)
|
||||
// Retry original request with new token
|
||||
val retryRequest = original.newBuilder()
|
||||
@@ -47,9 +68,12 @@ class AuthInterceptor @Inject constructor(
|
||||
.build()
|
||||
return chain.proceed(retryRequest)
|
||||
} else {
|
||||
Log.e(TAG, "Token refresh FAILED, clearing session")
|
||||
// Refresh failed, clear session
|
||||
tokenStore.clearSession()
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "401 but no refresh token available")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ interface LckApiService {
|
||||
@POST("providers/twitch/callback")
|
||||
suspend fun twitchCallback(@Body body: ProviderCallbackRequest): LinkedAccountResponse
|
||||
|
||||
@POST("providers/accounts/custom-rtmp")
|
||||
suspend fun createCustomRtmpAccount(@Body body: CreateCustomRtmpRequest): LinkedAccountResponse
|
||||
|
||||
@DELETE("providers/accounts/{id}")
|
||||
suspend fun unlinkAccount(@Path("id") id: String): SuccessResponse
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.omixlab.lckcontrol.data.repository
|
||||
|
||||
import com.omixlab.lckcontrol.data.local.dao.LinkedAccountDao
|
||||
import com.omixlab.lckcontrol.data.local.entity.LinkedAccountEntity
|
||||
import com.omixlab.lckcontrol.data.remote.CreateCustomRtmpRequest
|
||||
import com.omixlab.lckcontrol.data.remote.LckApiService
|
||||
import com.omixlab.lckcontrol.data.remote.ProviderCallbackRequest
|
||||
import com.omixlab.lckcontrol.shared.LinkedAccount
|
||||
@@ -36,6 +37,8 @@ class AccountRepository @Inject constructor(
|
||||
accountId = account.accountId,
|
||||
avatarUrl = account.avatarUrl,
|
||||
isEnabled = localMap[account.id]?.isEnabled ?: true,
|
||||
rtmpUrl = account.rtmpUrl,
|
||||
streamKey = account.streamKey,
|
||||
)
|
||||
}
|
||||
// Detect removals
|
||||
@@ -54,6 +57,12 @@ class AccountRepository @Inject constructor(
|
||||
accountDao.setEnabled(id, enabled)
|
||||
}
|
||||
|
||||
/** Create a custom RTMP account on backend and sync */
|
||||
suspend fun createCustomRtmpAccount(displayName: String, rtmpUrl: String, streamKey: String) {
|
||||
apiService.createCustomRtmpAccount(CreateCustomRtmpRequest(displayName, rtmpUrl, streamKey))
|
||||
syncAccounts()
|
||||
}
|
||||
|
||||
/** Get YouTube OAuth URL from backend (for Custom Tabs) */
|
||||
suspend fun getYouTubeAuthUrl(): String {
|
||||
val response = apiService.getYouTubeAuthUrl()
|
||||
@@ -92,5 +101,7 @@ class AccountRepository @Inject constructor(
|
||||
avatarUrl = avatarUrl,
|
||||
isAuthenticated = true, // Backend manages auth state
|
||||
isEnabled = isEnabled,
|
||||
rtmpUrl = rtmpUrl,
|
||||
streamKey = streamKey,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,12 +55,14 @@ class StreamPlanRepository @Inject constructor(
|
||||
gameId = gameId.ifBlank { null },
|
||||
destinations = destinations.map { dest ->
|
||||
CreateDestinationRequest(
|
||||
linkedAccountId = dest.linkedAccountId,
|
||||
linkedAccountId = dest.linkedAccountId.ifBlank { null },
|
||||
title = dest.title,
|
||||
description = dest.description,
|
||||
privacyStatus = dest.privacyStatus,
|
||||
gameId = dest.gameId,
|
||||
tags = dest.tags.joinToString(","),
|
||||
rtmpUrl = dest.rtmpUrl.ifBlank { null },
|
||||
streamKey = dest.streamKey.ifBlank { null },
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -83,12 +85,14 @@ class StreamPlanRepository @Inject constructor(
|
||||
gameId = gameId.ifBlank { null },
|
||||
destinations = destinations.map { dest ->
|
||||
CreateDestinationRequest(
|
||||
linkedAccountId = dest.linkedAccountId,
|
||||
linkedAccountId = dest.linkedAccountId.ifBlank { null },
|
||||
title = dest.title,
|
||||
description = dest.description,
|
||||
privacyStatus = dest.privacyStatus,
|
||||
gameId = dest.gameId,
|
||||
tags = dest.tags.joinToString(","),
|
||||
rtmpUrl = dest.rtmpUrl.ifBlank { null },
|
||||
streamKey = dest.streamKey.ifBlank { null },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ object DatabaseModule {
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): LckDatabase =
|
||||
Room.databaseBuilder(context, LckDatabase::class.java, "lck_control.db")
|
||||
.addMigrations(LckDatabase.MIGRATION_1_2, LckDatabase.MIGRATION_2_3, LckDatabase.MIGRATION_3_4, LckDatabase.MIGRATION_4_5)
|
||||
.addMigrations(LckDatabase.MIGRATION_1_2, LckDatabase.MIGRATION_2_3, LckDatabase.MIGRATION_3_4, LckDatabase.MIGRATION_4_5, LckDatabase.MIGRATION_5_6)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -302,15 +302,28 @@ class LckControlService : Service() {
|
||||
|
||||
// ── Auth logic ──────────────────────────────────────────
|
||||
|
||||
private fun extractJwtSub(jwt: String): String? {
|
||||
return try {
|
||||
val parts = jwt.split(".")
|
||||
if (parts.size < 2) return null
|
||||
val payload = String(android.util.Base64.decode(parts[1], android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP))
|
||||
org.json.JSONObject(payload).optString("sub", "").ifEmpty { null }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private suspend fun doAutoLogin() {
|
||||
// Try token refresh first
|
||||
val refreshToken = tokenStore.getRefreshToken()
|
||||
val oldJwt = tokenStore.getJwt()
|
||||
val oldSub = oldJwt?.let { extractJwtSub(it) }
|
||||
Log.d(TAG, "doAutoLogin: hasRefreshToken=${refreshToken != null}, currentUserId=$oldSub")
|
||||
if (refreshToken != null) {
|
||||
Log.d(TAG, "Attempting token refresh...")
|
||||
try {
|
||||
val response = apiService.refreshSession(RefreshRequest(refreshToken))
|
||||
val newSub = extractJwtSub(response.accessToken)
|
||||
Log.d(TAG, "Token refresh successful, userId=$newSub (was $oldSub)")
|
||||
tokenStore.saveSession(response.accessToken, response.refreshToken)
|
||||
Log.d(TAG, "Token refresh successful")
|
||||
broadcastAuthStateChanged(true)
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
@@ -320,6 +333,7 @@ class LckControlService : Service() {
|
||||
}
|
||||
|
||||
// Full Quest SDK login
|
||||
Log.d(TAG, "Starting Quest SDK login (previous userId=$oldSub)")
|
||||
doQuestLogin()
|
||||
}
|
||||
|
||||
@@ -358,8 +372,9 @@ class LckControlService : Service() {
|
||||
)
|
||||
)
|
||||
|
||||
val loginSub = extractJwtSub(response.accessToken)
|
||||
tokenStore.saveSession(response.accessToken, response.refreshToken)
|
||||
Log.d(TAG, "Quest SDK login successful")
|
||||
Log.d(TAG, "Quest SDK login successful, userId=$loginSub")
|
||||
broadcastAuthStateChanged(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.omixlab.lckcontrol.streaming
|
||||
|
||||
import android.hardware.HardwareBuffer
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
|
||||
/**
|
||||
* Thin JNI wrapper around the C++ StreamingEngine.
|
||||
@@ -77,6 +78,52 @@ class NativeStreamingEngine {
|
||||
return nativeIsRunning(nativePtr)
|
||||
}
|
||||
|
||||
// Preview surface
|
||||
fun setPreviewSurface(surface: Surface) {
|
||||
if (nativePtr == 0L) return
|
||||
nativeSetPreviewSurface(nativePtr, surface)
|
||||
}
|
||||
|
||||
fun removePreviewSurface() {
|
||||
if (nativePtr == 0L) return
|
||||
nativeRemovePreviewSurface(nativePtr)
|
||||
}
|
||||
|
||||
// Composition layers
|
||||
fun addCompositionLayer(
|
||||
rgbaData: ByteArray, w: Int, h: Int,
|
||||
posX: Float, posY: Float, scaleX: Float, scaleY: Float,
|
||||
rotation: Float, opacity: Float, zOrder: Int, tag: String,
|
||||
): Int {
|
||||
if (nativePtr == 0L) return -1
|
||||
return nativeAddCompositionLayer(nativePtr, rgbaData, w, h,
|
||||
posX, posY, scaleX, scaleY, rotation, opacity, zOrder, tag)
|
||||
}
|
||||
|
||||
fun removeCompositionLayer(layerId: Int) {
|
||||
if (nativePtr == 0L) return
|
||||
nativeRemoveCompositionLayer(nativePtr, layerId)
|
||||
}
|
||||
|
||||
fun updateCompositionLayerTransform(
|
||||
layerId: Int, posX: Float, posY: Float,
|
||||
scaleX: Float, scaleY: Float, rotation: Float,
|
||||
) {
|
||||
if (nativePtr == 0L) return
|
||||
nativeUpdateCompositionLayerTransform(nativePtr, layerId,
|
||||
posX, posY, scaleX, scaleY, rotation)
|
||||
}
|
||||
|
||||
fun updateCompositionLayerOpacity(layerId: Int, opacity: Float) {
|
||||
if (nativePtr == 0L) return
|
||||
nativeUpdateCompositionLayerOpacity(nativePtr, layerId, opacity)
|
||||
}
|
||||
|
||||
fun setCompositionLayerEnabled(layerId: Int, enabled: Boolean) {
|
||||
if (nativePtr == 0L) return
|
||||
nativeSetCompositionLayerEnabled(nativePtr, layerId, enabled)
|
||||
}
|
||||
|
||||
// Called from native code (JNI callbacks)
|
||||
@Suppress("unused")
|
||||
private fun onNativeStats(videoBitrate: Long, audioBitrate: Long, fps: Int, droppedFrames: Int) {
|
||||
@@ -109,4 +156,22 @@ class NativeStreamingEngine {
|
||||
private external fun nativeStop(ptr: Long)
|
||||
private external fun nativeDestroy(ptr: Long)
|
||||
private external fun nativeIsRunning(ptr: Long): Boolean
|
||||
|
||||
// Preview surface
|
||||
private external fun nativeSetPreviewSurface(ptr: Long, surface: Surface)
|
||||
private external fun nativeRemovePreviewSurface(ptr: Long)
|
||||
|
||||
// Composition layers
|
||||
private external fun nativeAddCompositionLayer(
|
||||
ptr: Long, rgbaData: ByteArray, w: Int, h: Int,
|
||||
posX: Float, posY: Float, scaleX: Float, scaleY: Float,
|
||||
rotation: Float, opacity: Float, zOrder: Int, tag: String,
|
||||
): Int
|
||||
private external fun nativeRemoveCompositionLayer(ptr: Long, layerId: Int)
|
||||
private external fun nativeUpdateCompositionLayerTransform(
|
||||
ptr: Long, layerId: Int, posX: Float, posY: Float,
|
||||
scaleX: Float, scaleY: Float, rotation: Float,
|
||||
)
|
||||
private external fun nativeUpdateCompositionLayerOpacity(ptr: Long, layerId: Int, opacity: Float)
|
||||
private external fun nativeSetCompositionLayerEnabled(ptr: Long, layerId: Int, enabled: Boolean)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package com.omixlab.lckcontrol.streaming
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.hardware.HardwareBuffer
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import com.omixlab.lckcontrol.shared.StreamPlan
|
||||
import com.omixlab.lckcontrol.shared.StreamingConfig
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.nio.ByteBuffer
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -153,4 +156,61 @@ class StreamingManager @Inject constructor() {
|
||||
}
|
||||
|
||||
fun isStreaming(): Boolean = _state.value == StreamingState.LIVE
|
||||
|
||||
// --- Preview surface ---
|
||||
|
||||
fun setPreviewSurface(surface: Surface) {
|
||||
engine?.setPreviewSurface(surface)
|
||||
}
|
||||
|
||||
fun removePreviewSurface() {
|
||||
engine?.removePreviewSurface()
|
||||
}
|
||||
|
||||
// --- Composition layers ---
|
||||
|
||||
fun addCompositionLayer(
|
||||
bitmap: Bitmap,
|
||||
posX: Float, posY: Float,
|
||||
scaleX: Float, scaleY: Float,
|
||||
rotation: Float, opacity: Float,
|
||||
zOrder: Int, tag: String,
|
||||
): Int {
|
||||
val rgba = bitmapToRgba(bitmap)
|
||||
return engine?.addCompositionLayer(
|
||||
rgba, bitmap.width, bitmap.height,
|
||||
posX, posY, scaleX, scaleY, rotation, opacity, zOrder, tag,
|
||||
) ?: -1
|
||||
}
|
||||
|
||||
fun removeCompositionLayer(layerId: Int) {
|
||||
engine?.removeCompositionLayer(layerId)
|
||||
}
|
||||
|
||||
fun updateCompositionLayerTransform(
|
||||
layerId: Int, posX: Float, posY: Float,
|
||||
scaleX: Float, scaleY: Float, rotation: Float,
|
||||
) {
|
||||
engine?.updateCompositionLayerTransform(layerId, posX, posY, scaleX, scaleY, rotation)
|
||||
}
|
||||
|
||||
fun updateCompositionLayerOpacity(layerId: Int, opacity: Float) {
|
||||
engine?.updateCompositionLayerOpacity(layerId, opacity)
|
||||
}
|
||||
|
||||
fun setCompositionLayerEnabled(layerId: Int, enabled: Boolean) {
|
||||
engine?.setCompositionLayerEnabled(layerId, enabled)
|
||||
}
|
||||
|
||||
private fun bitmapToRgba(bitmap: Bitmap): ByteArray {
|
||||
val argbBitmap = if (bitmap.config != Bitmap.Config.ARGB_8888) {
|
||||
bitmap.copy(Bitmap.Config.ARGB_8888, false)
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
val buffer = ByteBuffer.allocate(argbBitmap.byteCount)
|
||||
argbBitmap.copyPixelsToBuffer(buffer)
|
||||
if (argbBitmap !== bitmap) argbBitmap.recycle()
|
||||
return buffer.array()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,17 +14,20 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.LinkOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -44,6 +47,11 @@ fun AccountsScreen(
|
||||
) {
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
val linkError by viewModel.linkError.collectAsStateWithLifecycle()
|
||||
val showDialog by viewModel.showCustomRtmpDialog.collectAsStateWithLifecycle()
|
||||
val customName by viewModel.customRtmpName.collectAsStateWithLifecycle()
|
||||
val customUrl by viewModel.customRtmpUrl.collectAsStateWithLifecycle()
|
||||
val customKey by viewModel.customRtmpKey.collectAsStateWithLifecycle()
|
||||
val isCreating by viewModel.isCreatingCustomRtmp.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -54,6 +62,54 @@ fun AccountsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.dismissCustomRtmpDialog() },
|
||||
title = { Text("Add Custom RTMP") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = customName,
|
||||
onValueChange = viewModel::setCustomRtmpName,
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text("Local Test Server") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = customUrl,
|
||||
onValueChange = viewModel::setCustomRtmpUrl,
|
||||
label = { Text("RTMP URL") },
|
||||
placeholder = { Text("rtmp://192.168.1.60:1935/live") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = customKey,
|
||||
onValueChange = viewModel::setCustomRtmpKey,
|
||||
label = { Text("Stream Key") },
|
||||
placeholder = { Text("test") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.createCustomRtmpAccount() },
|
||||
enabled = !isCreating,
|
||||
) {
|
||||
Text(if (isCreating) "Saving..." else "Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.dismissCustomRtmpDialog() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(title = { Text("Linked Accounts") })
|
||||
@@ -79,7 +135,11 @@ fun AccountsScreen(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(account.displayName, style = MaterialTheme.typography.titleSmall)
|
||||
Text(account.serviceId, style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
if (account.serviceId == "CUSTOM_RTMP") account.rtmpUrl ?: "Custom RTMP"
|
||||
else account.serviceId,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = account.isEnabled,
|
||||
@@ -113,6 +173,17 @@ fun AccountsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.showCustomRtmpDialog() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Spacer(Modifier.padding(4.dp))
|
||||
Text("Add Custom RTMP")
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,22 @@ class AccountsViewModel @Inject constructor(
|
||||
private val _linkError = MutableStateFlow<String?>(null)
|
||||
val linkError: StateFlow<String?> = _linkError.asStateFlow()
|
||||
|
||||
// Custom RTMP dialog state
|
||||
private val _showCustomRtmpDialog = MutableStateFlow(false)
|
||||
val showCustomRtmpDialog: StateFlow<Boolean> = _showCustomRtmpDialog.asStateFlow()
|
||||
|
||||
private val _customRtmpName = MutableStateFlow("")
|
||||
val customRtmpName: StateFlow<String> = _customRtmpName.asStateFlow()
|
||||
|
||||
private val _customRtmpUrl = MutableStateFlow("")
|
||||
val customRtmpUrl: StateFlow<String> = _customRtmpUrl.asStateFlow()
|
||||
|
||||
private val _customRtmpKey = MutableStateFlow("")
|
||||
val customRtmpKey: StateFlow<String> = _customRtmpKey.asStateFlow()
|
||||
|
||||
private val _isCreatingCustomRtmp = MutableStateFlow(false)
|
||||
val isCreatingCustomRtmp: StateFlow<Boolean> = _isCreatingCustomRtmp.asStateFlow()
|
||||
|
||||
init {
|
||||
// Sync accounts from backend on load
|
||||
viewModelScope.launch {
|
||||
@@ -85,6 +101,42 @@ class AccountsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun showCustomRtmpDialog() {
|
||||
_customRtmpName.value = ""
|
||||
_customRtmpUrl.value = ""
|
||||
_customRtmpKey.value = ""
|
||||
_showCustomRtmpDialog.value = true
|
||||
}
|
||||
|
||||
fun dismissCustomRtmpDialog() {
|
||||
_showCustomRtmpDialog.value = false
|
||||
}
|
||||
|
||||
fun setCustomRtmpName(name: String) { _customRtmpName.value = name }
|
||||
fun setCustomRtmpUrl(url: String) { _customRtmpUrl.value = url }
|
||||
fun setCustomRtmpKey(key: String) { _customRtmpKey.value = key }
|
||||
|
||||
fun createCustomRtmpAccount() {
|
||||
val name = _customRtmpName.value.trim()
|
||||
val url = _customRtmpUrl.value.trim()
|
||||
val key = _customRtmpKey.value.trim()
|
||||
if (name.isBlank() || url.isBlank() || key.isBlank()) {
|
||||
_linkError.value = "All fields are required"
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_isCreatingCustomRtmp.value = true
|
||||
try {
|
||||
accountRepository.createCustomRtmpAccount(name, url, key)
|
||||
_showCustomRtmpDialog.value = false
|
||||
} catch (e: Exception) {
|
||||
_linkError.value = e.message ?: "Failed to create custom RTMP account"
|
||||
} finally {
|
||||
_isCreatingCustomRtmp.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_linkError.value = null
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.filled.ClearAll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
@@ -23,6 +24,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -35,7 +37,10 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.omixlab.lckcontrol.shared.StreamPlan
|
||||
import com.omixlab.lckcontrol.streaming.StreamingState
|
||||
import com.omixlab.lckcontrol.ui.components.GameInfoRow
|
||||
import com.omixlab.lckcontrol.ui.plans.StreamPreviewSurface
|
||||
import com.omixlab.lckcontrol.ui.plans.StreamingStatsCard
|
||||
import com.omixlab.lckcontrol.util.GameInfoProvider
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -49,6 +54,8 @@ fun DashboardScreen(
|
||||
val backendHealthy by viewModel.backendHealthy.collectAsStateWithLifecycle()
|
||||
val backendVersion by viewModel.backendVersion.collectAsStateWithLifecycle()
|
||||
val defaultExecutionMode by viewModel.defaultExecutionMode.collectAsStateWithLifecycle()
|
||||
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
||||
val streamingStats by viewModel.streamingStats.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -103,6 +110,28 @@ fun DashboardScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Live preview + streaming stats (only for APP_STREAMING plans with active engine)
|
||||
val hasLiveAppStreaming = plans.any {
|
||||
it.status == "LIVE" && it.executionMode == "APP_STREAMING"
|
||||
}
|
||||
if (hasLiveAppStreaming && streamingState == StreamingState.LIVE) {
|
||||
item {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Live Preview", style = MaterialTheme.typography.labelMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
StreamPreviewSurface(
|
||||
streamingManager = viewModel.streamingManagerInstance,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
StreamingStatsCard(stats = streamingStats)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Default Streaming Mode", style = MaterialTheme.typography.titleMedium)
|
||||
@@ -126,8 +155,22 @@ fun DashboardScreen(
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Stream Plans", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Stream Plans", style = MaterialTheme.typography.titleMedium)
|
||||
if (plans.any { it.status == "ENDED" }) {
|
||||
IconButton(onClick = viewModel::clearEndedPlans) {
|
||||
Icon(
|
||||
Icons.Default.ClearAll,
|
||||
contentDescription = "Clear ended plans",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (plans.isEmpty()) {
|
||||
|
||||
@@ -6,6 +6,9 @@ import com.omixlab.lckcontrol.data.local.AppPreferences
|
||||
import com.omixlab.lckcontrol.data.remote.LckApiService
|
||||
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
|
||||
import com.omixlab.lckcontrol.shared.StreamPlan
|
||||
import com.omixlab.lckcontrol.streaming.StreamingManager
|
||||
import com.omixlab.lckcontrol.streaming.StreamingState
|
||||
import com.omixlab.lckcontrol.streaming.StreamingStats
|
||||
import com.omixlab.lckcontrol.util.GameInfoProvider
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -23,11 +26,16 @@ class DashboardViewModel @Inject constructor(
|
||||
private val apiService: LckApiService,
|
||||
private val appPreferences: AppPreferences,
|
||||
val gameInfoProvider: GameInfoProvider,
|
||||
private val streamingManager: StreamingManager,
|
||||
) : ViewModel() {
|
||||
|
||||
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val streamingState: StateFlow<StreamingState> = streamingManager.state
|
||||
val streamingStats: StateFlow<StreamingStats> = streamingManager.stats
|
||||
val streamingManagerInstance: StreamingManager = streamingManager
|
||||
|
||||
private val _backendHealthy = MutableStateFlow<Boolean?>(null)
|
||||
val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow()
|
||||
|
||||
@@ -59,4 +67,13 @@ class DashboardViewModel @Inject constructor(
|
||||
_defaultExecutionMode.value = mode
|
||||
appPreferences.setDefaultExecutionMode(mode)
|
||||
}
|
||||
|
||||
fun clearEndedPlans() {
|
||||
viewModelScope.launch {
|
||||
val ended = plans.value.filter { it.status == "ENDED" }
|
||||
for (plan in ended) {
|
||||
try { streamPlanRepository.deletePlan(plan.planId) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,13 +220,13 @@ private fun DestinationCard(
|
||||
}
|
||||
}
|
||||
|
||||
// Account picker (shows "YouTube - DisplayName" per account)
|
||||
// Account picker (shows linked accounts + "Custom RTMP" option)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = accountExpanded,
|
||||
onExpandedChange = { accountExpanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = destination.linkedAccountLabel,
|
||||
value = destination.linkedAccountLabel.ifBlank { if (destination.isCustom) "Custom RTMP" else "" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Account") },
|
||||
@@ -239,15 +239,39 @@ private fun DestinationCard(
|
||||
expanded = accountExpanded,
|
||||
onDismissRequest = { accountExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Custom RTMP") },
|
||||
onClick = {
|
||||
onUpdate(destination.copy(
|
||||
isCustom = true,
|
||||
linkedAccountId = "",
|
||||
linkedAccountLabel = "",
|
||||
))
|
||||
accountExpanded = false
|
||||
},
|
||||
)
|
||||
linkedAccounts.forEach { account ->
|
||||
val label = "${account.serviceId} - ${account.displayName}"
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
onClick = {
|
||||
onUpdate(destination.copy(
|
||||
linkedAccountId = account.id,
|
||||
linkedAccountLabel = label,
|
||||
))
|
||||
if (account.serviceId == "CUSTOM_RTMP") {
|
||||
onUpdate(destination.copy(
|
||||
isCustom = true,
|
||||
linkedAccountId = account.id,
|
||||
linkedAccountLabel = label,
|
||||
rtmpUrl = account.rtmpUrl ?: "",
|
||||
streamKey = account.streamKey ?: "",
|
||||
))
|
||||
} else {
|
||||
onUpdate(destination.copy(
|
||||
isCustom = false,
|
||||
linkedAccountId = account.id,
|
||||
linkedAccountLabel = label,
|
||||
rtmpUrl = "",
|
||||
streamKey = "",
|
||||
))
|
||||
}
|
||||
accountExpanded = false
|
||||
},
|
||||
)
|
||||
@@ -255,6 +279,25 @@ private fun DestinationCard(
|
||||
}
|
||||
}
|
||||
|
||||
if (destination.isCustom) {
|
||||
OutlinedTextField(
|
||||
value = destination.rtmpUrl,
|
||||
onValueChange = { onUpdate(destination.copy(rtmpUrl = it)) },
|
||||
label = { Text("RTMP URL") },
|
||||
placeholder = { Text("rtmp://192.168.1.60:1935/live") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = destination.streamKey,
|
||||
onValueChange = { onUpdate(destination.copy(streamKey = it)) },
|
||||
label = { Text("Stream Key") },
|
||||
placeholder = { Text("test") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = destination.title,
|
||||
onValueChange = { onUpdate(destination.copy(title = it)) },
|
||||
@@ -263,52 +306,54 @@ private fun DestinationCard(
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = destination.description,
|
||||
onValueChange = { onUpdate(destination.copy(description = it)) },
|
||||
label = { Text("Description") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
)
|
||||
|
||||
// Privacy status
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = privacyExpanded,
|
||||
onExpandedChange = { privacyExpanded = it },
|
||||
) {
|
||||
if (!destination.isCustom) {
|
||||
OutlinedTextField(
|
||||
value = destination.privacyStatus,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Privacy") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(privacyExpanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
value = destination.description,
|
||||
onValueChange = { onUpdate(destination.copy(description = it)) },
|
||||
label = { Text("Description") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
|
||||
// Privacy status
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = privacyExpanded,
|
||||
onDismissRequest = { privacyExpanded = false },
|
||||
onExpandedChange = { privacyExpanded = it },
|
||||
) {
|
||||
listOf("public", "unlisted", "private").forEach { status ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(status) },
|
||||
onClick = {
|
||||
onUpdate(destination.copy(privacyStatus = status))
|
||||
privacyExpanded = false
|
||||
},
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = destination.privacyStatus,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Privacy") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(privacyExpanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = privacyExpanded,
|
||||
onDismissRequest = { privacyExpanded = false },
|
||||
) {
|
||||
listOf("public", "unlisted", "private").forEach { status ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(status) },
|
||||
onClick = {
|
||||
onUpdate(destination.copy(privacyStatus = status))
|
||||
privacyExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = destination.tags,
|
||||
onValueChange = { onUpdate(destination.copy(tags = it)) },
|
||||
label = { Text("Tags (comma-separated)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = destination.tags,
|
||||
onValueChange = { onUpdate(destination.copy(tags = it)) },
|
||||
label = { Text("Tags (comma-separated)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ data class DestinationInput(
|
||||
val privacyStatus: String = "public",
|
||||
val gameId: String = "",
|
||||
val tags: String = "",
|
||||
val isCustom: Boolean = false,
|
||||
val rtmpUrl: String = "",
|
||||
val streamKey: String = "",
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -137,6 +140,7 @@ class CreatePlanViewModel @Inject constructor(
|
||||
_destinations.value = plan.destinations.map { dest ->
|
||||
val account = accounts.find { it.id == dest.linkedAccountId }
|
||||
val label = if (account != null) "${account.serviceId} - ${account.displayName}" else dest.service
|
||||
val isCustomRtmp = account?.serviceId == "CUSTOM_RTMP"
|
||||
DestinationInput(
|
||||
linkedAccountId = dest.linkedAccountId,
|
||||
linkedAccountLabel = label,
|
||||
@@ -145,6 +149,9 @@ class CreatePlanViewModel @Inject constructor(
|
||||
privacyStatus = dest.privacyStatus,
|
||||
gameId = dest.gameId,
|
||||
tags = dest.tags.joinToString(","),
|
||||
isCustom = isCustomRtmp || dest.service == "CUSTOM",
|
||||
rtmpUrl = if (isCustomRtmp) account?.rtmpUrl ?: dest.rtmpUrl else dest.rtmpUrl,
|
||||
streamKey = if (isCustomRtmp) account?.streamKey ?: dest.streamKey else dest.streamKey,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -193,9 +200,20 @@ class CreatePlanViewModel @Inject constructor(
|
||||
_error.value = "Add at least one destination"
|
||||
return
|
||||
}
|
||||
if (dests.any { it.linkedAccountId.isBlank() || it.title.isBlank() }) {
|
||||
_error.value = "All destinations need an account and title"
|
||||
return
|
||||
for (dest in dests) {
|
||||
if (dest.title.isBlank()) {
|
||||
_error.value = "All destinations need a title"
|
||||
return
|
||||
}
|
||||
if (dest.isCustom) {
|
||||
if (dest.rtmpUrl.isBlank() || dest.streamKey.isBlank()) {
|
||||
_error.value = "Custom destinations need RTMP URL and stream key"
|
||||
return
|
||||
}
|
||||
} else if (dest.linkedAccountId.isBlank()) {
|
||||
_error.value = "All destinations need an account (or use Custom RTMP)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
@@ -204,16 +222,25 @@ class CreatePlanViewModel @Inject constructor(
|
||||
try {
|
||||
val accounts = linkedAccounts.value
|
||||
val streamDests = dests.map { input ->
|
||||
val account = accounts.find { it.id == input.linkedAccountId }
|
||||
StreamDestination(
|
||||
service = account?.serviceId ?: "",
|
||||
linkedAccountId = input.linkedAccountId,
|
||||
title = input.title,
|
||||
description = input.description,
|
||||
privacyStatus = input.privacyStatus,
|
||||
gameId = input.gameId,
|
||||
tags = input.tags.split(",").map { it.trim() }.filter { it.isNotBlank() },
|
||||
)
|
||||
if (input.isCustom) {
|
||||
StreamDestination(
|
||||
service = "CUSTOM",
|
||||
title = input.title,
|
||||
rtmpUrl = input.rtmpUrl,
|
||||
streamKey = input.streamKey,
|
||||
)
|
||||
} else {
|
||||
val account = accounts.find { it.id == input.linkedAccountId }
|
||||
StreamDestination(
|
||||
service = account?.serviceId ?: "",
|
||||
linkedAccountId = input.linkedAccountId,
|
||||
title = input.title,
|
||||
description = input.description,
|
||||
privacyStatus = input.privacyStatus,
|
||||
gameId = input.gameId,
|
||||
tags = input.tags.split(",").map { it.trim() }.filter { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
}
|
||||
val plan = if (isEditMode) {
|
||||
streamPlanRepository.updatePlan(editingPlanId!!, name, streamDests, _executionMode.value, _gameId.value)
|
||||
|
||||
@@ -167,14 +167,6 @@ fun PlanDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming stats (only for APP_STREAMING + LIVE)
|
||||
if (currentPlan.executionMode == "APP_STREAMING" &&
|
||||
currentPlan.status == "LIVE" &&
|
||||
streamingState == StreamingState.LIVE) {
|
||||
item {
|
||||
StreamingStatsCard(stats = streamingStats)
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
item {
|
||||
@@ -213,6 +205,21 @@ fun PlanDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Stream preview + stats (when LIVE + APP_STREAMING)
|
||||
if (currentPlan.status == "LIVE" &&
|
||||
currentPlan.executionMode == "APP_STREAMING" &&
|
||||
streamingState == StreamingState.LIVE
|
||||
) {
|
||||
item {
|
||||
Text("Stream Preview", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
StreamPreviewSurface(streamingManager = viewModel.streamingManager)
|
||||
}
|
||||
item {
|
||||
StreamingStatsCard(stats = streamingStats)
|
||||
}
|
||||
}
|
||||
|
||||
// Destinations
|
||||
item {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
@@ -5,10 +5,11 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
|
||||
import com.omixlab.lckcontrol.shared.StreamPlan
|
||||
import com.omixlab.lckcontrol.shared.StreamingConfig
|
||||
import com.omixlab.lckcontrol.streaming.StreamingManager
|
||||
import com.omixlab.lckcontrol.util.GameInfoProvider
|
||||
import com.omixlab.lckcontrol.streaming.StreamingState
|
||||
import com.omixlab.lckcontrol.streaming.StreamingStats
|
||||
import com.omixlab.lckcontrol.util.GameInfoProvider
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -22,7 +23,7 @@ import javax.inject.Inject
|
||||
class PlanDetailViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val streamPlanRepository: StreamPlanRepository,
|
||||
private val streamingManager: StreamingManager,
|
||||
val streamingManager: StreamingManager,
|
||||
val gameInfoProvider: GameInfoProvider,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -67,6 +68,16 @@ class PlanDetailViewModel @Inject constructor(
|
||||
_error.value = null
|
||||
try {
|
||||
streamPlanRepository.startPlan(planId)
|
||||
// Start streaming engine for APP_STREAMING plans
|
||||
val updated = streamPlanRepository.getPlan(planId)
|
||||
if (updated?.executionMode == "APP_STREAMING") {
|
||||
streamingManager.startStreaming(
|
||||
plan = updated,
|
||||
config = StreamingConfig(),
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = e.message ?: "Failed to start plan"
|
||||
} finally {
|
||||
@@ -80,6 +91,10 @@ class PlanDetailViewModel @Inject constructor(
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
try {
|
||||
// Stop streaming engine if running
|
||||
if (streamingManager.isStreaming()) {
|
||||
streamingManager.stopStreaming()
|
||||
}
|
||||
streamPlanRepository.endPlan(planId)
|
||||
} catch (e: Exception) {
|
||||
_error.value = e.message ?: "Failed to end plan"
|
||||
|
||||
@@ -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.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
@@ -26,13 +27,23 @@ class GameInfoProvider @Inject constructor(
|
||||
return cache.getOrPut(packageName) {
|
||||
try {
|
||||
val pm = context.packageManager
|
||||
val appInfo = pm.getApplicationInfo(packageName, 0)
|
||||
val appInfo = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
|
||||
val label = pm.getApplicationLabel(appInfo).toString()
|
||||
val icon = pm.getApplicationIcon(appInfo).toBitmap().asImageBitmap()
|
||||
// Use loadIcon for higher density, fall back to getApplicationIcon
|
||||
val drawable = appInfo.loadIcon(pm)
|
||||
val size = (48 * context.resources.displayMetrics.density).toInt()
|
||||
val icon = drawable.toBitmap(size, size).asImageBitmap()
|
||||
Log.d("GameInfoProvider", "Resolved $packageName -> $label")
|
||||
GameInfo(packageName, label, icon)
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
Log.w("GameInfoProvider", "Package not found: $packageName")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Invalidate cache so icons are re-fetched on next resolve */
|
||||
fun invalidate(packageName: String) {
|
||||
cache.remove(packageName)
|
||||
}
|
||||
}
|
||||
|
||||
1063
package-lock.json
generated
Normal file
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 isAuthenticated: Boolean = false,
|
||||
val isEnabled: Boolean = true,
|
||||
val rtmpUrl: String? = null,
|
||||
val streamKey: String? = null,
|
||||
) : Parcelable {
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
@@ -21,6 +23,8 @@ data class LinkedAccount(
|
||||
avatarUrl = parcel.readString(),
|
||||
isAuthenticated = parcel.readInt() != 0,
|
||||
isEnabled = if (parcel.dataAvail() > 0) parcel.readInt() != 0 else true,
|
||||
rtmpUrl = if (parcel.dataAvail() > 0) parcel.readString() else null,
|
||||
streamKey = if (parcel.dataAvail() > 0) parcel.readString() else null,
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
@@ -31,6 +35,8 @@ data class LinkedAccount(
|
||||
parcel.writeString(avatarUrl)
|
||||
parcel.writeInt(if (isAuthenticated) 1 else 0)
|
||||
parcel.writeInt(if (isEnabled) 1 else 0)
|
||||
parcel.writeString(rtmpUrl)
|
||||
parcel.writeString(streamKey)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int = 0
|
||||
|
||||
82
tools/rtmp-server.js
Normal file
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